diff --git a/docs/ddl/slack-postgres.sql b/docs/ddl/slack-postgres.sql new file mode 100644 index 0000000..80d901b --- /dev/null +++ b/docs/ddl/slack-postgres.sql @@ -0,0 +1,166 @@ +-- Phase 1b: slack_workspace, slack_connection, slack_channel, slack_user_group, slack_template, +-- slack_message and slack_message_attempt. Apply before producing the qbit. +-- Host tenant (security) columns, configured through SlackQBitConfig.securityFields, must be +-- added by the host to slack_workspace and slack_template (children inherit them by join). +-- bot_token and user_token hold AES-256-GCM ciphertext (v1:keyId:base64), never plaintext. + +CREATE TABLE slack_workspace ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + team_id VARCHAR(500) NOT NULL, + team_name VARCHAR(500), + enterprise_id VARCHAR(500), + app_id VARCHAR(500), + tenant_key VARCHAR(500), + installed_by_user_id VARCHAR(500), + installed_at TIMESTAMP, + bot_user_id VARCHAR(500), + bot_scopes TEXT, + bot_token VARCHAR(2000), + bot_token_fingerprint VARCHAR(500), + status VARCHAR(500), + readiness_status VARCHAR(500), + readiness_checked_at TIMESTAMP, + readiness_summary VARCHAR(500), + health VARCHAR(500), + consecutive_failures INTEGER, + rate_per_minute INTEGER, + UNIQUE (team_id, tenant_key) +); + +CREATE TABLE slack_connection ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + user_id VARCHAR(500) NOT NULL, + workspace_id INTEGER NOT NULL, + slack_user_id VARCHAR(500), + slack_user_name VARCHAR(500), + user_token VARCHAR(2000), + user_token_fingerprint VARCHAR(500), + user_scopes TEXT, + status VARCHAR(500), + connected_at TIMESTAMP, + last_verified_at TIMESTAMP, + revoked_at TIMESTAMP, + last_error VARCHAR(500), + UNIQUE (user_id, workspace_id) +); + +CREATE TABLE slack_channel ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500) NOT NULL, + label VARCHAR(500), + workspace_id INTEGER NOT NULL, + slack_channel_id VARCHAR(500), + slack_channel_name VARCHAR(500), + is_private BOOLEAN, + bot_is_member BOOLEAN, + is_archived BOOLEAN, + enabled BOOLEAN, + description TEXT, + checked_at TIMESTAMP, + UNIQUE (workspace_id, name) +); + +CREATE TABLE slack_user_group ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500) NOT NULL, + label VARCHAR(500), + workspace_id INTEGER NOT NULL, + slack_usergroup_id VARCHAR(500), + handle VARCHAR(500), + channel_id VARCHAR(500), + enabled BOOLEAN, + UNIQUE (workspace_id, name) +); + +CREATE TABLE slack_template ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500) NOT NULL, + label VARCHAR(500), + description TEXT, + category VARCHAR(500), + enabled BOOLEAN, + tenant_key VARCHAR(500), + text TEXT, + blocks_template TEXT, + variables_schema TEXT, + UNIQUE (name) +); + +CREATE TABLE slack_message ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + owner_user_id VARCHAR(500) NOT NULL, + workspace_id INTEGER NOT NULL, + connection_id INTEGER, + post_as VARCHAR(500), + operation VARCHAR(500), + target_type VARCHAR(500), + target_ref VARCHAR(500), + slack_channel_id VARCHAR(500), + channel_label VARCHAR(500), + status VARCHAR(500), + category VARCHAR(500), + template_name VARCHAR(500), + text TEXT, + blocks_json TEXT, + variables_snapshot TEXT, + idempotency_key VARCHAR(500), + parent_message_id INTEGER, + thread_ts VARCHAR(500), + target_message_id INTEGER, + attachment_storage_ref VARCHAR(500), + attachment_name VARCHAR(500), + attachment_bytes BIGINT, + slack_file_id VARCHAR(500), + slack_ts VARCHAR(500), + permalink TEXT, + attempt_count INTEGER, + next_attempt_date TIMESTAMP, + lease_expires_date TIMESTAMP, + last_error_code VARCHAR(500), + last_error VARCHAR(500), + sent_date TIMESTAMP, + dead_letter_date TIMESTAMP, + linked_table_name VARCHAR(500), + linked_record_id VARCHAR(500), + sender_name VARCHAR(500), + delivery_log_id VARCHAR(500), + UNIQUE (idempotency_key) +); + +CREATE TABLE slack_message_attempt ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + message_id INTEGER NOT NULL, + attempt_number INTEGER NOT NULL, + outcome VARCHAR(500), + error_code VARCHAR(500), + http_status INTEGER, + retry_after_seconds INTEGER, + posted_as VARCHAR(500), + claimed_by VARCHAR(500), + start_date TIMESTAMP, + end_date TIMESTAMP, + error VARCHAR(500), + UNIQUE (message_id, attempt_number) +); + +CREATE INDEX slack_message_due ON slack_message (status, next_attempt_date); +CREATE INDEX slack_message_owner ON slack_message (owner_user_id, status); +CREATE INDEX slack_message_workspace ON slack_message (workspace_id); +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); diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java index c3f5aab..a7fe6a6 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java @@ -21,9 +21,17 @@ package com.kingsrook.qbits.slack; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* @@ -32,9 +40,33 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; *******************************************************************************/ public class SlackQBitConfig implements QBitConfig { + public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$"); + private String backendName; private String schedulerName; private String bridgeProviderName = "slack"; + private transient Function secretResolver = System::getenv; + private Set allowedEnvNames = new LinkedHashSet<>(List.of("SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", "SLACK_TOKEN_KEY", "SLACK_TOKEN_KEY_PREVIOUS")); + private String tokenKeyEnvName = "SLACK_TOKEN_KEY"; + private String previousTokenKeyEnvName = "SLACK_TOKEN_KEY_PREVIOUS"; + private List securityFields = List.of(); + private List recordSecurityLocks = List.of(); + private MetaDataCustomizerInterface tableMetaDataCustomizer; + private String appName = "slack"; + private String appLabel = "Slack"; + private Integer bodyRetentionDays = 30; + private Integer messageRetentionDays = 0; + private Integer maxAttempts = 6; + private List retryMinutes = List.of(1, 5, 15, 60, 240, 720); + private Integer leaseSeconds = 120; + private Integer batchSize = 50; + private Integer dispatchIntervalSeconds = 10; + private Integer membershipCacheMinutes = 10; + private Long maxAttachmentBytes = 50L * 1024 * 1024; + private String slackApiBaseUrl = "https://slack.com/api"; + private String publicBaseUrl; + private Integer connectTimeoutSeconds = 5; + private Integer requestTimeoutSeconds = 15; @@ -47,6 +79,50 @@ public class SlackQBitConfig implements QBitConfig assertCondition(backendName != null && !backendName.isBlank(), "Slack qbit: backendName is required", errors); assertCondition(schedulerName != null && !schedulerName.isBlank(), "Slack qbit: schedulerName is required", errors); assertCondition(bridgeProviderName != null && bridgeProviderName.matches("[a-zA-Z][a-zA-Z0-9_]*"), "Slack qbit: bridgeProviderName must be a simple name", errors); + assertCondition(secretResolver != null, "Slack qbit: secretResolver is required", errors); + assertCondition(allowedEnvNames != null && allowedEnvNames.stream().allMatch(n -> n != null && ENV_NAME.matcher(n).matches()), "Slack qbit: allowedEnvNames must be valid environment variable names", errors); + assertCondition(tokenKeyEnvName != null && ENV_NAME.matcher(tokenKeyEnvName).matches(), "Slack qbit: tokenKeyEnvName must be a valid environment variable name", errors); + assertCondition(previousTokenKeyEnvName == null || ENV_NAME.matcher(previousTokenKeyEnvName).matches(), "Slack qbit: previousTokenKeyEnvName must be a valid environment variable name", errors); + assertCondition(appName != null && !appName.isBlank() && appLabel != null && !appLabel.isBlank(), "Slack qbit: appName and appLabel are required", errors); + assertCondition(securityFields != null && recordSecurityLocks != null, "Slack qbit: securityFields and recordSecurityLocks must not be null", errors); + assertCondition(positive(maxAttempts) && positive(batchSize) && positive(dispatchIntervalSeconds) && positive(membershipCacheMinutes), "Slack qbit: attempts, batch size, dispatch interval and membership cache must be positive", errors); + assertCondition(positive(connectTimeoutSeconds) && positive(requestTimeoutSeconds), "Slack qbit: HTTP timeouts must be positive", errors); + assertCondition(leaseSeconds != null && leaseSeconds >= 60, "Slack qbit: leaseSeconds must be at least 60", errors); + assertCondition(retryMinutes != null && !retryMinutes.isEmpty() && retryMinutes.stream().allMatch(n -> n != null && n >= 1), "Slack qbit: retryMinutes must contain positive delays", errors); + assertCondition(bodyRetentionDays != null && bodyRetentionDays >= 0 && messageRetentionDays != null && messageRetentionDays >= 0, "Slack qbit: retention days must not be negative", errors); + assertCondition(maxAttachmentBytes != null && maxAttachmentBytes > 0, "Slack qbit: maxAttachmentBytes must be positive", errors); + assertCondition(slackApiBaseUrl != null && secureOrLoopback(slackApiBaseUrl), "Slack qbit: slackApiBaseUrl must be https (or loopback http)", errors); + assertCondition(publicBaseUrl == null || secureOrLoopback(publicBaseUrl), "Slack qbit: publicBaseUrl must be https (or loopback http)", errors); + } + + + + /******************************************************************************* + ** True for a non-null positive number. + *******************************************************************************/ + private static boolean positive(Integer value) + { + return (value != null && value >= 1); + } + + + + /******************************************************************************* + ** True for an https URL, or an http URL to a loopback host (tests). + *******************************************************************************/ + private static boolean secureOrLoopback(String url) + { + return (url.startsWith("https://") || url.startsWith("http://localhost") || url.startsWith("http://127.0.0.1")); + } + + + + /******************************************************************************* + ** Retry delay in minutes after a one-based attempt number, repeating the last. + *******************************************************************************/ + public int retryMinutesAfterAttempt(int attemptNumber) + { + return (retryMinutes.get(Math.max(0, Math.min(attemptNumber - 1, retryMinutes.size() - 1)))); } @@ -122,4 +198,466 @@ public class SlackQBitConfig implements QBitConfig this.bridgeProviderName = bridgeProviderName; return (this); } + + + + /******************************************************************************* + ** Getter for secretResolver + *******************************************************************************/ + public Function getSecretResolver() + { + return (this.secretResolver); + } + + + + /******************************************************************************* + ** Fluent setter for secretResolver + *******************************************************************************/ + public SlackQBitConfig withSecretResolver(Function secretResolver) + { + this.secretResolver = secretResolver; + return (this); + } + + + + /******************************************************************************* + ** Getter for allowedEnvNames + *******************************************************************************/ + public Set getAllowedEnvNames() + { + return (this.allowedEnvNames); + } + + + + /******************************************************************************* + ** Fluent setter for allowedEnvNames + *******************************************************************************/ + public SlackQBitConfig withAllowedEnvNames(Set allowedEnvNames) + { + this.allowedEnvNames = allowedEnvNames; + return (this); + } + + + + /******************************************************************************* + ** Getter for tokenKeyEnvName + *******************************************************************************/ + public String getTokenKeyEnvName() + { + return (this.tokenKeyEnvName); + } + + + + /******************************************************************************* + ** Fluent setter for tokenKeyEnvName + *******************************************************************************/ + public SlackQBitConfig withTokenKeyEnvName(String tokenKeyEnvName) + { + this.tokenKeyEnvName = tokenKeyEnvName; + return (this); + } + + + + /******************************************************************************* + ** Getter for previousTokenKeyEnvName + *******************************************************************************/ + public String getPreviousTokenKeyEnvName() + { + return (this.previousTokenKeyEnvName); + } + + + + /******************************************************************************* + ** Fluent setter for previousTokenKeyEnvName + *******************************************************************************/ + public SlackQBitConfig withPreviousTokenKeyEnvName(String previousTokenKeyEnvName) + { + this.previousTokenKeyEnvName = previousTokenKeyEnvName; + return (this); + } + + + + /******************************************************************************* + ** Getter for securityFields + *******************************************************************************/ + public List getSecurityFields() + { + return (this.securityFields); + } + + + + /******************************************************************************* + ** Fluent setter for securityFields + *******************************************************************************/ + public SlackQBitConfig withSecurityFields(List securityFields) + { + this.securityFields = securityFields; + return (this); + } + + + + /******************************************************************************* + ** Getter for recordSecurityLocks + *******************************************************************************/ + public List getRecordSecurityLocks() + { + return (this.recordSecurityLocks); + } + + + + /******************************************************************************* + ** Fluent setter for recordSecurityLocks + *******************************************************************************/ + public SlackQBitConfig withRecordSecurityLocks(List recordSecurityLocks) + { + this.recordSecurityLocks = recordSecurityLocks; + return (this); + } + + + + /******************************************************************************* + ** Getter for tableMetaDataCustomizer + *******************************************************************************/ + public MetaDataCustomizerInterface getTableMetaDataCustomizer() + { + return (this.tableMetaDataCustomizer); + } + + + + /******************************************************************************* + ** Fluent setter for tableMetaDataCustomizer + *******************************************************************************/ + public SlackQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface tableMetaDataCustomizer) + { + this.tableMetaDataCustomizer = tableMetaDataCustomizer; + return (this); + } + + + + /******************************************************************************* + ** Getter for appName + *******************************************************************************/ + public String getAppName() + { + return (this.appName); + } + + + + /******************************************************************************* + ** Fluent setter for appName + *******************************************************************************/ + public SlackQBitConfig withAppName(String appName) + { + this.appName = appName; + return (this); + } + + + + /******************************************************************************* + ** Getter for appLabel + *******************************************************************************/ + public String getAppLabel() + { + return (this.appLabel); + } + + + + /******************************************************************************* + ** Fluent setter for appLabel + *******************************************************************************/ + public SlackQBitConfig withAppLabel(String appLabel) + { + this.appLabel = appLabel; + return (this); + } + + + + /******************************************************************************* + ** Getter for bodyRetentionDays + *******************************************************************************/ + public Integer getBodyRetentionDays() + { + return (this.bodyRetentionDays); + } + + + + /******************************************************************************* + ** Fluent setter for bodyRetentionDays + *******************************************************************************/ + public SlackQBitConfig withBodyRetentionDays(Integer bodyRetentionDays) + { + this.bodyRetentionDays = bodyRetentionDays; + return (this); + } + + + + /******************************************************************************* + ** Getter for messageRetentionDays + *******************************************************************************/ + public Integer getMessageRetentionDays() + { + return (this.messageRetentionDays); + } + + + + /******************************************************************************* + ** Fluent setter for messageRetentionDays + *******************************************************************************/ + public SlackQBitConfig withMessageRetentionDays(Integer messageRetentionDays) + { + this.messageRetentionDays = messageRetentionDays; + return (this); + } + + + + /******************************************************************************* + ** Getter for maxAttempts + *******************************************************************************/ + public Integer getMaxAttempts() + { + return (this.maxAttempts); + } + + + + /******************************************************************************* + ** Fluent setter for maxAttempts + *******************************************************************************/ + public SlackQBitConfig withMaxAttempts(Integer maxAttempts) + { + this.maxAttempts = maxAttempts; + return (this); + } + + + + /******************************************************************************* + ** Getter for retryMinutes + *******************************************************************************/ + public List getRetryMinutes() + { + return (this.retryMinutes); + } + + + + /******************************************************************************* + ** Fluent setter for retryMinutes + *******************************************************************************/ + public SlackQBitConfig withRetryMinutes(List retryMinutes) + { + this.retryMinutes = retryMinutes; + return (this); + } + + + + /******************************************************************************* + ** Getter for leaseSeconds + *******************************************************************************/ + public Integer getLeaseSeconds() + { + return (this.leaseSeconds); + } + + + + /******************************************************************************* + ** Fluent setter for leaseSeconds + *******************************************************************************/ + public SlackQBitConfig withLeaseSeconds(Integer leaseSeconds) + { + this.leaseSeconds = leaseSeconds; + return (this); + } + + + + /******************************************************************************* + ** Getter for batchSize + *******************************************************************************/ + public Integer getBatchSize() + { + return (this.batchSize); + } + + + + /******************************************************************************* + ** Fluent setter for batchSize + *******************************************************************************/ + public SlackQBitConfig withBatchSize(Integer batchSize) + { + this.batchSize = batchSize; + return (this); + } + + + + /******************************************************************************* + ** Getter for dispatchIntervalSeconds + *******************************************************************************/ + public Integer getDispatchIntervalSeconds() + { + return (this.dispatchIntervalSeconds); + } + + + + /******************************************************************************* + ** Fluent setter for dispatchIntervalSeconds + *******************************************************************************/ + public SlackQBitConfig withDispatchIntervalSeconds(Integer dispatchIntervalSeconds) + { + this.dispatchIntervalSeconds = dispatchIntervalSeconds; + return (this); + } + + + + /******************************************************************************* + ** Getter for membershipCacheMinutes + *******************************************************************************/ + public Integer getMembershipCacheMinutes() + { + return (this.membershipCacheMinutes); + } + + + + /******************************************************************************* + ** Fluent setter for membershipCacheMinutes + *******************************************************************************/ + public SlackQBitConfig withMembershipCacheMinutes(Integer membershipCacheMinutes) + { + this.membershipCacheMinutes = membershipCacheMinutes; + return (this); + } + + + + /******************************************************************************* + ** Getter for maxAttachmentBytes + *******************************************************************************/ + public Long getMaxAttachmentBytes() + { + return (this.maxAttachmentBytes); + } + + + + /******************************************************************************* + ** Fluent setter for maxAttachmentBytes + *******************************************************************************/ + public SlackQBitConfig withMaxAttachmentBytes(Long maxAttachmentBytes) + { + this.maxAttachmentBytes = maxAttachmentBytes; + return (this); + } + + + + /******************************************************************************* + ** Getter for slackApiBaseUrl + *******************************************************************************/ + public String getSlackApiBaseUrl() + { + return (this.slackApiBaseUrl); + } + + + + /******************************************************************************* + ** Fluent setter for slackApiBaseUrl + *******************************************************************************/ + public SlackQBitConfig withSlackApiBaseUrl(String slackApiBaseUrl) + { + this.slackApiBaseUrl = slackApiBaseUrl; + return (this); + } + + + + /******************************************************************************* + ** Getter for publicBaseUrl + *******************************************************************************/ + public String getPublicBaseUrl() + { + return (this.publicBaseUrl); + } + + + + /******************************************************************************* + ** Fluent setter for publicBaseUrl + *******************************************************************************/ + public SlackQBitConfig withPublicBaseUrl(String publicBaseUrl) + { + this.publicBaseUrl = publicBaseUrl; + return (this); + } + + + + /******************************************************************************* + ** Getter for connectTimeoutSeconds + *******************************************************************************/ + public Integer getConnectTimeoutSeconds() + { + return (this.connectTimeoutSeconds); + } + + + + /******************************************************************************* + ** Fluent setter for connectTimeoutSeconds + *******************************************************************************/ + public SlackQBitConfig withConnectTimeoutSeconds(Integer connectTimeoutSeconds) + { + this.connectTimeoutSeconds = connectTimeoutSeconds; + return (this); + } + + + + /******************************************************************************* + ** Getter for requestTimeoutSeconds + *******************************************************************************/ + public Integer getRequestTimeoutSeconds() + { + return (this.requestTimeoutSeconds); + } + + + + /******************************************************************************* + ** Fluent setter for requestTimeoutSeconds + *******************************************************************************/ + public SlackQBitConfig withRequestTimeoutSeconds(Integer requestTimeoutSeconds) + { + this.requestTimeoutSeconds = requestTimeoutSeconds; + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index 156893c..7c0b3cd 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -21,6 +21,16 @@ package com.kingsrook.qbits.slack; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData; +import com.kingsrook.qbits.slack.delivery.SlackAlerts; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer; @@ -71,7 +81,66 @@ public class SlackQBitProducer implements QBitMetaDataProducer .withGroupId(GROUP_ID) .withArtifactId(ARTIFACT_ID) .withVersion(VERSION) - .withNamespace(namespace)); + .withNamespace(namespace) + .withConfig(qBitConfig)); + } + + + + /******************************************************************************* + ** A producer found by package scanning has no config: skip it. + *******************************************************************************/ + @Override + public boolean isEnabled() + { + return (qBitConfig != null); + } + + + + /******************************************************************************* + ** Validate the config, then produce the security key types, possible-value + ** sources, tables, relations and child-list widgets (docs/DESIGN.md 14.1). + *******************************************************************************/ + @Override + public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException + { + List errors = new ArrayList<>(); + qBitConfig.validate(qInstance, errors); + if(!errors.isEmpty()) + { + throw (new QException("Slack qbit configuration is invalid: " + String.join("; ", errors))); + } + + qInstance.addQBit(getQBitMetaData()); + + MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput(); + SlackSecurity.buildSecurityKeyTypes().forEach(output::add); + SlackTables.possibleValueSources().forEach(output::add); + + for(com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table : SlackTables.tables(qBitConfig)) + { + if(qBitConfig.getTableMetaDataCustomizer() != null) + { + table = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table); + } + table.setSourceQBitName(getQBitMetaData().getName()); + output.add(table); + } + + SlackTables.joins().forEach(output::add); + SlackTables.childWidgets().forEach(output::add); + + SlackProcesses.processes(qBitConfig).forEach(process -> + { + process.setSourceQBitName(getQBitMetaData().getName()); + output.add(process); + }); + SlackAlerts.types().forEach(output::add); + output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName())); + + output.addSelfToInstance(qInstance); + return (new MetaDataProducerMultiOutput()); } diff --git a/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProvider.java b/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProvider.java new file mode 100644 index 0000000..c024995 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProvider.java @@ -0,0 +1,171 @@ +/* + * 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.bridge; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput; +import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** The core messaging provider for Slack (type QBIT_SLACK, registered by the + ** producer under the config's bridgeProviderName), so any process sends with + ** core SendMessageAction and depends only on core (docs/DESIGN.md 4.2). + ** + ** How the QQQ 4.1.0-RC.6 input maps: + ** - to: a Party's address is a Slack address (me, user:<id>, + ** channel:<workspace>/<channel>, channel:<name>, #<name>, + ** group:<name>); a MultiParty gives one outbox row per recipient; + ** - subject: the bold first line of the message; + ** - contentList: the bodies, one per line, as plain text (the service escapes it); + ** - the owner is the current session's user, so an unconnected user, another + ** user's channel or another tenant's workspace is refused. + ** + ** A refusal is a QUserFacingException with the reason. RC.6 has no REFUSED or + ** QUEUED status on its output and no idempotency field, so the status is only + ** logged here. + *******************************************************************************/ +public class SlackOutboxMessagingProvider implements MessagingProviderInterface +{ + private static final QLogger LOG = QLogger.getLogger(SlackOutboxMessagingProvider.class); + + + + /******************************************************************************* + ** The provider type. + *******************************************************************************/ + @Override + public String getType() + { + return (SlackOutboxMessagingProviderMetaData.TYPE); + } + + + + /******************************************************************************* + ** Queue one outbox row per recipient. Anything refused is reported after the + ** others were queued, as one QUserFacingException. + *******************************************************************************/ + @Override + public SendMessageOutput sendMessage(SendMessageInput input) throws QException + { + if(input.getAttachmentList() != null && !input.getAttachmentList().isEmpty()) + { + throw (new QUserFacingException("Slack attachments aren't supported yet")); + } + + List addresses = new ArrayList<>(); + collect(input.getTo(), addresses); + if(addresses.isEmpty()) + { + throw (new QUserFacingException("A Slack recipient is required")); + } + + String text = compose(input); + + List refusals = new ArrayList<>(); + for(String address : addresses) + { + SlackSendResult result = SlackService.enqueue(new SlackSendRequest().withTo(address).withText(text)); + if(result.status() == SlackSendResult.Status.REFUSED) + { + refusals.addAll(result.reasons()); + } + else + { + /////////////////////////////////////////////////////////////////////////////////// + // STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6's SendMessageOutput // + // has no status or message id fields, so the outcome is only logged here // + /////////////////////////////////////////////////////////////////////////////////// + LOG.debug("Slack message handed to the outbox", logPair("status", result.status().name()), logPair("messageId", result.messageId())); + } + } + + if(!refusals.isEmpty()) + { + throw (new QUserFacingException(String.join("; ", refusals))); + } + return (new SendMessageOutput()); + } + + + + /******************************************************************************* + ** The message text: the subject in bold, then each content body. + *******************************************************************************/ + private static String compose(SendMessageInput input) + { + List lines = new ArrayList<>(); + if(input.getSubject() != null && !input.getSubject().isBlank()) + { + lines.add("*" + input.getSubject().trim() + "*"); + } + if(input.getContentList() != null) + { + for(Content content : input.getContentList()) + { + if(content != null && content.getBody() != null && !content.getBody().isBlank()) + { + lines.add(content.getBody()); + } + } + } + return (String.join("\n", lines)); + } + + + + /******************************************************************************* + ** Walk a Party or MultiParty (nested ones too) into plain addresses. + *******************************************************************************/ + private static void collect(Party party, List addresses) + { + if(party == null) + { + return; + } + if(party instanceof MultiParty multi) + { + if(multi.getPartyList() != null) + { + multi.getPartyList().forEach(child -> collect(child, addresses)); + } + return; + } + if(party.getAddress() != null && !party.getAddress().isBlank()) + { + addresses.add(party.getAddress()); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProviderMetaData.java b/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProviderMetaData.java new file mode 100644 index 0000000..7dbb65c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProviderMetaData.java @@ -0,0 +1,51 @@ +/* + * 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.bridge; + + +import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData; +import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher; + + +/******************************************************************************* + ** The core messaging provider metadata for the Slack outbox. Loading this class + ** registers the provider type with core's dispatcher, the same pattern core's + ** own providers use. + *******************************************************************************/ +public class SlackOutboxMessagingProviderMetaData extends QMessagingProviderMetaData +{ + public static final String TYPE = "QBIT_SLACK"; + + static + { + QMessagingProviderDispatcher.registerMessagingProvider(new SlackOutboxMessagingProvider()); + } + + + + /******************************************************************************* + ** Constructor: the type is fixed. + *******************************************************************************/ + public SlackOutboxMessagingProviderMetaData() + { + setType(TYPE); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/SlackAlerts.java b/src/main/java/com/kingsrook/qbits/slack/delivery/SlackAlerts.java new file mode 100644 index 0000000..d4f78aa --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/SlackAlerts.java @@ -0,0 +1,135 @@ +/* + * 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.delivery; + + +import java.util.List; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; + + +/******************************************************************************* + ** The alerts and notifications the dispatcher raises through core QAlerts + ** (docs/DESIGN.md section 5.3), so there is no dependency on qbit-alerts. The + ** signals carry no message text, address or token: only ids and fixed words. + ** Core guards a missing or failing sink. + ** + ** The alert type names follow core's dotted pattern, which has no underscore, + ** so the dead-letter type is slack.message.dead-letter. + *******************************************************************************/ +public final class SlackAlerts +{ + public static final String UNAUTHORIZED = "slack.delivery.unauthorized"; + public static final String UNHEALTHY = "slack.workspace.unhealthy"; + public static final String DEAD_LETTER = "slack.message.dead-letter"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackAlerts() + { + } + + + + /******************************************************************************* + ** The alert types, for the producer. + *******************************************************************************/ + public static List types() + { + return (List.of( + new QAlertTypeMetaData().withName(UNAUTHORIZED).withLabel("Slack delivery not authorized").withDefaultSeverity(AlertSeverity.WARNING) + .withSubjectTableName(SlackTables.MESSAGE).withVisibilityPermission("slack.view").withSourceName("slack"), + new QAlertTypeMetaData().withName(UNHEALTHY).withLabel("Slack workspace unhealthy").withDefaultSeverity(AlertSeverity.ERROR) + .withSubjectTableName(SlackTables.WORKSPACE).withVisibilityPermission("slack.admin").withSourceName("slack"), + new QAlertTypeMetaData().withName(DEAD_LETTER).withLabel("Slack dead letters").withDefaultSeverity(AlertSeverity.WARNING) + .withSubjectTableName(SlackTables.MESSAGE).withVisibilityPermission("slack.view").withSourceName("slack"))); + } + + + + /******************************************************************************* + ** A delivery failed authorization: one alert per owner per day. + *******************************************************************************/ + public static void unauthorized(String ownerUserId) + { + long day = SlackRuntime.clock().instant().getEpochSecond() / 86400; + QAlerts.raise(new AlertSignal(UNAUTHORIZED).withDedupKey("owner:" + ownerUserId + ":" + day) + .withTitle("A Slack delivery was not authorized") + .withSummary("A scheduled or queued Slack message could not be sent for its owner. Review the Slack messages.")); + } + + + + /******************************************************************************* + ** A workspace has failed ten sends in a row. + *******************************************************************************/ + public static void unhealthy(Integer workspaceId) + { + QAlerts.raise(new AlertSignal(UNHEALTHY).withDedupKey("workspace:" + workspaceId).withTitle("A Slack workspace is unhealthy") + .withSummary("Slack sends have failed repeatedly for a workspace. Review the Slack workspaces and messages.")); + } + + + + /******************************************************************************* + ** A send succeeded after the workspace was unhealthy. + *******************************************************************************/ + public static void recovered(Integer workspaceId) + { + QAlerts.resolve(UNHEALTHY, "workspace:" + workspaceId, "A Slack message was delivered"); + } + + + + /******************************************************************************* + ** A message was dead-lettered: one alert per workspace per hour. + *******************************************************************************/ + public static void deadLetter(Integer workspaceId) + { + long hour = SlackRuntime.clock().instant().getEpochSecond() / 3600; + QAlerts.raise(new AlertSignal(DEAD_LETTER).withDedupKey("workspace:" + workspaceId + ":" + hour).withTitle("A Slack message was dead-lettered") + .withSummary("A Slack message could not be delivered. Review the Slack messages.")); + } + + + + /******************************************************************************* + ** Tell the owner, in their notifications, that a delivery failed and why. + ** The reason is one of the qbit's own fixed texts, never a Slack payload. + *******************************************************************************/ + public static void notifyOwner(String ownerUserId, String channelLabel, String reason) + { + QAlerts.notify(new NotificationSignal("Your Slack delivery failed") + .withBody("Your Slack delivery to " + (channelLabel == null ? "Slack" : channelLabel) + " failed: " + reason + ". Reconnect Slack.") + .withAudience(NotificationAudience.user(ownerUserId)) + .withCategory(NotificationCategory.SYSTEM)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDispatcher.java b/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDispatcher.java new file mode 100644 index 0000000..943aad1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDispatcher.java @@ -0,0 +1,795 @@ +/* + * 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.delivery; + + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.metadata.SlackEnums; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.security.AuthorizedSend; +import com.kingsrook.qbits.slack.security.SlackAuthorization; +import com.kingsrook.qbits.slack.security.SlackAuthorizer; +import com.kingsrook.qbits.slack.security.SlackRedaction; +import com.kingsrook.qbits.slack.security.SlackRefusal; +import com.kingsrook.qbits.slack.security.SlackTokens; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qbits.slack.transport.SlackClient; +import com.kingsrook.qbits.slack.transport.SlackErrorClassifier; +import com.kingsrook.qbits.slack.transport.SlackResponse; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONArray; +import org.json.JSONObject; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** The Slack outbox dispatcher (docs/DESIGN.md sections 4.1, 5.2 and 5.3), run + ** by a scheduled process. It mirrors qbit-email's: a claim, a lease, attempt + ** rows, and no network call inside a database transaction. + ** + ** For each due message: + ** 1. Client-side limits (one post a second per channel, ratePerMinute per + ** workspace, one probe per five minutes for an unhealthy workspace). Waiting + ** here leaves the message untouched: it isn't an attempt. + ** 2. Claim. Inside a transaction that locks the message row, the runner inserts + ** a slack_message_attempt (messageId, attemptNumber). That pair is unique, + ** so one runner wins. The message becomes SENDING with a lease. + ** 3. Re-authorize as the owner (SlackAuthorizer), exactly as at queue time, + ** because access may have changed. Only then is the one chosen row's token + ** read and decrypted. A refusal is FAILED_AUTH, the owner is notified, and + ** nothing is posted. Another row's token is never used. + ** 4. Expired lease: the previous runner may have posted and died. Before + ** posting again, conversations.history is searched for our message metadata + ** (event_payload.id equal to the message id). Found: record that ts as SENT. + ** History unreadable: RETRY_WAIT, and nothing is posted. + ** 5. Post (chat.postMessage) or update (chat.update), then record the outcome. + ** + ** The attempt number is the count of attempt rows plus one, so it only grows. + ** The message's attemptCount is the budget: how many attempts have been spent. + ** A 429 hands the spent attempt back: attemptCount returns to what it was + ** before the claim, the attempt row is marked RATE_LIMITED, and the message + ** waits for Retry-After (at most 15 minutes). The next claim still gets a new + ** attempt number, so the unique pair is never reused, and maxAttempts is never + ** consumed by rate limiting. + *******************************************************************************/ +public class SlackDispatcher +{ + private static final QLogger LOG = QLogger.getLogger(SlackDispatcher.class); + + public static final List DUE = List.of("QUEUED", "RETRY_WAIT"); + + private static final int MAX_HISTORY_PAGES = 5; + private static final int UNHEALTHY_AFTER = 10; + private static final int MAX_RETRY_AFTER_SECONDS = 900; + private static final int DEFAULT_RATE_PER_MINUTE = 50; + private static final int HISTORY_LOOKBACK_SECONDS = 120; + + private final SlackAuthorizer authorizer = new SlackAuthorizer(); + private final String runner = SlackRuntime.nodeId() + "/" + UUID.randomUUID(); + + + + /******************************************************************************* + ** The result of one message attempt. + *******************************************************************************/ + public enum SendResult + { + SENT, + RETRY_WAIT, + DEAD_LETTER, + FAILED_AUTH, + RATE_LIMITED, + NOT_SENT + } + + + + /******************************************************************************* + ** Counts from one dispatch pass. + *******************************************************************************/ + public record RunSummary(int sent, int failed, int skipped) + { + } + + + + /******************************************************************************* + ** What a successful claim returns: the message as claimed, the attempt row, + ** the budget spent before this claim, and whether a lease had expired (so a + ** reconcile is needed before posting). + *******************************************************************************/ + private record Claim(QRecord message, Integer attemptId, int previousCount, boolean reconcile) + { + } + + + + /******************************************************************************* + ** How Slack answered, in the terms the dispatcher acts on. + *******************************************************************************/ + private enum Result + { + SENT, + RATE_LIMITED, + TRANSIENT, + PERMANENT, + AUTH + } + + + + /******************************************************************************* + ** The result of working on a claimed message. reason is fit for a person (and + ** for lastError); it never holds a token or a Slack payload. + *******************************************************************************/ + private record Outcome(Result result, String code, String reason, Integer httpStatus, Integer retryAfter, String ts, String channelId, SlackEnums.PostAs postedAs, Integer connectionId, boolean revoke) + { + } + + + + /******************************************************************************* + ** Dispatch the due messages, up to the configured batch size. + *******************************************************************************/ + public RunSummary runOnce() throws QException + { + SlackQBitConfig config = SlackRuntime.config(); + if(config == null) + { + return (new RunSummary(0, 0, 0)); + } + + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, "QUEUED", "RETRY_WAIT", "SENDING")) + .withCriteria(new QFilterCriteria("nextAttemptDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, SlackRuntime.clock().instant())) + .withOrderBy(new QFilterOrderBy("id")).withLimit(config.getBatchSize()); + + int sent = 0; + int failed = 0; + int skipped = 0; + for(QRecord row : SlackStore.query(SlackTables.MESSAGE, filter)) + { + Integer id = row.getValueInteger("id"); + try + { + SendResult result = attempt(id, false); + if(result == SendResult.SENT) + { + sent++; + } + else if(result == SendResult.NOT_SENT || result == SendResult.RATE_LIMITED) + { + skipped++; + } + else + { + failed++; + } + } + catch(QException | RuntimeException e) + { + failed++; + LOG.warn("Slack attempt failed", logPair("messageId", id), logPair("error", SlackRedaction.scrub(e.getMessage()))); + } + } + return (new RunSummary(sent, failed, skipped)); + } + + + + /******************************************************************************* + ** Send one message now, ignoring its due time (but not the limits or lease). + *******************************************************************************/ + public SendResult sendNow(Integer id) throws QException + { + return (attempt(id, true)); + } + + + + /******************************************************************************* + ** Limits, claim, work, and record the outcome. + *******************************************************************************/ + protected SendResult attempt(Integer id, boolean immediate) throws QException + { + SlackQBitConfig config = SlackRuntime.config(); + QRecord initial = config == null ? null : SlackStore.get(SlackTables.MESSAGE, id); + if(initial == null) + { + return (SendResult.NOT_SENT); + } + + Integer workspaceId = initial.getValueInteger("workspaceId"); + QRecord workspace = SlackStore.get(SlackTables.WORKSPACE, workspaceId); + Instant now = SlackRuntime.clock().instant(); + String channelKey = initial.getValueString("slackChannelId") != null ? initial.getValueString("slackChannelId") : "dm:" + initial.getValueString("ownerUserId"); + boolean unhealthy = workspace != null && SlackEnums.HealthStatus.UNHEALTHY.name().equals(workspace.getValueString("health")); + + if(!immediate && workspace != null) + { + Integer rate = workspace.getValueInteger("ratePerMinute"); + if(SlackRuntime.limiter().waitMillis(workspaceId, channelKey, rate == null ? DEFAULT_RATE_PER_MINUTE : rate, now) > 0 + || (unhealthy && SlackRuntime.limiter().probeWaitMillis(workspaceId, now) > 0)) + { + return (SendResult.NOT_SENT); + } + } + + Claim claim = claim(id, immediate, config); + if(claim == null) + { + return (SendResult.NOT_SENT); + } + + SlackRuntime.limiter().record(workspaceId, channelKey, now); + if(unhealthy) + { + SlackRuntime.limiter().recordProbe(workspaceId, now); + } + + Outcome outcome; + try + { + outcome = work(config, claim); + } + catch(QException | RuntimeException e) + { + LOG.warn("Slack delivery work failed", logPair("messageId", id), logPair("error", SlackRedaction.scrub(e.getMessage()))); + outcome = new Outcome(Result.TRANSIENT, "dispatch_error", "An unexpected error stopped the delivery", null, null, null, null, null, null, false); + } + return (finish(config, claim, outcome)); + } + + + + /******************************************************************************* + ** Claim a message: lock its row, check it is still due (or its lease + ** expired), insert the unique attempt row, and set SENDING with a lease. The + ** transaction is committed before any network call. + *******************************************************************************/ + private Claim claim(Integer id, boolean immediate, SlackQBitConfig config) throws QException + { + try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(SlackTables.MESSAGE))) + { + Instant time = SlackRuntime.clock().instant(); + + /////////////////////////////////////////////////////////////////////// + // a write takes the row lock: a second runner waits here, then sees // + // SENDING with a live lease // + /////////////////////////////////////////////////////////////////////// + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("modifyDate", time), tx); + QRecord message = SlackStore.get(SlackTables.MESSAGE, id, tx); + if(message == null) + { + return (null); + } + + String status = message.getValueString("status"); + Integer spent = message.getValueInteger("attemptCount"); + int previous = spent == null ? 0 : spent; + boolean reconcile = false; + Integer workspaceId = message.getValueInteger("workspaceId"); + + if(SlackEnums.MessageStatus.SENDING.name().equals(status)) + { + Instant lease = message.getValueInstant("leaseExpiresDate"); + if(lease != null && lease.isAfter(time)) + { + return (null); + } + reconcile = true; + expire(id, tx, time); + + if(previous >= config.getMaxAttempts()) + { + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", SlackEnums.MessageStatus.DEAD_LETTER.name()) + .withValue("deadLetterDate", time).withValue("leaseExpiresDate", null).withValue("nextAttemptDate", null).withValue("lastError", "Attempt lease expired"), tx); + tx.commit(); + alert(() -> SlackAlerts.deadLetter(workspaceId)); + return (null); + } + } + else if(!DUE.contains(status)) + { + return (null); + } + + Instant next = message.getValueInstant("nextAttemptDate"); + if(!immediate && next != null && next.isAfter(time)) + { + return (null); + } + + int attemptNumber = SlackStore.count(SlackTables.ATTEMPT, SlackStore.equal("messageId", id), tx) + 1; + Instant lease = time.plusSeconds(config.getLeaseSeconds()); + QRecord inserted; + try + { + inserted = SlackStore.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", id).withValue("attemptNumber", attemptNumber) + .withValue("outcome", SlackEnums.AttemptOutcome.CLAIMED.name()).withValue("claimedBy", runner).withValue("startDate", time), tx); + } + catch(QException e) + { + ///////////////////////////////////////////////////////////////////// + // the unique (messageId, attemptNumber) pair: another runner won // + ///////////////////////////////////////////////////////////////////// + return (null); + } + if(inserted.getValueInteger("id") == null || (inserted.getErrors() != null && !inserted.getErrors().isEmpty())) + { + return (null); + } + + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", SlackEnums.MessageStatus.SENDING.name()) + .withValue("attemptCount", previous + 1).withValue("leaseExpiresDate", lease).withValue("nextAttemptDate", lease), tx); + tx.commit(); + return (new Claim(message, inserted.getValueInteger("id"), previous, reconcile)); + } + } + + + + /******************************************************************************* + ** Mark the abandoned attempt rows of a message whose lease expired. + *******************************************************************************/ + private static void expire(Integer messageId, QBackendTransaction tx, Instant now) throws QException + { + for(QRecord attempt : SlackStore.query(SlackTables.ATTEMPT, SlackStore.equal("messageId", messageId) + .withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, SlackEnums.AttemptOutcome.CLAIMED.name())), tx)) + { + SlackStore.update(SlackTables.ATTEMPT, new QRecord().withValue("id", attempt.getValueInteger("id")) + .withValue("outcome", SlackEnums.AttemptOutcome.LEASE_EXPIRED.name()).withValue("endDate", now).withValue("error", "Attempt lease expired"), tx); + } + } + + + + /******************************************************************************* + ** Everything between the claim and the record of the outcome: authorize as + ** the owner, read the one token, find the channel, reconcile after a crash, + ** and call Slack. + *******************************************************************************/ + private Outcome work(SlackQBitConfig config, Claim claim) throws QException + { + QRecord message = claim.message(); + Integer messageId = message.getValueInteger("id"); + Integer workspaceId = message.getValueInteger("workspaceId"); + String owner = message.getValueString("ownerUserId"); + boolean update = SlackEnums.Operation.UPDATE.name().equals(message.getValueString("operation")); + + if(SlackEnums.Operation.FILE.name().equals(message.getValueString("operation"))) + { + return (new Outcome(Result.PERMANENT, "unsupported", "File messages aren't supported yet", null, null, null, null, null, null, false)); + } + + /////////////////////////////////////////////////////////////////////////////// + // authorize again, as the owner: access may have changed since queue time. // + // the posting identity chosen at queue time is kept (an update must keep it, // + // because Slack only lets a token edit its own messages), so a lost identity // + // is a failed authorization rather than a silent switch to another token // + /////////////////////////////////////////////////////////////////////////////// + String queuedIdentity = message.getValueString("postAs"); + SlackEnums.PostAs fixedIdentity = queuedIdentity == null ? null : SlackEnums.PostAs.valueOf(queuedIdentity); + SlackAuthorization authorization = SlackStore.asSystem(() -> authorizer.authorize(new SlackAuthorizer.Request() + .withOwnerUserId(owner).withAddress(message.getValueString("targetRef")).withWorkspaceId(workspaceId).withPostAs(fixedIdentity))); + if(!authorization.isAuthorized()) + { + return (refusalOutcome(authorization.getRefusal())); + } + + AuthorizedSend send = authorization.getSend(); + if(!workspaceId.equals(send.workspaceId())) + { + return (authFailure("workspace_changed", "That Slack workspace is no longer available to you", null)); + } + + String token; + try + { + token = send.postAs() == SlackEnums.PostAs.BOT ? SlackTokens.botToken(config, workspaceId) : SlackTokens.userToken(config, send.connectionId()); + } + catch(QException e) + { + return (authFailure("token_unreadable", "Your Slack connection can't be used", send)); + } + + SlackClient client = SlackRuntime.client(config); + String channel = send.slackChannelId() != null ? send.slackChannelId() : message.getValueString("slackChannelId"); + if(channel == null) + { + SlackResponse open = client.conversationsOpen(token, send.slackUserId()); + if(!open.isOk() || open.getJson().optJSONObject("channel") == null) + { + return (failure(open, send, null)); + } + channel = open.getJson().getJSONObject("channel").optString("id", null); + } + + if(claim.reconcile() && !update) + { + Outcome reconciled = reconcile(client, token, channel, message, send); + if(reconciled != null) + { + return (reconciled); + } + } + + SlackResponse response = update + ? client.chatUpdate(token, channel, message.getValueString("slackTs"), message.getValueString("text"), message.getValueString("blocksJson")) + : client.chatPostMessage(token, channel, message.getValueString("text"), message.getValueString("blocksJson"), message.getValueString("threadTs"), messageId, message.getValueString("idempotencyKey")); + if(!response.isOk()) + { + return (failure(response, send, channel)); + } + + String ts = update ? message.getValueString("slackTs") : response.getString("ts"); + return (new Outcome(Result.SENT, null, null, response.getHttpStatus(), null, ts, response.getString("channel") == null ? channel : response.getString("channel"), send.postAs(), send.connectionId(), false)); + } + + + + /******************************************************************************* + ** After an expired lease: look in the channel's history for a message that + ** carries our id. Returns SENT with its ts when found, a failure when the + ** history can't be read (so nothing is posted blind), and null when it is + ** certain the message never went out. + *******************************************************************************/ + private Outcome reconcile(SlackClient client, String token, String channel, QRecord message, AuthorizedSend send) throws QException + { + List attempts = SlackStore.query(SlackTables.ATTEMPT, SlackStore.equal("messageId", message.getValueInteger("id")).withOrderBy(new QFilterOrderBy("startDate")).withLimit(1)); + Instant since = attempts.isEmpty() || attempts.get(0).getValueInstant("startDate") == null ? message.getValueInstant("createDate") : attempts.get(0).getValueInstant("startDate"); + String oldest = since == null ? null : String.valueOf(since.getEpochSecond() - HISTORY_LOOKBACK_SECONDS); + + String cursor = null; + for(int page = 0; page < MAX_HISTORY_PAGES; page++) + { + SlackResponse history = client.conversationsHistory(token, channel, oldest, cursor); + if(!history.isOk()) + { + /////////////////////////////////////////////////////////////////////// + // never post blind: any failure here is a wait, unless Slack says // + // the token is dead or asks us to slow down // + /////////////////////////////////////////////////////////////////////// + SlackErrorClassifier.Kind kind = SlackErrorClassifier.classify(history); + if(kind == SlackErrorClassifier.Kind.RATE_LIMITED || (kind == SlackErrorClassifier.Kind.AUTH && SlackErrorClassifier.revokesAccess(history.getError()))) + { + return (failure(history, send, channel)); + } + return (new Outcome(Result.TRANSIENT, "history_unavailable", "Slack history couldn't be read to confirm the earlier attempt", history.getHttpStatus(), null, null, channel, send.postAs(), send.connectionId(), false)); + } + + JSONArray messages = history.getJson().optJSONArray("messages"); + for(int i = 0; messages != null && i < messages.length(); i++) + { + JSONObject found = ours(messages.optJSONObject(i), message.getValueInteger("id")); + if(found != null) + { + return (new Outcome(Result.SENT, null, null, history.getHttpStatus(), null, found.optString("ts", null), channel, send.postAs(), send.connectionId(), false)); + } + } + + JSONObject metadata = history.getJson().optJSONObject("response_metadata"); + cursor = metadata == null ? null : metadata.optString("next_cursor", null); + if(cursor == null || cursor.isEmpty()) + { + break; + } + } + return (null); + } + + + + /******************************************************************************* + ** The history message if it carries our qqq_message metadata for this id. + *******************************************************************************/ + private static JSONObject ours(JSONObject message, Integer messageId) + { + JSONObject metadata = message == null ? null : message.optJSONObject("metadata"); + JSONObject payload = metadata == null || !SlackClient.EVENT_TYPE.equals(metadata.optString("event_type")) ? null : metadata.optJSONObject("event_payload"); + return (payload != null && String.valueOf(messageId).equals(String.valueOf(payload.opt("id"))) ? message : null); + } + + + + /******************************************************************************* + ** The outcome for a refused authorization at send time. A refusal that came + ** from a Slack outage is a retry or a wait, not a failure of the owner's + ** access; a channel that Slack says is gone is permanent; the rest is the + ** owner losing access. + *******************************************************************************/ + private static Outcome refusalOutcome(SlackRefusal refusal) + { + String code = refusal.slackError() != null ? refusal.slackError() : refusal.reason().name().toLowerCase(java.util.Locale.ROOT); + if(refusal.reason() == SlackRefusal.Reason.SLACK_ERROR && refusal.slackKind() == SlackErrorClassifier.Kind.RATE_LIMITED) + { + return (new Outcome(Result.RATE_LIMITED, code, refusal.message(), 429, refusal.retryAfterSeconds(), null, null, null, null, false)); + } + if(refusal.reason() == SlackRefusal.Reason.SLACK_ERROR) + { + return (new Outcome(Result.TRANSIENT, code, refusal.message(), null, null, null, null, null, null, false)); + } + if(refusal.slackKind() == SlackErrorClassifier.Kind.PERMANENT) + { + return (new Outcome(Result.PERMANENT, code, refusal.message(), null, null, null, null, null, null, false)); + } + return (new Outcome(Result.AUTH, code, refusal.message(), null, null, null, null, null, null, false)); + } + + + + /******************************************************************************* + ** An authorization failure that wasn't a Slack answer. + *******************************************************************************/ + private static Outcome authFailure(String code, String reason, AuthorizedSend send) + { + return (new Outcome(Result.AUTH, code, reason, null, null, null, null, send == null ? null : send.postAs(), send == null ? null : send.connectionId(), false)); + } + + + + /******************************************************************************* + ** The outcome for a failed Slack call, by the classifier's verdict. + *******************************************************************************/ + private static Outcome failure(SlackResponse response, AuthorizedSend send, String channel) + { + SlackErrorClassifier.Kind kind = SlackErrorClassifier.classify(response); + String code = response.getError(); + SlackEnums.PostAs as = send == null ? null : send.postAs(); + Integer conn = send == null ? null : send.connectionId(); + + if(kind == SlackErrorClassifier.Kind.RATE_LIMITED) + { + return (new Outcome(Result.RATE_LIMITED, "ratelimited", "Slack asked us to wait", response.getHttpStatus(), response.getRetryAfterSeconds(), null, channel, as, conn, false)); + } + if(kind == SlackErrorClassifier.Kind.TRANSIENT) + { + return (new Outcome(Result.TRANSIENT, code, "Slack was temporarily unavailable (" + code + ")", response.getHttpStatus(), null, null, channel, as, conn, false)); + } + if(kind == SlackErrorClassifier.Kind.AUTH) + { + boolean revoke = SlackErrorClassifier.revokesAccess(code); + return (new Outcome(Result.AUTH, code, revoke ? "Slack access was revoked" : "Slack refused the request (" + code + ")", response.getHttpStatus(), null, null, channel, as, conn, revoke)); + } + return (new Outcome(Result.PERMANENT, code, "Slack rejected the message (" + code + ")", response.getHttpStatus(), null, null, channel, as, conn, false)); + } + + + + /******************************************************************************* + ** Record the outcome in one transaction, fenced by the claim (a late finish + ** of an expired attempt changes nothing), then do the side effects that must + ** only follow a commit: alerts, notifications and revocation. + *******************************************************************************/ + private SendResult finish(SlackQBitConfig config, Claim claim, Outcome outcome) throws QException + { + List afterCommit = new ArrayList<>(); + SendResult sendResult; + QRecord message = claim.message(); + Integer id = message.getValueInteger("id"); + Integer workspaceId = message.getValueInteger("workspaceId"); + String owner = message.getValueString("ownerUserId"); + + try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(SlackTables.MESSAGE))) + { + QRecord current = SlackStore.get(SlackTables.MESSAGE, id, tx); + int counted = claim.previousCount() + 1; + if(current == null || !SlackEnums.MessageStatus.SENDING.name().equals(current.getValueString("status")) || !Integer.valueOf(counted).equals(current.getValueInteger("attemptCount"))) + { + return (SendResult.NOT_SENT); + } + + Instant now = SlackRuntime.clock().instant(); + String text = SlackRedaction.scrub(outcome.reason()); + + SlackStore.update(SlackTables.ATTEMPT, new QRecord().withValue("id", claim.attemptId()).withValue("outcome", attemptOutcome(outcome.result())) + .withValue("errorCode", outcome.code()).withValue("httpStatus", outcome.httpStatus()).withValue("retryAfterSeconds", outcome.retryAfter()) + .withValue("postedAs", outcome.postedAs() == null ? null : outcome.postedAs().name()).withValue("endDate", now).withValue("error", text), tx); + + QRecord update = new QRecord().withValue("id", id).withValue("leaseExpiresDate", null).withValue("lastErrorCode", outcome.code()).withValue("lastError", text); + if(outcome.result() == Result.SENT) + { + sendResult = SendResult.SENT; + update.withValue("status", SlackEnums.MessageStatus.SENT.name()).withValue("sentDate", now).withValue("slackTs", outcome.ts()).withValue("slackChannelId", outcome.channelId()) + .withValue("nextAttemptDate", null).withValue("lastErrorCode", null).withValue("lastError", null); + if(outcome.postedAs() != null) + { + update.withValue("postAs", outcome.postedAs().name()).withValue("connectionId", outcome.connectionId()); + } + } + else if(outcome.result() == Result.RATE_LIMITED) + { + /////////////////////////////////////////////////////////////////////// + // not an attempt: hand the spent attempt back, and wait // + /////////////////////////////////////////////////////////////////////// + sendResult = SendResult.RATE_LIMITED; + int seconds = Math.max(1, Math.min(MAX_RETRY_AFTER_SECONDS, outcome.retryAfter() == null ? 30 : outcome.retryAfter())); + update.withValue("status", SlackEnums.MessageStatus.RETRY_WAIT.name()).withValue("attemptCount", claim.previousCount()).withValue("nextAttemptDate", now.plusSeconds(seconds)) + .withValue("lastError", "Slack asked us to wait " + seconds + " seconds"); + } + else if(outcome.result() == Result.TRANSIENT && counted < config.getMaxAttempts()) + { + sendResult = SendResult.RETRY_WAIT; + update.withValue("status", SlackEnums.MessageStatus.RETRY_WAIT.name()).withValue("nextAttemptDate", now.plusSeconds(60L * config.retryMinutesAfterAttempt(counted))); + } + else if(outcome.result() == Result.AUTH) + { + sendResult = SendResult.FAILED_AUTH; + update.withValue("status", SlackEnums.MessageStatus.FAILED_AUTH.name()).withValue("nextAttemptDate", null); + } + else + { + sendResult = SendResult.DEAD_LETTER; + update.withValue("status", SlackEnums.MessageStatus.DEAD_LETTER.name()).withValue("deadLetterDate", now).withValue("nextAttemptDate", null); + } + SlackStore.update(SlackTables.MESSAGE, update, tx); + + /////////////////////////////////////////////////////////////////////// + // threads: replies waiting on this message are released, or dropped // + /////////////////////////////////////////////////////////////////////// + if(sendResult == SendResult.SENT || sendResult == SendResult.DEAD_LETTER || sendResult == SendResult.FAILED_AUTH) + { + for(QRecord child : SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", id) + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, SlackEnums.MessageStatus.WAITING_PARENT.name())), tx)) + { + QRecord childUpdate = new QRecord().withValue("id", child.getValueInteger("id")); + if(sendResult == SendResult.SENT) + { + childUpdate.withValue("status", SlackEnums.MessageStatus.QUEUED.name()).withValue("threadTs", outcome.ts()).withValue("nextAttemptDate", now); + } + else + { + childUpdate.withValue("status", SlackEnums.MessageStatus.CANCELLED.name()).withValue("lastError", "The message this replies to was not delivered").withValue("nextAttemptDate", null); + } + SlackStore.update(SlackTables.MESSAGE, childUpdate, tx); + } + } + + trackHealth(workspaceId, outcome, tx, afterCommit); + tx.commit(); + } + + /////////////////////////////////////////////////////////////////////////// + // only now, after the commit: revoke, alert and tell the owner. none of // + // it can change what was recorded // + /////////////////////////////////////////////////////////////////////////// + if(outcome.result() == Result.AUTH) + { + if(outcome.revoke()) + { + afterCommit.add(() -> + { + try + { + SlackAuthorizer.markRevoked(outcome.postedAs(), workspaceId, outcome.connectionId()); + } + catch(QException e) + { + LOG.warn("Could not mark a Slack connection revoked", logPair("messageId", id)); + } + }); + } + if(message.getValueString("slackChannelId") != null) + { + SlackRuntime.membership().invalidate(workspaceId, message.getValueString("slackChannelId")); + } + afterCommit.add(() -> SlackAlerts.unauthorized(owner)); + if(!SlackAuthorizer.isOwnerless(owner)) + { + String reason = outcome.reason() == null ? "access was lost" : outcome.reason().replaceAll("\\.$", ""); + afterCommit.add(() -> SlackAlerts.notifyOwner(owner, message.getValueString("channelLabel"), reason)); + } + } + if(sendResult == SendResult.DEAD_LETTER) + { + afterCommit.add(() -> SlackAlerts.deadLetter(workspaceId)); + } + afterCommit.forEach(SlackDispatcher::alert); + + LOG.info("Slack attempt completed", logPair("messageId", id), logPair("status", sendResult.name())); + return (sendResult); + } + + + + /******************************************************************************* + ** Workspace health: ten transient failures in a row make it UNHEALTHY, and a + ** success makes it HEALTHY again. Other failures leave the streak alone. + *******************************************************************************/ + private static void trackHealth(Integer workspaceId, Outcome outcome, QBackendTransaction tx, List afterCommit) throws QException + { + QRecord workspace = SlackStore.get(SlackTables.WORKSPACE, workspaceId, tx); + if(workspace == null) + { + return; + } + + boolean wasUnhealthy = SlackEnums.HealthStatus.UNHEALTHY.name().equals(workspace.getValueString("health")); + Integer streak = workspace.getValueInteger("consecutiveFailures"); + int failures = streak == null ? 0 : streak; + + if(outcome.result() == Result.SENT && (failures > 0 || wasUnhealthy)) + { + SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", workspaceId).withValue("consecutiveFailures", 0).withValue("health", SlackEnums.HealthStatus.HEALTHY.name()), tx); + if(wasUnhealthy) + { + afterCommit.add(() -> SlackAlerts.recovered(workspaceId)); + } + } + else if(outcome.result() == Result.TRANSIENT) + { + boolean unhealthy = failures + 1 >= UNHEALTHY_AFTER; + SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", workspaceId).withValue("consecutiveFailures", failures + 1) + .withValue("health", unhealthy || wasUnhealthy ? SlackEnums.HealthStatus.UNHEALTHY.name() : SlackEnums.HealthStatus.HEALTHY.name()), tx); + if(unhealthy && !wasUnhealthy) + { + afterCommit.add(() -> SlackAlerts.unhealthy(workspaceId)); + } + } + } + + + + /******************************************************************************* + ** The attempt row's outcome for a result. + *******************************************************************************/ + private static String attemptOutcome(Result result) + { + return (switch(result) + { + case SENT -> SlackEnums.AttemptOutcome.SENT.name(); + case RATE_LIMITED -> SlackEnums.AttemptOutcome.RATE_LIMITED.name(); + case TRANSIENT -> SlackEnums.AttemptOutcome.TRANSIENT_FAILURE.name(); + case PERMANENT -> SlackEnums.AttemptOutcome.PERMANENT_FAILURE.name(); + case AUTH -> SlackEnums.AttemptOutcome.AUTH_FAILURE.name(); + }); + } + + + + /******************************************************************************* + ** Alerts and notifications must never change the result of a committed + ** delivery. + *******************************************************************************/ + private static void alert(Runnable notification) + { + try + { + notification.run(); + } + catch(RuntimeException e) + { + LOG.warn("Slack alert unavailable"); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackEnums.java b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackEnums.java new file mode 100644 index 0000000..cda8b09 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackEnums.java @@ -0,0 +1,198 @@ +/* + * 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.metadata; + + +import java.util.Locale; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum; + + +/******************************************************************************* + ** Enumerated database values and their display labels (docs/DESIGN.md + ** sections 3, 5 and 8). Each is registered as a possible-value source by + ** SlackTables. + *******************************************************************************/ +public final class SlackEnums +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackEnums() + { + } + + + + /******************************************************************************* + ** Shared id and label rules: the stored value is the constant name, and the + ** label is its plain English form. + *******************************************************************************/ + public interface SlackEnum extends PossibleValueEnum + { + /******************************************************************************* + ** The constant name (satisfied by every enum). + *******************************************************************************/ + String name(); + + + + /******************************************************************************* + ** Stable database value. + *******************************************************************************/ + @Override + default String getPossibleValueId() + { + return (name()); + } + + + + /******************************************************************************* + ** Plain display label. + *******************************************************************************/ + @Override + default String getPossibleValueLabel() + { + return (name().charAt(0) + name().substring(1).toLowerCase(Locale.ROOT).replace('_', ' ')); + } + } + + + + /******************************************************************************* + ** Workspace status. + *******************************************************************************/ + public enum WorkspaceStatus implements SlackEnum + { + ACTIVE, + REVOKED, + UNINSTALLED, + ERROR + } + + + + /******************************************************************************* + ** Connection status. + *******************************************************************************/ + public enum ConnectionStatus implements SlackEnum + { + ACTIVE, + REVOKED, + ERROR + } + + + + /******************************************************************************* + ** Readiness check result (section 8). + *******************************************************************************/ + public enum ReadinessStatus implements SlackEnum + { + UNKNOWN, + OK, + WARN, + FAIL + } + + + + /******************************************************************************* + ** Workspace delivery health (section 5.3). + *******************************************************************************/ + public enum HealthStatus implements SlackEnum + { + HEALTHY, + UNHEALTHY + } + + + + /******************************************************************************* + ** Outbox message status (section 5.1). + *******************************************************************************/ + public enum MessageStatus implements SlackEnum + { + QUEUED, + WAITING_PARENT, + SENDING, + RETRY_WAIT, + SENT, + DEAD_LETTER, + FAILED_AUTH, + REFUSED, + SUPPRESSED, + CANCELLED + } + + + + /******************************************************************************* + ** What the dispatcher does with a message (section 5.1). + *******************************************************************************/ + public enum Operation implements SlackEnum + { + POST, + UPDATE, + FILE + } + + + + /******************************************************************************* + ** Kind of target a message is addressed to (section 5.1). + *******************************************************************************/ + public enum TargetType implements SlackEnum + { + ME, + USER, + CHANNEL, + REGISTERED_CHANNEL, + USER_GROUP + } + + + + /******************************************************************************* + ** Posting identity (section 4.1). + *******************************************************************************/ + public enum PostAs implements SlackEnum + { + BOT, + USER + } + + + + /******************************************************************************* + ** Result of one delivery attempt (section 5.2 and 5.3). + *******************************************************************************/ + public enum AttemptOutcome implements SlackEnum + { + CLAIMED, + SENT, + RATE_LIMITED, + TRANSIENT_FAILURE, + PERMANENT_FAILURE, + AUTH_FAILURE, + LEASE_EXPIRED + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java new file mode 100644 index 0000000..590f99c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java @@ -0,0 +1,568 @@ +/* + * 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.metadata; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; +import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; +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. + ** + ** Security, in one place: + ** - The host's tenant lock (config securityFields and recordSecurityLocks) goes + ** on slack_workspace, and on slack_template (where a null tenant means shared). + ** Every other table inherits it from its workspace through a join lock, so no + ** child can be read or written across tenants. + ** - slack_connection is owner-locked on userId, and slack_message on + ** ownerUserId (slack_user key type). Attempts inherit the message's owner and + ** tenant through the message. + ** - bot_token and user_token are PASSWORD fields that are hidden, sensitive and + ** not editable, sit in no section, and carry a FieldSecurityLock nobody can + ** open (see tokenField). + *******************************************************************************/ +public final class SlackTables +{ + public static final String WORKSPACE = "slack_workspace"; + public static final String CONNECTION = "slack_connection"; + public static final String CHANNEL = "slack_channel"; + 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 ATTEMPT = "slack_message_attempt"; + + public static final List ALL = List.of(WORKSPACE, CONNECTION, CHANNEL, USER_GROUP, TEMPLATE, MESSAGE, ATTEMPT); + + ///////////////////////////////////////////////////////////////////////////// + // 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 String JOIN_WORKSPACE_CONNECTION = WORKSPACE + "_join_" + CONNECTION; + public static final String JOIN_WORKSPACE_CHANNEL = WORKSPACE + "_join_" + CHANNEL; + public static final String JOIN_WORKSPACE_USER_GROUP = WORKSPACE + "_join_" + USER_GROUP; + public static final String JOIN_WORKSPACE_MESSAGE = WORKSPACE + "_join_" + MESSAGE; + public static final String JOIN_MESSAGE_ATTEMPT = MESSAGE + "_join_" + ATTEMPT; + public static final String JOIN_MESSAGE_PARENT_CHILD = MESSAGE + "_join_replies"; + + public static final String BOT_TOKEN = "botToken"; + public static final String USER_TOKEN = "userToken"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackTables() + { + } + + + + /******************************************************************************* + ** Permission base shared by every table. + *******************************************************************************/ + public static QPermissionRules permissionRules() + { + return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("slack")); + } + + + + /******************************************************************************* + ** All enum and table possible-value sources. + *******************************************************************************/ + public static List possibleValueSources() + { + List result = new ArrayList<>(); + result.add(QPossibleValueSource.newForEnum("slack_workspace_status", SlackEnums.WorkspaceStatus.values())); + result.add(QPossibleValueSource.newForEnum("slack_connection_status", SlackEnums.ConnectionStatus.values())); + result.add(QPossibleValueSource.newForEnum("slack_readiness_status", SlackEnums.ReadinessStatus.values())); + result.add(QPossibleValueSource.newForEnum("slack_health_status", SlackEnums.HealthStatus.values())); + result.add(QPossibleValueSource.newForEnum("slack_message_status", SlackEnums.MessageStatus.values())); + 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_attempt_outcome", SlackEnums.AttemptOutcome.values())); + + for(String name : ALL) + { + QPossibleValueSource source = QPossibleValueSource.newForTable(name); + switch(name) + { + case WORKSPACE -> source.withSearchFields(List.of("teamName", "teamId")).withOrderByField("id"); + case CHANNEL, USER_GROUP, TEMPLATE -> source.withSearchFields(List.of("label", "name")).withOrderByField("id"); + default -> source.withOrderByField("id"); + } + result.add(source); + } + return (result); + } + + + + /******************************************************************************* + ** The tables, with the host's tenant lock and the owner locks applied. + *******************************************************************************/ + public static List tables(SlackQBitConfig config) + { + List tables = new ArrayList<>(); + tables.add(workspace(config)); + tables.add(connection(config)); + tables.add(channel(config)); + tables.add(userGroup(config)); + tables.add(template(config)); + tables.add(message(config)); + tables.add(attempt(config)); + + for(QTableMetaData table : tables) + { + switch(table.getName()) + { + case WORKSPACE -> addTenantLocks(table, config, true, null); + case TEMPLATE -> addTenantLocks(table, config, false, null); + 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 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")); + } + + if(SYSTEM_WRITTEN.contains(table.getName())) + { + table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE); + table.getFields().values().forEach(f -> f.setIsEditable(false)); + } + else if(List.of(WORKSPACE, CONNECTION).contains(table.getName())) + { + ////////////////////////////////////////////////////////////////// + // created only by the OAuth callback, never from a table form // + ////////////////////////////////////////////////////////////////// + table.withoutCapabilities(Capability.TABLE_INSERT); + } + + table.getFields().values().forEach(f -> f.setBackendName(QInstanceEnricher.inferBackendName(f.getName()))); + } + return (tables); + } + + + + /******************************************************************************* + ** Add the host's tenant fields and locks. A table that holds the tenant value + ** itself (the workspace, and the template) gets the fields and the locks as + ** configured; the template's null tenant means shared, so its locks allow + ** nulls. Every other table gets the same locks reached through a join chain + ** from the workspace. + *******************************************************************************/ + private static void addTenantLocks(QTableMetaData table, SlackQBitConfig config, boolean denyNull, List chainFromWorkspace) + { + if(chainFromWorkspace == null) + { + for(QFieldMetaData field : config.getSecurityFields()) + { + if(!table.getFields().containsKey(field.getName())) + { + table.addField(field.clone()); + table.getSections().get(0).getFieldNames().add(field.getName()); + } + } + } + + for(RecordSecurityLock lock : config.getRecordSecurityLocks()) + { + RecordSecurityLock clone = lock.clone(); + if(chainFromWorkspace != null) + { + clone.setFieldName(WORKSPACE + "." + lock.getFieldName()); + clone.setJoinNameChain(chainFromWorkspace); + } + if(!denyNull) + { + clone.setNullValueBehavior(RecordSecurityLock.NullValueBehavior.ALLOW); + } + table.withRecordSecurityLock(clone); + } + + if(CONNECTION.equals(table.getName())) + { + table.withRecordSecurityLock(SlackSecurity.ownerLock("userId")); + } + if(MESSAGE.equals(table.getName())) + { + table.withRecordSecurityLock(SlackSecurity.ownerLock("ownerUserId")); + } + if(ATTEMPT.equals(table.getName())) + { + table.withRecordSecurityLock(SlackSecurity.ownerLock(MESSAGE + ".ownerUserId", List.of(JOIN_MESSAGE_ATTEMPT))); + } + } + + + + /******************************************************************************* + ** Common fields. + *******************************************************************************/ + private static QTableMetaData base(SlackQBitConfig config, String name, String label) + { + return (new QTableMetaData().withName(name).withLabel(label).withBackendName(config.getBackendName()) + .withPrimaryKeyField("id").withIcon(new QIcon("chat")).withPermissionRules(permissionRules()) + .withRecordLabelFormatAndFields("%s", "id") + .withField(field("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(field("createDate", QFieldType.DATE_TIME).withIsEditable(false)) + .withField(field("modifyDate", QFieldType.DATE_TIME).withIsEditable(false))); + } + + + + /******************************************************************************* + ** Field with a plain English label. + *******************************************************************************/ + private static QFieldMetaData field(String name, QFieldType type) + { + String words = name.replaceAll("([a-z])([A-Z])", "$1 $2"); + return (new QFieldMetaData(name, type).withLabel(Character.toUpperCase(words.charAt(0)) + words.substring(1))); + } + + + + /******************************************************************************* + ** A token column, hidden from every API and screen. RC.6 has no single "never + ** returned" switch, so every layer is used: + ** - PASSWORD type: masked in any query that masks passwords (the default); + ** - isHidden: omitted from query and get results by default, and from forms; + ** - FieldSecurityLock (default DENY, key nobody holds): when core enforces + ** permissions (every UI and API call does), the value is removed from + ** results, the field is removed from the table's metadata, and filtering, + ** ordering or writing it is refused. The system user is exempt, which is + ** how the dispatcher reads it, with shouldOmitHiddenFields and + ** shouldMaskPasswords both false; + ** - isSensitive: excluded from exports; + ** - not editable, and in no section or record label; + ** - the table has no audit rules, so the value is never audited. + *******************************************************************************/ + private static QFieldMetaData tokenField(String name) + { + return (field(name, QFieldType.PASSWORD).withMaxLength(2000).withIsHidden(true).withIsSensitive(true).withIsEditable(false) + .withFieldSecurityLock(SlackSecurity.tokenFieldLock())); + } + + + + /******************************************************************************* + ** Metadata for slack_workspace. + *******************************************************************************/ + private static QTableMetaData workspace(SlackQBitConfig config) + { + QTableMetaData table = base(config, WORKSPACE, "Workspaces") + .withField(field("teamId", QFieldType.STRING).withIsRequired(true).withMaxLength(500)) + .withField(field("teamName", QFieldType.STRING).withMaxLength(500)) + .withField(field("enterpriseId", QFieldType.STRING).withMaxLength(500)) + .withField(field("appId", QFieldType.STRING).withMaxLength(500)) + .withField(field("tenantKey", QFieldType.STRING).withIsRequired(!config.getRecordSecurityLocks().isEmpty()).withMaxLength(500)) + .withField(field("installedByUserId", QFieldType.STRING).withMaxLength(500)) + .withField(field("installedAt", QFieldType.DATE_TIME)) + .withField(field("botUserId", QFieldType.STRING).withMaxLength(500)) + .withField(field("botScopes", QFieldType.TEXT)) + .withField(tokenField(BOT_TOKEN)) + .withField(field("botTokenFingerprint", QFieldType.STRING).withMaxLength(500)) + .withField(field("status", QFieldType.STRING).withPossibleValueSourceName("slack_workspace_status").withDefaultValue("ACTIVE").withMaxLength(500)) + .withField(field("readinessStatus", QFieldType.STRING).withPossibleValueSourceName("slack_readiness_status").withDefaultValue("UNKNOWN").withMaxLength(500)) + .withField(field("readinessCheckedAt", QFieldType.DATE_TIME)) + .withField(field("readinessSummary", QFieldType.STRING).withMaxLength(500)) + .withField(field("health", QFieldType.STRING).withPossibleValueSourceName("slack_health_status").withDefaultValue("HEALTHY").withMaxLength(500)) + .withField(field("consecutiveFailures", QFieldType.INTEGER).withDefaultValue(0)) + .withField(field("ratePerMinute", QFieldType.INTEGER).withDefaultValue(50)); + table.withRecordLabelFormatAndFields("%s", "teamName"); + table.withUniqueKey(new UniqueKey("teamId", "tenantKey")); + table.withSection(SectionFactory.defaultT1("id", "teamId", "teamName", "tenantKey", "status")); + table.withSection(SectionFactory.defaultT2("enterpriseId", "appId", "installedByUserId", "installedAt", "botUserId", "botScopes", "botTokenFingerprint", "readinessStatus", "readinessCheckedAt", "readinessSummary", "health", "consecutiveFailures", "ratePerMinute")); + table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + table.withSection(SectionFactory.customT2("connections", new QIcon("list")).withLabel("Connections").withWidgetName(childWidgetName(CONNECTION))); + table.withSection(SectionFactory.customT2("channels", new QIcon("list")).withLabel("Registered Channels").withWidgetName(childWidgetName(CHANNEL))); + table.withSection(SectionFactory.customT2("groups", new QIcon("list")).withLabel("User Groups").withWidgetName(childWidgetName(USER_GROUP))); + return (table); + } + + + + /******************************************************************************* + ** Metadata for slack_connection. + *******************************************************************************/ + private static QTableMetaData connection(SlackQBitConfig config) + { + QTableMetaData table = base(config, CONNECTION, "Connections") + .withField(field("userId", QFieldType.STRING).withIsRequired(true).withMaxLength(500)) + .withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true)) + .withField(field("slackUserId", QFieldType.STRING).withMaxLength(500)) + .withField(field("slackUserName", QFieldType.STRING).withMaxLength(500)) + .withField(tokenField(USER_TOKEN)) + .withField(field("userTokenFingerprint", QFieldType.STRING).withMaxLength(500)) + .withField(field("userScopes", QFieldType.TEXT)) + .withField(field("status", QFieldType.STRING).withPossibleValueSourceName("slack_connection_status").withDefaultValue("ACTIVE").withMaxLength(500)) + .withField(field("connectedAt", QFieldType.DATE_TIME)) + .withField(field("lastVerifiedAt", QFieldType.DATE_TIME)) + .withField(field("revokedAt", QFieldType.DATE_TIME)) + .withField(field("lastError", QFieldType.STRING).withMaxLength(500)); + table.withUniqueKey(new UniqueKey("userId", "workspaceId")); + table.withSection(SectionFactory.defaultT1("id", "userId", "workspaceId", "slackUserName", "status")); + table.withSection(SectionFactory.defaultT2("slackUserId", "userTokenFingerprint", "userScopes", "connectedAt", "lastVerifiedAt", "revokedAt", "lastError")); + table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + return (table); + } + + + + /******************************************************************************* + ** Metadata for slack_channel (registered channels). + *******************************************************************************/ + private static QTableMetaData channel(SlackQBitConfig config) + { + QTableMetaData table = base(config, CHANNEL, "Registered Channels") + .withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500)) + .withField(field("label", QFieldType.STRING).withMaxLength(500)) + .withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true)) + .withField(field("slackChannelId", QFieldType.STRING).withMaxLength(500)) + .withField(field("slackChannelName", QFieldType.STRING).withMaxLength(500)) + .withField(field("isPrivate", QFieldType.BOOLEAN).withDefaultValue(false)) + .withField(field("botIsMember", QFieldType.BOOLEAN).withDefaultValue(false)) + .withField(field("isArchived", QFieldType.BOOLEAN).withDefaultValue(false)) + .withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true)) + .withField(field("description", QFieldType.TEXT)) + .withField(field("checkedAt", QFieldType.DATE_TIME)); + table.withRecordLabelFormatAndFields("%s", "name"); + table.withUniqueKey(new UniqueKey("workspaceId", "name")); + table.withSection(SectionFactory.defaultT1("id", "name", "label", "workspaceId", "enabled")); + table.withSection(SectionFactory.defaultT2("slackChannelId", "slackChannelName", "isPrivate", "botIsMember", "isArchived", "description", "checkedAt")); + table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + return (table); + } + + + + /******************************************************************************* + ** Metadata for slack_user_group. + *******************************************************************************/ + private static QTableMetaData userGroup(SlackQBitConfig config) + { + QTableMetaData table = base(config, USER_GROUP, "User Groups") + .withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500)) + .withField(field("label", QFieldType.STRING).withMaxLength(500)) + .withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true)) + .withField(field("slackUsergroupId", QFieldType.STRING).withMaxLength(500)) + .withField(field("handle", QFieldType.STRING).withMaxLength(500)) + .withField(field("channelId", QFieldType.STRING).withMaxLength(500)) + .withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true)); + table.withRecordLabelFormatAndFields("%s", "name"); + table.withUniqueKey(new UniqueKey("workspaceId", "name")); + table.withSection(SectionFactory.defaultT1("id", "name", "label", "workspaceId", "enabled")); + table.withSection(SectionFactory.defaultT2("slackUsergroupId", "handle", "channelId")); + table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + return (table); + } + + + + /******************************************************************************* + ** Metadata for slack_template. + *******************************************************************************/ + private static QTableMetaData template(SlackQBitConfig config) + { + QTableMetaData table = base(config, TEMPLATE, "Templates") + .withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500)) + .withField(field("label", QFieldType.STRING).withMaxLength(500)) + .withField(field("description", QFieldType.TEXT)) + .withField(field("category", QFieldType.STRING).withMaxLength(500)) + .withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true)) + .withField(field("tenantKey", QFieldType.STRING).withMaxLength(500)) + .withField(field("text", QFieldType.TEXT)) + .withField(field("blocksTemplate", QFieldType.TEXT)) + .withField(field("variablesSchema", QFieldType.TEXT)); + table.withRecordLabelFormatAndFields("%s", "name"); + table.withUniqueKey(new UniqueKey("name")); + table.withSection(SectionFactory.defaultT1("id", "name", "label", "category", "enabled", "tenantKey")); + table.withSection(SectionFactory.defaultT2("description", "text", "blocksTemplate", "variablesSchema")); + table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + return (table); + } + + + + /******************************************************************************* + ** Metadata for slack_message. + *******************************************************************************/ + private static QTableMetaData message(SlackQBitConfig config) + { + QTableMetaData table = base(config, MESSAGE, "Messages") + .withField(field("ownerUserId", QFieldType.STRING).withIsRequired(true).withMaxLength(500)) + .withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true)) + .withField(field("connectionId", QFieldType.INTEGER).withPossibleValueSourceName(CONNECTION)) + .withField(field("postAs", QFieldType.STRING).withPossibleValueSourceName("slack_post_as").withMaxLength(500)) + .withField(field("operation", QFieldType.STRING).withPossibleValueSourceName("slack_message_operation").withDefaultValue("POST").withMaxLength(500)) + .withField(field("targetType", QFieldType.STRING).withPossibleValueSourceName("slack_target_type").withMaxLength(500)) + .withField(field("targetRef", QFieldType.STRING).withMaxLength(500)) + .withField(field("slackChannelId", QFieldType.STRING).withMaxLength(500)) + .withField(field("channelLabel", QFieldType.STRING).withMaxLength(500)) + .withField(field("status", QFieldType.STRING).withPossibleValueSourceName("slack_message_status").withDefaultValue("QUEUED").withMaxLength(500)) + .withField(field("category", QFieldType.STRING).withMaxLength(500)) + .withField(field("templateName", QFieldType.STRING).withMaxLength(500)) + .withField(field("text", QFieldType.TEXT).withIsSensitive(true)) + .withField(field("blocksJson", QFieldType.TEXT).withIsSensitive(true)) + .withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true)) + .withField(field("idempotencyKey", QFieldType.STRING).withMaxLength(500)) + .withField(field("parentMessageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE)) + .withField(field("threadTs", QFieldType.STRING).withMaxLength(500)) + .withField(field("targetMessageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE)) + .withField(field("attachmentStorageRef", QFieldType.STRING).withMaxLength(500)) + .withField(field("attachmentName", QFieldType.STRING).withMaxLength(500)) + .withField(field("attachmentBytes", QFieldType.LONG)) + .withField(field("slackFileId", QFieldType.STRING).withMaxLength(500)) + .withField(field("slackTs", QFieldType.STRING).withMaxLength(500)) + .withField(field("permalink", QFieldType.TEXT)) + .withField(field("attemptCount", QFieldType.INTEGER).withDefaultValue(0)) + .withField(field("nextAttemptDate", QFieldType.DATE_TIME)) + .withField(field("leaseExpiresDate", QFieldType.DATE_TIME)) + .withField(field("lastErrorCode", QFieldType.STRING).withMaxLength(500)) + .withField(field("lastError", QFieldType.STRING).withMaxLength(500)) + .withField(field("sentDate", QFieldType.DATE_TIME)) + .withField(field("deadLetterDate", QFieldType.DATE_TIME)) + .withField(field("linkedTableName", QFieldType.STRING).withMaxLength(500)) + .withField(field("linkedRecordId", QFieldType.STRING).withMaxLength(500)) + .withField(field("senderName", QFieldType.STRING).withMaxLength(500)) + .withField(field("deliveryLogId", QFieldType.STRING).withMaxLength(500)); + table.withUniqueKey(new UniqueKey("idempotencyKey")); + 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", "attachmentStorageRef", "attachmentName", "attachmentBytes", "slackFileId", "slackTs", "permalink", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastErrorCode", "lastError", "sentDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderName", "deliveryLogId")); + table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + table.withSection(SectionFactory.customT2("attempts", new QIcon("list")).withLabel("Attempts").withWidgetName(childWidgetName(ATTEMPT))); + return (table); + } + + + + /******************************************************************************* + ** Metadata for slack_message_attempt. + *******************************************************************************/ + private static QTableMetaData attempt(SlackQBitConfig config) + { + QTableMetaData table = base(config, ATTEMPT, "Message Attempts") + .withField(field("messageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE).withIsRequired(true)) + .withField(field("attemptNumber", QFieldType.INTEGER).withIsRequired(true)) + .withField(field("outcome", QFieldType.STRING).withPossibleValueSourceName("slack_attempt_outcome").withMaxLength(500)) + .withField(field("errorCode", QFieldType.STRING).withMaxLength(500)) + .withField(field("httpStatus", QFieldType.INTEGER)) + .withField(field("retryAfterSeconds", QFieldType.INTEGER)) + .withField(field("postedAs", QFieldType.STRING).withPossibleValueSourceName("slack_post_as").withMaxLength(500)) + .withField(field("claimedBy", QFieldType.STRING).withMaxLength(500)) + .withField(field("startDate", QFieldType.DATE_TIME)) + .withField(field("endDate", QFieldType.DATE_TIME)) + .withField(field("error", QFieldType.STRING).withMaxLength(500)); + table.withUniqueKey(new UniqueKey("messageId", "attemptNumber")); + table.withSection(SectionFactory.defaultT1("id", "messageId", "attemptNumber", "outcome", "postedAs", "claimedBy")); + table.withSection(SectionFactory.defaultT2("errorCode", "httpStatus", "retryAfterSeconds", "startDate", "endDate", "error")); + table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + return (table); + } + + + + /******************************************************************************* + ** The relations: workspace to its connections, channels, groups and messages; + ** message to its attempts; and message to its replies (parentMessageId). + *******************************************************************************/ + public static List joins() + { + return (List.of( + join(JOIN_WORKSPACE_CONNECTION, WORKSPACE, CONNECTION, "workspaceId"), + 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_ATTEMPT, MESSAGE, ATTEMPT, "messageId"), + join(JOIN_MESSAGE_PARENT_CHILD, MESSAGE, MESSAGE, "parentMessageId"))); + } + + + + /******************************************************************************* + ** Parent to child relation. + *******************************************************************************/ + private static QJoinMetaData join(String name, String parent, String child, String field) + { + return (new QJoinMetaData().withName(name).withType(JoinType.ONE_TO_MANY) + .withLeftTable(parent).withRightTable(child).withJoinOn(new JoinOn("id", field))); + } + + + + /******************************************************************************* + ** Name of the child-list widget that shows a child table on its parent. + *******************************************************************************/ + private static String childWidgetName(String childTable) + { + return (childTable + "_children"); + } + + + + /******************************************************************************* + ** Child lists live on the parent record, never in navigation. The reply join + ** (message to message) has no list: replies are reached through the parent + ** link on each message. + *******************************************************************************/ + public static List childWidgets() + { + List widgets = new ArrayList<>(); + for(QJoinMetaData join : joins()) + { + if(JOIN_MESSAGE_PARENT_CHILD.equals(join.getName())) + { + continue; + } + widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(childWidgetName(join.getRightTable())) + .withMaxRows(50).getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules())); + } + return (widgets); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java new file mode 100644 index 0000000..e4106de --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java @@ -0,0 +1,312 @@ +/* + * 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.outbox; + + +import com.kingsrook.qbits.slack.metadata.SlackEnums; + + +/******************************************************************************* + ** One Slack message to queue (docs/DESIGN.md 4.3). The owner is a QQQ user id + ** (when null, the signed-in user). The address uses the forms in 4.2. The text + ** is plain: the service escapes it. Raw Block Kit JSON needs the slack.send.blocks + ** permission. tenantKey is for an ownerless send from a system session only. + *******************************************************************************/ +public class SlackSendRequest +{ + private String ownerUserId; + private String tenantKey; + private String to; + private String text; + private String blocksJson; + private String idempotencyKey; + private String category; + private SlackEnums.PostAs postAs; + private Integer workspaceId; + private Integer threadOfMessageId; + private String linkedTableName; + private String linkedRecordId; + private String senderName; + + + + /******************************************************************************* + ** Getter for ownerUserId + *******************************************************************************/ + public String getOwnerUserId() + { + return (this.ownerUserId); + } + + + + /******************************************************************************* + ** Fluent setter for ownerUserId + *******************************************************************************/ + public SlackSendRequest withOwnerUserId(String ownerUserId) + { + this.ownerUserId = ownerUserId; + return (this); + } + + + + /******************************************************************************* + ** Getter for tenantKey + *******************************************************************************/ + public String getTenantKey() + { + return (this.tenantKey); + } + + + + /******************************************************************************* + ** Fluent setter for tenantKey + *******************************************************************************/ + public SlackSendRequest withTenantKey(String tenantKey) + { + this.tenantKey = tenantKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for to + *******************************************************************************/ + public String getTo() + { + return (this.to); + } + + + + /******************************************************************************* + ** Fluent setter for to + *******************************************************************************/ + public SlackSendRequest withTo(String to) + { + this.to = to; + return (this); + } + + + + /******************************************************************************* + ** Getter for text + *******************************************************************************/ + public String getText() + { + return (this.text); + } + + + + /******************************************************************************* + ** Fluent setter for text + *******************************************************************************/ + public SlackSendRequest withText(String text) + { + this.text = text; + return (this); + } + + + + /******************************************************************************* + ** Getter for blocksJson + *******************************************************************************/ + public String getBlocksJson() + { + return (this.blocksJson); + } + + + + /******************************************************************************* + ** Fluent setter for blocksJson + *******************************************************************************/ + public SlackSendRequest withBlocksJson(String blocksJson) + { + this.blocksJson = blocksJson; + return (this); + } + + + + /******************************************************************************* + ** Getter for idempotencyKey + *******************************************************************************/ + public String getIdempotencyKey() + { + return (this.idempotencyKey); + } + + + + /******************************************************************************* + ** Fluent setter for idempotencyKey + *******************************************************************************/ + public SlackSendRequest withIdempotencyKey(String idempotencyKey) + { + this.idempotencyKey = idempotencyKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for category + *******************************************************************************/ + public String getCategory() + { + return (this.category); + } + + + + /******************************************************************************* + ** Fluent setter for category + *******************************************************************************/ + public SlackSendRequest withCategory(String category) + { + this.category = category; + return (this); + } + + + + /******************************************************************************* + ** Getter for postAs + *******************************************************************************/ + public SlackEnums.PostAs getPostAs() + { + return (this.postAs); + } + + + + /******************************************************************************* + ** Fluent setter for postAs + *******************************************************************************/ + public SlackSendRequest withPostAs(SlackEnums.PostAs postAs) + { + this.postAs = postAs; + return (this); + } + + + + /******************************************************************************* + ** Getter for workspaceId + *******************************************************************************/ + public Integer getWorkspaceId() + { + return (this.workspaceId); + } + + + + /******************************************************************************* + ** Fluent setter for workspaceId + *******************************************************************************/ + public SlackSendRequest withWorkspaceId(Integer workspaceId) + { + this.workspaceId = workspaceId; + return (this); + } + + + + /******************************************************************************* + ** Getter for threadOfMessageId + *******************************************************************************/ + public Integer getThreadOfMessageId() + { + return (this.threadOfMessageId); + } + + + + /******************************************************************************* + ** Fluent setter for threadOfMessageId: this message replies in the thread of + ** an earlier message of the same owner. + *******************************************************************************/ + public SlackSendRequest withThreadOfMessageId(Integer threadOfMessageId) + { + this.threadOfMessageId = threadOfMessageId; + return (this); + } + + + + /******************************************************************************* + ** Getter for linkedTableName + *******************************************************************************/ + public String getLinkedTableName() + { + return (this.linkedTableName); + } + + + + /******************************************************************************* + ** Getter for linkedRecordId + *******************************************************************************/ + public String getLinkedRecordId() + { + return (this.linkedRecordId); + } + + + + /******************************************************************************* + ** Fluent setter for the record this message is about. + *******************************************************************************/ + public SlackSendRequest withLinkedRecord(String tableName, String recordId) + { + this.linkedTableName = tableName; + this.linkedRecordId = recordId; + return (this); + } + + + + /******************************************************************************* + ** Getter for senderName + *******************************************************************************/ + public String getSenderName() + { + return (this.senderName); + } + + + + /******************************************************************************* + ** Fluent setter for senderName + *******************************************************************************/ + public SlackSendRequest withSenderName(String senderName) + { + this.senderName = senderName; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendResult.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendResult.java new file mode 100644 index 0000000..a9a6843 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendResult.java @@ -0,0 +1,55 @@ +/* + * 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.outbox; + + +import java.util.List; + + +/******************************************************************************* + ** What queueing a message did: QUEUED with the new id, DUPLICATE with the id + ** of the earlier message that has the same idempotency key, or REFUSED with + ** reasons fit for the user. For sendOnce, status is QUEUED_OR_DUPLICATE or + ** REFUSED (see SlackService.sendOnce). + *******************************************************************************/ +public record SlackSendResult(Status status, Integer messageId, List reasons) +{ + /******************************************************************************* + ** The outcomes. + *******************************************************************************/ + public enum Status + { + QUEUED, + DUPLICATE, + REFUSED, + QUEUED_OR_DUPLICATE + } + + + + /******************************************************************************* + ** A refusal with one reason. + *******************************************************************************/ + public static SlackSendResult refused(String reason) + { + return (new SlackSendResult(Status.REFUSED, null, List.of(reason))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java new file mode 100644 index 0000000..6f5877b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java @@ -0,0 +1,490 @@ +/* + * 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.outbox; + + +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.metadata.SlackEnums; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.security.AuthorizedSend; +import com.kingsrook.qbits.slack.security.SlackAuthorization; +import com.kingsrook.qbits.slack.security.SlackAuthorizer; +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.permissions.CorePermissionEnforcer; +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.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import org.json.JSONArray; +import org.json.JSONException; + + +/******************************************************************************* + ** The validated entry point for queueing a Slack message (docs/DESIGN.md 2, 4 + ** and 5). It never talks to Slack to deliver: it checks the request, asks + ** SlackAuthorizer whether the owner may post to the target (which does read + ** channel membership from Slack), renders the text, and inserts the + ** slack_message row for the dispatcher. + ** + ** Who the owner is: + ** - a user session: the signed-in user. A request may not name anyone else; + ** - a system session: the owner the request names. With none, the send is + ** ownerless, which is allowed only to a registered channel and only with a + ** tenant key (SlackAuthorizer). + *******************************************************************************/ +public final class SlackService +{ + public static final int MAX_TEXT_LENGTH = 40_000; + public static final int MAX_BLOCKS = 50; + public static final int MAX_KEY_LENGTH = 500; + public static final String PERMISSION_SEND = "slack.send"; + public static final String PERMISSION_BLOCKS = "slack.send.blocks"; + + private static final List NOT_DELIVERED = List.of("DEAD_LETTER", "FAILED_AUTH", "REFUSED", "CANCELLED", "SUPPRESSED"); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackService() + { + } + + + + /******************************************************************************* + ** Queue a message: validate, authorize, render, dedupe by idempotency key, + ** and insert it QUEUED (or WAITING_PARENT, for a thread reply whose parent + ** hasn't been sent yet). + *******************************************************************************/ + public static SlackSendResult enqueue(SlackSendRequest request) throws QException + { + SlackQBitConfig config = SlackRuntime.config(); + if(config == null) + { + return (SlackSendResult.refused("The Slack qbit isn't installed")); + } + if(request == null) + { + return (SlackSendResult.refused("A Slack message request is required")); + } + + String problem = validate(request, true); + if(problem != null) + { + return (SlackSendResult.refused(problem)); + } + + /////////////////////////////////////////////////////////////////////// + // who is sending: a user sends only as themselves // + /////////////////////////////////////////////////////////////////////// + String owner = resolveOwner(request); + if(owner != null && owner.isEmpty()) + { + return (SlackSendResult.refused("Slack messages can only be sent as the signed-in user")); + } + boolean ownerless = owner == null; + String storedOwner = ownerless ? SlackAuthorizer.systemOwner(request.getTenantKey()) : owner; + + String denied = permissionProblem(owner, request.getBlocksJson() != null && !request.getBlocksJson().isBlank()); + if(denied != null) + { + return (SlackSendResult.refused(denied)); + } + + SlackSendResult duplicate = duplicate(request.getIdempotencyKey(), storedOwner); + if(duplicate != null) + { + return (duplicate); + } + + /////////////////////////////////////////////////////////////////////////////////// + // a reply takes its target from the parent, which the owner's session must see // + /////////////////////////////////////////////////////////////////////////////////// + String address = request.getTo(); + Integer workspaceId = request.getWorkspaceId(); + QRecord parent = null; + if(request.getThreadOfMessageId() != null) + { + if(ownerless) + { + return (SlackSendResult.refused("A thread reply needs an owner")); + } + parent = ownerMessage(owner, request.getThreadOfMessageId()); + if(parent == null) + { + return (SlackSendResult.refused("That message to reply to wasn't found")); + } + if(!SlackEnums.Operation.POST.name().equals(parent.getValueString("operation")) || NOT_DELIVERED.contains(parent.getValueString("status"))) + { + return (SlackSendResult.refused("A reply needs a message that was posted or is waiting to be posted")); + } + address = address != null ? address : parent.getValueString("targetRef"); + workspaceId = parent.getValueInteger("workspaceId"); + } + + SlackAuthorization authorization = new SlackAuthorizer().authorize(new SlackAuthorizer.Request() + .withOwnerUserId(owner).withTenantKey(request.getTenantKey()).withAddress(address).withWorkspaceId(workspaceId).withPostAs(request.getPostAs())); + if(!authorization.isAuthorized()) + { + return (SlackSendResult.refused(authorization.getRefusal().message())); + } + AuthorizedSend send = authorization.getSend(); + + QRecord row = baseRow(request, storedOwner, send, SlackEnums.Operation.POST); + if(parent != null) + { + boolean parentSent = "SENT".equals(parent.getValueString("status")) && parent.getValueString("slackTs") != null; + row.withValue("parentMessageId", parent.getValueInteger("id")); + row.withValue("threadTs", parentSent ? parent.getValueString("slackTs") : null); + row.withValue("status", parentSent ? SlackEnums.MessageStatus.QUEUED.name() : SlackEnums.MessageStatus.WAITING_PARENT.name()); + if(!parentSent) + { + row.withValue("nextAttemptDate", null); + } + } + return (insert(row)); + } + + + + /******************************************************************************* + ** Queue once, for a durable handoff keyed by an effect key (the jobs effect + ** primitive): the key becomes the idempotency key, a repeat gives + ** QUEUED_OR_DUPLICATE with the same id, and the row is committed before this + ** returns. It refuses a caller transaction, because a handoff that can roll + ** back with the caller isn't a handoff. The request's idempotency key is + ** replaced by the effect key. + *******************************************************************************/ + public static SlackSendResult sendOnce(SlackSendRequest request, String effectKey) throws QException + { + if(QContext.getQBackendTransaction() != null) + { + return (SlackSendResult.refused("sendOnce can't run inside a caller transaction")); + } + if(effectKey == null || effectKey.isBlank()) + { + return (SlackSendResult.refused("An effect key is required")); + } + if(request == null) + { + return (SlackSendResult.refused("A Slack message request is required")); + } + + SlackSendResult result = enqueue(request.withIdempotencyKey(effectKey)); + if(result.status() == SlackSendResult.Status.QUEUED || result.status() == SlackSendResult.Status.DUPLICATE) + { + return (new SlackSendResult(SlackSendResult.Status.QUEUED_OR_DUPLICATE, result.messageId(), result.reasons())); + } + return (result); + } + + + + /******************************************************************************* + ** Queue a chat.update of a message that was sent. The new row has the same + ** owner, workspace, target, channel and posting identity as the sent one, + ** because Slack only lets a token edit its own messages. + *******************************************************************************/ + public static SlackSendResult update(Integer messageId, SlackSendRequest request) throws QException + { + SlackQBitConfig config = SlackRuntime.config(); + if(config == null) + { + return (SlackSendResult.refused("The Slack qbit isn't installed")); + } + if(request == null || messageId == null) + { + return (SlackSendResult.refused("A message and its new content are required")); + } + + String problem = validate(request, false); + if(problem != null) + { + return (SlackSendResult.refused(problem)); + } + + String owner = resolveOwner(request); + if(owner == null || owner.isEmpty()) + { + return (SlackSendResult.refused("Only the owner can update a Slack message")); + } + String denied = permissionProblem(owner, request.getBlocksJson() != null && !request.getBlocksJson().isBlank()); + if(denied != null) + { + return (SlackSendResult.refused(denied)); + } + + QRecord target = ownerMessage(owner, messageId); + if(target == null) + { + return (SlackSendResult.refused("That message wasn't found")); + } + if(!"SENT".equals(target.getValueString("status")) || target.getValueString("slackTs") == null || !SlackEnums.Operation.POST.name().equals(target.getValueString("operation"))) + { + return (SlackSendResult.refused("Only a message that was sent can be updated")); + } + + SlackEnums.PostAs postAs = SlackEnums.PostAs.valueOf(target.getValueString("postAs")); + SlackAuthorization authorization = new SlackAuthorizer().authorize(new SlackAuthorizer.Request() + .withOwnerUserId(owner).withAddress(target.getValueString("targetRef")).withWorkspaceId(target.getValueInteger("workspaceId")).withPostAs(postAs)); + if(!authorization.isAuthorized()) + { + return (SlackSendResult.refused(authorization.getRefusal().message())); + } + + QRecord row = baseRow(request, owner, authorization.getSend(), SlackEnums.Operation.UPDATE); + row.withValue("targetMessageId", messageId); + row.withValue("slackTs", target.getValueString("slackTs")); + row.withValue("slackChannelId", target.getValueString("slackChannelId")); + return (insert(row)); + } + + + + /******************************************************************************* + ** Escape the three characters Slack treats as markup, so text can't inject a + ** mention, a link or a channel reference. + *******************************************************************************/ + public static String escape(String text) + { + return (text == null ? null : text.replace("&", "&").replace("<", "<").replace(">", ">")); + } + + + + /******************************************************************************* + ** Check the parts of a request that need no lookup. Returns the problem, or + ** null. A new message needs an address (unless it is a reply); an update + ** doesn't. + *******************************************************************************/ + private static String validate(SlackSendRequest request, boolean needsAddress) + { + boolean hasText = request.getText() != null && !request.getText().isBlank(); + boolean hasBlocks = request.getBlocksJson() != null && !request.getBlocksJson().isBlank(); + if(!hasText && !hasBlocks) + { + return ("A Slack message needs text or blocks"); + } + if(hasText && request.getText().length() > MAX_TEXT_LENGTH) + { + return ("The Slack message text is too long"); + } + if(hasBlocks) + { + if(request.getBlocksJson().length() > MAX_TEXT_LENGTH) + { + return ("The Slack blocks are too long"); + } + try + { + int blocks = new JSONArray(request.getBlocksJson()).length(); + if(blocks == 0 || blocks > MAX_BLOCKS) + { + return ("Slack blocks must be an array of 1 to " + MAX_BLOCKS + " blocks"); + } + } + catch(JSONException e) + { + return ("Slack blocks must be a JSON array"); + } + } + if(request.getIdempotencyKey() != null && request.getIdempotencyKey().length() > MAX_KEY_LENGTH) + { + return ("The idempotency key is too long"); + } + if(needsAddress && (request.getTo() == null || request.getTo().isBlank()) && request.getThreadOfMessageId() == null) + { + return ("A Slack address is required"); + } + return (null); + } + + + + /******************************************************************************* + ** The owner a request is for. Returns null for an ownerless system send, and + ** the empty string when a user session names someone else (or has no user). + *******************************************************************************/ + private static String resolveOwner(SlackSendRequest request) + { + QSession caller = QContext.getQSession(); + String callerId = caller == null || caller.getUser() == null ? null : caller.getUser().getIdReference(); + String named = request.getOwnerUserId(); + + if(caller instanceof QSystemUserSession) + { + return (named == null || named.isBlank() ? null : named); + } + if(callerId == null || (named != null && !named.equals(callerId))) + { + return (""); + } + return (callerId); + } + + + + /******************************************************************************* + ** The permission the owner needs, checked only when core enforces permissions + ** (every UI and API call does). The owner's own session is used, so an + ** automated session for an owner has the owner's permissions. + *******************************************************************************/ + private static String permissionProblem(String owner, boolean blocks) throws QException + { + if(owner == null || !CorePermissionEnforcer.isInstanceEnforcementEnabled()) + { + return (null); + } + + QSession session = SlackOwnerSession.create(QContext.getQInstance(), owner); + if(!session.hasPermission(PERMISSION_SEND)) + { + return ("Sending to Slack needs the " + PERMISSION_SEND + " permission"); + } + if(blocks && !session.hasPermission(PERMISSION_BLOCKS)) + { + return ("Sending Slack blocks needs the " + PERMISSION_BLOCKS + " permission"); + } + return (null); + } + + + + /******************************************************************************* + ** A message of this owner, read in the owner's session, so another owner's + ** or another tenant's message is simply not there. + *******************************************************************************/ + private static QRecord ownerMessage(String owner, Integer messageId) throws QException + { + try + { + List rows = SlackOwnerSession.call(owner, () -> SlackStore.queryAsCurrent(SlackTables.MESSAGE, SlackStore.equal("id", messageId))); + return (rows.isEmpty() ? null : rows.get(0)); + } + catch(QPermissionDeniedException e) + { + return (null); + } + } + + + + /******************************************************************************* + ** The DUPLICATE result for a key already used by this owner. A key used by + ** someone else is refused, and never gives the other owner's message id. + *******************************************************************************/ + private static SlackSendResult duplicate(String key, String storedOwner) throws QException + { + if(key == null) + { + return (null); + } + + List rows = SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("idempotencyKey", key)); + if(rows.isEmpty()) + { + return (null); + } + if(!storedOwner.equals(rows.get(0).getValueString("ownerUserId"))) + { + return (SlackSendResult.refused("That idempotency key is already in use")); + } + return (new SlackSendResult(SlackSendResult.Status.DUPLICATE, rows.get(0).getValueInteger("id"), List.of())); + } + + + + /******************************************************************************* + ** The row common to a post and an update. + *******************************************************************************/ + private static QRecord baseRow(SlackSendRequest request, String storedOwner, AuthorizedSend send, SlackEnums.Operation operation) + { + String text = SlackService.escape(request.getText()); + if(text != null && send.mentionPrefix() != null) + { + text = send.mentionPrefix() + text; + } + + Instant now = SlackRuntime.clock().instant(); + return (new QRecord() + .withValue("ownerUserId", storedOwner) + .withValue("workspaceId", send.workspaceId()) + .withValue("connectionId", send.connectionId()) + .withValue("postAs", send.postAs().name()) + .withValue("operation", operation.name()) + .withValue("targetType", send.targetType().name()) + .withValue("targetRef", send.targetRef()) + .withValue("slackChannelId", send.slackChannelId()) + .withValue("channelLabel", send.channelLabel()) + .withValue("status", SlackEnums.MessageStatus.QUEUED.name()) + .withValue("category", request.getCategory()) + .withValue("text", text) + .withValue("blocksJson", request.getBlocksJson() == null || request.getBlocksJson().isBlank() ? null : request.getBlocksJson()) + .withValue("idempotencyKey", request.getIdempotencyKey()) + .withValue("attemptCount", 0) + .withValue("nextAttemptDate", now) + .withValue("linkedTableName", request.getLinkedTableName()) + .withValue("linkedRecordId", request.getLinkedRecordId()) + .withValue("senderName", request.getSenderName())); + } + + + + /******************************************************************************* + ** Insert the row. The unique key on the idempotency key arbitrates a race: a + ** failed insert that finds the key already there is a DUPLICATE. + *******************************************************************************/ + private static SlackSendResult insert(QRecord row) throws QException + { + QRecord inserted = null; + try + { + inserted = SlackStore.insert(SlackTables.MESSAGE, row, null); + } + catch(QException e) + { + if(row.getValueString("idempotencyKey") == null) + { + throw (new QException("Could not queue the Slack message")); + } + } + + if(inserted != null && inserted.getValueInteger("id") != null && (inserted.getErrors() == null || inserted.getErrors().isEmpty())) + { + return (new SlackSendResult(SlackSendResult.Status.QUEUED, inserted.getValueInteger("id"), List.of())); + } + + SlackSendResult existing = duplicate(row.getValueString("idempotencyKey"), row.getValueString("ownerUserId")); + if(existing == null) + { + throw (new QException("Could not queue the Slack message")); + } + return (existing); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java new file mode 100644 index 0000000..0f00183 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java @@ -0,0 +1,90 @@ +/* + * 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.processes; + + +import java.util.List; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.delivery.SlackDispatcher; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +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.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; + + +/******************************************************************************* + ** The qbit's scheduled processes. Hidden: they are never in navigation. The + ** dispatch job runs on the config's scheduler every dispatchIntervalSeconds. + *******************************************************************************/ +public final class SlackProcesses +{ + public static final String DISPATCH = "slack_dispatch"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackProcesses() + { + } + + + + /******************************************************************************* + ** The processes, for the producer. + *******************************************************************************/ + public static List processes(SlackQBitConfig config) + { + QProcessMetaData dispatch = new QProcessMetaData().withName(DISPATCH).withLabel("Dispatch Slack Messages").withIsHidden(true) + .withPermissionRules(SlackTables.permissionRules()) + .withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(ScheduledStep.class))); + + if(config.getSchedulerName() != null) + { + dispatch.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()) + .withRepeatSeconds(config.getDispatchIntervalSeconds()).withInitialDelaySeconds(Math.min(config.getDispatchIntervalSeconds(), 30))); + } + return (List.of(dispatch)); + } + + + + /******************************************************************************* + ** The scheduler's entry point. + *******************************************************************************/ + public static class ScheduledStep implements BackendStep + { + /******************************************************************************* + ** Dispatch the due messages. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new SlackDispatcher().runOnce(); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/AuthorizedSend.java b/src/main/java/com/kingsrook/qbits/slack/security/AuthorizedSend.java new file mode 100644 index 0000000..f259d9c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/AuthorizedSend.java @@ -0,0 +1,49 @@ +/* + * 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 com.kingsrook.qbits.slack.metadata.SlackEnums; + + +/******************************************************************************* + ** The result of a successful authorization (docs/DESIGN.md section 4.1): the + ** one workspace row and (for an owned send) the one connection row whose token + ** may be used, the posting identity, and the resolved target. It holds ids + ** only, never a token. + ** + ** slackChannelId is null for a direct message until the dispatcher opens it. + ** connectionId and slackUserId are null for an ownerless registered-channel + ** send. mentionPrefix is the user-group mention to put before the text. + *******************************************************************************/ +public record AuthorizedSend( + Integer workspaceId, + Integer connectionId, + SlackEnums.PostAs postAs, + String slackChannelId, + String channelLabel, + SlackEnums.TargetType targetType, + String targetRef, + String slackUserId, + String mentionPrefix, + String tenantKey) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorization.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorization.java new file mode 100644 index 0000000..58e533f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorization.java @@ -0,0 +1,102 @@ +/* + * 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; + + +/******************************************************************************* + ** Either an AuthorizedSend or a SlackRefusal, never both. + *******************************************************************************/ +public class SlackAuthorization +{ + private final AuthorizedSend send; + private final SlackRefusal refusal; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + private SlackAuthorization(AuthorizedSend send, SlackRefusal refusal) + { + this.send = send; + this.refusal = refusal; + } + + + + /******************************************************************************* + ** The send was authorized. + *******************************************************************************/ + public static SlackAuthorization allow(AuthorizedSend send) + { + return (new SlackAuthorization(send, null)); + } + + + + /******************************************************************************* + ** The send was refused. + *******************************************************************************/ + public static SlackAuthorization refuse(SlackRefusal refusal) + { + return (new SlackAuthorization(null, refusal)); + } + + + + /******************************************************************************* + ** The send was refused, without a call to Slack being involved. + *******************************************************************************/ + public static SlackAuthorization refuse(SlackRefusal.Reason reason, String message) + { + return (refuse(SlackRefusal.of(reason, message))); + } + + + + /******************************************************************************* + ** True when the send was authorized. + *******************************************************************************/ + public boolean isAuthorized() + { + return (send != null); + } + + + + /******************************************************************************* + ** The authorized send, or null when refused. + *******************************************************************************/ + public AuthorizedSend getSend() + { + return (send); + } + + + + /******************************************************************************* + ** The refusal, or null when authorized. + *******************************************************************************/ + public SlackRefusal getRefusal() + { + return (refusal); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorizer.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorizer.java new file mode 100644 index 0000000..af71f24 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorizer.java @@ -0,0 +1,781 @@ +/* + * 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 java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.metadata.SlackEnums; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.support.SlackMembershipCache; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qbits.slack.transport.SlackClient; +import com.kingsrook.qbits.slack.transport.SlackErrorClassifier; +import com.kingsrook.qbits.slack.transport.SlackResponse; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Decides whether a QQQ user (the owner) may post to a target, and with which + ** token (docs/DESIGN.md section 4.1, requirements R1 to R4). The same code runs + ** when a message is queued and again when it is sent. + ** + ** The order is the security argument: + ** 1. Every lookup of workspace, connection, registered channel and user group + ** rows runs in the owner's session (SlackOwnerSession), so the host's tenant + ** lock and the owner lock apply on their own. A row of another tenant or + ** another user is invisible, never merely filtered afterwards. + ** 2. Only then is one chosen row's token read (SlackTokens, a system read by + ** primary key), to ask Slack who is in the channel. + ** 3. The owner's Slack user id must be in conversations.members. The answer is + ** cached per workspace row and channel for membershipCacheMinutes. + ** + ** There is never a fallback to another connection, user or tenant. + ** + ** An ownerless send (a null owner, or "system:" on a stored row) is + ** allowed only to a registered channel, only from a system session, and only + ** with a tenant key equal to the workspace's, and always posts as the bot. + *******************************************************************************/ +public class SlackAuthorizer +{ + public static final String SYSTEM_OWNER_PREFIX = "system:"; + + private static final int MAX_MEMBER_PAGES = 100; + + private static final Pattern CHANNEL_ID = Pattern.compile("[A-Za-z0-9]{1,50}"); + + private static final String ADDRESS_HELP = "That Slack address isn't recognized. Use me, channel:/, channel:, # or group:."; + + + + /******************************************************************************* + ** What to authorize: the owner (null for none), the tenant key an ownerless + ** send claims, the address (docs/DESIGN.md 4.2), an optional workspace to + ** pin, and an optional posting identity to insist on. + *******************************************************************************/ + public static class Request + { + private String ownerUserId; + private String tenantKey; + private String address; + private Integer workspaceId; + private SlackEnums.PostAs postAs; + + + + /******************************************************************************* + ** Fluent setter for ownerUserId + *******************************************************************************/ + public Request withOwnerUserId(String ownerUserId) + { + this.ownerUserId = ownerUserId; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for tenantKey + *******************************************************************************/ + public Request withTenantKey(String tenantKey) + { + this.tenantKey = tenantKey; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for address + *******************************************************************************/ + public Request withAddress(String address) + { + this.address = address; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for workspaceId + *******************************************************************************/ + public Request withWorkspaceId(Integer workspaceId) + { + this.workspaceId = workspaceId; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for postAs + *******************************************************************************/ + public Request withPostAs(SlackEnums.PostAs postAs) + { + this.postAs = postAs; + return (this); + } + } + + + + /******************************************************************************* + ** A parsed address. + *******************************************************************************/ + private record Target(SlackEnums.TargetType type, String ref, Integer workspaceId, String channelId, String name) + { + } + + + + /******************************************************************************* + ** A workspace and the owner's connection in it, or why there isn't one. + *******************************************************************************/ + private record Pick(QRecord workspace, QRecord connection, SlackAuthorization refusal) + { + } + + + + /******************************************************************************* + ** Who is in a channel, or why that couldn't be found out. + *******************************************************************************/ + private record Lookup(SlackMembershipCache.Entry entry, SlackAuthorization refusal) + { + } + + + + /******************************************************************************* + ** The owner value stored on a message that has no user owner. + *******************************************************************************/ + public static String systemOwner(String tenantKey) + { + return (SYSTEM_OWNER_PREFIX + (tenantKey == null ? "" : tenantKey)); + } + + + + /******************************************************************************* + ** True for a null owner or a stored ownerless value. + *******************************************************************************/ + public static boolean isOwnerless(String ownerUserId) + { + return (ownerUserId == null || ownerUserId.startsWith(SYSTEM_OWNER_PREFIX)); + } + + + + /******************************************************************************* + ** The tenant key inside a stored ownerless value, or null. + *******************************************************************************/ + public static String tenantKeyOf(String ownerUserId) + { + if(ownerUserId == null || !ownerUserId.startsWith(SYSTEM_OWNER_PREFIX) || ownerUserId.length() == SYSTEM_OWNER_PREFIX.length()) + { + return (null); + } + return (ownerUserId.substring(SYSTEM_OWNER_PREFIX.length())); + } + + + + /******************************************************************************* + ** Authorize one send. A refusal carries a reason fit for the user. + *******************************************************************************/ + public SlackAuthorization authorize(Request request) throws QException + { + SlackQBitConfig config = SlackRuntime.config(); + if(config == null) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "The Slack qbit isn't installed")); + } + + Target target = parse(request.address); + if(target == null) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, ADDRESS_HELP)); + } + + if(isOwnerless(request.ownerUserId)) + { + return (authorizeOwnerless(config, request, target)); + } + + QSession session; + try + { + session = SlackOwnerSession.create(QContext.getQInstance(), request.ownerUserId); + } + catch(QException e) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "Slack couldn't verify your access")); + } + + try + { + return (SlackOwnerSession.run(session, () -> authorizeAsOwner(config, request, target))); + } + catch(QPermissionDeniedException e) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "You don't have access to Slack")); + } + } + + + + /******************************************************************************* + ** Parse an address into its target, or null when it isn't one of the forms. + *******************************************************************************/ + private static Target parse(String address) + { + if(address == null || address.isBlank()) + { + return (null); + } + + String a = address.trim(); + if(a.equals("me")) + { + return (new Target(SlackEnums.TargetType.ME, a, null, null, null)); + } + if(a.startsWith("user:") && a.length() > "user:".length()) + { + return (new Target(SlackEnums.TargetType.USER, a, null, null, a.substring("user:".length()))); + } + if(a.startsWith("group:") && a.length() > "group:".length()) + { + return (new Target(SlackEnums.TargetType.USER_GROUP, a, null, null, a.substring("group:".length()))); + } + if(a.startsWith("#") && a.length() > 1) + { + return (new Target(SlackEnums.TargetType.REGISTERED_CHANNEL, a, null, null, a.substring(1))); + } + if(a.startsWith("channel:") && a.length() > "channel:".length()) + { + String rest = a.substring("channel:".length()); + int slash = rest.indexOf('/'); + if(slash < 0) + { + return (new Target(SlackEnums.TargetType.REGISTERED_CHANNEL, a, null, null, rest)); + } + + try + { + Integer workspaceId = Integer.valueOf(rest.substring(0, slash)); + String channelId = rest.substring(slash + 1); + return (CHANNEL_ID.matcher(channelId).matches() ? new Target(SlackEnums.TargetType.CHANNEL, a, workspaceId, channelId, null) : null); + } + catch(NumberFormatException e) + { + return (null); + } + } + return (null); + } + + + + /******************************************************************************* + ** All the owner-session lookups and the membership check. Runs inside the + ** owner's session. + *******************************************************************************/ + private SlackAuthorization authorizeAsOwner(SlackQBitConfig config, Request request, Target target) throws QException + { + String owner = request.ownerUserId; + + QRecord workspace; + QRecord connection; + String channelId = null; + String channelLabel = null; + String mention = null; + + if(target.type == SlackEnums.TargetType.ME || target.type == SlackEnums.TargetType.USER) + { + if(target.type == SlackEnums.TargetType.USER && !owner.equals(target.name)) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "You can only send Slack direct messages to yourself")); + } + Pick pick = pick(owner, request.workspaceId); + if(pick.refusal != null) + { + return (pick.refusal); + } + workspace = pick.workspace; + connection = pick.connection; + } + else if(target.type == SlackEnums.TargetType.CHANNEL) + { + if(request.workspaceId != null && !request.workspaceId.equals(target.workspaceId)) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, "That channel isn't in the workspace this message is for")); + } + Pick pick = pick(owner, target.workspaceId); + if(pick.refusal != null) + { + return (pick.refusal); + } + workspace = pick.workspace; + connection = pick.connection; + channelId = target.channelId; + } + else + { + boolean group = target.type == SlackEnums.TargetType.USER_GROUP; + String table = group ? SlackTables.USER_GROUP : SlackTables.CHANNEL; + + QQueryFilter filter = SlackStore.equal("name", target.name).withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)); + if(request.workspaceId != null) + { + filter.withCriteria(new QFilterCriteria("workspaceId", QCriteriaOperator.EQUALS, request.workspaceId)); + } + + /////////////////////////////////////////////////////////////////////////// + // these rows are tenant-locked through their workspace: another tenant's // + // channel or group of the same name is invisible to this owner // + /////////////////////////////////////////////////////////////////////////// + List rows = SlackStore.queryAsCurrent(table, filter); + List picks = new ArrayList<>(); + List picked = new ArrayList<>(); + for(QRecord row : rows) + { + Pick candidate = pick(owner, row.getValueInteger("workspaceId")); + if(candidate.refusal == null) + { + picks.add(candidate); + picked.add(row); + } + } + if(picks.isEmpty()) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't a registered Slack " + (group ? "user group" : "channel") + " available to you")); + } + if(picks.size() > 1) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, target.name + " is registered in more than one of your workspaces. Name the workspace.")); + } + + workspace = picks.get(0).workspace; + connection = picks.get(0).connection; + QRecord row = picked.get(0); + channelId = group ? row.getValueString("channelId") : row.getValueString("slackChannelId"); + channelLabel = "#" + (group || row.getValueString("slackChannelName") == null ? target.name : row.getValueString("slackChannelName")); + if(channelId == null || channelId.isBlank()) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't linked to a Slack channel yet")); + } + if(group) + { + if(row.getValueString("slackUsergroupId") == null) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't linked to a Slack user group yet")); + } + mention = " "; + } + } + + String slackUserId = connection.getValueString("slackUserId"); + boolean hasBot = notBlank(workspace.getValueString("botTokenFingerprint")); + boolean hasUser = notBlank(connection.getValueString("userTokenFingerprint")); + if(slackUserId == null || slackUserId.isBlank()) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_CONNECTION, "Your Slack connection isn't complete. Reconnect Slack.")); + } + + Integer workspaceId = workspace.getValueInteger("id"); + Integer connectionId = connection.getValueInteger("id"); + String tenantKey = workspace.getValueString("tenantKey"); + + /////////////////////////////////////////////////////////////////////// + // direct messages: no channel to check, and the dispatcher opens it // + /////////////////////////////////////////////////////////////////////// + if(channelId == null) + { + SlackEnums.PostAs identity = request.postAs != null ? request.postAs : hasBot ? SlackEnums.PostAs.BOT : SlackEnums.PostAs.USER; + if(identity == SlackEnums.PostAs.BOT ? !hasBot : !hasUser) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "No Slack token is available to send as that identity. Reconnect Slack.")); + } + return (SlackAuthorization.allow(new AuthorizedSend(workspaceId, connectionId, identity, null, "Direct message", target.type, target.ref, slackUserId, null, tenantKey))); + } + + Lookup lookup = lookupMembership(config, workspace, connection, channelId, request.postAs, hasBot, hasUser); + if(lookup.refusal != null) + { + return (lookup.refusal); + } + + SlackMembershipCache.Entry entry = lookup.entry; + if(channelLabel == null) + { + channelLabel = entry.channelName() == null ? channelId : "#" + entry.channelName(); + } + if(!entry.members().contains(slackUserId)) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_MEMBER, "You aren't a member of " + channelLabel)); + } + + SlackEnums.PostAs identity = request.postAs; + if(identity == null) + { + identity = entry.botIsMember() && hasBot ? SlackEnums.PostAs.BOT : hasUser ? SlackEnums.PostAs.USER : null; + } + if(identity == SlackEnums.PostAs.BOT && !(entry.botIsMember() && hasBot)) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "The Slack bot isn't in " + channelLabel + ". Invite it to the channel.")); + } + if(identity == null || (identity == SlackEnums.PostAs.USER && !hasUser)) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "The Slack app isn't in " + channelLabel + " and you have no personal Slack token. Reconnect Slack.")); + } + + return (SlackAuthorization.allow(new AuthorizedSend(workspaceId, connectionId, identity, channelId, channelLabel, target.type, target.ref, slackUserId, mention, tenantKey))); + } + + + + /******************************************************************************* + ** Find the owner's ACTIVE connection and its ACTIVE workspace, in the owner's + ** session (so only the owner's tenant's workspaces and the owner's own + ** connections can match). More than one match needs a named workspace. + *******************************************************************************/ + private Pick pick(String owner, Integer workspaceId) throws QException + { + QQueryFilter filter = SlackStore.equal("userId", owner).withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, SlackEnums.ConnectionStatus.ACTIVE.name())); + if(workspaceId != null) + { + filter.withCriteria(new QFilterCriteria("workspaceId", QCriteriaOperator.EQUALS, workspaceId)); + } + + List connections = SlackStore.queryAsCurrent(SlackTables.CONNECTION, filter); + if(connections.isEmpty()) + { + return (new Pick(null, null, SlackAuthorization.refuse(SlackRefusal.Reason.NO_CONNECTION, "Connect Slack first"))); + } + + List ids = new ArrayList<>(); + connections.forEach(c -> ids.add(c.getValueInteger("workspaceId"))); + List workspaces = SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids)) + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, SlackEnums.WorkspaceStatus.ACTIVE.name()))); + + QRecord foundWorkspace = null; + QRecord foundConnection = null; + int matches = 0; + for(QRecord connection : connections) + { + for(QRecord workspace : workspaces) + { + if(workspace.getValueInteger("id").equals(connection.getValueInteger("workspaceId"))) + { + foundWorkspace = workspace; + foundConnection = connection; + matches++; + } + } + } + + if(matches == 0) + { + return (new Pick(null, null, SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, "This Slack workspace isn't available to you"))); + } + if(matches > 1) + { + return (new Pick(null, null, SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, "You are connected to more than one Slack workspace. Name one."))); + } + return (new Pick(foundWorkspace, foundConnection, null)); + } + + + + /******************************************************************************* + ** Who is in the channel: from the cache, or from conversations.members with + ** the chosen rows' tokens (the bot's first, unless the caller asked for the + ** owner's own identity). A bot that isn't in the channel falls back to the + ** owner's token; no other token is ever tried. + *******************************************************************************/ + private Lookup lookupMembership(SlackQBitConfig config, QRecord workspace, QRecord connection, String channelId, SlackEnums.PostAs requested, boolean hasBot, boolean hasUser) throws QException + { + Integer workspaceId = workspace.getValueInteger("id"); + Integer connectionId = connection.getValueInteger("id"); + Instant now = SlackRuntime.clock().instant(); + + SlackMembershipCache.Entry cached = SlackRuntime.membership().get(workspaceId, channelId, now); + if(cached != null) + { + return (new Lookup(cached, null)); + } + + List order = new ArrayList<>(); + if(requested != null) + { + order.add(requested); + } + else + { + if(hasBot) + { + order.add(SlackEnums.PostAs.BOT); + } + if(hasUser) + { + order.add(SlackEnums.PostAs.USER); + } + } + + SlackAuthorization last = SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "No Slack token is available. Reconnect Slack."); + SlackClient client = SlackRuntime.client(config); + for(SlackEnums.PostAs identity : order) + { + if(identity == SlackEnums.PostAs.BOT ? !hasBot : !hasUser) + { + continue; + } + + String token; + try + { + token = identity == SlackEnums.PostAs.BOT ? SlackTokens.botToken(config, workspaceId) : SlackTokens.userToken(config, connectionId); + } + catch(QException e) + { + last = SlackAuthorization.refuse(new SlackRefusal(SlackRefusal.Reason.AUTH, "Your Slack connection can't be used. Reconnect Slack.", SlackErrorClassifier.Kind.AUTH, "token_unreadable", null)); + continue; + } + + Set members = new LinkedHashSet<>(); + SlackResponse failure = readMembers(client, token, channelId, members); + if(failure == null) + { + //////////////////////////////////////////////////////////////////////// + // the member list is the channel's, whichever token read it. without // + // the bot's user id, only a read by the bot itself says it is in it // + //////////////////////////////////////////////////////////////////////// + String botUserId = workspace.getValueString("botUserId"); + boolean botIsMember = botUserId == null ? identity == SlackEnums.PostAs.BOT : members.contains(botUserId); + SlackMembershipCache.Entry entry = new SlackMembershipCache.Entry(Set.copyOf(members), botIsMember, channelName(client, token, channelId), now.plusSeconds(60L * config.getMembershipCacheMinutes())); + SlackRuntime.membership().put(workspaceId, channelId, entry, now); + return (new Lookup(entry, null)); + } + + SlackErrorClassifier.Kind kind = SlackErrorClassifier.classify(failure); + last = SlackAuthorization.refuse(refusalFor(failure, kind)); + if(SlackErrorClassifier.revokesAccess(failure.getError())) + { + markRevoked(identity, workspaceId, connectionId); + } + if(!"not_in_channel".equals(failure.getError())) + { + break; + } + } + return (new Lookup(null, last)); + } + + + + /******************************************************************************* + ** Read every page of conversations.members into the set. Returns the first + ** failing response, or null when all pages were read. + *******************************************************************************/ + private static SlackResponse readMembers(SlackClient client, String token, String channelId, Set members) + { + String cursor = null; + for(int page = 0; page < MAX_MEMBER_PAGES; page++) + { + SlackResponse response = client.conversationsMembers(token, channelId, cursor); + if(!response.isOk()) + { + return (response); + } + + JSONArray array = response.getJson().optJSONArray("members"); + for(int i = 0; array != null && i < array.length(); i++) + { + members.add(array.optString(i)); + } + + JSONObject metadata = response.getJson().optJSONObject("response_metadata"); + cursor = metadata == null ? null : metadata.optString("next_cursor", null); + if(cursor == null || cursor.isEmpty()) + { + return (null); + } + } + return (null); + } + + + + /******************************************************************************* + ** The channel's name from conversations.info, or null when Slack won't say + ** (the label then falls back to the channel id). + *******************************************************************************/ + private static String channelName(SlackClient client, String token, String channelId) + { + SlackResponse response = client.conversationsInfo(token, channelId); + if(!response.isOk() || response.getJson().optJSONObject("channel") == null) + { + return (null); + } + return (response.getJson().getJSONObject("channel").optString("name", null)); + } + + + + /******************************************************************************* + ** A user-facing refusal for a failed Slack call. + *******************************************************************************/ + private static SlackRefusal refusalFor(SlackResponse failure, SlackErrorClassifier.Kind kind) + { + String code = failure.getError(); + if(kind == SlackErrorClassifier.Kind.AUTH) + { + String message = SlackErrorClassifier.revokesAccess(code) ? "Slack access was revoked. Reconnect Slack." : "The Slack app can't see that channel (" + code + ")"; + return (new SlackRefusal(SlackRefusal.Reason.AUTH, message, kind, code, null)); + } + if(kind == SlackErrorClassifier.Kind.RATE_LIMITED) + { + return (new SlackRefusal(SlackRefusal.Reason.SLACK_ERROR, "Slack is limiting requests right now. Try again shortly.", kind, code, failure.getRetryAfterSeconds())); + } + if(kind == SlackErrorClassifier.Kind.TRANSIENT) + { + return (new SlackRefusal(SlackRefusal.Reason.SLACK_ERROR, "Slack isn't reachable right now. Try again shortly.", kind, code, null)); + } + return (new SlackRefusal(SlackRefusal.Reason.NOT_FOUND, "That Slack channel wasn't found (" + code + ")", kind, code, null)); + } + + + + /******************************************************************************* + ** An ownerless send: system session only, registered channel only, the + ** workspace's own tenant, and the bot. When the host declares no tenant lock + ** there is no tenant key to match. + *******************************************************************************/ + private SlackAuthorization authorizeOwnerless(SlackQBitConfig config, Request request, Target target) throws QException + { + if(target.type != SlackEnums.TargetType.REGISTERED_CHANNEL) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "A Slack message without an owner can only go to a registered channel")); + } + if(!(QContext.getQSession() instanceof QSystemUserSession)) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "Only a system session may send a Slack message without an owner")); + } + + boolean tenantAware = !config.getRecordSecurityLocks().isEmpty(); + String tenantKey = request.tenantKey != null ? request.tenantKey : tenantKeyOf(request.ownerUserId); + if(tenantAware && !notBlank(tenantKey)) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "A tenant is required for a Slack message without an owner")); + } + + QQueryFilter workspaceFilter = SlackStore.equal("status", SlackEnums.WorkspaceStatus.ACTIVE.name()); + if(tenantAware) + { + workspaceFilter.withCriteria(new QFilterCriteria("tenantKey", QCriteriaOperator.EQUALS, tenantKey)); + } + if(request.workspaceId != null) + { + workspaceFilter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.EQUALS, request.workspaceId)); + } + + List workspaces = SlackStore.query(SlackTables.WORKSPACE, workspaceFilter); + List ids = new ArrayList<>(); + workspaces.forEach(w -> ids.add(w.getValueInteger("id"))); + if(ids.isEmpty()) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't a registered Slack channel for that tenant")); + } + + List channels = SlackStore.query(SlackTables.CHANNEL, SlackStore.equal("name", target.name) + .withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)).withCriteria(new QFilterCriteria("workspaceId", QCriteriaOperator.IN, ids))); + if(channels.isEmpty()) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't a registered Slack channel for that tenant")); + } + if(channels.size() > 1) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, target.name + " is registered in more than one workspace")); + } + + QRecord channel = channels.get(0); + QRecord workspace = workspaces.stream().filter(w -> w.getValueInteger("id").equals(channel.getValueInteger("workspaceId"))).findFirst().orElseThrow(); + if(!notBlank(workspace.getValueString("botTokenFingerprint"))) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "A registered channel needs the workspace's Slack bot")); + } + if(!notBlank(channel.getValueString("slackChannelId"))) + { + return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't linked to a Slack channel yet")); + } + + String name = channel.getValueString("slackChannelName") == null ? target.name : channel.getValueString("slackChannelName"); + return (SlackAuthorization.allow(new AuthorizedSend(workspace.getValueInteger("id"), null, SlackEnums.PostAs.BOT, channel.getValueString("slackChannelId"), "#" + name, + SlackEnums.TargetType.REGISTERED_CHANNEL, target.ref, null, null, workspace.getValueString("tenantKey")))); + } + + + + /******************************************************************************* + ** Mark the row whose token Slack says is no longer valid as REVOKED: the + ** workspace for the bot, the owner's connection for a user token. + *******************************************************************************/ + public static void markRevoked(SlackEnums.PostAs identity, Integer workspaceId, Integer connectionId) throws QException + { + Instant now = SlackRuntime.clock().instant(); + if(identity == SlackEnums.PostAs.BOT) + { + SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", workspaceId).withValue("status", SlackEnums.WorkspaceStatus.REVOKED.name()), null); + } + else if(connectionId != null) + { + SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", connectionId).withValue("status", SlackEnums.ConnectionStatus.REVOKED.name()) + .withValue("revokedAt", now).withValue("lastError", "Slack access was revoked"), null); + } + } + + + + /******************************************************************************* + ** True for a string with text in it. + *******************************************************************************/ + private static boolean notBlank(String value) + { + return (value != null && !value.isBlank()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackOwnerSession.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackOwnerSession.java new file mode 100644 index 0000000..e7e2708 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackOwnerSession.java @@ -0,0 +1,113 @@ +/* + * 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 java.util.Objects; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface; + + +/******************************************************************************* + ** Runs work as a QQQ user, the way core's scheduled reports do + ** (RunScheduledReportExecuteStep.updateSessionForUser): the session comes from + ** the instance's authentication module through createAutomatedSessionForUser, + ** so the host's customizer puts the user's permissions and tenant key on it. + ** The session must name that user and be valid, and is never a system session. + ** + ** The slack_user key is then set to the owner (and any all-access key removed), + ** so the owner locks on connections and messages match this user and only this + ** user, whatever the host's customizer did. Queries run inside the work see + ** the tenant lock, the owner lock and the table permissions of that user. + *******************************************************************************/ +public final class SlackOwnerSession +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackOwnerSession() + { + } + + + + /******************************************************************************* + ** Build the owner's session. + *******************************************************************************/ + public static QSession create(QInstance qInstance, String ownerUserId) throws QException + { + try + { + QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication()); + QSession session = module.createAutomatedSessionForUser(qInstance, ownerUserId); + if(session == null || session instanceof QSystemUserSession || session.getUser() == null + || !Objects.equals(ownerUserId, session.getUser().getIdReference()) || !module.isSessionValid(qInstance, session)) + { + throw (new QPermissionDeniedException("Could not establish the Slack owner's session.")); + } + + SlackSecurity.applyToSession(session, ownerUserId, false); + return (session); + } + catch(QException e) + { + throw (e); + } + catch(Exception e) + { + throw (new QException("Could not establish the Slack owner's session.")); + } + } + + + + /******************************************************************************* + ** Run work in the owner's session, then restore the caller's context. The + ** caller's transaction is not visible to the work. + *******************************************************************************/ + public static T call(String ownerUserId, SlackStore.Work work) throws QException + { + return (run(create(QContext.getQInstance(), ownerUserId), work)); + } + + + + /******************************************************************************* + ** Run work in an owner session that was already built (so a caller can tell a + ** session that couldn't be built from work that failed), then restore the + ** caller's context. + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static T run(QSession session, SlackStore.Work work) throws QException + { + Object[] holder = new Object[1]; + QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session), () -> holder[0] = work.run()); + return ((T) holder[0]); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackRedaction.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackRedaction.java new file mode 100644 index 0000000..615b7bd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackRedaction.java @@ -0,0 +1,77 @@ +/* + * 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 java.util.regex.Pattern; + + +/******************************************************************************* + ** Scrubs secrets out of any text that may be stored or shown (lastError, + ** exception text, log lines): Slack tokens, bearer credentials, authorization + ** headers and OAuth client_secret and code values. The result is capped, so an + ** error body can't flood a column. + *******************************************************************************/ +public final class SlackRedaction +{ + public static final int MAX_LENGTH = 500; + + public static final String REDACTED = "[REDACTED]"; + + private static final Pattern SLACK_TOKEN = Pattern.compile("xox[abeprs]-[A-Za-z0-9-]+"); + private static final Pattern AUTH_HEADER = Pattern.compile("(?i)\\bauthorization\\s*[:=]\\s*(?:bearer\\s+|basic\\s+)?[^\\s\"',;]+"); + private static final Pattern BEARER = Pattern.compile("(?i)\\bbearer\\s+[^\\s\"',;]+"); + private static final Pattern QUERY_SECRETS = Pattern.compile("(?i)\\b(client_secret|code)=[^&\\s\"']*"); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackRedaction() + { + } + + + + /******************************************************************************* + ** Remove token and secret material from the text, then cap its length. + ** Null stays null. + *******************************************************************************/ + public static String scrub(String text) + { + if(text == null) + { + return (null); + } + + String result = SLACK_TOKEN.matcher(text).replaceAll(REDACTED); + result = AUTH_HEADER.matcher(result).replaceAll("Authorization: " + REDACTED); + result = BEARER.matcher(result).replaceAll("Bearer " + REDACTED); + result = QUERY_SECRETS.matcher(result).replaceAll("$1=" + REDACTED); + + if(result.length() > MAX_LENGTH) + { + result = result.substring(0, MAX_LENGTH); + } + return (result); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackRefusal.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackRefusal.java new file mode 100644 index 0000000..ff82213 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackRefusal.java @@ -0,0 +1,60 @@ +/* + * 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 com.kingsrook.qbits.slack.transport.SlackErrorClassifier; + + +/******************************************************************************* + ** Why an authorization was refused. The message is for the user and contains + ** no token or Slack payload. reason says what kind of refusal it is; when a + ** call to Slack caused it (membership lookups), slackKind, slackError and + ** retryAfterSeconds carry how Slack's answer was classified, so the dispatcher + ** can retry a Slack outage instead of failing the owner's authorization. + *******************************************************************************/ +public record SlackRefusal(Reason reason, String message, SlackErrorClassifier.Kind slackKind, String slackError, Integer retryAfterSeconds) +{ + /******************************************************************************* + ** The kinds of refusal. + *******************************************************************************/ + public enum Reason + { + BAD_ADDRESS, + NOT_FOUND, + NO_CONNECTION, + NO_ACCESS, + NOT_MEMBER, + POSTING_IDENTITY, + AUTH, + SLACK_ERROR + } + + + + /******************************************************************************* + ** A refusal that didn't involve a call to Slack. + *******************************************************************************/ + public static SlackRefusal of(Reason reason, String message) + { + return (new SlackRefusal(reason, message, null, null, null)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackSecurity.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackSecurity.java new file mode 100644 index 0000000..c580a2a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackSecurity.java @@ -0,0 +1,165 @@ +/* + * 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 java.io.Serializable; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.session.QSession; + + +/******************************************************************************* + ** The Slack record-security keys (docs/DESIGN.md sections 1, 3 and 14.1). + ** + ** Two key types guard the qbit's own tables: + ** - slack_user: the session's QQQ user id. Connections and messages are locked + ** to it on their owner column (requirement R1). Slack admins may hold the + ** all-access key, and still stay inside their tenant lock. + ** - slack_token_access: a key nobody ever holds. It backs the FieldSecurityLock + ** on the token columns, so no user session can read or write them (R5). The + ** engine runs as the system user, which core lets through, so only the + ** dispatcher and the OAuth callback touch tokens. + ** + ** The tenant lock is the host's: its securityFields and recordSecurityLocks go + ** on slack_workspace, and every child inherits it through a join lock. + ** + ** Hosts call applyToSession from their authentication customizer, after the + ** session's user is set. + *******************************************************************************/ +public final class SlackSecurity +{ + public static final String KEY_USER = "slack_user"; + public static final String KEY_USER_ALL_ACCESS = "slack_user_all_access"; + public static final String KEY_TOKEN_ACCESS = "slack_token_access"; + + ///////////////////////////////////////////////////////////////////////////// + // the one value that would open a token field. No session ever holds it, // + // and the key type has no all-access key. // + ///////////////////////////////////////////////////////////////////////////// + private static final String NEVER_HELD = "slack-token-access-never-granted"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackSecurity() + { + } + + + + /******************************************************************************* + ** The key types, for the producer. + *******************************************************************************/ + public static List buildSecurityKeyTypes() + { + return (List.of( + new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS), + new QSecurityKeyType().withName(KEY_TOKEN_ACCESS))); + } + + + + /******************************************************************************* + ** Owner lock: the session's slack_user value must equal the field. Reads and + ** writes are both locked, so a user can't insert a row for someone else. + *******************************************************************************/ + public static RecordSecurityLock ownerLock(String fieldName) + { + return (new RecordSecurityLock() + .withSecurityKeyType(KEY_USER) + .withFieldName(fieldName) + .withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE)); + } + + + + /******************************************************************************* + ** Owner lock reached through a join chain (for example attempt to message). + *******************************************************************************/ + public static RecordSecurityLock ownerLock(String fieldName, List joinNameChain) + { + return (ownerLock(fieldName).withJoinNameChain(joinNameChain)); + } + + + + /******************************************************************************* + ** The lock that keeps a token column away from every non-system session. + ** Default DENY, and the only override value is one nobody holds. + *******************************************************************************/ + public static FieldSecurityLock tokenFieldLock() + { + return (new FieldSecurityLock() + .withSecurityKeyType(KEY_TOKEN_ACCESS) + .withDefaultBehavior(FieldSecurityLock.Behavior.DENY) + .withOverrideValues(List.of(NEVER_HELD))); + } + + + + /******************************************************************************* + ** Put the user id (and, for admins, the all-access key) into a session. + ** Idempotent: earlier slack keys are replaced. A blank user gets no key, so + ** the owner locks deny. + *******************************************************************************/ + public static void applyToSession(QSession session, String userId, boolean allUsers) + { + if(session == null) + { + return; + } + + if(session.getSecurityKeyValues() != null) + { + session.getSecurityKeyValues().remove(KEY_USER); + session.getSecurityKeyValues().remove(KEY_USER_ALL_ACCESS); + } + + if(userId != null && !userId.isBlank()) + { + session.withSecurityKeyValue(KEY_USER, userId); + } + if(allUsers) + { + session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true); + } + } + + + + /******************************************************************************* + ** The value the session holds for the owner key, or null. + *******************************************************************************/ + public static String userId(QSession session) + { + if(session == null) + { + return (null); + } + List values = session.getSecurityKeyValues(KEY_USER); + return (values == null || values.isEmpty() || values.get(0) == null ? null : String.valueOf(values.get(0))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackTokenCipher.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackTokenCipher.java new file mode 100644 index 0000000..b7eb897 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackTokenCipher.java @@ -0,0 +1,230 @@ +/* + * 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.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Set; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** AES-256-GCM encryption of Slack tokens at rest (docs/DESIGN.md section 3.4). + ** + ** Stored format: v1:keyId:base64(nonce|ciphertext|tag), with a random 96-bit + ** nonce per value. The associated data is a caller-supplied binding string (for + ** example slack_connection:12:userId), so a ciphertext copied into another row, + ** user or tenant fails to decrypt. + ** + ** Keys are never in the database. They come from environment variables named by + ** the config (tokenKeyEnvName: key id "current"; previousTokenKeyEnvName: key id + ** "previous", used to decrypt after a rotation), each the base64 of 32 bytes, + ** resolved through the config's secretResolver and its allowedEnvNames. + ** + ** Every failure is a QException with a generic message: no key, token, binding + ** or underlying exception text is ever included. + *******************************************************************************/ +public class SlackTokenCipher +{ + public static final String VERSION = "v1"; + public static final String KEY_CURRENT = "current"; + public static final String KEY_PREVIOUS = "previous"; + + private static final int NONCE_BYTES = 12; + private static final int TAG_BITS = 128; + private static final int KEY_BYTES = 32; + + private static final SecureRandom RANDOM = new SecureRandom(); + + private final SlackQBitConfig config; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public SlackTokenCipher(SlackQBitConfig config) + { + this.config = config; + } + + + + /******************************************************************************* + ** Encrypt a token with the current key, bound to the given row binding. + *******************************************************************************/ + public String encrypt(String plaintext, String binding) throws QException + { + if(plaintext == null || binding == null || binding.isEmpty()) + { + throw (new QException("Slack token could not be encrypted")); + } + + byte[] key = resolveKey(KEY_CURRENT); + try + { + byte[] nonce = new byte[NONCE_BYTES]; + RANDOM.nextBytes(nonce); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(binding.getBytes(StandardCharsets.UTF_8)); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + byte[] combined = new byte[nonce.length + encrypted.length]; + System.arraycopy(nonce, 0, combined, 0, nonce.length); + System.arraycopy(encrypted, 0, combined, nonce.length, encrypted.length); + + return (VERSION + ":" + KEY_CURRENT + ":" + Base64.getEncoder().encodeToString(combined)); + } + catch(GeneralSecurityException e) + { + ////////////////////////////////////////////////////////////////////// + // deliberately no cause and no detail: nothing about the key or // + // the token may escape through an exception // + ////////////////////////////////////////////////////////////////////// + throw (new QException("Slack token could not be encrypted")); + } + } + + + + /******************************************************************************* + ** Decrypt a stored value for the given row binding, using the key it names. + *******************************************************************************/ + public String decrypt(String stored, String binding) throws QException + { + if(stored == null || binding == null || binding.isEmpty()) + { + throw (new QException("Slack token could not be decrypted")); + } + + String[] parts = stored.split(":", 3); + if(parts.length != 3 || !VERSION.equals(parts[0]) || !Set.of(KEY_CURRENT, KEY_PREVIOUS).contains(parts[1])) + { + throw (new QException("Slack token could not be decrypted")); + } + + byte[] key = resolveKey(parts[1]); + try + { + byte[] combined = Base64.getDecoder().decode(parts[2]); + if(combined.length <= NONCE_BYTES) + { + throw (new QException("Slack token could not be decrypted")); + } + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_BITS, combined, 0, NONCE_BYTES)); + cipher.updateAAD(binding.getBytes(StandardCharsets.UTF_8)); + byte[] plain = cipher.doFinal(combined, NONCE_BYTES, combined.length - NONCE_BYTES); + return (new String(plain, StandardCharsets.UTF_8)); + } + catch(GeneralSecurityException | IllegalArgumentException e) + { + ///////////////////////////////////////////////////////////////////// + // wrong key, wrong binding, tampering or bad base64: all look the // + // same, with no detail // + ///////////////////////////////////////////////////////////////////// + throw (new QException("Slack token could not be decrypted")); + } + } + + + + /******************************************************************************* + ** The first 8 hex characters of the SHA-256 of a token: a support-friendly, + ** non-reversible hint that tells two tokens apart. + *******************************************************************************/ + public String fingerprint(String plaintext) throws QException + { + if(plaintext == null) + { + throw (new QException("Slack token could not be fingerprinted")); + } + + try + { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(plaintext.getBytes(StandardCharsets.UTF_8)); + return (HexFormat.of().formatHex(digest).substring(0, 8)); + } + catch(GeneralSecurityException e) + { + throw (new QException("Slack token could not be fingerprinted")); + } + } + + + + /******************************************************************************* + ** Resolve one key (current or previous) through the allow-listed environment + ** name. A missing, disallowed or malformed key is a generic failure. + *******************************************************************************/ + private byte[] resolveKey(String keyId) throws QException + { + String envName = KEY_CURRENT.equals(keyId) ? config.getTokenKeyEnvName() : config.getPreviousTokenKeyEnvName(); + if(envName == null || config.getSecretResolver() == null || config.getAllowedEnvNames() == null || !config.getAllowedEnvNames().contains(envName)) + { + throw (new QException("Slack token key is not configured")); + } + + String value; + try + { + value = config.getSecretResolver().apply(envName); + } + catch(RuntimeException e) + { + /////////////////////////////////////////////////// + // a failing resolver must never leak its text // + /////////////////////////////////////////////////// + throw (new QException("Slack token key is not configured")); + } + + if(value == null || value.isBlank()) + { + throw (new QException("Slack token key is not configured")); + } + + try + { + byte[] key = Base64.getDecoder().decode(value.trim()); + if(key.length != KEY_BYTES) + { + throw (new QException("Slack token key is not configured")); + } + return (key); + } + catch(IllegalArgumentException e) + { + throw (new QException("Slack token key is not configured")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackTokens.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackTokens.java new file mode 100644 index 0000000..2377217 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackTokens.java @@ -0,0 +1,101 @@ +/* + * 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 java.util.Objects; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** The only reader of stored tokens. A token is read by primary key, in a + ** system session, with hidden and password fields switched on (the one place + ** that is allowed), and decrypted with the binding of the row it came from, so + ** a ciphertext copied into another row or another tenant's row fails. + ** + ** Callers must already hold an AuthorizedSend from SlackAuthorizer: the row id + ** comes from that, never from a request. The plaintext is returned to the + ** caller's stack only. It is never logged, stored or put in an exception, and + ** every failure here is a generic QException. + *******************************************************************************/ +public final class SlackTokens +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackTokens() + { + } + + + + /******************************************************************************* + ** The binding of a workspace's bot token: its table, id and tenant key. + *******************************************************************************/ + public static String workspaceBinding(Integer workspaceId, String tenantKey) + { + return (SlackTables.WORKSPACE + ":" + workspaceId + ":" + Objects.toString(tenantKey, "")); + } + + + + /******************************************************************************* + ** The binding of a connection's user token: its table, id and owner. + *******************************************************************************/ + public static String connectionBinding(Integer connectionId, String userId) + { + return (SlackTables.CONNECTION + ":" + connectionId + ":" + Objects.toString(userId, "")); + } + + + + /******************************************************************************* + ** The bot token of an authorized workspace row. + *******************************************************************************/ + public static String botToken(SlackQBitConfig config, Integer workspaceId) throws QException + { + QRecord row = SlackStore.getWithToken(SlackTables.WORKSPACE, workspaceId); + if(row == null || row.getValueString(SlackTables.BOT_TOKEN) == null) + { + throw (new QException("Slack token is not available")); + } + return (new SlackTokenCipher(config).decrypt(row.getValueString(SlackTables.BOT_TOKEN), workspaceBinding(workspaceId, row.getValueString("tenantKey")))); + } + + + + /******************************************************************************* + ** The user token of an authorized connection row. + *******************************************************************************/ + public static String userToken(SlackQBitConfig config, Integer connectionId) throws QException + { + QRecord row = SlackStore.getWithToken(SlackTables.CONNECTION, connectionId); + if(row == null || row.getValueString(SlackTables.USER_TOKEN) == null) + { + throw (new QException("Slack token is not available")); + } + return (new SlackTokenCipher(config).decrypt(row.getValueString(SlackTables.USER_TOKEN), connectionBinding(connectionId, row.getValueString("userId")))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/support/SlackMembershipCache.java b/src/main/java/com/kingsrook/qbits/slack/support/SlackMembershipCache.java new file mode 100644 index 0000000..35ac430 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/support/SlackMembershipCache.java @@ -0,0 +1,133 @@ +/* + * 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.support; + + +import java.time.Instant; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + + +/******************************************************************************* + ** In-memory cache of who is in a channel, per (workspaceId, channelId), kept + ** for membershipCacheMinutes (docs/DESIGN.md sections 4.1 and 16). The key + ** names the tenant-scoped workspace row, so an entry can never be shared + ** across tenants. The map is bounded at MAX_ENTRIES: expired entries go first, + ** then the oldest. Nothing is written to disk. + *******************************************************************************/ +public class SlackMembershipCache +{ + public static final int MAX_ENTRIES = 10_000; + + private final Map entries = new LinkedHashMap<>(); + + + + /******************************************************************************* + ** What was learned about a channel: its members' Slack user ids, whether the + ** workspace bot is one of them, its display name, and when the entry expires. + *******************************************************************************/ + public record Entry(Set members, boolean botIsMember, String channelName, Instant expiresAt) + { + } + + + + /******************************************************************************* + ** The live entry for a channel, or null when there is none or it expired. + *******************************************************************************/ + public synchronized Entry get(Integer workspaceId, String channelId, Instant now) + { + String key = key(workspaceId, channelId); + Entry entry = entries.get(key); + if(entry != null && !entry.expiresAt().isAfter(now)) + { + entries.remove(key); + return (null); + } + return (entry); + } + + + + /******************************************************************************* + ** Keep an entry, making room first when the map is full. + *******************************************************************************/ + public synchronized void put(Integer workspaceId, String channelId, Entry entry, Instant now) + { + String key = key(workspaceId, channelId); + entries.remove(key); + + if(entries.size() >= MAX_ENTRIES) + { + entries.values().removeIf(e -> !e.expiresAt().isAfter(now)); + } + while(entries.size() >= MAX_ENTRIES) + { + Iterator oldest = entries.keySet().iterator(); + oldest.next(); + oldest.remove(); + } + entries.put(key, entry); + } + + + + /******************************************************************************* + ** Forget one channel, after a send shows the cached answer was wrong. + *******************************************************************************/ + public synchronized void invalidate(Integer workspaceId, String channelId) + { + entries.remove(key(workspaceId, channelId)); + } + + + + /******************************************************************************* + ** The number of entries held. + *******************************************************************************/ + public synchronized int size() + { + return (entries.size()); + } + + + + /******************************************************************************* + ** Forget everything (tests). + *******************************************************************************/ + public synchronized void clear() + { + entries.clear(); + } + + + + /******************************************************************************* + ** Map key. + *******************************************************************************/ + private static String key(Integer workspaceId, String channelId) + { + return (workspaceId + "/" + channelId); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/support/SlackRateLimiter.java b/src/main/java/com/kingsrook/qbits/slack/support/SlackRateLimiter.java new file mode 100644 index 0000000..c743f6e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/support/SlackRateLimiter.java @@ -0,0 +1,163 @@ +/* + * 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.support; + + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + + +/******************************************************************************* + ** The client-side limiter (docs/DESIGN.md section 5.3), in memory only: at most + ** one post per second per channel, ratePerMinute posts per workspace, and one + ** probe every five minutes while a workspace is unhealthy. Waiting under a + ** limit is never an attempt: the dispatcher just leaves the message due. + ** + ** Time is passed in by the caller, so the dispatcher's clock (which tests + ** replace) drives it. + *******************************************************************************/ +public class SlackRateLimiter +{ + public static final int MAX_CHANNELS = 10_000; + + private static final long CHANNEL_SPACING_MILLIS = 1000L; + private static final long PROBE_SPACING_MILLIS = 300_000L; + + private final Map lastPostByChannel = new HashMap<>(); + private final Map> minuteWindows = new HashMap<>(); + private final Map lastProbe = new HashMap<>(); + + + + /******************************************************************************* + ** Milliseconds to wait before a post to this channel is allowed, or 0. + *******************************************************************************/ + public synchronized long waitMillis(Integer workspaceId, String channelKey, int ratePerMinute, Instant now) + { + long wait = 0; + + Instant last = lastPostByChannel.get(key(workspaceId, channelKey)); + if(last != null) + { + wait = Math.max(wait, CHANNEL_SPACING_MILLIS - Duration.between(last, now).toMillis()); + } + + ArrayDeque window = minuteWindows.get(workspaceId); + if(window != null) + { + prune(window, now); + if(window.size() >= ratePerMinute) + { + wait = Math.max(wait, 60_000L - Duration.between(window.peekFirst(), now).toMillis()); + } + } + return (Math.max(0, wait)); + } + + + + /******************************************************************************* + ** Note that a post to the channel is being made now. + *******************************************************************************/ + public synchronized void record(Integer workspaceId, String channelKey, Instant now) + { + if(lastPostByChannel.size() >= MAX_CHANNELS) + { + Iterator> iterator = lastPostByChannel.entrySet().iterator(); + while(iterator.hasNext()) + { + if(Duration.between(iterator.next().getValue(), now).toMillis() >= CHANNEL_SPACING_MILLIS) + { + iterator.remove(); + } + } + if(lastPostByChannel.size() >= MAX_CHANNELS) + { + lastPostByChannel.clear(); + } + } + lastPostByChannel.put(key(workspaceId, channelKey), now); + + ArrayDeque window = minuteWindows.computeIfAbsent(workspaceId, k -> new ArrayDeque<>()); + prune(window, now); + window.addLast(now); + } + + + + /******************************************************************************* + ** Milliseconds until an unhealthy workspace may be probed again, or 0. + *******************************************************************************/ + public synchronized long probeWaitMillis(Integer workspaceId, Instant now) + { + Instant last = lastProbe.get(workspaceId); + return (last == null ? 0 : Math.max(0, PROBE_SPACING_MILLIS - Duration.between(last, now).toMillis())); + } + + + + /******************************************************************************* + ** Note that an unhealthy workspace is being probed now. + *******************************************************************************/ + public synchronized void recordProbe(Integer workspaceId, Instant now) + { + lastProbe.put(workspaceId, now); + } + + + + /******************************************************************************* + ** Forget everything (tests). + *******************************************************************************/ + public synchronized void clear() + { + lastPostByChannel.clear(); + minuteWindows.clear(); + lastProbe.clear(); + } + + + + /******************************************************************************* + ** Drop window entries older than a minute. + *******************************************************************************/ + private static void prune(ArrayDeque window, Instant now) + { + while(!window.isEmpty() && Duration.between(window.peekFirst(), now).toMillis() >= 60_000L) + { + window.removeFirst(); + } + } + + + + /******************************************************************************* + ** Map key for a workspace's channel. + *******************************************************************************/ + private static String key(Integer workspaceId, String channelKey) + { + return (workspaceId + "/" + channelKey); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/support/SlackRuntime.java b/src/main/java/com/kingsrook/qbits/slack/support/SlackRuntime.java new file mode 100644 index 0000000..07ddee4 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/support/SlackRuntime.java @@ -0,0 +1,167 @@ +/* + * 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.support; + + +import java.lang.management.ManagementFactory; +import java.time.Clock; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.SlackQBitProducer; +import com.kingsrook.qbits.slack.transport.SlackClient; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; + + +/******************************************************************************* + ** Runtime lookups for the qbit. The config is read from the current QInstance + ** on every call, never held, so two instances in one JVM never see each other's + ** settings. The JVM-wide state is the runner identity, the clock (tests replace + ** it), the HTTP client per config, and the two in-memory caches (the limiter + ** and the channel membership cache), which never touch disk. + *******************************************************************************/ +public final class SlackRuntime +{ + private static final String NODE_ID = ManagementFactory.getRuntimeMXBean().getName() + "/" + UUID.randomUUID().toString().substring(0, 8); + + private static volatile Clock clock = Clock.systemUTC(); + + private static final Map CLIENTS = Collections.synchronizedMap(new WeakHashMap<>()); + private static final SlackRateLimiter LIMITER = new SlackRateLimiter(); + private static final SlackMembershipCache MEMBERSHIP = new SlackMembershipCache(); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackRuntime() + { + } + + + + /******************************************************************************* + ** The config in the current instance, or null when the qbit isn't installed. + *******************************************************************************/ + public static SlackQBitConfig config() + { + return (config(QContext.getQInstance())); + } + + + + /******************************************************************************* + ** The config in an instance, or null when the qbit isn't installed. + *******************************************************************************/ + public static SlackQBitConfig config(QInstance qInstance) + { + if(qInstance == null || qInstance.getQBits() == null) + { + return (null); + } + for(QBitMetaData qBit : qInstance.getQBits().values()) + { + if(SlackQBitProducer.ARTIFACT_ID.equals(qBit.getArtifactId()) && qBit.getConfig() instanceof SlackQBitConfig config) + { + return (config); + } + } + return (null); + } + + + + /******************************************************************************* + ** The Slack client for a config. + *******************************************************************************/ + public static SlackClient client(SlackQBitConfig config) + { + return (CLIENTS.computeIfAbsent(config, SlackClient::new)); + } + + + + /******************************************************************************* + ** The clock (tests replace it). + *******************************************************************************/ + public static Clock clock() + { + return (clock); + } + + + + /******************************************************************************* + ** Replace the clock (tests). Null restores the system clock. + *******************************************************************************/ + public static void setClock(Clock newClock) + { + clock = newClock == null ? Clock.systemUTC() : newClock; + } + + + + /******************************************************************************* + ** Runner identity, stored on claimed attempts. + *******************************************************************************/ + public static String nodeId() + { + return (NODE_ID); + } + + + + /******************************************************************************* + ** The client-side limiter. + *******************************************************************************/ + public static SlackRateLimiter limiter() + { + return (LIMITER); + } + + + + /******************************************************************************* + ** The channel membership cache. + *******************************************************************************/ + public static SlackMembershipCache membership() + { + return (MEMBERSHIP); + } + + + + /******************************************************************************* + ** Forget the in-memory state and restore the system clock (tests). + *******************************************************************************/ + public static void reset() + { + LIMITER.clear(); + MEMBERSHIP.clear(); + CLIENTS.clear(); + clock = Clock.systemUTC(); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/support/SlackStore.java b/src/main/java/com/kingsrook/qbits/slack/support/SlackStore.java new file mode 100644 index 0000000..456f2e6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/support/SlackStore.java @@ -0,0 +1,199 @@ +/* + * 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.support; + + +import java.io.Serializable; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; + + +/******************************************************************************* + ** The qbit's own reads and writes. Every method except queryAsCurrent runs in + ** a temporary system session: the outbox, the dispatcher and the token reader + ** are the qbit acting for itself, after the owner's access was checked + ** somewhere else (SlackAuthorizer). queryAsCurrent is the one deliberate + ** exception, and runs in whatever session is current, so a caller that put the + ** owner's session in place gets the owner's tenant and record locks. + *******************************************************************************/ +public final class SlackStore +{ + /******************************************************************************* + ** Work that may throw. + *******************************************************************************/ + @FunctionalInterface + public interface Work + { + /******************************************************************************* + ** Do it. + *******************************************************************************/ + T run() throws QException; + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackStore() + { + } + + + + /******************************************************************************* + ** Run work in a temporary system session, then restore the caller's context + ** (including a caller transaction, which the work doesn't see). + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static T asSystem(Work work) throws QException + { + Object[] holder = new Object[1]; + QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), new QSystemUserSession(QContext.getQInstance())), () -> holder[0] = work.run()); + return ((T) holder[0]); + } + + + + /******************************************************************************* + ** Equality filter. + *******************************************************************************/ + public static QQueryFilter equal(String field, Serializable value) + { + return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value))); + } + + + + /******************************************************************************* + ** Query as the system. Hidden and password fields stay out of the result. + *******************************************************************************/ + public static List query(String table, QQueryFilter filter) throws QException + { + return (query(table, filter, null)); + } + + + + /******************************************************************************* + ** Query as the system, inside a transaction. + *******************************************************************************/ + public static List query(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException + { + return (asSystem(() -> new QueryAction().execute(new QueryInput(table).withShouldFetchHeavyFields(true).withFilter(filter).withTransaction(transaction)).getRecords())); + } + + + + /******************************************************************************* + ** Query in the current session, with its tenant and owner locks. Hidden and + ** password fields stay out of the result. + *******************************************************************************/ + public static List queryAsCurrent(String table, QQueryFilter filter) throws QException + { + return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()); + } + + + + /******************************************************************************* + ** One row by primary key, as the system, or null. + *******************************************************************************/ + public static QRecord get(String table, Integer id) throws QException + { + return (get(table, id, null)); + } + + + + /******************************************************************************* + ** One row by primary key, as the system, inside a transaction, or null. + *******************************************************************************/ + public static QRecord get(String table, Integer id, QBackendTransaction transaction) throws QException + { + List rows = query(table, equal("id", id), transaction); + return (rows.isEmpty() ? null : rows.get(0)); + } + + + + /******************************************************************************* + ** One row by primary key with its hidden token column. Only the token reader + ** calls this, and only for a row the owner's session already authorized. + *******************************************************************************/ + public static QRecord getWithToken(String table, Integer id) throws QException + { + List rows = asSystem(() -> new QueryAction().execute(new QueryInput(table).withFilter(equal("id", id)).withShouldOmitHiddenFields(false).withShouldMaskPasswords(false)).getRecords()); + return (rows.isEmpty() ? null : rows.get(0)); + } + + + + /******************************************************************************* + ** Count rows, as the system, optionally inside a transaction. + *******************************************************************************/ + public static int count(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException + { + Integer count = asSystem(() -> new CountAction().execute(new CountInput(table).withFilter(filter).withTransaction(transaction)).getCount()); + return (count == null ? 0 : count); + } + + + + /******************************************************************************* + ** Insert one row as the system. The returned record carries any errors. + *******************************************************************************/ + public static QRecord insert(String table, QRecord record, QBackendTransaction transaction) throws QException + { + return (asSystem(() -> new InsertAction().execute(new InsertInput(table).withRecords(List.of(record)).withTransaction(transaction)).getRecords().get(0))); + } + + + + /******************************************************************************* + ** Update one row as the system, failing on any error. The message never + ** includes record contents. + *******************************************************************************/ + public static void update(String table, QRecord record, QBackendTransaction transaction) throws QException + { + List rows = asSystem(() -> new UpdateAction().execute(new UpdateInput(table).withRecords(List.of(record)).withTransaction(transaction)).getRecords()); + if(rows.isEmpty() || (rows.get(0).getErrors() != null && !rows.get(0).getErrors().isEmpty())) + { + throw (new QException("Slack database update failed")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java new file mode 100644 index 0000000..b5465fb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java @@ -0,0 +1,348 @@ +/* + * 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.transport; + + +import java.io.IOException; +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.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.security.SlackRedaction; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + + +/******************************************************************************* + ** A small Slack Web API client on java.net.http. The base URL comes from the + ** config (so tests point it at a fake server). The bearer token is a parameter + ** of every call and is never kept in a field, logged, or put in an error text. + ** Writes send JSON bodies; reads use the query string. + ** + ** Every method returns a SlackResponse, including when the network fails (HTTP + ** status 0), so a caller classifies one thing with SlackErrorClassifier. + *******************************************************************************/ +public class SlackClient +{ + public static final String EVENT_TYPE = "qqq_message"; + + private static final int DEFAULT_RETRY_AFTER_SECONDS = 30; + + private final String baseUrl; + private final HttpClient http; + private final Duration requestTimeout; + + + + /******************************************************************************* + ** Constructor: the base URL and the connect and request timeouts come from + ** the config. + *******************************************************************************/ + public SlackClient(SlackQBitConfig config) + { + String url = config.getSlackApiBaseUrl(); + this.baseUrl = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + this.requestTimeout = Duration.ofSeconds(config.getRequestTimeoutSeconds()); + this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(config.getConnectTimeoutSeconds())).build(); + } + + + + /******************************************************************************* + ** auth.test: who a token is, and which scopes it carries. + *******************************************************************************/ + public SlackResponse authTest(String token) + { + return (post(token, "auth.test", new JSONObject())); + } + + + + /******************************************************************************* + ** chat.postMessage, with the qqq_message metadata (message id and idempotency + ** key) that lets a crashed attempt be recognised in conversations.history, and + ** link unfurling off. + *******************************************************************************/ + public SlackResponse chatPostMessage(String token, String channel, String text, String blocksJson, String threadTs, Integer messageId, String idempotencyKey) + { + JSONObject body = new JSONObject().put("channel", channel).put("unfurl_links", false).put("unfurl_media", false); + if(text != null) + { + body.put("text", text); + } + if(threadTs != null) + { + body.put("thread_ts", threadTs); + } + + JSONObject payload = new JSONObject().put("id", messageId); + if(idempotencyKey != null) + { + payload.put("key", idempotencyKey); + } + body.put("metadata", new JSONObject().put("event_type", EVENT_TYPE).put("event_payload", payload)); + + return (withBlocks(token, "chat.postMessage", body, blocksJson)); + } + + + + /******************************************************************************* + ** chat.update of a message this token posted. + *******************************************************************************/ + public SlackResponse chatUpdate(String token, String channel, String ts, String text, String blocksJson) + { + JSONObject body = new JSONObject().put("channel", channel).put("ts", ts); + if(text != null) + { + body.put("text", text); + } + return (withBlocks(token, "chat.update", body, blocksJson)); + } + + + + /******************************************************************************* + ** conversations.open: the direct-message channel with one user. + *******************************************************************************/ + public SlackResponse conversationsOpen(String token, String slackUserId) + { + return (post(token, "conversations.open", new JSONObject().put("users", slackUserId))); + } + + + + /******************************************************************************* + ** One page of conversations.members. + *******************************************************************************/ + public SlackResponse conversationsMembers(String token, String channel, String cursor) + { + Map query = new LinkedHashMap<>(); + query.put("channel", channel); + query.put("limit", "1000"); + if(cursor != null && !cursor.isEmpty()) + { + query.put("cursor", cursor); + } + return (get(token, "conversations.members", query)); + } + + + + /******************************************************************************* + ** One page of conversations.history, with message metadata included, from + ** the given oldest timestamp (epoch seconds). + *******************************************************************************/ + public SlackResponse conversationsHistory(String token, String channel, String oldest, String cursor) + { + Map query = new LinkedHashMap<>(); + query.put("channel", channel); + query.put("limit", "200"); + query.put("include_all_metadata", "true"); + if(oldest != null) + { + query.put("oldest", oldest); + } + if(cursor != null && !cursor.isEmpty()) + { + query.put("cursor", cursor); + } + return (get(token, "conversations.history", query)); + } + + + + /******************************************************************************* + ** conversations.info: a channel's name and flags. + *******************************************************************************/ + public SlackResponse conversationsInfo(String token, String channel) + { + Map query = new LinkedHashMap<>(); + query.put("channel", channel); + return (get(token, "conversations.info", query)); + } + + + + /******************************************************************************* + ** One page of users.conversations: the channels the token's user is in. + *******************************************************************************/ + public SlackResponse usersConversations(String token, String cursor) + { + Map query = new LinkedHashMap<>(); + query.put("types", "public_channel,private_channel"); + query.put("exclude_archived", "true"); + query.put("limit", "1000"); + if(cursor != null && !cursor.isEmpty()) + { + query.put("cursor", cursor); + } + return (get(token, "users.conversations", query)); + } + + + + /******************************************************************************* + ** Add the Block Kit array, when there is one. Text that isn't a JSON array + ** is the caller's invalid_blocks error, and never reaches Slack. + *******************************************************************************/ + private SlackResponse withBlocks(String token, String method, JSONObject body, String blocksJson) + { + if(blocksJson != null && !blocksJson.isBlank()) + { + try + { + body.put("blocks", new JSONArray(blocksJson)); + } + catch(JSONException e) + { + return (SlackResponse.failure(400, "invalid_blocks")); + } + } + return (post(token, method, body)); + } + + + + /******************************************************************************* + ** POST a JSON body. + *******************************************************************************/ + private SlackResponse post(String token, String method, JSONObject body) + { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/" + method)) + .timeout(requestTimeout) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)); + return (send(builder)); + } + + + + /******************************************************************************* + ** GET with a query string. + *******************************************************************************/ + private SlackResponse get(String token, String method, Map query) + { + StringBuilder url = new StringBuilder(baseUrl).append("/").append(method); + String sep = "?"; + for(Map.Entry entry : query.entrySet()) + { + url.append(sep).append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)).append("=").append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); + sep = "&"; + } + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url.toString())) + .timeout(requestTimeout) + .header("Authorization", "Bearer " + token) + .GET(); + return (send(builder)); + } + + + + /******************************************************************************* + ** Send, and fold every outcome (including a network failure) into a + ** SlackResponse. Error texts are fixed words or Slack's own error code run + ** through the scrubber; a raw body is never kept or logged. + *******************************************************************************/ + private SlackResponse send(HttpRequest.Builder builder) + { + try + { + HttpResponse response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + JSONObject json = parse(response.body()); + int status = response.statusCode(); + + boolean ok = json != null && json.optBoolean("ok", false) && status >= 200 && status < 300; + String error = json == null ? null : json.optString("error", null); + if(!ok && error == null) + { + error = "http_" + status; + } + + Integer retryAfter = null; + if(status == 429) + { + retryAfter = response.headers().firstValue("Retry-After").map(SlackClient::parseSeconds).orElse(DEFAULT_RETRY_AFTER_SECONDS); + } + + return (new SlackResponse(status, ok, SlackRedaction.scrub(error), retryAfter, response.headers().firstValue("x-oauth-scopes").orElse(null), json)); + } + catch(HttpTimeoutException e) + { + return (SlackResponse.failure(0, "timeout")); + } + catch(IOException e) + { + return (SlackResponse.failure(0, "network_error")); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + return (SlackResponse.failure(0, "interrupted")); + } + } + + + + /******************************************************************************* + ** Parse a body as a JSON object, or null when it isn't one. + *******************************************************************************/ + private static JSONObject parse(String body) + { + try + { + return (body == null || body.isBlank() ? null : new JSONObject(body)); + } + catch(JSONException e) + { + return (null); + } + } + + + + /******************************************************************************* + ** Retry-After as whole seconds (at least 1), or the default when unreadable. + *******************************************************************************/ + private static int parseSeconds(String value) + { + try + { + return (Math.max(1, Integer.parseInt(value.trim()))); + } + catch(NumberFormatException e) + { + return (DEFAULT_RETRY_AFTER_SECONDS); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/transport/SlackErrorClassifier.java b/src/main/java/com/kingsrook/qbits/slack/transport/SlackErrorClassifier.java new file mode 100644 index 0000000..e534d60 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/transport/SlackErrorClassifier.java @@ -0,0 +1,129 @@ +/* + * 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.transport; + + +import java.util.Set; + + +/******************************************************************************* + ** Maps a Slack response or exception to what the dispatcher does next + ** (docs/DESIGN.md section 5.3): + ** - RATE_LIMITED: HTTP 429; wait Retry-After, and it isn't an attempt; + ** - TRANSIENT: 5xx, timeouts, network errors, and the transient error codes; + ** - PERMANENT: the permanent error codes, other 4xx, and any error code this + ** class doesn't know (so a new Slack error is seen, not retried blindly); + ** - AUTH: the credential, scope and membership error codes. The caller is + ** told, and nothing is retried with another token. + *******************************************************************************/ +public final class SlackErrorClassifier +{ + private static final Set TRANSIENT_CODES = Set.of("ratelimited", "service_unavailable", "internal_error", "fatal_error", "request_timeout"); + private static final Set PERMANENT_CODES = Set.of("channel_not_found", "is_archived", "msg_too_long", "invalid_blocks", "no_text", "user_not_found", "cannot_dm_bot", "user_disabled"); + private static final Set AUTH_CODES = Set.of("not_in_channel", "invalid_auth", "token_revoked", "account_inactive", "missing_scope", "not_authed", "token_expired"); + private static final Set REVOKING_CODES = Set.of("invalid_auth", "token_revoked", "account_inactive", "not_authed", "token_expired"); + + + + /******************************************************************************* + ** What to do with a call's result. + *******************************************************************************/ + public enum Kind + { + OK, + RATE_LIMITED, + TRANSIENT, + PERMANENT, + AUTH + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackErrorClassifier() + { + } + + + + /******************************************************************************* + ** Classify a response. + *******************************************************************************/ + public static Kind classify(SlackResponse response) + { + if(response == null) + { + return (Kind.TRANSIENT); + } + if(response.getHttpStatus() == 429) + { + return (Kind.RATE_LIMITED); + } + if(response.getHttpStatus() == 0 || response.getHttpStatus() >= 500) + { + return (Kind.TRANSIENT); + } + if(response.isOk()) + { + return (Kind.OK); + } + + String code = response.getError(); + if(code != null && AUTH_CODES.contains(code)) + { + return (Kind.AUTH); + } + if(code != null && TRANSIENT_CODES.contains(code)) + { + return (Kind.TRANSIENT); + } + if(response.getHttpStatus() == 401 || response.getHttpStatus() == 403) + { + return (Kind.AUTH); + } + return (Kind.PERMANENT); + } + + + + /******************************************************************************* + ** Classify an exception thrown while calling Slack. Anything that stops the + ** call before an answer arrives is worth a retry. + *******************************************************************************/ + public static Kind classify(Throwable throwable) + { + return (Kind.TRANSIENT); + } + + + + /******************************************************************************* + ** True when the error code means the token itself is no longer valid, so the + ** connection (or workspace) should be marked revoked. A missing scope or a + ** missing channel membership leaves the token valid. + *******************************************************************************/ + public static boolean revokesAccess(String errorCode) + { + return (errorCode != null && REVOKING_CODES.contains(errorCode)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/transport/SlackResponse.java b/src/main/java/com/kingsrook/qbits/slack/transport/SlackResponse.java new file mode 100644 index 0000000..6de8702 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/transport/SlackResponse.java @@ -0,0 +1,136 @@ +/* + * 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.transport; + + +import org.json.JSONObject; + + +/******************************************************************************* + ** What one Slack Web API call returned: the HTTP status (0 when no response + ** arrived), Slack's ok flag and error code, the Retry-After seconds, the + ** granted scopes header, and the parsed JSON body. It never holds the token + ** that made the call. + *******************************************************************************/ +public class SlackResponse +{ + private final int httpStatus; + private final boolean ok; + private final String error; + private final Integer retryAfterSeconds; + private final String scopes; + private final JSONObject json; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public SlackResponse(int httpStatus, boolean ok, String error, Integer retryAfterSeconds, String scopes, JSONObject json) + { + this.httpStatus = httpStatus; + this.ok = ok; + this.error = error; + this.retryAfterSeconds = retryAfterSeconds; + this.scopes = scopes; + this.json = json; + } + + + + /******************************************************************************* + ** A response for a call that never got an answer (network error, timeout). + *******************************************************************************/ + public static SlackResponse failure(int httpStatus, String error) + { + return (new SlackResponse(httpStatus, false, error, null, null, null)); + } + + + + /******************************************************************************* + ** Getter for httpStatus + *******************************************************************************/ + public int getHttpStatus() + { + return (this.httpStatus); + } + + + + /******************************************************************************* + ** Getter for ok + *******************************************************************************/ + public boolean isOk() + { + return (this.ok); + } + + + + /******************************************************************************* + ** Getter for error + *******************************************************************************/ + public String getError() + { + return (this.error); + } + + + + /******************************************************************************* + ** Getter for retryAfterSeconds + *******************************************************************************/ + public Integer getRetryAfterSeconds() + { + return (this.retryAfterSeconds); + } + + + + /******************************************************************************* + ** Getter for scopes (the x-oauth-scopes header) + *******************************************************************************/ + public String getScopes() + { + return (this.scopes); + } + + + + /******************************************************************************* + ** Getter for json, which is null when the body wasn't a JSON object. + *******************************************************************************/ + public JSONObject getJson() + { + return (this.json); + } + + + + /******************************************************************************* + ** A top-level string from the body, or null. + *******************************************************************************/ + public String getString(String key) + { + return (json == null ? null : json.optString(key, null)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java b/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java new file mode 100644 index 0000000..6d391f5 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java @@ -0,0 +1,331 @@ +/* + * 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 java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** A fake Slack Web API on an ephemeral port. It records every request (Slack + ** method, Authorization header, query string and body), and answers from + ** scripted replies first, then from simple defaults: auth.test, chat.postMessage + ** (a fresh ts each time), chat.update, conversations.open, members, info and + ** history of the channels the test set up. + *******************************************************************************/ +public class FakeSlackServer implements AutoCloseable +{ + private final HttpServer server; + + private final List requests = new CopyOnWriteArrayList<>(); + private final Map> scripts = new ConcurrentHashMap<>(); + private final Map> members = new ConcurrentHashMap<>(); + private final Map names = new ConcurrentHashMap<>(); + private final Map history = new ConcurrentHashMap<>(); + private final AtomicInteger tsCount = new AtomicInteger(100); + private volatile long delayMillis; + private final AtomicBoolean closed = new AtomicBoolean(); + + + + /******************************************************************************* + ** One request the server saw. + *******************************************************************************/ + public record Recorded(String method, String authorization, String query, String body) + { + /******************************************************************************* + ** The body as JSON (an empty object for a GET). + *******************************************************************************/ + public JSONObject json() + { + return (body == null || body.isBlank() ? new JSONObject() : new JSONObject(body)); + } + } + + + + /******************************************************************************* + ** One scripted answer. + *******************************************************************************/ + public record Reply(int status, String body, Map headers) + { + /******************************************************************************* + ** An answer with no extra headers. + *******************************************************************************/ + public static Reply of(int status, String body) + { + return (new Reply(status, body, Map.of())); + } + + + + /******************************************************************************* + ** A Slack error answered with HTTP 200, the way Slack does. + *******************************************************************************/ + public static Reply error(String code) + { + return (of(200, "{\"ok\":false,\"error\":\"" + code + "\"}")); + } + } + + + + /******************************************************************************* + ** Start on an ephemeral loopback port. + *******************************************************************************/ + public FakeSlackServer() throws IOException + { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/api/", this::handle); + server.start(); + } + + + + /******************************************************************************* + ** The base URL for the config. + *******************************************************************************/ + public String baseUrl() + { + return ("http://127.0.0.1:" + server.getAddress().getPort() + "/api"); + } + + + + /******************************************************************************* + ** Answer the next calls to a Slack method with these replies, in order. + *******************************************************************************/ + public void script(String method, Reply... replies) + { + Deque queue = scripts.computeIfAbsent(method, k -> new ArrayDeque<>()); + synchronized(queue) + { + queue.addAll(List.of(replies)); + } + } + + + + /******************************************************************************* + ** Set who is in a channel (Slack user ids). + *******************************************************************************/ + public void members(String channel, String... slackUserIds) + { + members.put(channel, List.of(slackUserIds)); + } + + + + /******************************************************************************* + ** Set a channel's name for conversations.info. + *******************************************************************************/ + public void name(String channel, String name) + { + names.put(channel, name); + } + + + + /******************************************************************************* + ** Set what conversations.history returns for a channel. + *******************************************************************************/ + public void history(String channel, JSONArray messages) + { + history.put(channel, messages); + } + + + + /******************************************************************************* + ** Hold every answer for this long (a slow server). + *******************************************************************************/ + public void delay(long millis) + { + this.delayMillis = millis; + } + + + + /******************************************************************************* + ** Every request seen so far. + *******************************************************************************/ + public List requests() + { + return (List.copyOf(requests)); + } + + + + /******************************************************************************* + ** The requests for one Slack method. + *******************************************************************************/ + public List requests(String method) + { + return (requests.stream().filter(r -> r.method().equals(method)).collect(Collectors.toList())); + } + + + + /******************************************************************************* + ** Every distinct Authorization header seen. + *******************************************************************************/ + public java.util.Set authorizations() + { + return (requests.stream().map(Recorded::authorization).collect(Collectors.toSet())); + } + + + + /******************************************************************************* + ** Forget the recorded requests. + *******************************************************************************/ + public void clearRequests() + { + requests.clear(); + } + + + + /******************************************************************************* + ** Handle one HTTP exchange. + *******************************************************************************/ + private void handle(HttpExchange exchange) throws IOException + { + String method = exchange.getRequestURI().getPath().substring("/api/".length()); + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + String query = exchange.getRequestURI().getRawQuery(); + Recorded recorded = new Recorded(method, exchange.getRequestHeaders().getFirst("Authorization"), query, body); + requests.add(recorded); + + try + { + if(delayMillis > 0) + { + Thread.sleep(delayMillis); + } + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + } + + Reply reply = scripted(method); + if(reply == null) + { + reply = defaultReply(recorded); + } + + byte[] bytes = reply.body().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8"); + reply.headers().forEach((k, v) -> exchange.getResponseHeaders().add(k, v)); + exchange.sendResponseHeaders(reply.status(), bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + + + /******************************************************************************* + ** The next scripted reply for a method, or null. + *******************************************************************************/ + private Reply scripted(String method) + { + Deque queue = scripts.get(method); + if(queue == null) + { + return (null); + } + synchronized(queue) + { + return (queue.poll()); + } + } + + + + /******************************************************************************* + ** The default answer for a method. + *******************************************************************************/ + private Reply defaultReply(Recorded request) + { + Map query = new LinkedHashMap<>(); + if(request.query() != null) + { + for(String pair : request.query().split("&")) + { + String[] kv = pair.split("=", 2); + query.put(java.net.URLDecoder.decode(kv[0], StandardCharsets.UTF_8), kv.length > 1 ? java.net.URLDecoder.decode(kv[1], StandardCharsets.UTF_8) : ""); + } + } + + switch(request.method()) + { + case "chat.postMessage": + return (Reply.of(200, new JSONObject().put("ok", true).put("channel", request.json().optString("channel")).put("ts", "1700000000." + String.format("%06d", tsCount.incrementAndGet())).toString())); + case "chat.update": + return (Reply.of(200, new JSONObject().put("ok", true).put("channel", request.json().optString("channel")).put("ts", request.json().optString("ts")).toString())); + case "conversations.open": + return (Reply.of(200, new JSONObject().put("ok", true).put("channel", new JSONObject().put("id", "D" + request.json().optString("users"))).toString())); + case "conversations.members": + List ids = members.get(query.get("channel")); + return (ids == null + ? Reply.error("channel_not_found") + : Reply.of(200, new JSONObject().put("ok", true).put("members", new JSONArray(ids)).put("response_metadata", new JSONObject().put("next_cursor", "")).toString())); + case "conversations.info": + return (Reply.of(200, new JSONObject().put("ok", true).put("channel", new JSONObject().put("id", query.get("channel")).put("name", names.getOrDefault(query.get("channel"), "general"))).toString())); + case "conversations.history": + return (Reply.of(200, new JSONObject().put("ok", true).put("messages", history.getOrDefault(query.get("channel"), new JSONArray())).toString())); + default: + return (Reply.of(200, "{\"ok\":true}")); + } + } + + + + /******************************************************************************* + ** Stop the server. + *******************************************************************************/ + @Override + public void close() + { + if(closed.compareAndSet(false, true)) + { + server.stop(0); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/LogCapture.java b/src/test/java/com/kingsrook/qbits/slack/LogCapture.java new file mode 100644 index 0000000..12fd910 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/LogCapture.java @@ -0,0 +1,101 @@ +/* + * 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 java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; + + +/******************************************************************************* + ** Captures every log event (message, and any exception with its stack trace and + ** causes) while open, so a test can assert that a secret never reached a log + ** line. It attaches an appender to the root logger, the way QLogger's output + ** reaches the console. + *******************************************************************************/ +public final class LogCapture implements AutoCloseable +{ + private final List lines = new CopyOnWriteArrayList<>(); + private final LoggerContext context; + private final AbstractAppender appender; + + + + /******************************************************************************* + ** Start capturing. + *******************************************************************************/ + public LogCapture() + { + context = (LoggerContext) LogManager.getContext(false); + appender = new AbstractAppender("slack-test-capture", null, null, true, Property.EMPTY_ARRAY) + { + /******************************************************************************* + ** Keep the event's text. + *******************************************************************************/ + @Override + public void append(LogEvent event) + { + StringBuilder text = new StringBuilder(event.getMessage().getFormattedMessage()); + if(event.getThrown() != null) + { + StringWriter writer = new StringWriter(); + event.getThrown().printStackTrace(new PrintWriter(writer)); + text.append(' ').append(writer); + } + lines.add(text.toString()); + } + }; + appender.start(); + context.getConfiguration().getRootLogger().addAppender(appender, Level.ALL, null); + context.updateLoggers(); + } + + + + /******************************************************************************* + ** Everything captured so far. + *******************************************************************************/ + public List lines() + { + return (List.copyOf(lines)); + } + + + + /******************************************************************************* + ** Stop capturing. + *******************************************************************************/ + @Override + public void close() + { + context.getConfiguration().getRootLogger().removeAppender(appender.getName()); + appender.stop(); + context.updateLoggers(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackAuthorizerTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackAuthorizerTest.java new file mode 100644 index 0000000..468836b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackAuthorizerTest.java @@ -0,0 +1,382 @@ +/* + * 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 java.time.Duration; +import com.kingsrook.qbits.slack.metadata.SlackEnums; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.security.SlackAuthorization; +import com.kingsrook.qbits.slack.security.SlackAuthorizer; +import com.kingsrook.qbits.slack.security.SlackRefusal; +import com.kingsrook.qbits.slack.support.SlackMembershipCache; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +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; + + +/******************************************************************************* + ** The authorizer and the service's request checks: the address forms + ** (docs/DESIGN.md 4.2), permissions, workspace choice, the membership cache, + ** and the owner lock on a reply or an update of someone else's message. + *******************************************************************************/ +class SlackAuthorizerTest +{ + private SlackTestEnv env; + private int workspaceId; + + + + /******************************************************************************* + ** One workspace with a bot, userA connected, a registered channel "ops", and + ** a user group "oncall" that posts in it. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + env = SlackTestEnv.create(false, c -> + { + }); + workspaceId = env.workspace("T1", null, "xoxb-CANARY-AUTH", "UBOT"); + env.connection("userA", workspaceId, "UA", "xoxp-CANARY-AUTH-USER"); + env.channel(workspaceId, "ops", "COPS", "ops-alerts"); + env.userGroup(workspaceId, "oncall", "S123", "COPS"); + env.slack().members("COPS", "UA", "UBOT"); + env.slack().members("C1", "UA", "UBOT"); + } + + + + /******************************************************************************* + ** Restore process-wide state. + *******************************************************************************/ + @AfterEach + void cleanup() + { + env.close(); + } + + + + /******************************************************************************* + ** Queue as userA. + *******************************************************************************/ + private SlackSendResult queue(SlackSendRequest request) throws Exception + { + env.asOwner("userA"); + return (SlackService.enqueue(request)); + } + + + + /******************************************************************************* + ** The registered forms resolve: user:, #name, channel:name and + ** group:name (which mentions the group, and posts in its channel). + *******************************************************************************/ + @Test + void testAddressForms() throws Exception + { + QRecord self = env.row(SlackTables.MESSAGE, queue(new SlackSendRequest().withTo("user:userA").withText("hi")).messageId()); + assertThat(self.getValueString("targetType")).isEqualTo("USER"); + + QRecord hash = env.row(SlackTables.MESSAGE, queue(new SlackSendRequest().withTo("#ops").withText("hi")).messageId()); + assertThat(hash.getValueString("targetType")).isEqualTo("REGISTERED_CHANNEL"); + assertThat(hash.getValueString("slackChannelId")).isEqualTo("COPS"); + assertThat(hash.getValueString("channelLabel")).isEqualTo("#ops-alerts"); + + QRecord named = env.row(SlackTables.MESSAGE, queue(new SlackSendRequest().withTo("channel:ops").withText("hi")).messageId()); + assertThat(named.getValueString("targetRef")).isEqualTo("channel:ops"); + + QRecord group = env.row(SlackTables.MESSAGE, queue(new SlackSendRequest().withTo("group:oncall").withText("page ")).messageId()); + assertThat(group.getValueString("targetType")).isEqualTo("USER_GROUP"); + assertThat(group.getValueString("slackChannelId")).isEqualTo("COPS"); + assertThat(group.getValueString("text")).isEqualTo(" page <me>"); + + int sent = 0; + for(int i = 0; i < 4; i++) + { + env.clock().advance(Duration.ofSeconds(2)); + sent += env.dispatch().sent(); + } + assertThat(sent).isEqualTo(4); + assertThat(env.slack().requests("chat.postMessage").stream().map(r -> r.json().getString("text")).toList()).contains(" page <me>"); + } + + + + /******************************************************************************* + ** Anything else is refused, with a reason that names the accepted forms, and + ** nobody can address someone else's direct messages. + *******************************************************************************/ + @Test + void testBadAddressesAreRefused() throws Exception + { + for(String address : new String[] { "", " ", "UA", "#", "channel:", "user:", "group:", "channel:abc/C1", "channel:" + workspaceId + "/bad id", "channel:" + workspaceId + "/", "email:a@b.c", "channel:" + workspaceId + "/C1/extra" }) + { + SlackSendResult result = queue(new SlackSendRequest().withTo(address).withText("hi")); + assertThat(result.status()).as(address).isEqualTo(SlackSendResult.Status.REFUSED); + } + assertThat(queue(new SlackSendRequest().withTo("garbage").withText("hi")).reasons().get(0)).contains("isn't recognized"); + assertThat(queue(new SlackSendRequest().withTo("user:userB").withText("hi")).reasons().get(0)).contains("only send Slack direct messages to yourself"); + assertThat(queue(new SlackSendRequest().withTo("#nope").withText("hi")).reasons().get(0)).contains("isn't a registered Slack channel"); + assertThat(queue(new SlackSendRequest().withTo("group:nope").withText("hi")).reasons().get(0)).contains("isn't a registered Slack user group"); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).isEmpty(); + } + + + + /******************************************************************************* + ** The request's own checks: text or blocks, lengths, the idempotency key, + ** an address. + *******************************************************************************/ + @Test + void testRequestValidation() throws Exception + { + assertThat(queue(new SlackSendRequest().withTo("me")).reasons().get(0)).contains("needs text or blocks"); + assertThat(queue(new SlackSendRequest().withTo("me").withText("x".repeat(SlackService.MAX_TEXT_LENGTH + 1))).reasons().get(0)).contains("too long"); + assertThat(queue(new SlackSendRequest().withText("hi")).reasons().get(0)).contains("address is required"); + assertThat(queue(new SlackSendRequest().withTo("me").withText("hi").withIdempotencyKey("k".repeat(501))).reasons().get(0)).contains("idempotency key is too long"); + assertThat(queue(new SlackSendRequest().withTo("me").withBlocksJson("not json")).reasons().get(0)).contains("JSON array"); + assertThat(queue(new SlackSendRequest().withTo("me").withBlocksJson("[]")).reasons().get(0)).contains("1 to 50"); + assertThat(queue(new SlackSendRequest().withTo("me").withBlocksJson("[" + "{\"type\":\"divider\"},".repeat(51) + "{\"type\":\"divider\"}]")).reasons().get(0)).contains("1 to 50"); + assertThat(queue(new SlackSendRequest().withTo("me").withBlocksJson("[{\"type\":\"x\"}]" + " ".repeat(SlackService.MAX_TEXT_LENGTH)).withText("hi")).reasons().get(0)).contains("too long"); + assertThat(SlackService.enqueue(null).status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(SlackService.update(null, null).status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(SlackService.sendOnce(null, "k").status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(SlackService.update(1, new SlackSendRequest()).status()).isEqualTo(SlackSendResult.Status.REFUSED); + } + + + + /******************************************************************************* + ** Permissions are the owner's: slack.send is needed, and raw blocks need + ** slack.send.blocks. A user with neither can't get past it, and the session + ** with no slack access at all is refused by the authorizer. + *******************************************************************************/ + @Test + void testPermissions() throws Exception + { + String blocks = "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"hi\"}}]"; + SlackSendResult noBlocksPermission = queue(new SlackSendRequest().withTo("me").withBlocksJson(blocks)); + assertThat(noBlocksPermission.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(noBlocksPermission.reasons().get(0)).contains("slack.send.blocks"); + + SlackTestEnv.TestAuthCustomizer.BLOCK_USERS.add("userA"); + SlackSendResult withPermission = queue(new SlackSendRequest().withTo("me").withText("fallback").withBlocksJson(blocks)); + assertThat(withPermission.status()).isEqualTo(SlackSendResult.Status.QUEUED); + assertThat(env.row(SlackTables.MESSAGE, withPermission.messageId()).getValueString("blocksJson")).isEqualTo(blocks); + env.dispatch(); + assertThat(env.slack().requests("chat.postMessage").get(0).json().getJSONArray("blocks").getJSONObject(0).getString("type")).isEqualTo("section"); + + SlackTestEnv.TestAuthCustomizer.NO_ACCESS.add("userA"); + SlackSendResult noAccess = queue(new SlackSendRequest().withTo("me").withText("hi")); + assertThat(noAccess.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(noAccess.reasons().get(0)).contains("slack.send"); + + env.asSystem(); + SlackAuthorization denied = new SlackAuthorizer().authorize(new SlackAuthorizer.Request().withOwnerUserId("userA").withAddress("me")); + assertThat(denied.isAuthorized()).isFalse(); + assertThat(denied.getRefusal().reason()).isEqualTo(SlackRefusal.Reason.NO_ACCESS); + assertThat(denied.getRefusal().message()).contains("don't have access"); + + SlackAuthorization noSession = new SlackAuthorizer().authorize(new SlackAuthorizer.Request().withOwnerUserId("").withAddress("me")); + assertThat(noSession.getRefusal().message()).contains("couldn't verify your access"); + } + + + + /******************************************************************************* + ** Two connected workspaces need a named one; naming one works; the other + ** workspace's bot is not used. + *******************************************************************************/ + @Test + void testSeveralWorkspacesNeedANamedOne() throws Exception + { + int second = env.workspace("T2", null, "xoxb-CANARY-AUTH-2", "UBOT2"); + env.connection("userA", second, "UA2", null); + + SlackSendResult ambiguous = queue(new SlackSendRequest().withTo("me").withText("hi")); + assertThat(ambiguous.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(ambiguous.reasons().get(0)).contains("more than one Slack workspace"); + + SlackSendResult named = queue(new SlackSendRequest().withTo("me").withText("hi").withWorkspaceId(second)); + assertThat(named.status()).isEqualTo(SlackSendResult.Status.QUEUED); + assertThat(env.row(SlackTables.MESSAGE, named.messageId()).getValueInteger("workspaceId")).isEqualTo(second); + + SlackSendResult mismatch = queue(new SlackSendRequest().withTo("channel:" + workspaceId + "/C1").withText("hi").withWorkspaceId(second)); + assertThat(mismatch.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(queue(new SlackSendRequest().withTo("#ops").withText("hi")).status()).isEqualTo(SlackSendResult.Status.QUEUED); + } + + + + /******************************************************************************* + ** Membership is cached per workspace and channel for membershipCacheMinutes: + ** a second queue makes no Slack call, an expired entry is read again, and + ** the map is bounded. + *******************************************************************************/ + @Test + void testMembershipCache() throws Exception + { + String channel = "channel:" + workspaceId + "/C1"; + queue(new SlackSendRequest().withTo(channel).withText("one")); + queue(new SlackSendRequest().withTo(channel).withText("two")); + assertThat(env.slack().requests("conversations.members")).hasSize(1); + assertThat(SlackRuntime.membership().size()).isEqualTo(1); + + env.clock().advance(Duration.ofMinutes(9)); + queue(new SlackSendRequest().withTo(channel).withText("three")); + assertThat(env.slack().requests("conversations.members")).hasSize(1); + + env.clock().advance(Duration.ofMinutes(2)); + queue(new SlackSendRequest().withTo(channel).withText("four")); + assertThat(env.slack().requests("conversations.members")).hasSize(2); + + SlackRuntime.membership().invalidate(workspaceId, "C1"); + queue(new SlackSendRequest().withTo(channel).withText("five")); + assertThat(env.slack().requests("conversations.members")).hasSize(3); + + for(int i = 0; i < SlackMembershipCache.MAX_ENTRIES + 25; i++) + { + SlackRuntime.membership().put(workspaceId, "X" + i, new SlackMembershipCache.Entry(java.util.Set.of("UA"), false, null, env.clock().instant().plusSeconds(600)), env.clock().instant()); + } + assertThat(SlackRuntime.membership().size()).isEqualTo(SlackMembershipCache.MAX_ENTRIES); + } + + + + /******************************************************************************* + ** Slack errors while checking membership: a missing channel, a revoked token + ** and an outage are each refused with the right reason, and a revoked token + ** marks the workspace. + *******************************************************************************/ + @Test + void testMembershipLookupFailures() throws Exception + { + SlackSendResult missing = queue(new SlackSendRequest().withTo("channel:" + workspaceId + "/CNONE").withText("hi")); + assertThat(missing.reasons().get(0)).contains("wasn't found"); + + env.slack().script("conversations.members", FakeSlackServer.Reply.of(503, "{\"ok\":false,\"error\":\"service_unavailable\"}")); + assertThat(queue(new SlackSendRequest().withTo("channel:" + workspaceId + "/C1").withText("hi")).reasons().get(0)).contains("isn't reachable"); + + env.slack().script("conversations.members", new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", java.util.Map.of("Retry-After", "3"))); + assertThat(queue(new SlackSendRequest().withTo("channel:" + workspaceId + "/C1").withText("hi")).reasons().get(0)).contains("limiting requests"); + + env.slack().script("conversations.members", FakeSlackServer.Reply.error("not_in_channel"), FakeSlackServer.Reply.error("missing_scope")); + assertThat(queue(new SlackSendRequest().withTo("channel:" + workspaceId + "/C1").withText("hi")).reasons().get(0)).contains("can't see that channel"); + assertThat(env.row(SlackTables.WORKSPACE, workspaceId).getValueString("status")).isEqualTo("ACTIVE"); + + env.slack().script("conversations.members", FakeSlackServer.Reply.error("token_revoked")); + assertThat(queue(new SlackSendRequest().withTo("channel:" + workspaceId + "/C1").withText("hi")).reasons().get(0)).contains("revoked"); + assertThat(env.row(SlackTables.WORKSPACE, workspaceId).getValueString("status")).isEqualTo("REVOKED"); + } + + + + /******************************************************************************* + ** The posting identity can be insisted on: USER needs the owner's token, BOT + ** needs the bot in the channel; and with the user's token only (no bot) the + ** owner's token is the identity. + *******************************************************************************/ + @Test + void testPostingIdentityRequests() throws Exception + { + env.slack().members("C5", "UA"); + SlackSendResult botNotIn = queue(new SlackSendRequest().withTo("channel:" + workspaceId + "/C5").withText("hi").withPostAs(SlackEnums.PostAs.BOT)); + assertThat(botNotIn.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(botNotIn.reasons().get(0)).contains("bot isn't in"); + + SlackSendResult asUser = queue(new SlackSendRequest().withTo("channel:" + workspaceId + "/C1").withText("hi").withPostAs(SlackEnums.PostAs.USER)); + assertThat(env.row(SlackTables.MESSAGE, asUser.messageId()).getValueString("postAs")).isEqualTo("USER"); + + int userOnly = env.workspace("T9", null, null, null); + env.connection("userA", userOnly, "UA9", "xoxp-CANARY-AUTH-USER-9"); + env.set(SlackTables.WORKSPACE, workspaceId, "status", "REVOKED"); + SlackSendResult viaUser = queue(new SlackSendRequest().withTo("me").withText("hi")); + assertThat(env.row(SlackTables.MESSAGE, viaUser.messageId()).getValueString("postAs")).isEqualTo("USER"); + + int noTokens = env.workspace("T10", null, null, null); + env.connection("userA", noTokens, "UA10", null); + env.set(SlackTables.WORKSPACE, userOnly, "status", "REVOKED"); + SlackSendResult none = queue(new SlackSendRequest().withTo("me").withText("hi")); + assertThat(none.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(none.reasons().get(0)).contains("No Slack token is available"); + } + + + + /******************************************************************************* + ** The owner lock covers replies and updates: another user, connected in the + ** same workspace, can't thread under or edit userA's message, and can't see + ** that it exists. + *******************************************************************************/ + @Test + void testOtherUsersCannotReplyToOrUpdateMyMessages() throws Exception + { + SlackSendResult mine = queue(new SlackSendRequest().withTo("me").withText("mine")); + env.dispatch(); + + env.connection("userB", workspaceId, "UB", null); + env.asOwner("userB"); + SlackSendResult reply = SlackService.enqueue(new SlackSendRequest().withText("hijack").withThreadOfMessageId(mine.messageId())); + SlackSendResult update = SlackService.update(mine.messageId(), new SlackSendRequest().withText("hijack")); + assertThat(reply.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(reply.reasons().get(0)).contains("wasn't found"); + assertThat(update.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(update.reasons().get(0)).contains("wasn't found"); + + env.asSystem(); + SlackSendResult systemReply = SlackService.enqueue(new SlackSendRequest().withText("no owner").withThreadOfMessageId(mine.messageId())); + assertThat(systemReply.reasons().get(0)).contains("needs an owner"); + assertThat(SlackService.update(mine.messageId(), new SlackSendRequest().withText("no owner")).reasons().get(0)).contains("Only the owner"); + } + + + + /******************************************************************************* + ** The dispatcher's other entry points: sendNow ignores the due time, an + ** unknown id does nothing, and a FILE message is a permanent failure until + ** file delivery exists. + *******************************************************************************/ + @Test + void testSendNowUnknownIdAndFileMessage() throws Exception + { + SlackSendResult later = queue(new SlackSendRequest().withTo("me").withText("later")); + env.set(SlackTables.MESSAGE, later.messageId(), "nextAttemptDate", env.clock().instant().plusSeconds(3600)); + env.asSystem(); + assertThat(env.dispatch().sent()).isZero(); + assertThat(new com.kingsrook.qbits.slack.delivery.SlackDispatcher().sendNow(later.messageId())).isEqualTo(com.kingsrook.qbits.slack.delivery.SlackDispatcher.SendResult.SENT); + assertThat(new com.kingsrook.qbits.slack.delivery.SlackDispatcher().sendNow(999_999)).isEqualTo(com.kingsrook.qbits.slack.delivery.SlackDispatcher.SendResult.NOT_SENT); + + env.clock().advance(Duration.ofSeconds(2)); + SlackSendResult file = queue(new SlackSendRequest().withTo("me").withText("file")); + env.set(SlackTables.MESSAGE, file.messageId(), "operation", "FILE"); + env.dispatch(); + assertThat(env.row(SlackTables.MESSAGE, file.messageId()).getValueString("status")).isEqualTo("DEAD_LETTER"); + assertThat(env.row(SlackTables.MESSAGE, file.messageId()).getValueString("lastErrorCode")).isEqualTo("unsupported"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackClientTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackClientTest.java new file mode 100644 index 0000000..a56d407 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackClientTest.java @@ -0,0 +1,278 @@ +/* + * 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 java.util.Map; +import com.kingsrook.qbits.slack.transport.SlackClient; +import com.kingsrook.qbits.slack.transport.SlackErrorClassifier; +import com.kingsrook.qbits.slack.transport.SlackResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The Slack client against the fake server, and the error classifier: what + ** goes on the wire (paths, bearer header, JSON or query), what comes back + ** (status, ok, error, Retry-After, scopes), and how each answer is classified. + *******************************************************************************/ +class SlackClientTest +{ + private FakeSlackServer server; + private SlackClient client; + + + + /******************************************************************************* + ** A client with short timeouts, pointed at the fake server. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + server = new FakeSlackServer(); + client = new SlackClient(new SlackQBitConfig().withSlackApiBaseUrl(server.baseUrl() + "/").withConnectTimeoutSeconds(1).withRequestTimeoutSeconds(1)); + } + + + + /******************************************************************************* + ** Stop the server. + *******************************************************************************/ + @AfterEach + void cleanup() + { + server.close(); + } + + + + /******************************************************************************* + ** auth.test sends the bearer token per call, and returns the scopes header. + *******************************************************************************/ + @Test + void testAuthTestReturnsScopesAndSendsBearerToken() throws Exception + { + server.script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\"}", Map.of("x-oauth-scopes", "chat:write,im:write"))); + SlackResponse response = client.authTest("xoxb-CANARY-CLIENT"); + + assertThat(response.isOk()).isTrue(); + assertThat(response.getHttpStatus()).isEqualTo(200); + assertThat(response.getScopes()).isEqualTo("chat:write,im:write"); + assertThat(response.getString("team_id")).isEqualTo("T1"); + assertThat(SlackErrorClassifier.classify(response)).isEqualTo(SlackErrorClassifier.Kind.OK); + assertThat(server.requests()).hasSize(1); + assertThat(server.requests().get(0).method()).isEqualTo("auth.test"); + assertThat(server.requests().get(0).authorization()).isEqualTo("Bearer xoxb-CANARY-CLIENT"); + + assertThat(client.authTest("xoxp-CANARY-OTHER").isOk()).isTrue(); + assertThat(server.requests().get(1).authorization()).isEqualTo("Bearer xoxp-CANARY-OTHER"); + } + + + + /******************************************************************************* + ** Writes are JSON; reads are query strings, encoded. + *******************************************************************************/ + @Test + void testRequestShapes() throws Exception + { + client.chatPostMessage("t", "C1", "hello", "[{\"type\":\"divider\"}]", "123.456", 42, "key-1"); + assertThat(server.requests().get(0).json().getString("channel")).isEqualTo("C1"); + assertThat(server.requests().get(0).json().getString("thread_ts")).isEqualTo("123.456"); + assertThat(server.requests().get(0).json().getJSONArray("blocks").length()).isEqualTo(1); + assertThat(server.requests().get(0).json().getJSONObject("metadata").getJSONObject("event_payload").getString("key")).isEqualTo("key-1"); + assertThat(server.requests().get(0).json().getBoolean("unfurl_media")).isFalse(); + + client.chatPostMessage("t", "C1", null, "[{\"type\":\"divider\"}]", null, 7, null); + assertThat(server.requests().get(1).json().has("text")).isFalse(); + assertThat(server.requests().get(1).json().has("thread_ts")).isFalse(); + assertThat(server.requests().get(1).json().getJSONObject("metadata").getJSONObject("event_payload").has("key")).isFalse(); + + client.chatUpdate("t", "C1", "123.456", "edited", null); + assertThat(server.requests().get(2).method()).isEqualTo("chat.update"); + assertThat(server.requests().get(2).json().getString("ts")).isEqualTo("123.456"); + + client.conversationsOpen("t", "U1"); + assertThat(server.requests().get(3).json().getString("users")).isEqualTo("U1"); + + client.conversationsMembers("t", "C1", "dXNlcjpVMDYxTkZUVDI="); + assertThat(server.requests().get(4).query()).contains("channel=C1").contains("limit=1000").contains("cursor=dXNlcjpVMDYxTkZUVDI%3D"); + client.conversationsMembers("t", "C1", null); + assertThat(server.requests().get(5).query()).doesNotContain("cursor"); + + client.conversationsHistory("t", "C1", "1700000000", "next page"); + assertThat(server.requests().get(6).query()).contains("include_all_metadata=true").contains("oldest=1700000000").contains("cursor=next+page"); + client.conversationsHistory("t", "C1", null, null); + assertThat(server.requests().get(7).query()).doesNotContain("oldest"); + + client.conversationsInfo("t", "C1"); + assertThat(server.requests().get(8).query()).isEqualTo("channel=C1"); + + client.usersConversations("t", "abc"); + assertThat(server.requests().get(9).query()).contains("types=public_channel%2Cprivate_channel").contains("cursor=abc"); + client.usersConversations("t", ""); + assertThat(server.requests().get(10).query()).doesNotContain("cursor"); + } + + + + /******************************************************************************* + ** Blocks that aren't a JSON array never reach Slack. + *******************************************************************************/ + @Test + void testInvalidBlocksNeverReachSlack() throws Exception + { + SlackResponse post = client.chatPostMessage("t", "C1", "hi", "{not an array", null, 1, null); + SlackResponse update = client.chatUpdate("t", "C1", "1.2", "hi", "oops"); + + assertThat(post.isOk()).isFalse(); + assertThat(post.getError()).isEqualTo("invalid_blocks"); + assertThat(update.getError()).isEqualTo("invalid_blocks"); + assertThat(SlackErrorClassifier.classify(post)).isEqualTo(SlackErrorClassifier.Kind.PERMANENT); + assertThat(server.requests()).isEmpty(); + } + + + + /******************************************************************************* + ** Retry-After on a 429: read, floored at one second, and defaulted when + ** absent or unreadable. + *******************************************************************************/ + @Test + void testRetryAfter() throws Exception + { + server.script("auth.test", + new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", Map.of("Retry-After", "7")), + new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", Map.of("Retry-After", "0")), + new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", Map.of("Retry-After", "soon")), + FakeSlackServer.Reply.of(429, "{\"ok\":false,\"error\":\"ratelimited\"}"), + new FakeSlackServer.Reply(500, "{\"ok\":false}", Map.of("Retry-After", "9"))); + + SlackResponse seven = client.authTest("t"); + assertThat(seven.getRetryAfterSeconds()).isEqualTo(7); + assertThat(SlackErrorClassifier.classify(seven)).isEqualTo(SlackErrorClassifier.Kind.RATE_LIMITED); + assertThat(client.authTest("t").getRetryAfterSeconds()).isEqualTo(1); + assertThat(client.authTest("t").getRetryAfterSeconds()).isEqualTo(30); + assertThat(client.authTest("t").getRetryAfterSeconds()).isEqualTo(30); + assertThat(client.authTest("t").getRetryAfterSeconds()).isNull(); + } + + + + /******************************************************************************* + ** A slow server is a timeout (HTTP status 0), and a closed port is a network + ** error; both are transient. + *******************************************************************************/ + @Test + void testTimeoutAndNetworkErrorAreTransient() throws Exception + { + server.delay(2500); + SlackResponse slow = client.authTest("t"); + assertThat(slow.getHttpStatus()).isZero(); + assertThat(slow.getError()).isEqualTo("timeout"); + assertThat(SlackErrorClassifier.classify(slow)).isEqualTo(SlackErrorClassifier.Kind.TRANSIENT); + + String closedUrl = server.baseUrl(); + server.close(); + SlackResponse refused = new SlackClient(new SlackQBitConfig().withSlackApiBaseUrl(closedUrl).withConnectTimeoutSeconds(1).withRequestTimeoutSeconds(1)).authTest("t"); + assertThat(refused.getHttpStatus()).isZero(); + assertThat(refused.getError()).isEqualTo("network_error"); + assertThat(SlackErrorClassifier.classify(refused)).isEqualTo(SlackErrorClassifier.Kind.TRANSIENT); + } + + + + /******************************************************************************* + ** An answer that isn't JSON still classifies: an error page is transient, a + ** 200 that isn't Slack's is permanent, and an error body that echoes a token + ** has it scrubbed. + *******************************************************************************/ + @Test + void testUnexpectedBodies() throws Exception + { + server.script("auth.test", + FakeSlackServer.Reply.of(502, "bad gateway"), + FakeSlackServer.Reply.of(200, "not json"), + FakeSlackServer.Reply.of(200, "{\"ok\":false,\"error\":\"oops xoxb-CANARY-ECHO-1\"}"), + FakeSlackServer.Reply.of(403, "{\"ok\":false}")); + + SlackResponse gateway = client.authTest("t"); + assertThat(gateway.getJson()).isNull(); + assertThat(gateway.getError()).isEqualTo("http_502"); + assertThat(SlackErrorClassifier.classify(gateway)).isEqualTo(SlackErrorClassifier.Kind.TRANSIENT); + + SlackResponse notJson = client.authTest("t"); + assertThat(notJson.isOk()).isFalse(); + assertThat(SlackErrorClassifier.classify(notJson)).isEqualTo(SlackErrorClassifier.Kind.PERMANENT); + assertThat(notJson.getString("anything")).isNull(); + + SlackResponse echo = client.authTest("t"); + assertThat(echo.getError()).doesNotContain("xoxb-").doesNotContain("CANARY").contains("[REDACTED]"); + + assertThat(SlackErrorClassifier.classify(client.authTest("t"))).isEqualTo(SlackErrorClassifier.Kind.AUTH); + } + + + + /******************************************************************************* + ** The classifier's table (docs/DESIGN.md 5.3): HTTP 200 with an error code. + *******************************************************************************/ + @ParameterizedTest + @CsvSource({ + "ratelimited,TRANSIENT", "service_unavailable,TRANSIENT", "internal_error,TRANSIENT", "fatal_error,TRANSIENT", "request_timeout,TRANSIENT", + "channel_not_found,PERMANENT", "is_archived,PERMANENT", "msg_too_long,PERMANENT", "invalid_blocks,PERMANENT", "no_text,PERMANENT", "user_not_found,PERMANENT", + "cannot_dm_bot,PERMANENT", "user_disabled,PERMANENT", "something_new,PERMANENT", + "not_in_channel,AUTH", "invalid_auth,AUTH", "token_revoked,AUTH", "account_inactive,AUTH", "missing_scope,AUTH", "not_authed,AUTH", "token_expired,AUTH"}) + void testClassifierTable(String code, String expected) throws Exception + { + SlackResponse response = new SlackResponse(200, false, code, null, null, null); + assertThat(SlackErrorClassifier.classify(response).name()).isEqualTo(expected); + } + + + + /******************************************************************************* + ** The classifier's remaining cases, and which errors revoke a token. + *******************************************************************************/ + @Test + void testClassifierOtherCases() throws Exception + { + assertThat(SlackErrorClassifier.classify(new SlackResponse(429, false, null, 5, null, null))).isEqualTo(SlackErrorClassifier.Kind.RATE_LIMITED); + assertThat(SlackErrorClassifier.classify(new SlackResponse(500, false, null, null, null, null))).isEqualTo(SlackErrorClassifier.Kind.TRANSIENT); + assertThat(SlackErrorClassifier.classify(new SlackResponse(0, false, "timeout", null, null, null))).isEqualTo(SlackErrorClassifier.Kind.TRANSIENT); + assertThat(SlackErrorClassifier.classify(new SlackResponse(200, true, null, null, null, null))).isEqualTo(SlackErrorClassifier.Kind.OK); + assertThat(SlackErrorClassifier.classify(new SlackResponse(401, false, null, null, null, null))).isEqualTo(SlackErrorClassifier.Kind.AUTH); + assertThat(SlackErrorClassifier.classify(new SlackResponse(400, false, null, null, null, null))).isEqualTo(SlackErrorClassifier.Kind.PERMANENT); + assertThat(SlackErrorClassifier.classify((SlackResponse) null)).isEqualTo(SlackErrorClassifier.Kind.TRANSIENT); + assertThat(SlackErrorClassifier.classify(new java.io.IOException("boom"))).isEqualTo(SlackErrorClassifier.Kind.TRANSIENT); + + assertThat(SlackErrorClassifier.revokesAccess("invalid_auth")).isTrue(); + assertThat(SlackErrorClassifier.revokesAccess("token_revoked")).isTrue(); + assertThat(SlackErrorClassifier.revokesAccess("account_inactive")).isTrue(); + assertThat(SlackErrorClassifier.revokesAccess("missing_scope")).isFalse(); + assertThat(SlackErrorClassifier.revokesAccess("not_in_channel")).isFalse(); + assertThat(SlackErrorClassifier.revokesAccess(null)).isFalse(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java b/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java new file mode 100644 index 0000000..0fd7970 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java @@ -0,0 +1,206 @@ +/* + * 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 java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; + + +/******************************************************************************* + ** Test support: an in-memory H2 database loaded from slack-h2.sql, an instance + ** with the qbit produced into it, a host tenant lock, and session helpers. + *******************************************************************************/ +public final class SlackH2Support +{ + public static final String TENANT_KEY = "tenant"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackH2Support() + { + } + + + + /******************************************************************************* + ** A fresh in-memory backend. + *******************************************************************************/ + public static RDBMSBackendMetaData backend() + { + RDBMSBackendMetaData backend = new RDBMSBackendMetaData() + .withName("rdbms") + .withVendor("h2") + .withHostName("mem") + .withDatabaseName("slack_" + UUID.randomUUID()) + .withUsername("sa"); + backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000"); + return (backend); + } + + + + /******************************************************************************* + ** Create the tables. + *******************************************************************************/ + public static void prime(RDBMSBackendMetaData backend) throws Exception + { + try(Connection connection = new ConnectionManager().getConnection(backend)) + { + String sql = new String(SlackH2Support.class.getResourceAsStream("/slack-h2.sql").readAllBytes(), StandardCharsets.UTF_8); + for(String statement : sql.split(";")) + { + if(!statement.isBlank()) + { + connection.createStatement().execute(statement); + } + } + } + } + + + + /******************************************************************************* + ** Config that adds a host tenant column and lock (tenantId, key "tenant"). + *******************************************************************************/ + public static Consumer tenantLock() + { + return (c -> c + .withSecurityFields(List.of(new QFieldMetaData("tenantId", QFieldType.INTEGER))) + .withRecordSecurityLocks(List.of(new RecordSecurityLock().withSecurityKeyType(TENANT_KEY).withFieldName("tenantId")))); + } + + + + /******************************************************************************* + ** Produce the qbit into a new instance on the given backend. + *******************************************************************************/ + public static QInstance instance(RDBMSBackendMetaData backend, Consumer tweak) throws Exception + { + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + instance.addBackend(backend); + instance.addScheduler(new SimpleSchedulerMetaData().withName("scheduler")); + instance.addSecurityKeyType(new QSecurityKeyType().withName(TENANT_KEY).withAllAccessKeyName(TENANT_KEY + "_all_access")); + + SlackQBitConfig config = new SlackQBitConfig().withBackendName("rdbms").withSchedulerName("scheduler"); + tweak.accept(config); + new SlackQBitProducer().withQBitConfig(config).produce(instance); + new QInstanceValidator().validate(instance); + return (instance); + } + + + + /******************************************************************************* + ** Prime a database, produce the instance, and run as the system user. + *******************************************************************************/ + public static QInstance setup(Consumer tweak) throws Exception + { + ConnectionManager.resetConnectionProviders(); + RDBMSBackendMetaData backend = backend(); + prime(backend); + QInstance instance = instance(backend, tweak); + asSystem(instance); + return (instance); + } + + + + /******************************************************************************* + ** Run as the system user (the dispatcher and the OAuth callback). + *******************************************************************************/ + public static void asSystem(QInstance instance) + { + QContext.init(instance, new QSystemUserSession(instance)); + } + + + + /******************************************************************************* + ** Run as an ordinary user of a tenant: has slack access, owner key, tenant key. + ** Core permission enforcement is turned on, as it is for every UI and API call. + *******************************************************************************/ + public static QSession asUser(QInstance instance, String userId, Integer tenantId, boolean allUsers) + { + instance.setEnforcePermissionsInCoreActions(true); + QSession session = new QSession().withPermissions("slack.hasAccess", "slack.view"); + SlackSecurity.applyToSession(session, userId, allUsers); + if(tenantId != null) + { + session.withSecurityKeyValue(TENANT_KEY, tenantId); + } + QContext.init(instance, session); + return (session); + } + + + + /******************************************************************************* + ** Insert one row as whoever the context is, failing on any error. + *******************************************************************************/ + public static QRecord insert(String table, QRecord record) throws Exception + { + QRecord result = new InsertAction().execute(new InsertInput(table).withRecords(List.of(record))).getRecords().get(0); + if(result.getErrors() != null && !result.getErrors().isEmpty()) + { + throw (new IllegalStateException("Insert failed: " + result.getErrors())); + } + return (result); + } + + + + /******************************************************************************* + ** Query a table as the current context. + *******************************************************************************/ + public static List query(String table) throws Exception + { + return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter())).getRecords()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackMetadataTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackMetadataTest.java new file mode 100644 index 0000000..eabeae5 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackMetadataTest.java @@ -0,0 +1,289 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.slack.metadata.SlackEnums; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The tables produce into an instance and pass QInstanceValidator, with the + ** shape the design asks for; plus config validation and enum labels. + *******************************************************************************/ +class SlackMetadataTest +{ + + /******************************************************************************* + ** All seven tables are produced and the instance validates (H2Support runs + ** QInstanceValidator), with unique keys and relations as designed. + *******************************************************************************/ + @Test + void testTablesProduceAndValidate() throws Exception + { + QInstance instance = SlackH2Support.setup(c -> + { + }); + + assertThat(SlackTables.ALL).hasSize(7); + for(String name : SlackTables.ALL) + { + QTableMetaData table = instance.getTable(name); + assertThat(table).as(name).isNotNull(); + assertThat(name).matches("[a-z][a-z_]*"); + assertThat(table.getBackendName()).isEqualTo("rdbms"); + assertThat(table.getSourceQBitName()).isNotNull(); + assertThat(table.getFields().get("id").getBackendName()).isEqualTo("id"); + } + assertThat(instance.getTable("slack_interaction")).isNull(); + + assertThat(uniqueKeys(instance.getTable(SlackTables.WORKSPACE))).contains(List.of("teamId", "tenantKey")); + assertThat(uniqueKeys(instance.getTable(SlackTables.CONNECTION))).contains(List.of("userId", "workspaceId")); + assertThat(uniqueKeys(instance.getTable(SlackTables.MESSAGE))).contains(List.of("idempotencyKey")); + assertThat(uniqueKeys(instance.getTable(SlackTables.ATTEMPT))).contains(List.of("messageId", "attemptNumber")); + assertThat(uniqueKeys(instance.getTable(SlackTables.CHANNEL))).contains(List.of("workspaceId", "name")); + assertThat(uniqueKeys(instance.getTable(SlackTables.TEMPLATE))).contains(List.of("name")); + + assertThat(instance.getJoin(SlackTables.JOIN_WORKSPACE_CONNECTION)).isNotNull(); + assertThat(instance.getJoin(SlackTables.JOIN_WORKSPACE_CHANNEL)).isNotNull(); + assertThat(instance.getJoin(SlackTables.JOIN_WORKSPACE_USER_GROUP)).isNotNull(); + assertThat(instance.getJoin(SlackTables.JOIN_MESSAGE_ATTEMPT)).isNotNull(); + assertThat(instance.getJoin(SlackTables.JOIN_MESSAGE_PARENT_CHILD).getRightTable()).isEqualTo(SlackTables.MESSAGE); + + assertThat(instance.getPossibleValueSource("slack_message_status")).isNotNull(); + assertThat(instance.getTable(SlackTables.MESSAGE).getField("status").getPossibleValueSourceName()).isEqualTo("slack_message_status"); + assertThat(instance.getTable(SlackTables.MESSAGE).getField("targetType").getPossibleValueSourceName()).isEqualTo("slack_target_type"); + assertThat(instance.getTable(SlackTables.MESSAGE).getField("parentMessageId").getPossibleValueSourceName()).isEqualTo(SlackTables.MESSAGE); + assertThat(instance.getSecurityKeyType(SlackSecurity.KEY_USER).getAllAccessKeyName()).isEqualTo(SlackSecurity.KEY_USER_ALL_ACCESS); + + // the owner locks are on the owner columns + assertThat(instance.getTable(SlackTables.CONNECTION).getRecordSecurityLocks()).extracting(RecordSecurityLock::getFieldName).containsExactly("userId"); + assertThat(instance.getTable(SlackTables.MESSAGE).getRecordSecurityLocks()).extracting(RecordSecurityLock::getFieldName).contains("ownerUserId"); + } + + + + /******************************************************************************* + ** The host's tenant field and lock land on the workspace and template, and + ** reach every child through a join chain. + *******************************************************************************/ + @Test + void testTenantLockMetadata() throws Exception + { + QInstance instance = SlackH2Support.setup(SlackH2Support.tenantLock()); + + assertThat(instance.getTable(SlackTables.WORKSPACE).getFields()).containsKey("tenantId"); + assertThat(instance.getTable(SlackTables.TEMPLATE).getFields()).containsKey("tenantId"); + assertThat(instance.getTable(SlackTables.CONNECTION).getFields()).doesNotContainKey("tenantId"); + assertThat(instance.getTable(SlackTables.WORKSPACE).getField("tenantId").getBackendName()).isEqualTo("tenant_id"); + assertThat(instance.getTable(SlackTables.WORKSPACE).getField("tenantKey").getIsRequired()).isTrue(); + + assertThat(instance.getTable(SlackTables.WORKSPACE).getRecordSecurityLocks()).hasSize(1); + assertThat(instance.getTable(SlackTables.WORKSPACE).getRecordSecurityLocks().get(0).getJoinNameChain()).isNullOrEmpty(); + assertThat(instance.getTable(SlackTables.TEMPLATE).getRecordSecurityLocks().get(0).getNullValueBehavior()).isEqualTo(RecordSecurityLock.NullValueBehavior.ALLOW); + + RecordSecurityLock attemptTenant = instance.getTable(SlackTables.ATTEMPT).getRecordSecurityLocks().stream() + .filter(l -> "slack_workspace.tenantId".equals(l.getFieldName())).findFirst().orElseThrow(); + assertThat(attemptTenant.getJoinNameChain()).containsExactly(SlackTables.JOIN_WORKSPACE_MESSAGE, SlackTables.JOIN_MESSAGE_ATTEMPT); + assertThat(instance.getTable(SlackTables.ATTEMPT).getRecordSecurityLocks()).extracting(RecordSecurityLock::getFieldName).contains("slack_message.ownerUserId"); + } + + + + /******************************************************************************* + ** Token columns are hidden, sensitive, locked, in no section, and + ** fingerprints are plain strings. + *******************************************************************************/ + @Test + void testTokenFieldMetadata() throws Exception + { + QInstance instance = SlackH2Support.setup(c -> + { + }); + + for(String[] pair : new String[][] { { SlackTables.WORKSPACE, "botToken" }, { SlackTables.CONNECTION, "userToken" } }) + { + QTableMetaData table = instance.getTable(pair[0]); + QFieldMetaData field = table.getField(pair[1]); + assertThat(field.getType()).isEqualTo(QFieldType.PASSWORD); + assertThat(field.getIsHidden()).isTrue(); + assertThat(field.getIsSensitive()).isTrue(); + assertThat(field.getIsEditable()).isFalse(); + FieldSecurityLock lock = field.getFieldSecurityLock(); + assertThat(lock.getDefaultBehavior()).isEqualTo(FieldSecurityLock.Behavior.DENY); + assertThat(lock.getSecurityKeyType()).isEqualTo(SlackSecurity.KEY_TOKEN_ACCESS); + assertThat(instance.getSecurityKeyType(SlackSecurity.KEY_TOKEN_ACCESS).getAllAccessKeyName()).isNull(); + for(QFieldSection section : table.getSections()) + { + assertThat(section.getFieldNames() == null ? List.of() : section.getFieldNames()).doesNotContain(pair[1]); + } + } + assertThat(instance.getTable(SlackTables.WORKSPACE).getField("botTokenFingerprint").getType()).isEqualTo(QFieldType.STRING); + assertThat(instance.getTable(SlackTables.CONNECTION).getField("userTokenFingerprint").getType()).isEqualTo(QFieldType.STRING); + } + + + + /******************************************************************************* + ** The customizer runs on every table, and a bad config fails to produce. + *******************************************************************************/ + @Test + void testCustomizerAndInvalidConfig() throws Exception + { + QInstance instance = SlackH2Support.setup(c -> c.withTableMetaDataCustomizer((i, table) -> table.withLabel("Custom " + table.getLabel()))); + assertThat(instance.getTable(SlackTables.MESSAGE).getLabel()).isEqualTo("Custom Messages"); + + QInstance fresh = new QInstance(); + assertThatThrownBy(() -> new SlackQBitProducer().withQBitConfig(new SlackQBitConfig().withBackendName("rdbms").withSchedulerName("s").withMaxAttempts(0)).produce(fresh)) + .isInstanceOf(QException.class).hasMessageContaining("configuration is invalid"); + } + + + + /******************************************************************************* + ** Config defaults and validation (DESIGN 14.2). + *******************************************************************************/ + @Test + void testConfigDefaultsAndValidation() + { + SlackQBitConfig config = new SlackQBitConfig().withBackendName("b").withSchedulerName("s"); + assertThat(config.getAllowedEnvNames()).containsExactlyInAnyOrder("SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", "SLACK_TOKEN_KEY", "SLACK_TOKEN_KEY_PREVIOUS"); + assertThat(config.getTokenKeyEnvName()).isEqualTo("SLACK_TOKEN_KEY"); + assertThat(config.getPreviousTokenKeyEnvName()).isEqualTo("SLACK_TOKEN_KEY_PREVIOUS"); + assertThat(config.getSecretResolver()).isNotNull(); + assertThat(config.getSlackApiBaseUrl()).isEqualTo("https://slack.com/api"); + assertThat(config.getAppName()).isEqualTo("slack"); + assertThat(config.getAppLabel()).isEqualTo("Slack"); + assertThat(config.getBodyRetentionDays()).isEqualTo(30); + assertThat(config.getMessageRetentionDays()).isEqualTo(0); + assertThat(config.getMaxAttempts()).isEqualTo(6); + assertThat(config.getRetryMinutes()).containsExactly(1, 5, 15, 60, 240, 720); + assertThat(config.getLeaseSeconds()).isEqualTo(120); + assertThat(config.getBatchSize()).isEqualTo(50); + assertThat(config.getDispatchIntervalSeconds()).isEqualTo(10); + assertThat(config.getMembershipCacheMinutes()).isEqualTo(10); + assertThat(config.getMaxAttachmentBytes()).isEqualTo(50L * 1024 * 1024); + assertThat(config.getPublicBaseUrl()).isNull(); + assertThat(config.getTableMetaDataCustomizer()).isNull(); + assertThat(config.getSecurityFields()).isEmpty(); + assertThat(config.getRecordSecurityLocks()).isEmpty(); + assertThat(config.retryMinutesAfterAttempt(1)).isEqualTo(1); + assertThat(config.retryMinutesAfterAttempt(3)).isEqualTo(15); + assertThat(config.retryMinutesAfterAttempt(50)).isEqualTo(720); + assertThat(errors(config)).isEmpty(); + + SlackQBitConfig bad = new SlackQBitConfig() + .withBackendName("b").withSchedulerName("s") + .withSecretResolver(null) + .withAllowedEnvNames(Set.of("bad-name")) + .withTokenKeyEnvName("lower") + .withPreviousTokenKeyEnvName("nope!") + .withAppName("").withAppLabel("") + .withMaxAttempts(0).withBatchSize(0).withDispatchIntervalSeconds(0).withMembershipCacheMinutes(0) + .withLeaseSeconds(1) + .withRetryMinutes(List.of()) + .withBodyRetentionDays(-1) + .withMaxAttachmentBytes(0L) + .withSlackApiBaseUrl("http://example.com") + .withPublicBaseUrl("ftp://example.com"); + assertThat(errors(bad)).hasSize(12); + + SlackQBitConfig nulls = new SlackQBitConfig().withBackendName("b").withSchedulerName("s") + .withSecurityFields(null).withRecordSecurityLocks(null).withRetryMinutes(List.of(0)) + .withSlackApiBaseUrl("http://localhost:8080/api").withPublicBaseUrl("http://127.0.0.1:9"); + assertThat(errors(nulls)).hasSize(2); + } + + + + /******************************************************************************* + ** Possible-value enums have stable ids and plain labels. + *******************************************************************************/ + @Test + void testEnumLabels() + { + assertThat(SlackEnums.MessageStatus.DEAD_LETTER.getPossibleValueId()).isEqualTo("DEAD_LETTER"); + assertThat(SlackEnums.MessageStatus.DEAD_LETTER.getPossibleValueLabel()).isEqualTo("Dead letter"); + assertThat(SlackEnums.TargetType.REGISTERED_CHANNEL.getPossibleValueLabel()).isEqualTo("Registered channel"); + assertThat(SlackEnums.PostAs.BOT.getPossibleValueLabel()).isEqualTo("Bot"); + } + + + + /******************************************************************************* + ** Security session helpers. + *******************************************************************************/ + @Test + void testSecuritySessionHelpers() + { + QSession session = new QSession(); + SlackSecurity.applyToSession(session, "u1", true); + assertThat(SlackSecurity.userId(session)).isEqualTo("u1"); + assertThat(session.getSecurityKeyValues(SlackSecurity.KEY_USER_ALL_ACCESS)).containsExactly(true); + + SlackSecurity.applyToSession(session, "u2", false); + assertThat(SlackSecurity.userId(session)).isEqualTo("u2"); + assertThat(session.getSecurityKeyValues(SlackSecurity.KEY_USER_ALL_ACCESS)).isNullOrEmpty(); + + SlackSecurity.applyToSession(session, " ", false); + assertThat(SlackSecurity.userId(session)).isNull(); + assertThat(SlackSecurity.userId(null)).isNull(); + SlackSecurity.applyToSession(null, "u", true); + } + + + + /******************************************************************************* + ** Errors from validating a config. + *******************************************************************************/ + private static List errors(SlackQBitConfig config) + { + List errors = new ArrayList<>(); + config.validate(new QInstance(), errors); + return (errors); + } + + + + /******************************************************************************* + ** The field-name lists of a table's unique keys. + *******************************************************************************/ + private static List> uniqueKeys(QTableMetaData table) + { + return (table.getUniqueKeys().stream().map(k -> k.getFieldNames()).toList()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackOutboxTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackOutboxTest.java new file mode 100644 index 0000000..11589ae --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackOutboxTest.java @@ -0,0 +1,794 @@ +/* + * 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 java.time.Duration; +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.slack.delivery.SlackDispatcher; +import com.kingsrook.qbits.slack.metadata.SlackEnums; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +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; + + +/******************************************************************************* + ** The outbox end to end against a fake Slack server and H2: queueing, the + ** dispatcher's claim, lease and reconcile, every Slack response class, threads, + ** updates and the owner's access being checked again at send time. + *******************************************************************************/ +class SlackOutboxTest +{ + private static final String BOT_TOKEN = "xoxb-CANARY-BOT-1"; + private static final String USER_TOKEN = "xoxp-CANARY-USER-A"; + + private SlackTestEnv env; + private int workspaceId; + + + + /******************************************************************************* + ** One workspace with a bot, one connected user (UA) and a channel C1 that + ** both the user and the bot are in. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + env = SlackTestEnv.create(false, c -> + { + }); + workspaceId = env.workspace("T1", null, BOT_TOKEN, "UBOT"); + env.connection("userA", workspaceId, "UA", USER_TOKEN); + env.slack().members("C1", "UA", "UBOT"); + } + + + + /******************************************************************************* + ** Restore process-wide state. + *******************************************************************************/ + @AfterEach + void cleanup() + { + env.close(); + } + + + + /******************************************************************************* + ** Queue as userA. + *******************************************************************************/ + private SlackSendResult queue(String to, String text) throws Exception + { + env.asOwner("userA"); + return (SlackService.enqueue(new SlackSendRequest().withTo(to).withText(text))); + } + + + + /******************************************************************************* + ** A message row. + *******************************************************************************/ + private QRecord row(Integer id) throws Exception + { + return (env.row(SlackTables.MESSAGE, id)); + } + + + + /******************************************************************************* + ** A direct message to "me", and a channel message, each posted with the right + ** token, the qqq_message metadata and escaped text. + *******************************************************************************/ + @Test + void testDirectMessageAndChannelMessage() throws Exception + { + SlackSendResult dm = queue("me", "hello & "); + assertThat(dm.status()).isEqualTo(SlackSendResult.Status.QUEUED); + QRecord queued = row(dm.messageId()); + assertThat(queued.getValueString("status")).isEqualTo("QUEUED"); + assertThat(queued.getValueString("targetType")).isEqualTo("ME"); + assertThat(queued.getValueString("postAs")).isEqualTo("BOT"); + assertThat(queued.getValueString("ownerUserId")).isEqualTo("userA"); + assertThat(queued.getValueString("text")).isEqualTo("hello & <world>"); + + assertThat(env.dispatch().sent()).isEqualTo(1); + + assertThat(env.slack().requests("conversations.open")).hasSize(1); + assertThat(env.slack().requests("conversations.open").get(0).authorization()).isEqualTo("Bearer " + BOT_TOKEN); + assertThat(env.slack().requests("conversations.open").get(0).json().getString("users")).isEqualTo("UA"); + + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + SlackFakeRequest post = new SlackFakeRequest(env.slack().requests("chat.postMessage").get(0)); + assertThat(post.authorization()).isEqualTo("Bearer " + BOT_TOKEN); + assertThat(post.json().getString("channel")).isEqualTo("DUA"); + assertThat(post.json().getString("text")).isEqualTo("hello & <world>"); + assertThat(post.json().getBoolean("unfurl_links")).isFalse(); + assertThat(post.json().getJSONObject("metadata").getString("event_type")).isEqualTo("qqq_message"); + assertThat(post.json().getJSONObject("metadata").getJSONObject("event_payload").getInt("id")).isEqualTo(dm.messageId()); + + QRecord sent = row(dm.messageId()); + assertThat(sent.getValueString("status")).isEqualTo("SENT"); + assertThat(sent.getValueString("slackTs")).startsWith("1700000000."); + assertThat(sent.getValueString("slackChannelId")).isEqualTo("DUA"); + assertThat(sent.getValueInteger("attemptCount")).isEqualTo(1); + assertThat(env.attempts(dm.messageId())).hasSize(1); + assertThat(env.attempts(dm.messageId()).get(0).getValueString("outcome")).isEqualTo("SENT"); + assertThat(env.attempts(dm.messageId()).get(0).getValueString("postedAs")).isEqualTo("BOT"); + + env.clock().advance(Duration.ofSeconds(2)); + SlackSendResult channel = queue("channel:" + workspaceId + "/C1", "to the channel"); + assertThat(channel.status()).isEqualTo(SlackSendResult.Status.QUEUED); + assertThat(env.slack().requests("conversations.members")).hasSize(1); + assertThat(env.slack().requests("conversations.members").get(0).authorization()).isEqualTo("Bearer " + BOT_TOKEN); + assertThat(row(channel.messageId()).getValueString("channelLabel")).isEqualTo("#general"); + + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(env.slack().requests("chat.postMessage")).hasSize(2); + assertThat(env.slack().requests("chat.postMessage").get(1).json().getString("channel")).isEqualTo("C1"); + assertThat(row(channel.messageId()).getValueString("status")).isEqualTo("SENT"); + } + + + + /******************************************************************************* + ** A 429 with Retry-After is a wait, not an attempt: the message is rescheduled + ** with its attempt count unchanged, its claim is kept as a RATE_LIMITED + ** attempt row, and the next claim gets a new attempt number and succeeds. + *******************************************************************************/ + @Test + void testRateLimitedIsNotAnAttempt() throws Exception + { + env.slack().script("chat.postMessage", new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", java.util.Map.of("Retry-After", "2"))); + SlackSendResult queued = queue("me", "hi"); + Instant start = env.clock().instant(); + + SlackDispatcher.RunSummary summary = env.dispatch(); + assertThat(summary.skipped()).isEqualTo(1); + QRecord waiting = row(queued.messageId()); + assertThat(waiting.getValueString("status")).isEqualTo("RETRY_WAIT"); + assertThat(waiting.getValueInteger("attemptCount")).isZero(); + assertThat(waiting.getValueInstant("nextAttemptDate")).isEqualTo(start.plusSeconds(2)); + assertThat(env.attempts(queued.messageId())).hasSize(1); + assertThat(env.attempts(queued.messageId()).get(0).getValueString("outcome")).isEqualTo("RATE_LIMITED"); + assertThat(env.attempts(queued.messageId()).get(0).getValueInteger("retryAfterSeconds")).isEqualTo(2); + + assertThat(env.dispatch().sent()).isZero(); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + + env.clock().advance(Duration.ofSeconds(3)); + assertThat(env.dispatch().sent()).isEqualTo(1); + QRecord sent = row(queued.messageId()); + assertThat(sent.getValueString("status")).isEqualTo("SENT"); + assertThat(sent.getValueInteger("attemptCount")).isEqualTo(1); + List attempts = env.attempts(queued.messageId()); + assertThat(attempts).extracting(a -> a.getValueInteger("attemptNumber")).containsExactly(1, 2); + assertThat(attempts).extracting(a -> a.getValueString("outcome")).containsExactly("RATE_LIMITED", "SENT"); + } + + + + /******************************************************************************* + ** The wait is capped at fifteen minutes, and a rate-limited message never + ** reaches maxAttempts however often it is limited. + *******************************************************************************/ + @Test + void testRateLimitWaitIsCappedAndNeverConsumesAttempts() throws Exception + { + env.config().withMaxAttempts(1); + env.slack().script("chat.postMessage", + new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", java.util.Map.of("Retry-After", "86400")), + new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", java.util.Map.of("Retry-After", "1"))); + SlackSendResult queued = queue("me", "hi"); + Instant start = env.clock().instant(); + + env.dispatch(); + assertThat(row(queued.messageId()).getValueInstant("nextAttemptDate")).isEqualTo(start.plusSeconds(900)); + + env.clock().advance(Duration.ofSeconds(901)); + env.dispatch(); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("RETRY_WAIT"); + assertThat(row(queued.messageId()).getValueInteger("attemptCount")).isZero(); + + env.clock().advance(Duration.ofSeconds(5)); + assertThat(env.dispatch().sent()).isEqualTo(1); + } + + + + /******************************************************************************* + ** A transient error waits retryMinutes, then succeeds. + *******************************************************************************/ + @Test + void testTransientThenSuccess() throws Exception + { + env.slack().script("chat.postMessage", FakeSlackServer.Reply.of(500, "{\"ok\":false,\"error\":\"internal_error\"}")); + SlackSendResult queued = queue("me", "hi"); + Instant start = env.clock().instant(); + + assertThat(env.dispatch().failed()).isEqualTo(1); + QRecord waiting = row(queued.messageId()); + assertThat(waiting.getValueString("status")).isEqualTo("RETRY_WAIT"); + assertThat(waiting.getValueInteger("attemptCount")).isEqualTo(1); + assertThat(waiting.getValueInstant("nextAttemptDate")).isEqualTo(start.plusSeconds(60)); + assertThat(waiting.getValueString("lastErrorCode")).isEqualTo("internal_error"); + + env.clock().advance(Duration.ofSeconds(61)); + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("SENT"); + assertThat(row(queued.messageId()).getValueString("lastError")).isNull(); + } + + + + /******************************************************************************* + ** A permanent error is a dead letter at once, with an alert. + *******************************************************************************/ + @Test + void testPermanentErrorIsDeadLetter() throws Exception + { + env.slack().script("chat.postMessage", FakeSlackServer.Reply.error("msg_too_long")); + SlackSendResult queued = queue("me", "hi"); + + assertThat(env.dispatch().failed()).isEqualTo(1); + QRecord dead = row(queued.messageId()); + assertThat(dead.getValueString("status")).isEqualTo("DEAD_LETTER"); + assertThat(dead.getValueString("lastErrorCode")).isEqualTo("msg_too_long"); + assertThat(dead.getValueInstant("deadLetterDate")).isNotNull(); + assertThat(SlackTestEnv.CapturingAlertSink.RAISED).extracting(a -> a.getAlertTypeName()).contains("slack.message.dead-letter"); + } + + + + /******************************************************************************* + ** Transient errors become a dead letter once maxAttempts are spent. + *******************************************************************************/ + @Test + void testMaxAttemptsIsDeadLetter() throws Exception + { + env.config().withMaxAttempts(2); + env.slack().script("chat.postMessage", FakeSlackServer.Reply.of(503, "{\"ok\":false,\"error\":\"service_unavailable\"}"), FakeSlackServer.Reply.of(503, "{\"ok\":false,\"error\":\"service_unavailable\"}")); + SlackSendResult queued = queue("me", "hi"); + + env.dispatch(); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("RETRY_WAIT"); + + env.clock().advance(Duration.ofMinutes(2)); + env.dispatch(); + QRecord dead = row(queued.messageId()); + assertThat(dead.getValueString("status")).isEqualTo("DEAD_LETTER"); + assertThat(dead.getValueInteger("attemptCount")).isEqualTo(2); + assertThat(env.attempts(queued.messageId())).extracting(a -> a.getValueString("outcome")).containsExactly("TRANSIENT_FAILURE", "TRANSIENT_FAILURE"); + } + + + + /******************************************************************************* + ** A runner that died after posting leaves a SENDING row with an expired lease. + ** The next runner finds our metadata id in the channel's history, records + ** that ts as SENT, and does not post again. + *******************************************************************************/ + @Test + void testCrashReconcileFindsEarlierPostAndDoesNotPostAgain() throws Exception + { + SlackSendResult queued = queue("channel:" + workspaceId + "/C1", "posted before the crash"); + Instant now = env.clock().instant(); + env.asSystem(); + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", queued.messageId()).withValue("status", "SENDING").withValue("attemptCount", 1) + .withValue("leaseExpiresDate", now.minusSeconds(10)).withValue("nextAttemptDate", now.minusSeconds(10)), null); + SlackH2Support.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", queued.messageId()).withValue("attemptNumber", 1).withValue("outcome", "CLAIMED").withValue("startDate", now.minusSeconds(60))); + + env.slack().history("C1", new JSONArray() + .put(new JSONObject().put("ts", "1700000009.000001").put("text", "someone else").put("metadata", new JSONObject().put("event_type", "qqq_message").put("event_payload", new JSONObject().put("id", queued.messageId() + 100)))) + .put(new JSONObject().put("ts", "1700000001.000100").put("text", "ours").put("metadata", new JSONObject().put("event_type", "qqq_message").put("event_payload", new JSONObject().put("id", queued.messageId()).put("key", "k"))))); + + assertThat(env.dispatch().sent()).isEqualTo(1); + + QRecord sent = row(queued.messageId()); + assertThat(sent.getValueString("status")).isEqualTo("SENT"); + assertThat(sent.getValueString("slackTs")).isEqualTo("1700000001.000100"); + assertThat(env.slack().requests("chat.postMessage")).isEmpty(); + assertThat(env.slack().requests("conversations.history")).hasSize(1); + assertThat(env.slack().requests("conversations.history").get(0).authorization()).isEqualTo("Bearer " + BOT_TOKEN); + assertThat(env.slack().requests("conversations.history").get(0).query()).contains("include_all_metadata=true").contains("channel=C1"); + assertThat(env.attempts(queued.messageId())).extracting(a -> a.getValueString("outcome")).containsExactly("LEASE_EXPIRED", "SENT"); + } + + + + /******************************************************************************* + ** After an expired lease, a history that does not contain our id means it + ** never went out, so it is posted. A history that can't be read means nothing + ** is posted blind: RETRY_WAIT. + *******************************************************************************/ + @Test + void testCrashReconcilePostsWhenNotFoundAndWaitsWhenHistoryFails() throws Exception + { + SlackSendResult first = queue("channel:" + workspaceId + "/C1", "never went out"); + expireLease(first.messageId()); + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(env.slack().requests("conversations.history")).hasSize(1); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + assertThat(row(first.messageId()).getValueString("status")).isEqualTo("SENT"); + + env.clock().advance(Duration.ofSeconds(5)); + SlackSendResult second = queue("channel:" + workspaceId + "/C1", "history is down"); + expireLease(second.messageId()); + env.slack().script("conversations.history", FakeSlackServer.Reply.of(500, "{\"ok\":false,\"error\":\"internal_error\"}")); + env.dispatch(); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + QRecord waiting = row(second.messageId()); + assertThat(waiting.getValueString("status")).isEqualTo("RETRY_WAIT"); + assertThat(waiting.getValueString("lastErrorCode")).isEqualTo("history_unavailable"); + } + + + + /******************************************************************************* + ** Leave a message as a dead runner would: SENDING, lease in the past. + *******************************************************************************/ + private void expireLease(Integer messageId) throws Exception + { + Instant now = env.clock().instant(); + env.asSystem(); + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", messageId).withValue("status", "SENDING").withValue("attemptCount", 1) + .withValue("leaseExpiresDate", now.minusSeconds(10)).withValue("nextAttemptDate", now.minusSeconds(10)), null); + SlackH2Support.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", messageId).withValue("attemptNumber", 1).withValue("outcome", "CLAIMED").withValue("startDate", now.minusSeconds(60))); + } + + + + /******************************************************************************* + ** A live lease is respected: a second pass leaves a SENDING message alone. + *******************************************************************************/ + @Test + void testLiveLeaseIsNotClaimed() throws Exception + { + SlackSendResult queued = queue("me", "hi"); + Instant now = env.clock().instant(); + env.asSystem(); + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", queued.messageId()).withValue("status", "SENDING").withValue("attemptCount", 1) + .withValue("leaseExpiresDate", now.plusSeconds(100)).withValue("nextAttemptDate", now.minusSeconds(1)), null); + + assertThat(env.dispatch().skipped()).isEqualTo(1); + assertThat(env.slack().requests()).isEmpty(); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("SENDING"); + } + + + + /******************************************************************************* + ** The same idempotency key gives DUPLICATE and the same id; sendOnce replays + ** give QUEUED_OR_DUPLICATE; another owner can't learn the id. + *******************************************************************************/ + @Test + void testDuplicateKeysAndSendOnce() throws Exception + { + env.asOwner("userA"); + SlackSendResult first = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("one").withIdempotencyKey("key-1")); + SlackSendResult second = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("one again").withIdempotencyKey("key-1")); + assertThat(first.status()).isEqualTo(SlackSendResult.Status.QUEUED); + assertThat(second.status()).isEqualTo(SlackSendResult.Status.DUPLICATE); + assertThat(second.messageId()).isEqualTo(first.messageId()); + assertThat(env.row(SlackTables.MESSAGE, first.messageId()).getValueString("text")).isEqualTo("one"); + + SlackSendResult once = SlackService.sendOnce(new SlackSendRequest().withTo("me").withText("effect"), "effect-1"); + SlackSendResult replay = SlackService.sendOnce(new SlackSendRequest().withTo("me").withText("effect"), "effect-1"); + assertThat(once.status()).isEqualTo(SlackSendResult.Status.QUEUED_OR_DUPLICATE); + assertThat(replay.status()).isEqualTo(SlackSendResult.Status.QUEUED_OR_DUPLICATE); + assertThat(replay.messageId()).isEqualTo(once.messageId()); + assertThat(env.row(SlackTables.MESSAGE, once.messageId()).getValueString("idempotencyKey")).isEqualTo("effect-1"); + + assertThat(SlackService.sendOnce(new SlackSendRequest().withTo("me").withText("x"), " ").status()).isEqualTo(SlackSendResult.Status.REFUSED); + + env.connection("userB", workspaceId, "UB", null); + env.asOwner("userB"); + SlackSendResult other = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("steal").withIdempotencyKey("key-1")); + assertThat(other.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(other.messageId()).isNull(); + } + + + + /******************************************************************************* + ** sendOnce refuses a caller transaction: it must be a handoff that commits. + *******************************************************************************/ + @Test + void testSendOnceRefusesCallerTransaction() throws Exception + { + env.asOwner("userA"); + try(com.kingsrook.qqq.backend.core.actions.QBackendTransaction tx = com.kingsrook.qqq.backend.core.actions.QBackendTransaction.openFor(new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(SlackTables.MESSAGE))) + { + com.kingsrook.qqq.backend.core.context.QContext.setTransaction(tx); + SlackSendResult result = SlackService.sendOnce(new SlackSendRequest().withTo("me").withText("x"), "tx-key"); + com.kingsrook.qqq.backend.core.context.QContext.clearTransaction(); + assertThat(result.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(result.reasons().get(0)).contains("transaction"); + } + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).isEmpty(); + } + + + + /******************************************************************************* + ** A reply waits for its parent (WAITING_PARENT), is released with the + ** parent's ts when the parent is sent, and then posts with thread_ts. + *******************************************************************************/ + @Test + void testThreadReplyWaitsForParent() throws Exception + { + SlackSendResult parent = queue("me", "parent"); + env.asOwner("userA"); + SlackSendResult reply = SlackService.enqueue(new SlackSendRequest().withText("reply").withThreadOfMessageId(parent.messageId())); + assertThat(reply.status()).isEqualTo(SlackSendResult.Status.QUEUED); + QRecord waiting = row(reply.messageId()); + assertThat(waiting.getValueString("status")).isEqualTo("WAITING_PARENT"); + assertThat(waiting.getValueInteger("parentMessageId")).isEqualTo(parent.messageId()); + assertThat(waiting.getValueString("targetRef")).isEqualTo("me"); + + assertThat(env.dispatch().sent()).isEqualTo(1); + String parentTs = row(parent.messageId()).getValueString("slackTs"); + QRecord released = row(reply.messageId()); + assertThat(released.getValueString("status")).isEqualTo("QUEUED"); + assertThat(released.getValueString("threadTs")).isEqualTo(parentTs); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + + env.clock().advance(Duration.ofSeconds(2)); + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(env.slack().requests("chat.postMessage")).hasSize(2); + assertThat(env.slack().requests("chat.postMessage").get(1).json().getString("thread_ts")).isEqualTo(parentTs); + assertThat(row(reply.messageId()).getValueString("status")).isEqualTo("SENT"); + + env.asOwner("userA"); + SlackSendResult late = SlackService.enqueue(new SlackSendRequest().withText("later").withThreadOfMessageId(parent.messageId())); + assertThat(row(late.messageId()).getValueString("status")).isEqualTo("QUEUED"); + assertThat(row(late.messageId()).getValueString("threadTs")).isEqualTo(parentTs); + } + + + + /******************************************************************************* + ** A reply to a message that is dead-lettered is dropped with it. + *******************************************************************************/ + @Test + void testReplyIsCancelledWhenParentFails() throws Exception + { + env.slack().script("chat.postMessage", FakeSlackServer.Reply.error("msg_too_long")); + SlackSendResult parent = queue("me", "parent"); + env.asOwner("userA"); + SlackSendResult reply = SlackService.enqueue(new SlackSendRequest().withText("reply").withThreadOfMessageId(parent.messageId())); + + env.dispatch(); + assertThat(row(parent.messageId()).getValueString("status")).isEqualTo("DEAD_LETTER"); + assertThat(row(reply.messageId()).getValueString("status")).isEqualTo("CANCELLED"); + + env.asOwner("userA"); + assertThat(SlackService.enqueue(new SlackSendRequest().withText("again").withThreadOfMessageId(parent.messageId())).status()).isEqualTo(SlackSendResult.Status.REFUSED); + } + + + + /******************************************************************************* + ** An update posts chat.update to the same channel and ts, with the same + ** identity, and the row records that ts. + *******************************************************************************/ + @Test + void testUpdatePostsChatUpdateWithSameChannelAndTs() throws Exception + { + SlackSendResult original = queue("me", "first"); + env.dispatch(); + QRecord sent = row(original.messageId()); + + env.asOwner("userA"); + SlackSendResult update = SlackService.update(original.messageId(), new SlackSendRequest().withText("edited ")); + assertThat(update.status()).isEqualTo(SlackSendResult.Status.QUEUED); + QRecord updateRow = row(update.messageId()); + assertThat(updateRow.getValueString("operation")).isEqualTo("UPDATE"); + assertThat(updateRow.getValueInteger("targetMessageId")).isEqualTo(original.messageId()); + + env.clock().advance(Duration.ofSeconds(2)); + assertThat(env.dispatch().sent()).isEqualTo(1); + + assertThat(env.slack().requests("chat.update")).hasSize(1); + JSONObject body = env.slack().requests("chat.update").get(0).json(); + assertThat(body.getString("channel")).isEqualTo(sent.getValueString("slackChannelId")); + assertThat(body.getString("ts")).isEqualTo(sent.getValueString("slackTs")); + assertThat(body.getString("text")).isEqualTo("edited <b>"); + assertThat(env.slack().requests("chat.update").get(0).authorization()).isEqualTo("Bearer " + BOT_TOKEN); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + assertThat(row(update.messageId()).getValueString("status")).isEqualTo("SENT"); + assertThat(row(update.messageId()).getValueString("slackTs")).isEqualTo(sent.getValueString("slackTs")); + + env.asOwner("userA"); + assertThat(SlackService.update(update.messageId(), new SlackSendRequest().withText("x")).status()).isEqualTo(SlackSendResult.Status.REFUSED); + env.set(SlackTables.MESSAGE, original.messageId(), "status", "DEAD_LETTER"); + env.asOwner("userA"); + assertThat(SlackService.update(original.messageId(), new SlackSendRequest().withText("x")).status()).isEqualTo(SlackSendResult.Status.REFUSED); + } + + + + /******************************************************************************* + ** The owner isn't in the channel: refused at queue time, and no row. + *******************************************************************************/ + @Test + void testOwnerNotInChannelIsRefusedAtQueueTime() throws Exception + { + env.slack().members("C2", "UOTHER", "UBOT"); + SlackSendResult result = queue("channel:" + workspaceId + "/C2", "hello"); + assertThat(result.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(result.reasons().get(0)).contains("aren't a member"); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).isEmpty(); + } + + + + /******************************************************************************* + ** Membership lost after queueing: the send-time authorization (after the + ** cache expired) says FAILED_AUTH, the owner is notified, an alert is raised, + ** and nothing is posted. + *******************************************************************************/ + @Test + void testMembershipLostAfterQueueingFailsAuthAtSendTime() throws Exception + { + SlackSendResult queued = queue("channel:" + workspaceId + "/C1", "hello"); + assertThat(queued.status()).isEqualTo(SlackSendResult.Status.QUEUED); + + env.slack().members("C1", "UBOT"); + env.clock().advance(Duration.ofMinutes(11)); + assertThat(env.dispatch().failed()).isEqualTo(1); + + QRecord failed = row(queued.messageId()); + assertThat(failed.getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(failed.getValueString("lastError")).contains("aren't a member"); + assertThat(env.slack().requests("chat.postMessage")).isEmpty(); + assertThat(env.attempts(queued.messageId()).get(0).getValueString("outcome")).isEqualTo("AUTH_FAILURE"); + + assertThat(SlackTestEnv.CapturingAlertSink.NOTIFIED).hasSize(1); + assertThat(SlackTestEnv.CapturingAlertSink.NOTIFIED.get(0).getAudience().getValues()).containsExactly("userA"); + assertThat(SlackTestEnv.CapturingAlertSink.NOTIFIED.get(0).getBody()).contains("failed").contains("Reconnect Slack"); + assertThat(SlackTestEnv.CapturingAlertSink.RAISED).extracting(a -> a.getAlertTypeName()).containsExactly("slack.delivery.unauthorized"); + assertThat(SlackTestEnv.CapturingAlertSink.RAISED.get(0).getDedupKey()).startsWith("owner:userA:"); + } + + + + /******************************************************************************* + ** A revoked user token (invalid_auth) is FAILED_AUTH, the connection is marked + ** REVOKED, the owner is told, and nothing was posted. + *******************************************************************************/ + @Test + void testRevokedUserTokenMarksConnectionRevoked() throws Exception + { + env.slack().script("conversations.open", FakeSlackServer.Reply.error("invalid_auth")); + env.asOwner("userA"); + SlackSendResult queued = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("hi").withPostAs(SlackEnums.PostAs.USER)); + assertThat(queued.status()).isEqualTo(SlackSendResult.Status.QUEUED); + + assertThat(env.dispatch().failed()).isEqualTo(1); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(row(queued.messageId()).getValueString("lastErrorCode")).isEqualTo("invalid_auth"); + assertThat(env.slack().requests("conversations.open").get(0).authorization()).isEqualTo("Bearer " + USER_TOKEN); + assertThat(env.slack().requests("chat.postMessage")).isEmpty(); + + QRecord connection = SlackStore.query(SlackTables.CONNECTION, SlackStore.equal("userId", "userA")).get(0); + assertThat(connection.getValueString("status")).isEqualTo("REVOKED"); + assertThat(connection.getValueInstant("revokedAt")).isNotNull(); + assertThat(SlackTestEnv.CapturingAlertSink.NOTIFIED).hasSize(1); + + env.asOwner("userA"); + SlackSendResult after = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("again")); + assertThat(after.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(after.reasons().get(0)).contains("Connect Slack"); + } + + + + /******************************************************************************* + ** A revoked bot token marks the workspace REVOKED. + *******************************************************************************/ + @Test + void testRevokedBotTokenMarksWorkspaceRevoked() throws Exception + { + env.slack().script("chat.postMessage", FakeSlackServer.Reply.error("token_revoked")); + SlackSendResult queued = queue("me", "hi"); + + env.dispatch(); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(env.row(SlackTables.WORKSPACE, workspaceId).getValueString("status")).isEqualTo("REVOKED"); + assertThat(queue("me", "again").status()).isEqualTo(SlackSendResult.Status.REFUSED); + } + + + + /******************************************************************************* + ** A missing scope is FAILED_AUTH but does not revoke anything. + *******************************************************************************/ + @Test + void testMissingScopeFailsAuthWithoutRevoking() throws Exception + { + env.slack().script("chat.postMessage", FakeSlackServer.Reply.error("missing_scope")); + SlackSendResult queued = queue("me", "hi"); + + env.dispatch(); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(env.row(SlackTables.WORKSPACE, workspaceId).getValueString("status")).isEqualTo("ACTIVE"); + } + + + + /******************************************************************************* + ** Ten transient failures in a row make a workspace UNHEALTHY (with an alert); + ** a success makes it HEALTHY again (and resolves the alert). + *******************************************************************************/ + @Test + void testWorkspaceHealth() throws Exception + { + env.config().withMaxAttempts(50).withRetryMinutes(List.of(1)); + for(int i = 0; i < 10; i++) + { + env.slack().script("chat.postMessage", FakeSlackServer.Reply.of(500, "{\"ok\":false,\"error\":\"internal_error\"}")); + } + SlackSendResult queued = queue("me", "hi"); + + for(int i = 0; i < 10; i++) + { + env.dispatch(); + env.clock().advance(Duration.ofMinutes(6)); + } + QRecord workspace = env.row(SlackTables.WORKSPACE, workspaceId); + assertThat(workspace.getValueString("health")).isEqualTo("UNHEALTHY"); + assertThat(workspace.getValueInteger("consecutiveFailures")).isEqualTo(10); + assertThat(SlackTestEnv.CapturingAlertSink.RAISED).extracting(a -> a.getAlertTypeName()).contains("slack.workspace.unhealthy"); + + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(env.row(SlackTables.WORKSPACE, workspaceId).getValueString("health")).isEqualTo("HEALTHY"); + assertThat(env.row(SlackTables.WORKSPACE, workspaceId).getValueInteger("consecutiveFailures")).isZero(); + assertThat(SlackTestEnv.CapturingAlertSink.RESOLVED).extracting(r -> r.getAlertTypeName()).contains("slack.workspace.unhealthy"); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("SENT"); + } + + + + /******************************************************************************* + ** An unhealthy workspace is probed once every five minutes, not on every pass. + *******************************************************************************/ + @Test + void testUnhealthyWorkspaceIsProbedEveryFiveMinutes() throws Exception + { + env.set(SlackTables.WORKSPACE, workspaceId, "health", "UNHEALTHY"); + env.slack().script("chat.postMessage", FakeSlackServer.Reply.of(500, "{\"ok\":false,\"error\":\"internal_error\"}")); + SlackSendResult queued = queue("me", "hi"); + + env.dispatch(); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + + env.clock().advance(Duration.ofSeconds(90)); + env.dispatch(); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + + env.clock().advance(Duration.ofMinutes(5)); + env.dispatch(); + assertThat(env.slack().requests("chat.postMessage")).hasSize(2); + assertThat(row(queued.messageId()).getValueString("status")).isEqualTo("SENT"); + } + + + + /******************************************************************************* + ** The client-side limiter: two messages to one channel go out a second + ** apart, the second one waiting (not failing, not an attempt). + *******************************************************************************/ + @Test + void testOnePostPerSecondPerChannel() throws Exception + { + SlackSendResult first = queue("channel:" + workspaceId + "/C1", "one"); + SlackSendResult second = queue("channel:" + workspaceId + "/C1", "two"); + + SlackDispatcher.RunSummary summary = env.dispatch(); + assertThat(summary.sent()).isEqualTo(1); + assertThat(summary.skipped()).isEqualTo(1); + assertThat(row(second.messageId()).getValueInteger("attemptCount")).isZero(); + assertThat(env.attempts(second.messageId())).isEmpty(); + + env.clock().advance(Duration.ofMillis(1100)); + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(row(first.messageId()).getValueString("status")).isEqualTo("SENT"); + assertThat(row(second.messageId()).getValueString("status")).isEqualTo("SENT"); + } + + + + /******************************************************************************* + ** The workspace's ratePerMinute caps posts across channels. + *******************************************************************************/ + @Test + void testRatePerMinutePerWorkspace() throws Exception + { + env.set(SlackTables.WORKSPACE, workspaceId, "ratePerMinute", 1); + env.slack().members("C3", "UA", "UBOT"); + queue("channel:" + workspaceId + "/C1", "one"); + queue("channel:" + workspaceId + "/C3", "two"); + + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(env.dispatch().sent()).isZero(); + env.clock().advance(Duration.ofSeconds(61)); + assertThat(env.dispatch().sent()).isEqualTo(1); + } + + + + /******************************************************************************* + ** Posting as the owner uses the owner's token when the bot is not a member of + ** the channel, and the bot's when it is. + *******************************************************************************/ + @Test + void testIdentityFollowsBotMembership() throws Exception + { + env.slack().members("C4", "UA"); + SlackSendResult viaUser = queue("channel:" + workspaceId + "/C4", "as me"); + assertThat(row(viaUser.messageId()).getValueString("postAs")).isEqualTo("USER"); + env.dispatch(); + assertThat(env.slack().requests("chat.postMessage").get(0).authorization()).isEqualTo("Bearer " + USER_TOKEN); + + env.clock().advance(Duration.ofSeconds(2)); + SlackSendResult viaBot = queue("channel:" + workspaceId + "/C1", "as bot"); + assertThat(row(viaBot.messageId()).getValueString("postAs")).isEqualTo("BOT"); + env.dispatch(); + assertThat(env.slack().requests("chat.postMessage").get(1).authorization()).isEqualTo("Bearer " + BOT_TOKEN); + } + + + + /******************************************************************************* + ** Wrapper so a test can read a recorded request in one expression. + *******************************************************************************/ + private record SlackFakeRequest(FakeSlackServer.Recorded recorded) + { + /******************************************************************************* + ** The Authorization header. + *******************************************************************************/ + String authorization() + { + return (recorded.authorization()); + } + + + + /******************************************************************************* + ** The JSON body. + *******************************************************************************/ + JSONObject json() + { + return (recorded.json()); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java new file mode 100644 index 0000000..0c752a1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java @@ -0,0 +1,184 @@ +/* + * 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 java.util.List; +import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProvider; +import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Attachment; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput; +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.modules.messaging.QMessagingProviderDispatcher; +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; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The core messaging provider: core SendMessageAction with the provider named + ** "slack" queues rows for the current user, and its refusals reach the caller + ** as user-facing exceptions. The scheduled dispatch process delivers them. + *******************************************************************************/ +class SlackProviderTest +{ + private SlackTestEnv env; + private int workspaceId; + + + + /******************************************************************************* + ** One workspace with a bot, userA connected, userC not connected. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + env = SlackTestEnv.create(false, c -> + { + }); + workspaceId = env.workspace("T1", null, "xoxb-CANARY-PROVIDER", "UBOT"); + env.connection("userA", workspaceId, "UA", null); + env.slack().members("C1", "UA", "UBOT"); + } + + + + /******************************************************************************* + ** Restore process-wide state. + *******************************************************************************/ + @AfterEach + void cleanup() + { + env.close(); + } + + + + /******************************************************************************* + ** Input for the provider named slack. + *******************************************************************************/ + private static SendMessageInput input(Party to) + { + return (new SendMessageInput().withMessagingProviderName("slack").withTo(to).withSubject("Nightly report") + .withContent(new Content().withBody("3 & 1 warning"))); + } + + + + /******************************************************************************* + ** SendMessageAction with provider "slack" queues one row, owned by the + ** current session's user, with the subject as the bold first line and the + ** body escaped; the dispatch process then delivers it. + *******************************************************************************/ + @Test + void testSendMessageActionQueuesRowForSessionUser() throws Exception + { + env.asOwner("userA"); + SendMessageOutput output = new SendMessageAction().execute(input(new Party().withAddress("me"))); + assertThat(output).isNotNull(); + + List rows = SlackH2Support.query(SlackTables.MESSAGE); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getValueString("ownerUserId")).isEqualTo("userA"); + assertThat(rows.get(0).getValueString("status")).isEqualTo("QUEUED"); + assertThat(rows.get(0).getValueString("targetType")).isEqualTo("ME"); + assertThat(rows.get(0).getValueString("text")).isEqualTo("*Nightly report*\n3 <failures> & 1 warning"); + + env.asSystem(); + new SlackProcesses.ScheduledStep().run(new RunBackendStepInput(), new RunBackendStepOutput()); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + assertThat(env.slack().requests("chat.postMessage").get(0).json().getString("text")).startsWith("*Nightly report*"); + } + + + + /******************************************************************************* + ** A MultiParty gives one outbox row per recipient. + *******************************************************************************/ + @Test + void testMultiPartyQueuesOneRowPerRecipient() throws Exception + { + env.asOwner("userA"); + new SendMessageAction().execute(input(new MultiParty().withParty(new Party().withAddress("me")).withParty(new Party().withAddress("channel:" + workspaceId + "/C1")))); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).extracting(r -> r.getValueString("targetType")).containsExactlyInAnyOrder("ME", "CHANNEL"); + } + + + + /******************************************************************************* + ** A refusal is a user-facing exception with the reason: an unconnected user, + ** a channel the user isn't in, a missing recipient and an attachment. + ** Recipients that can be queued still are. + *******************************************************************************/ + @Test + void testRefusalsAreUserFacingExceptions() throws Exception + { + env.asOwner("userC"); + assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me")))) + .isInstanceOf(QUserFacingException.class).hasMessageContaining("Connect Slack"); + + env.asOwner("userA"); + env.slack().members("C2", "UOTHER"); + assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("channel:" + workspaceId + "/C2")))) + .isInstanceOf(QUserFacingException.class).hasMessageContaining("aren't a member"); + assertThatThrownBy(() -> new SendMessageAction().execute(input(null))) + .isInstanceOf(QUserFacingException.class).hasMessageContaining("recipient"); + assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me")).withAttachment(new Attachment()))) + .isInstanceOf(QUserFacingException.class).hasMessageContaining("attachments"); + + assertThatThrownBy(() -> new SendMessageAction().execute(input(new MultiParty().withParty(new Party().withAddress("me")).withParty(new Party().withAddress("garbage"))))) + .isInstanceOf(QUserFacingException.class).hasMessageContaining("isn't recognized"); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(1); + } + + + + /******************************************************************************* + ** The provider type is registered with core's dispatcher, the producer + ** registers the provider under the configured name, and the dispatch process + ** is scheduled on the configured scheduler (and unscheduled without one). + *******************************************************************************/ + @Test + void testRegistrationAndSchedule() throws Exception + { + assertThat(new QMessagingProviderDispatcher().getMessagingProviderInterface(SlackOutboxMessagingProviderMetaData.TYPE)).isInstanceOf(SlackOutboxMessagingProvider.class); + assertThat(env.instance().getMessagingProvider("slack").getType()).isEqualTo("QBIT_SLACK"); + assertThat(env.instance().getProcess(SlackProcesses.DISPATCH).getSchedule().getSchedulerName()).isEqualTo("scheduler"); + assertThat(env.instance().getProcess(SlackProcesses.DISPATCH).getIsHidden()).isTrue(); + assertThat(SlackProcesses.processes(new SlackQBitConfig()).get(0).getSchedule()).isNull(); + + env.close(); + env = SlackTestEnv.create(false, c -> c.withBridgeProviderName("chat")); + assertThat(env.instance().getMessagingProvider("chat").getType()).isEqualTo("QBIT_SLACK"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackSecurityTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackSecurityTest.java new file mode 100644 index 0000000..470a1fb --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackSecurityTest.java @@ -0,0 +1,315 @@ +/* + * 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 java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.security.SlackTokenCipher; +import com.kingsrook.qqq.backend.core.actions.metadata.TableMetaDataAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Owner locks, tenant locks and hidden token fields, against H2 with core + ** permission enforcement on, as every UI and API call has it. + *******************************************************************************/ +class SlackSecurityTest +{ + private static final String FAKE_BOT_CIPHERTEXT = "v1:current:RkFLRS1CT1QtQ0lQSEVSVEVYVA=="; + private static final String FAKE_USER_CIPHERTEXT = "v1:current:RkFLRS1VU0VSLUNJUEhFUlRFWFQ="; + + + + /******************************************************************************* + ** User A can't query user B's connection or message rows; an admin holding the + ** all-access owner key sees both; and A can't write a row for B. + *******************************************************************************/ + @Test + void testOwnerLock() throws Exception + { + QInstance instance = SlackH2Support.setup(c -> + { + }); + int workspaceId = seedWorkspace("T1", "1", null); + seedConnection("userA", workspaceId); + seedConnection("userB", workspaceId); + seedMessage("userA", workspaceId, "a-key"); + seedMessage("userB", workspaceId, "b-key"); + + SlackH2Support.asUser(instance, "userA", null, false); + assertThat(owners(SlackTables.CONNECTION, "userId")).containsExactly("userA"); + assertThat(owners(SlackTables.MESSAGE, "ownerUserId")).containsExactly("userA"); + + SlackH2Support.asUser(instance, "userB", null, false); + assertThat(owners(SlackTables.CONNECTION, "userId")).containsExactly("userB"); + assertThat(owners(SlackTables.MESSAGE, "ownerUserId")).containsExactly("userB"); + + // a user with no owner key at all sees nothing + SlackH2Support.asUser(instance, null, null, false); + assertThat(SlackH2Support.query(SlackTables.CONNECTION)).isEmpty(); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).isEmpty(); + + // an admin with the all-access owner key sees every user's rows + SlackH2Support.asUser(instance, "admin", null, true); + assertThat(owners(SlackTables.CONNECTION, "userId")).containsExactlyInAnyOrder("userA", "userB"); + + // by primary key too: A can't get B's row + SlackH2Support.asUser(instance, "userA", null, false); + Integer bConnectionId = SlackH2Support.query(SlackTables.CONNECTION).get(0).getValueInteger("id"); + assertThat(new GetAction().executeForRecord(new GetInput(SlackTables.CONNECTION).withPrimaryKey(bConnectionId)).getValueString("userId")).isEqualTo("userA"); + Integer otherId = bConnectionId + 1; + assertThat(new GetAction().executeForRecord(new GetInput(SlackTables.CONNECTION).withPrimaryKey(otherId))).isNull(); + + // a child inherits the owner through its message + SlackH2Support.asSystem(instance); + Integer messageA = SlackH2Support.query(SlackTables.MESSAGE).stream().filter(r -> "userA".equals(r.getValueString("ownerUserId"))).findFirst().orElseThrow().getValueInteger("id"); + Integer messageB = SlackH2Support.query(SlackTables.MESSAGE).stream().filter(r -> "userB".equals(r.getValueString("ownerUserId"))).findFirst().orElseThrow().getValueInteger("id"); + SlackH2Support.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", messageA).withValue("attemptNumber", 1).withValue("outcome", "SENT")); + SlackH2Support.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", messageB).withValue("attemptNumber", 1).withValue("outcome", "SENT")); + SlackH2Support.asUser(instance, "userA", null, false); + assertThat(SlackH2Support.query(SlackTables.ATTEMPT)).extracting(r -> r.getValueInteger("messageId")).containsExactly(messageA); + } + + + + /******************************************************************************* + ** Two tenants share one Slack workspace id (two rows). A tenant sees only its + ** own workspace, and its connections, channels and messages through the join + ** lock; a tenant admin who holds the all-access owner key still can't cross. + *******************************************************************************/ + @Test + void testTenantLock() throws Exception + { + QInstance instance = SlackH2Support.setup(SlackH2Support.tenantLock()); + int w1 = seedWorkspace("TSAME", "1", 1); + int w2 = seedWorkspace("TSAME", "2", 2); + seedConnection("userA", w1); + seedConnection("userB", w2); + seedMessage("userA", w1, "a-key"); + seedMessage("userB", w2, "b-key"); + SlackH2Support.insert(SlackTables.CHANNEL, new QRecord().withValue("name", "ops").withValue("workspaceId", w1)); + SlackH2Support.insert(SlackTables.CHANNEL, new QRecord().withValue("name", "ops").withValue("workspaceId", w2)); + SlackH2Support.insert(SlackTables.TEMPLATE, new QRecord().withValue("name", "t1").withValue("tenantId", 1)); + SlackH2Support.insert(SlackTables.TEMPLATE, new QRecord().withValue("name", "t2").withValue("tenantId", 2)); + SlackH2Support.insert(SlackTables.TEMPLATE, new QRecord().withValue("name", "shared")); + + // the system user sees everything + assertThat(SlackH2Support.query(SlackTables.WORKSPACE)).hasSize(2); + + SlackH2Support.asUser(instance, "admin1", 1, true); + assertThat(SlackH2Support.query(SlackTables.WORKSPACE)).extracting(r -> r.getValueInteger("tenantId")).containsExactly(1); + assertThat(owners(SlackTables.CONNECTION, "userId")).containsExactly("userA"); + assertThat(owners(SlackTables.MESSAGE, "ownerUserId")).containsExactly("userA"); + assertThat(SlackH2Support.query(SlackTables.CHANNEL)).extracting(r -> r.getValueInteger("workspaceId")).containsExactly(w1); + assertThat(SlackH2Support.query(SlackTables.TEMPLATE)).extracting(r -> r.getValueString("name")).containsExactlyInAnyOrder("t1", "shared"); + + SlackH2Support.asUser(instance, "admin2", 2, true); + assertThat(SlackH2Support.query(SlackTables.WORKSPACE)).extracting(r -> r.getValueInteger("tenantId")).containsExactly(2); + assertThat(owners(SlackTables.CONNECTION, "userId")).containsExactly("userB"); + assertThat(owners(SlackTables.MESSAGE, "ownerUserId")).containsExactly("userB"); + assertThat(SlackH2Support.query(SlackTables.TEMPLATE)).extracting(r -> r.getValueString("name")).containsExactlyInAnyOrder("t2", "shared"); + + // a tenant-1 user without the all-access owner key sees only their own row + SlackH2Support.asUser(instance, "userA", 1, false); + assertThat(owners(SlackTables.CONNECTION, "userId")).containsExactly("userA"); + + // and the tenant lock holds for the attempt grandchild + SlackH2Support.asSystem(instance); + for(QRecord message : SlackH2Support.query(SlackTables.MESSAGE)) + { + SlackH2Support.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", message.getValueInteger("id")).withValue("attemptNumber", 1)); + } + SlackH2Support.asUser(instance, "admin1", 1, true); + assertThat(SlackH2Support.query(SlackTables.ATTEMPT)).hasSize(1); + + // a tenant with no tenant key sees nothing + SlackH2Support.asUser(instance, "admin1", null, true); + assertThat(SlackH2Support.query(SlackTables.WORKSPACE)).isEmpty(); + assertThat(SlackH2Support.query(SlackTables.CONNECTION)).isEmpty(); + } + + + + /******************************************************************************* + ** Token columns never appear to a user session: not in a query or get result, + ** not in the table's frontend metadata, and they can't be filtered or written. + ** The system user (the dispatcher) still reads the stored ciphertext, and it + ** decrypts only with the row's own binding. + *******************************************************************************/ + @Test + void testTokenFieldsAreNeverReturned() throws Exception + { + QInstance instance = SlackH2Support.setup(c -> + { + }); + int workspaceId = seedWorkspace("T1", "1", null); + int connectionId = seedConnection("userA", workspaceId); + + // the dispatcher path: the system user, asking for hidden and password values explicitly + QRecord stored = new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE).withFilter(new QQueryFilter()).withShouldOmitHiddenFields(false).withShouldMaskPasswords(false)).getRecords().get(0); + assertThat(stored.getValueString("botToken")).isEqualTo(FAKE_BOT_CIPHERTEXT); + QRecord storedConnection = new QueryAction().execute(new QueryInput(SlackTables.CONNECTION).withFilter(new QQueryFilter()).withShouldOmitHiddenFields(false).withShouldMaskPasswords(false)).getRecords().get(0); + assertThat(storedConnection.getValueString("userToken")).isEqualTo(FAKE_USER_CIPHERTEXT); + + // the system user's ordinary query does not return them either (hidden fields are omitted by default) + assertThat(SlackH2Support.query(SlackTables.WORKSPACE).get(0).getValues()).doesNotContainKey("botToken"); + + // a user session: values, fields and metadata + SlackH2Support.asUser(instance, "userA", null, false); + for(QRecord record : SlackH2Support.query(SlackTables.WORKSPACE)) + { + assertThat(record.getValues()).doesNotContainKey("botToken"); + assertThat(record.getValues().values().stream().map(String::valueOf).collect(Collectors.joining("|"))).doesNotContain("CIPHERTEXT").doesNotContain("RkFLRS1"); + assertThat(record.getValues()).containsKey("botTokenFingerprint"); + } + QRecord userRow = new QueryAction().execute(new QueryInput(SlackTables.CONNECTION).withFilter(new QQueryFilter()).withShouldOmitHiddenFields(false).withShouldMaskPasswords(false)).getRecords().get(0); + assertThat(userRow.getValues()).doesNotContainKey("userToken"); + QRecord got = new GetAction().executeForRecord(new GetInput(SlackTables.CONNECTION).withPrimaryKey(connectionId).withShouldOmitHiddenFields(false).withShouldMaskPasswords(false)); + assertThat(got.getValues()).doesNotContainKey("userToken"); + + Map workspaceFields = new TableMetaDataAction().execute(tableInput(SlackTables.WORKSPACE)).getTable().getFields(); + assertThat(workspaceFields).containsKey("teamId").doesNotContainKey("botToken"); + Map connectionFields = new TableMetaDataAction().execute(tableInput(SlackTables.CONNECTION)).getTable().getFields(); + assertThat(connectionFields).containsKey("userId").doesNotContainKey("userToken"); + + // filtering, ordering or selecting a token is refused + assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE).withFilter(new QQueryFilter(new QFilterCriteria("botToken", QCriteriaOperator.STARTS_WITH, "v1:"))))) + .isInstanceOf(QPermissionDeniedException.class); + + // so is writing one + assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(SlackTables.WORKSPACE).withRecords(List.of(new QRecord().withValue("id", workspaceId).withValue("botToken", "v1:current:ZmFrZQ=="))))) + .isInstanceOf(QPermissionDeniedException.class); + + // the stored value is untouched, and still only decrypts for its own binding + SlackH2Support.asSystem(instance); + assertThat(new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE).withShouldOmitHiddenFields(false).withShouldMaskPasswords(false).withFilter(new QQueryFilter())).getRecords().get(0).getValueString("botToken")).isEqualTo(FAKE_BOT_CIPHERTEXT); + } + + + + /******************************************************************************* + ** A token written through the cipher round-trips through the table for the + ** dispatcher and stays out of a user's view of the same row. + *******************************************************************************/ + @Test + void testEncryptedTokenRoundTripThroughTable() throws Exception + { + QInstance instance = SlackH2Support.setup(c -> + { + }); + SlackQBitConfig config = new SlackQBitConfig().withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : null); + SlackTokenCipher cipher = new SlackTokenCipher(config); + + int workspaceId = seedWorkspace("T9", "1", null); + String binding = "slack_workspace:" + workspaceId + ":1"; + String ciphertext = cipher.encrypt("xoxb-FAKE-ROUNDTRIP", binding); + SlackH2Support.asSystem(instance); + new UpdateAction().execute(new UpdateInput(SlackTables.WORKSPACE).withRecords(List.of(new QRecord().withValue("id", workspaceId).withValue("botToken", ciphertext).withValue("botTokenFingerprint", cipher.fingerprint("xoxb-FAKE-ROUNDTRIP"))))); + + QRecord row = new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE).withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, workspaceId))).withShouldOmitHiddenFields(false).withShouldMaskPasswords(false)).getRecords().get(0); + assertThat(cipher.decrypt(row.getValueString("botToken"), binding)).isEqualTo("xoxb-FAKE-ROUNDTRIP"); + assertThatThrownBy(() -> cipher.decrypt(row.getValueString("botToken"), "slack_workspace:" + (workspaceId + 1) + ":1")).isInstanceOf(QException.class); + + SlackH2Support.asUser(instance, "userA", null, false); + QRecord asUser = SlackH2Support.query(SlackTables.WORKSPACE).stream().filter(r -> r.getValueInteger("id") == workspaceId).findFirst().orElseThrow(); + assertThat(asUser.getValues()).doesNotContainKey("botToken"); + assertThat(asUser.getValueString("botTokenFingerprint")).hasSize(8); + } + + + + /******************************************************************************* + ** Input for a table-metadata request. + *******************************************************************************/ + private static TableMetaDataInput tableInput(String tableName) + { + TableMetaDataInput input = new TableMetaDataInput(); + input.setTableName(tableName); + return (input); + } + + + + /******************************************************************************* + ** The distinct values of a column over the current context's query. + *******************************************************************************/ + private static List owners(String table, String field) throws Exception + { + return (SlackH2Support.query(table).stream().map(r -> r.getValueString(field)).toList()); + } + + + + /******************************************************************************* + ** Insert a workspace (as the system user) with a fake ciphertext in place. + *******************************************************************************/ + private static int seedWorkspace(String teamId, String tenantKey, Integer tenantId) throws Exception + { + QRecord record = new QRecord().withValue("teamId", teamId).withValue("teamName", "Team " + teamId).withValue("tenantKey", tenantKey) + .withValue("botToken", FAKE_BOT_CIPHERTEXT).withValue("botTokenFingerprint", "deadbeef"); + if(tenantId != null) + { + record.setValue("tenantId", tenantId); + } + return (SlackH2Support.insert(SlackTables.WORKSPACE, record).getValueInteger("id")); + } + + + + /******************************************************************************* + ** Insert a connection for a user (as the system user). + *******************************************************************************/ + private static int seedConnection(String userId, int workspaceId) throws Exception + { + return (SlackH2Support.insert(SlackTables.CONNECTION, new QRecord().withValue("userId", userId).withValue("workspaceId", workspaceId) + .withValue("userToken", FAKE_USER_CIPHERTEXT).withValue("userTokenFingerprint", "cafef00d")).getValueInteger("id")); + } + + + + /******************************************************************************* + ** Insert a message for an owner (as the system user). + *******************************************************************************/ + private static int seedMessage(String ownerUserId, int workspaceId, String idempotencyKey) throws Exception + { + return (SlackH2Support.insert(SlackTables.MESSAGE, new QRecord().withValue("ownerUserId", ownerUserId).withValue("workspaceId", workspaceId) + .withValue("targetType", "ME").withValue("text", "hello").withValue("idempotencyKey", idempotencyKey)).getValueInteger("id")); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackSupportTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackSupportTest.java new file mode 100644 index 0000000..e3ac5bd --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackSupportTest.java @@ -0,0 +1,217 @@ +/* + * 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 java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.slack.delivery.SlackAlerts; +import com.kingsrook.qbits.slack.security.SlackAuthorizer; +import com.kingsrook.qbits.slack.support.SlackMembershipCache; +import com.kingsrook.qbits.slack.support.SlackRateLimiter; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The small in-memory pieces: the client-side limiter, the membership cache, + ** the ownerless-owner helpers, the alert types and the new config settings. + *******************************************************************************/ +class SlackSupportTest +{ + private static final Instant T0 = Instant.parse("2026-10-14T12:00:00Z"); + + + + /******************************************************************************* + ** At most one post a second per channel; other channels are independent. + *******************************************************************************/ + @Test + void testOnePostPerSecondPerChannel() throws Exception + { + SlackRateLimiter limiter = new SlackRateLimiter(); + assertThat(limiter.waitMillis(1, "C1", 50, T0)).isZero(); + + limiter.record(1, "C1", T0); + assertThat(limiter.waitMillis(1, "C1", 50, T0)).isEqualTo(1000); + assertThat(limiter.waitMillis(1, "C1", 50, T0.plusMillis(400))).isEqualTo(600); + assertThat(limiter.waitMillis(1, "C1", 50, T0.plusMillis(1000))).isZero(); + assertThat(limiter.waitMillis(1, "C2", 50, T0)).isZero(); + assertThat(limiter.waitMillis(2, "C1", 50, T0)).isZero(); + } + + + + /******************************************************************************* + ** At most ratePerMinute posts a workspace a minute, whatever the channel; the + ** window slides. + *******************************************************************************/ + @Test + void testRatePerMinuteWindowSlides() throws Exception + { + SlackRateLimiter limiter = new SlackRateLimiter(); + for(int i = 0; i < 3; i++) + { + limiter.record(1, "C" + i, T0.plusSeconds(i * 10L)); + } + + assertThat(limiter.waitMillis(1, "C9", 3, T0.plusSeconds(30))).isEqualTo(30_000); + assertThat(limiter.waitMillis(1, "C9", 4, T0.plusSeconds(30))).isZero(); + assertThat(limiter.waitMillis(1, "C9", 3, T0.plusSeconds(50))).isEqualTo(10_000); + assertThat(limiter.waitMillis(1, "C9", 3, T0.plusSeconds(60))).isZero(); + } + + + + /******************************************************************************* + ** An unhealthy workspace is probed once every five minutes. + *******************************************************************************/ + @Test + void testProbeSpacing() throws Exception + { + SlackRateLimiter limiter = new SlackRateLimiter(); + assertThat(limiter.probeWaitMillis(1, T0)).isZero(); + limiter.recordProbe(1, T0); + assertThat(limiter.probeWaitMillis(1, T0.plusSeconds(60))).isEqualTo(240_000); + assertThat(limiter.probeWaitMillis(1, T0.plusSeconds(300))).isZero(); + assertThat(limiter.probeWaitMillis(2, T0)).isZero(); + + limiter.clear(); + assertThat(limiter.probeWaitMillis(1, T0.plusSeconds(1))).isZero(); + } + + + + /******************************************************************************* + ** The channel map is bounded: past the cap, old entries make room. + *******************************************************************************/ + @Test + void testLimiterChannelMapIsBounded() throws Exception + { + SlackRateLimiter limiter = new SlackRateLimiter(); + for(int i = 0; i < SlackRateLimiter.MAX_CHANNELS + 50; i++) + { + limiter.record(1, "C" + i, T0); + } + limiter.record(1, "NEW", T0.plusSeconds(5)); + assertThat(limiter.waitMillis(1, "NEW", 100_000, T0.plusSeconds(5))).isEqualTo(1000); + assertThat(limiter.waitMillis(1, "C0", 100_000, T0.plusSeconds(5))).isZero(); + } + + + + /******************************************************************************* + ** The membership cache: expiry, invalidation, replacement and the bound. + *******************************************************************************/ + @Test + void testMembershipCacheExpiryInvalidationAndBound() throws Exception + { + SlackMembershipCache cache = new SlackMembershipCache(); + assertThat(cache.get(1, "C1", T0)).isNull(); + + cache.put(1, "C1", new SlackMembershipCache.Entry(Set.of("UA"), true, "general", T0.plusSeconds(600)), T0); + assertThat(cache.get(1, "C1", T0.plusSeconds(599)).members()).containsExactly("UA"); + assertThat(cache.get(2, "C1", T0)).as("another workspace row is another key").isNull(); + assertThat(cache.get(1, "C1", T0.plusSeconds(600))).isNull(); + assertThat(cache.size()).isZero(); + + cache.put(1, "C1", new SlackMembershipCache.Entry(Set.of("UA"), true, "general", T0.plusSeconds(600)), T0); + cache.put(1, "C1", new SlackMembershipCache.Entry(Set.of("UB"), false, null, T0.plusSeconds(600)), T0); + assertThat(cache.size()).isEqualTo(1); + assertThat(cache.get(1, "C1", T0).members()).containsExactly("UB"); + cache.invalidate(1, "C1"); + assertThat(cache.get(1, "C1", T0)).isNull(); + + for(int i = 0; i < SlackMembershipCache.MAX_ENTRIES; i++) + { + cache.put(1, "X" + i, new SlackMembershipCache.Entry(Set.of(), false, null, T0.plusSeconds(i < 100 ? 10 : 600)), T0); + } + assertThat(cache.size()).isEqualTo(SlackMembershipCache.MAX_ENTRIES); + cache.put(1, "LATE", new SlackMembershipCache.Entry(Set.of(), false, null, T0.plusSeconds(600)), T0.plusSeconds(20)); + assertThat(cache.size()).isEqualTo(SlackMembershipCache.MAX_ENTRIES - 99); + + for(int i = 0; i < SlackMembershipCache.MAX_ENTRIES + 10; i++) + { + cache.put(2, "Y" + i, new SlackMembershipCache.Entry(Set.of(), false, null, T0.plusSeconds(600)), T0.plusSeconds(20)); + } + assertThat(cache.size()).isEqualTo(SlackMembershipCache.MAX_ENTRIES); + assertThat(cache.get(1, "LATE", T0.plusSeconds(20))).as("the oldest entries went first").isNull(); + assertThat(cache.get(2, "Y" + (SlackMembershipCache.MAX_ENTRIES + 9), T0.plusSeconds(20))).isNotNull(); + + cache.clear(); + assertThat(cache.size()).isZero(); + } + + + + /******************************************************************************* + ** The value stored on a message that has no user owner. + *******************************************************************************/ + @Test + void testOwnerlessOwnerHelpers() throws Exception + { + assertThat(SlackAuthorizer.systemOwner("7")).isEqualTo("system:7"); + assertThat(SlackAuthorizer.systemOwner(null)).isEqualTo("system:"); + assertThat(SlackAuthorizer.tenantKeyOf("system:7")).isEqualTo("7"); + assertThat(SlackAuthorizer.tenantKeyOf("system:")).isNull(); + assertThat(SlackAuthorizer.tenantKeyOf("userA")).isNull(); + assertThat(SlackAuthorizer.tenantKeyOf(null)).isNull(); + assertThat(SlackAuthorizer.isOwnerless(null)).isTrue(); + assertThat(SlackAuthorizer.isOwnerless("system:7")).isTrue(); + assertThat(SlackAuthorizer.isOwnerless("userA")).isFalse(); + } + + + + /******************************************************************************* + ** The alert types match core's dotted-name pattern. + *******************************************************************************/ + @Test + void testAlertTypeNames() throws Exception + { + List names = new ArrayList<>(); + SlackAlerts.types().forEach(t -> names.add(t.getName())); + assertThat(names).containsExactly("slack.delivery.unauthorized", "slack.workspace.unhealthy", "slack.message.dead-letter"); + assertThat(names).allMatch(n -> n.matches("^[a-z0-9]+(\\.[a-z0-9-]+)+$")); + } + + + + /******************************************************************************* + ** The HTTP timeouts are settings, and must be positive. + *******************************************************************************/ + @Test + void testTimeoutSettings() throws Exception + { + SlackQBitConfig config = new SlackQBitConfig(); + assertThat(config.getConnectTimeoutSeconds()).isEqualTo(5); + assertThat(config.getRequestTimeoutSeconds()).isEqualTo(15); + + List errors = new ArrayList<>(); + config.withBackendName("b").withSchedulerName("s").validate(null, errors); + assertThat(errors).isEmpty(); + + config.withConnectTimeoutSeconds(0).withRequestTimeoutSeconds(null).validate(null, errors); + assertThat(errors).hasSize(1).first().asString().contains("timeouts"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackTenantIsolationTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackTenantIsolationTest.java new file mode 100644 index 0000000..dd7c61e --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackTenantIsolationTest.java @@ -0,0 +1,301 @@ +/* + * 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 java.time.Duration; +import java.util.Set; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.security.SlackTokens; +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.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Two tenants, two workspaces (docs/DESIGN.md 4.1, last bullet), with the + ** host's tenant lock on. Each tenant has its own bot and its own user token; + ** the two workspace rows even share one Slack team id. + *******************************************************************************/ +class SlackTenantIsolationTest +{ + private static final String WA_BOT = "xoxb-CANARY-WA-BOT"; + private static final String WB_BOT = "xoxb-CANARY-WB-BOT"; + private static final String UA_USER = "xoxp-CANARY-WA-USER"; + private static final String UB_USER = "xoxp-CANARY-WB-USER"; + + private SlackTestEnv env; + private int wa; + private int wb; + + + + /******************************************************************************* + ** Tenant 1 has userA in workspace WA with a registered channel "ops"; tenant 2 + ** has userB in WB with registered channels "ops2" and "secret". + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + env = SlackTestEnv.create(true, c -> + { + }); + env.tenant("userA", 1); + env.tenant("userB", 2); + + wa = env.workspace("TSAME", 1, WA_BOT, "UBOTA"); + wb = env.workspace("TSAME", 2, WB_BOT, "UBOTB"); + env.connection("userA", wa, "UA", UA_USER); + env.connection("userB", wb, "UB", UB_USER); + + env.channel(wa, "ops", "COPSA", "ops-a"); + env.channel(wb, "ops2", "COPSB", "ops-b"); + env.channel(wb, "secret", "CSECRET", "secret-b"); + env.slack().members("COPSA", "UA", "UBOTA"); + env.slack().members("COPSB", "UB", "UBOTB"); + env.slack().members("CSECRET", "UB", "UBOTB"); + env.slack().members("CB1", "UB", "UBOTB"); + } + + + + /******************************************************************************* + ** Restore process-wide state. + *******************************************************************************/ + @AfterEach + void cleanup() + { + env.close(); + } + + + + /******************************************************************************* + ** User A can't target WB: not by workspace and channel id, not by pinning the + ** workspace, and not by the name of a channel registered in WB. Nothing is + ** queued, and WB's token is never used for A. B can't reach WA either. + *******************************************************************************/ + @Test + void testUserCannotTargetAnotherTenantsWorkspace() throws Exception + { + env.asOwner("userA"); + SlackSendResult byId = SlackService.enqueue(new SlackSendRequest().withTo("channel:" + wb + "/CB1").withText("x")); + SlackSendResult pinned = SlackService.enqueue(new SlackSendRequest().withTo("me").withWorkspaceId(wb).withText("x")); + SlackSendResult byName = SlackService.enqueue(new SlackSendRequest().withTo("channel:secret").withText("x")); + SlackSendResult byHash = SlackService.enqueue(new SlackSendRequest().withTo("#ops2").withText("x")); + SlackSendResult group = SlackService.enqueue(new SlackSendRequest().withTo("group:anything").withText("x")); + + assertThat(byId.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(pinned.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(byName.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(byName.reasons().get(0)).contains("isn't a registered Slack channel available to you"); + assertThat(byHash.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(group.status()).isEqualTo(SlackSendResult.Status.REFUSED); + + assertThat(env.slack().requests()).isEmpty(); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).isEmpty(); + env.asSystem(); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).isEmpty(); + + env.asOwner("userB"); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("channel:" + wa + "/COPSA").withText("x")).status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("channel:ops").withText("x")).status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("channel:ops2").withText("x")).status()).isEqualTo(SlackSendResult.Status.QUEUED); + assertThat(env.slack().authorizations()).doesNotContain("Bearer " + WA_BOT).doesNotContain("Bearer " + UA_USER); + } + + + + /******************************************************************************* + ** A's scheduled message, after A's connection is revoked, fails with + ** FAILED_AUTH and an alert to A. The fake server never sees WB's token on any + ** request made for A, and in fact sees no request for the message at all. + ** B's own message still goes out, with B's token only. + *******************************************************************************/ + @Test + void testRevokedConnectionFailsForOwnerAndNeverUsesAnotherTenantsToken() throws Exception + { + env.asOwner("userA"); + SlackSendResult toMe = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("scheduled for A")); + SlackSendResult toOps = SlackService.enqueue(new SlackSendRequest().withTo("#ops").withText("scheduled for A in ops")); + assertThat(toMe.status()).isEqualTo(SlackSendResult.Status.QUEUED); + assertThat(toOps.status()).isEqualTo(SlackSendResult.Status.QUEUED); + env.slack().clearRequests(); + + env.asSystem(); + QRecord connection = SlackStore.query(SlackTables.CONNECTION, SlackStore.equal("userId", "userA")).get(0); + env.set(SlackTables.CONNECTION, connection.getValueInteger("id"), "status", "REVOKED"); + + env.clock().advance(Duration.ofMinutes(11)); + assertThat(env.dispatch().failed()).isEqualTo(2); + + assertThat(env.row(SlackTables.MESSAGE, toMe.messageId()).getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(env.row(SlackTables.MESSAGE, toOps.messageId()).getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(env.slack().requests()).isEmpty(); + assertThat(env.slack().authorizations()).doesNotContain("Bearer " + WB_BOT).doesNotContain("Bearer " + UB_USER); + + assertThat(SlackTestEnv.CapturingAlertSink.NOTIFIED).hasSize(2); + assertThat(SlackTestEnv.CapturingAlertSink.NOTIFIED).allSatisfy(n -> assertThat(n.getAudience().getValues()).containsExactly("userA")); + assertThat(SlackTestEnv.CapturingAlertSink.RAISED).extracting(a -> a.getAlertTypeName()).containsOnly("slack.delivery.unauthorized"); + assertThat(SlackTestEnv.CapturingAlertSink.RAISED).extracting(a -> a.getDedupKey()).hasSize(2).containsOnly(SlackTestEnv.CapturingAlertSink.RAISED.get(0).getDedupKey()); + + env.asOwner("userB"); + SlackSendResult forB = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("for B")); + env.clock().advance(Duration.ofSeconds(2)); + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(env.row(SlackTables.MESSAGE, forB.messageId()).getValueString("status")).isEqualTo("SENT"); + assertThat(env.slack().authorizations()).containsExactly("Bearer " + WB_BOT); + } + + + + /******************************************************************************* + ** A's ciphertext copied into B's row fails to decrypt (it is bound to its own + ** row and tenant), a send for B then fails authorization without calling + ** Slack, and A's own token still works in A's row. + *******************************************************************************/ + @Test + void testCiphertextCopiedToAnotherTenantsRowFailsToDecrypt() throws Exception + { + env.asOwner("userB"); + SlackSendResult forB = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("for B")); + assertThat(forB.status()).isEqualTo(SlackSendResult.Status.QUEUED); + + env.asSystem(); + String aWorkspaceCipher = SlackStore.getWithToken(SlackTables.WORKSPACE, wa).getValueString("botToken"); + String aUserCipher = SlackStore.getWithToken(SlackTables.CONNECTION, connectionOf("userA")).getValueString("userToken"); + env.set(SlackTables.WORKSPACE, wb, "botToken", aWorkspaceCipher); + env.set(SlackTables.CONNECTION, connectionOf("userB"), "userToken", aUserCipher); + + assertThatThrownBy(() -> SlackTokens.botToken(env.config(), wb)).isInstanceOf(QException.class).hasMessage("Slack token could not be decrypted"); + assertThatThrownBy(() -> SlackTokens.userToken(env.config(), connectionOf("userB"))).isInstanceOf(QException.class).hasMessage("Slack token could not be decrypted"); + assertThat(SlackTokens.botToken(env.config(), wa)).isEqualTo(WA_BOT); + assertThat(SlackTokens.userToken(env.config(), connectionOf("userA"))).isEqualTo(UA_USER); + + env.slack().clearRequests(); + assertThat(env.dispatch().failed()).isEqualTo(1); + assertThat(env.row(SlackTables.MESSAGE, forB.messageId()).getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(env.row(SlackTables.MESSAGE, forB.messageId()).getValueString("lastErrorCode")).isEqualTo("token_unreadable"); + assertThat(env.slack().requests()).isEmpty(); + } + + + + /******************************************************************************* + ** An ownerless send (a system notice) goes only to a registered channel of the + ** tenant it names, only from a system session, and always as that + ** workspace's bot. + *******************************************************************************/ + @Test + void testOwnerlessSendsAreTenantCheckedRegisteredChannelsFromSystemOnly() throws Exception + { + env.asSystem(); + SlackSendResult ok = SlackService.enqueue(new SlackSendRequest().withTo("#ops").withTenantKey("1").withText("system notice")); + assertThat(ok.status()).isEqualTo(SlackSendResult.Status.QUEUED); + QRecord row = env.row(SlackTables.MESSAGE, ok.messageId()); + assertThat(row.getValueString("ownerUserId")).isEqualTo("system:1"); + assertThat(row.getValueString("postAs")).isEqualTo("BOT"); + assertThat(row.getValueInteger("workspaceId")).isEqualTo(wa); + assertThat(row.getValueInteger("connectionId")).isNull(); + + Set refused = Set.of(SlackSendResult.Status.REFUSED); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("#ops").withTenantKey("2").withText("x")).status()).isIn(refused); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("channel:secret").withTenantKey("1").withText("x")).status()).isIn(refused); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("#ops").withText("x")).status()).isIn(refused); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("me").withTenantKey("1").withText("x")).status()).isIn(refused); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("channel:" + wa + "/COPSA").withTenantKey("1").withText("x")).status()).isIn(refused); + + env.asOwner("userA"); + SlackSendResult fromUser = SlackService.enqueue(new SlackSendRequest().withTo("#ops").withTenantKey("1").withOwnerUserId("someoneElse").withText("x")); + assertThat(fromUser.status()).isEqualTo(SlackSendResult.Status.REFUSED); + assertThat(fromUser.reasons().get(0)).contains("signed-in user"); + + assertThat(env.dispatch().sent()).isEqualTo(1); + assertThat(env.slack().requests("chat.postMessage")).hasSize(1); + assertThat(env.slack().requests("chat.postMessage").get(0).authorization()).isEqualTo("Bearer " + WA_BOT); + assertThat(env.slack().requests("chat.postMessage").get(0).json().getString("channel")).isEqualTo("COPSA"); + assertThat(env.row(SlackTables.MESSAGE, ok.messageId()).getValueString("status")).isEqualTo("SENT"); + assertThat(SlackTestEnv.CapturingAlertSink.NOTIFIED).isEmpty(); + + env.asSystem(); + SlackSendResult named = SlackService.enqueue(new SlackSendRequest().withOwnerUserId("userA").withTo("me").withText("named owner")); + assertThat(named.status()).isEqualTo(SlackSendResult.Status.QUEUED); + assertThat(env.row(SlackTables.MESSAGE, named.messageId()).getValueString("ownerUserId")).isEqualTo("userA"); + } + + + + /******************************************************************************* + ** An ownerless message whose workspace loses its bot fails authorization and + ** raises the alert, with nobody to notify. + *******************************************************************************/ + @Test + void testOwnerlessMessageFailsWhenItsWorkspaceIsRevoked() throws Exception + { + env.asSystem(); + SlackSendResult ok = SlackService.enqueue(new SlackSendRequest().withTo("#ops").withTenantKey("1").withText("system notice")); + env.set(SlackTables.WORKSPACE, wa, "status", "REVOKED"); + + assertThat(env.dispatch().failed()).isEqualTo(1); + assertThat(env.row(SlackTables.MESSAGE, ok.messageId()).getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(env.slack().requests("chat.postMessage")).isEmpty(); + assertThat(SlackTestEnv.CapturingAlertSink.NOTIFIED).isEmpty(); + assertThat(SlackTestEnv.CapturingAlertSink.RAISED).hasSize(1); + } + + + + /******************************************************************************* + ** A message queued for A keeps A's workspace: a row whose workspace was + ** changed to B's does not post (the pinned workspace no longer matches what A + ** is connected to). + *******************************************************************************/ + @Test + void testMessageMovedToAnotherWorkspaceDoesNotPost() throws Exception + { + env.asOwner("userA"); + SlackSendResult queued = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("hi")); + env.set(SlackTables.MESSAGE, queued.messageId(), "workspaceId", wb); + + env.slack().clearRequests(); + assertThat(env.dispatch().failed()).isEqualTo(1); + assertThat(env.row(SlackTables.MESSAGE, queued.messageId()).getValueString("status")).isEqualTo("FAILED_AUTH"); + assertThat(env.slack().requests()).isEmpty(); + } + + + + /******************************************************************************* + ** The connection row id of a user, read as the system. + *******************************************************************************/ + private int connectionOf(String userId) throws Exception + { + return (SlackStore.query(SlackTables.CONNECTION, SlackStore.equal("userId", userId)).get(0).getValueInteger("id")); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackTestEnv.java b/src/test/java/com/kingsrook/qbits/slack/SlackTestEnv.java new file mode 100644 index 0000000..a3a0d76 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackTestEnv.java @@ -0,0 +1,515 @@ +/* + * 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 java.io.Serializable; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import com.kingsrook.qbits.slack.delivery.SlackDispatcher; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qbits.slack.security.SlackTokenCipher; +import com.kingsrook.qbits.slack.security.SlackTokens; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.alerts.AlertResolution; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface; +import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; + + +/******************************************************************************* + ** Everything an outbox test needs: an H2 database with the qbit produced into + ** it (and optionally the host's tenant lock), a fake Slack server wired to the + ** config, an instance whose authentication module builds automated sessions + ** for users the way a host does (tenant key, slack owner key, permissions), + ** a clock the test moves, a capturing alerts sink, and seed helpers that + ** encrypt tokens with each row's own binding. + *******************************************************************************/ +public final class SlackTestEnv implements AutoCloseable +{ + private final FakeSlackServer slack; + private final MutableClock clock; + private final QInstance instance; + private final SlackQBitConfig config; + + + + /******************************************************************************* + ** A clock the test advances. + *******************************************************************************/ + public static class MutableClock extends Clock + { + private volatile Instant now; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public MutableClock(Instant now) + { + this.now = now; + } + + + + /******************************************************************************* + ** Move time forward. + *******************************************************************************/ + public void advance(Duration duration) + { + now = now.plus(duration); + } + + + + /******************************************************************************* + ** UTC. + *******************************************************************************/ + @Override + public ZoneId getZone() + { + return (ZoneOffset.UTC); + } + + + + /******************************************************************************* + ** Same clock. + *******************************************************************************/ + @Override + public Clock withZone(ZoneId zone) + { + return (this); + } + + + + /******************************************************************************* + ** The current fake time. + *******************************************************************************/ + @Override + public Instant instant() + { + return (now); + } + } + + + + /******************************************************************************* + ** Records the alerts, resolutions and notifications the qbit sends to core. + *******************************************************************************/ + public static class CapturingAlertSink implements AlertSinkInterface + { + public static final List RAISED = new CopyOnWriteArrayList<>(); + public static final List RESOLVED = new CopyOnWriteArrayList<>(); + public static final List NOTIFIED = new CopyOnWriteArrayList<>(); + + + + /******************************************************************************* + ** Forget everything captured. + *******************************************************************************/ + public static void reset() + { + RAISED.clear(); + RESOLVED.clear(); + NOTIFIED.clear(); + } + + + + /******************************************************************************* + ** Capture a raised alert. + *******************************************************************************/ + @Override + public void raise(AlertSignal signal) + { + RAISED.add(signal); + } + + + + /******************************************************************************* + ** Capture a resolution. + *******************************************************************************/ + @Override + public void resolve(AlertResolution resolution) + { + RESOLVED.add(resolution); + } + + + + /******************************************************************************* + ** Ignore heartbeats. + *******************************************************************************/ + @Override + public void heartbeat(String heartbeatKey, Instant at) + { + // nothing to do + } + + + + /******************************************************************************* + ** Capture a notification. + *******************************************************************************/ + @Override + public void notify(NotificationSignal signal) + { + NOTIFIED.add(signal); + } + } + + + + /******************************************************************************* + ** What a host's authentication customizer does for an automated session: the + ** user's permissions, tenant key and slack owner key. + *******************************************************************************/ + public static class TestAuthCustomizer implements QAuthenticationModuleCustomizerInterface + { + public static final Map TENANTS = new ConcurrentHashMap<>(); + public static final Set BLOCK_USERS = ConcurrentHashMap.newKeySet(); + public static final Set NO_ACCESS = ConcurrentHashMap.newKeySet(); + + + + /******************************************************************************* + ** Forget the test users. + *******************************************************************************/ + public static void reset() + { + TENANTS.clear(); + BLOCK_USERS.clear(); + NO_ACCESS.clear(); + } + + + + /******************************************************************************* + ** Permissions, tenant key and owner key for a user's automated session. + *******************************************************************************/ + @Override + public void customizeAutomatedSessionForUser(QInstance qInstance, QSession session, Serializable userId) + { + String id = String.valueOf(userId); + if(!NO_ACCESS.contains(id)) + { + session.withPermissions("slack.hasAccess", "slack.view", "slack.send"); + if(BLOCK_USERS.contains(id)) + { + session.withPermissions("slack.send.blocks"); + } + } + if(TENANTS.get(id) != null) + { + session.withSecurityKeyValue(SlackH2Support.TENANT_KEY, TENANTS.get(id)); + } + SlackSecurity.applyToSession(session, id, false); + } + } + + + + /******************************************************************************* + ** Constructor: use create. + *******************************************************************************/ + private SlackTestEnv(FakeSlackServer slack, MutableClock clock, QInstance instance, SlackQBitConfig config) + { + this.slack = slack; + this.clock = clock; + this.instance = instance; + this.config = config; + } + + + + /******************************************************************************* + ** Build the environment. When tenants is true the host's tenant lock is on + ** (tenantId column, security key "tenant"). + *******************************************************************************/ + public static SlackTestEnv create(boolean tenants, Consumer tweak) throws Exception + { + SlackRuntime.reset(); + TestAuthCustomizer.reset(); + CapturingAlertSink.reset(); + + MutableClock clock = new MutableClock(Instant.parse("2026-10-14T12:00:00Z")); + SlackRuntime.setClock(clock); + FakeSlackServer slack = new FakeSlackServer(); + + ConnectionManager.resetConnectionProviders(); + RDBMSBackendMetaData backend = SlackH2Support.backend(); + SlackH2Support.prime(backend); + + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK).withCustomizer(new QCodeReference(TestAuthCustomizer.class))); + instance.addBackend(backend); + instance.addScheduler(new SimpleSchedulerMetaData().withName("scheduler")); + instance.addSecurityKeyType(new QSecurityKeyType().withName(SlackH2Support.TENANT_KEY).withAllAccessKeyName(SlackH2Support.TENANT_KEY + "_all_access")); + + SlackQBitConfig config = new SlackQBitConfig().withBackendName("rdbms").withSchedulerName("scheduler").withSlackApiBaseUrl(slack.baseUrl()) + .withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : null); + if(tenants) + { + SlackH2Support.tenantLock().accept(config); + } + tweak.accept(config); + + new SlackQBitProducer().withQBitConfig(config).produce(instance); + new QInstanceValidator().validate(instance); + AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(CapturingAlertSink.class)); + instance.setEnforcePermissionsInCoreActions(true); + + QContext.init(instance, new QSystemUserSession(instance)); + return (new SlackTestEnv(slack, clock, instance, config)); + } + + + + /******************************************************************************* + ** The fake Slack server. + *******************************************************************************/ + public FakeSlackServer slack() + { + return (slack); + } + + + + /******************************************************************************* + ** The clock. + *******************************************************************************/ + public MutableClock clock() + { + return (clock); + } + + + + /******************************************************************************* + ** The instance. + *******************************************************************************/ + public QInstance instance() + { + return (instance); + } + + + + /******************************************************************************* + ** The qbit's config. + *******************************************************************************/ + public SlackQBitConfig config() + { + return (config); + } + + + + /******************************************************************************* + ** Run as the system user (the dispatcher, and seeding). + *******************************************************************************/ + public void asSystem() + { + QContext.init(instance, new QSystemUserSession(instance)); + } + + + + /******************************************************************************* + ** Run as a QQQ user, with the session the instance's authentication module + ** builds for that user. + *******************************************************************************/ + public QSession asOwner(String userId) throws Exception + { + asSystem(); + QSession session = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication()).createAutomatedSessionForUser(instance, userId); + QContext.init(instance, session); + return (session); + } + + + + /******************************************************************************* + ** Put a user in a tenant for their automated sessions. + *******************************************************************************/ + public void tenant(String userId, Integer tenantId) + { + TestAuthCustomizer.TENANTS.put(userId, tenantId); + } + + + + /******************************************************************************* + ** Seed a workspace (as the system). The bot token, when given, is encrypted + ** with this row's binding. Returns the row id. + *******************************************************************************/ + public int workspace(String teamId, Integer tenantId, String botToken, String botUserId) throws Exception + { + asSystem(); + String tenantKey = tenantId == null ? null : String.valueOf(tenantId); + QRecord record = new QRecord().withValue("teamId", teamId).withValue("teamName", "Team " + teamId).withValue("tenantKey", tenantKey).withValue("botUserId", botUserId) + .withValue("status", "ACTIVE").withValue("health", "HEALTHY").withValue("consecutiveFailures", 0).withValue("ratePerMinute", 50); + if(tenantId != null) + { + record.setValue("tenantId", tenantId); + } + + int id = SlackH2Support.insert(SlackTables.WORKSPACE, record).getValueInteger("id"); + if(botToken != null) + { + SlackTokenCipher cipher = new SlackTokenCipher(config); + SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("botToken", cipher.encrypt(botToken, SlackTokens.workspaceBinding(id, tenantKey))) + .withValue("botTokenFingerprint", cipher.fingerprint(botToken)), null); + } + return (id); + } + + + + /******************************************************************************* + ** Seed a connection (as the system). The user token, when given, is encrypted + ** with this row's binding. Returns the row id. + *******************************************************************************/ + public int connection(String userId, int workspaceId, String slackUserId, String userToken) throws Exception + { + asSystem(); + int id = SlackH2Support.insert(SlackTables.CONNECTION, new QRecord().withValue("userId", userId).withValue("workspaceId", workspaceId) + .withValue("slackUserId", slackUserId).withValue("status", "ACTIVE")).getValueInteger("id"); + if(userToken != null) + { + SlackTokenCipher cipher = new SlackTokenCipher(config); + SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", id).withValue("userToken", cipher.encrypt(userToken, SlackTokens.connectionBinding(id, userId))) + .withValue("userTokenFingerprint", cipher.fingerprint(userToken)), null); + } + return (id); + } + + + + /******************************************************************************* + ** Seed a registered channel (as the system). Returns the row id. + *******************************************************************************/ + public int channel(int workspaceId, String name, String slackChannelId, String slackChannelName) throws Exception + { + asSystem(); + return (SlackH2Support.insert(SlackTables.CHANNEL, new QRecord().withValue("name", name).withValue("workspaceId", workspaceId) + .withValue("slackChannelId", slackChannelId).withValue("slackChannelName", slackChannelName).withValue("enabled", true)).getValueInteger("id")); + } + + + + /******************************************************************************* + ** Seed a registered user group (as the system). Returns the row id. + *******************************************************************************/ + public int userGroup(int workspaceId, String name, String slackUsergroupId, String channelId) throws Exception + { + asSystem(); + return (SlackH2Support.insert(SlackTables.USER_GROUP, new QRecord().withValue("name", name).withValue("workspaceId", workspaceId) + .withValue("slackUsergroupId", slackUsergroupId).withValue("channelId", channelId).withValue("enabled", true)).getValueInteger("id")); + } + + + + /******************************************************************************* + ** A row by primary key, read as the system. + *******************************************************************************/ + public QRecord row(String table, Integer id) throws Exception + { + return (SlackStore.get(table, id)); + } + + + + /******************************************************************************* + ** Change a row as the system. + *******************************************************************************/ + public void set(String table, Integer id, String field, Serializable value) throws Exception + { + SlackStore.update(table, new QRecord().withValue("id", id).withValue(field, value), null); + } + + + + /******************************************************************************* + ** The attempt rows of a message, oldest first. + *******************************************************************************/ + public List attempts(Integer messageId) throws Exception + { + return (SlackStore.query(SlackTables.ATTEMPT, SlackStore.equal("messageId", messageId).withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("attemptNumber")))); + } + + + + /******************************************************************************* + ** Run one dispatch pass, as the system. + *******************************************************************************/ + public SlackDispatcher.RunSummary dispatch() throws Exception + { + asSystem(); + return (new SlackDispatcher().runOnce()); + } + + + + /******************************************************************************* + ** Restore the process-wide state. + *******************************************************************************/ + @Override + public void close() + { + slack.close(); + SlackRuntime.reset(); + TestAuthCustomizer.reset(); + QContext.clear(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackTokenCipherTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackTokenCipherTest.java new file mode 100644 index 0000000..61e0005 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackTokenCipherTest.java @@ -0,0 +1,313 @@ +/* + * 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 java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.slack.security.SlackRedaction; +import com.kingsrook.qbits.slack.security.SlackTokenCipher; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The token cipher, redaction, and the canary: a fake token driven through + ** every failure path never appears in an exception message. + *******************************************************************************/ +public class SlackTokenCipherTest +{ + ///////////////////////////////////////////////////////////////////////////// + // obviously fake keys: base64 of 32 bytes each (all 0x01, all 0x02) // + ///////////////////////////////////////////////////////////////////////////// + public static final String KEY_ONE = Base64.getEncoder().encodeToString(filled(1)); + public static final String KEY_TWO = Base64.getEncoder().encodeToString(filled(2)); + + private static final String CANARY = "xoxb-CANARY-123"; + private static final String BINDING = "slack_connection:7:userA"; + + + + /******************************************************************************* + ** Build a cipher whose resolver reads from the given fake environment. + *******************************************************************************/ + private static SlackTokenCipher cipher(Map env) + { + return (new SlackTokenCipher(new SlackQBitConfig().withSecretResolver(env::get))); + } + + + + /******************************************************************************* + ** A 32-byte array of one value. + *******************************************************************************/ + private static byte[] filled(int value) + { + byte[] bytes = new byte[32]; + java.util.Arrays.fill(bytes, (byte) value); + return (bytes); + } + + + + /******************************************************************************* + ** Encrypt then decrypt returns the token; the format is v1:current:base64; and + ** two encryptions of one token differ (random nonce). + *******************************************************************************/ + @Test + void testRoundTrip() throws Exception + { + SlackTokenCipher cipher = cipher(Map.of("SLACK_TOKEN_KEY", KEY_ONE)); + String stored = cipher.encrypt("xoxp-FAKE-USER-TOKEN", BINDING); + + assertThat(stored).startsWith("v1:current:"); + assertThat(stored).doesNotContain("xoxp-FAKE-USER-TOKEN"); + byte[] combined = Base64.getDecoder().decode(stored.substring("v1:current:".length())); + assertThat(combined.length).isEqualTo(12 + "xoxp-FAKE-USER-TOKEN".length() + 16); + assertThat(cipher.decrypt(stored, BINDING)).isEqualTo("xoxp-FAKE-USER-TOKEN"); + assertThat(cipher.encrypt("xoxp-FAKE-USER-TOKEN", BINDING)).isNotEqualTo(stored); + } + + + + /******************************************************************************* + ** The associated data binds a value to its row: another row, user or tenant + ** can't decrypt a copy. + *******************************************************************************/ + @Test + void testWrongBindingFails() throws Exception + { + SlackTokenCipher cipher = cipher(Map.of("SLACK_TOKEN_KEY", KEY_ONE)); + String stored = cipher.encrypt("xoxb-FAKE", BINDING); + + assertThatThrownBy(() -> cipher.decrypt(stored, "slack_connection:8:userA")).isInstanceOf(QException.class).hasMessage("Slack token could not be decrypted"); + assertThatThrownBy(() -> cipher.decrypt(stored, "slack_connection:7:userB")).isInstanceOf(QException.class); + assertThatThrownBy(() -> cipher.decrypt(stored, "")).isInstanceOf(QException.class); + assertThatThrownBy(() -> cipher.decrypt(stored, null)).isInstanceOf(QException.class); + } + + + + /******************************************************************************* + ** A tampered, truncated or malformed value fails with the generic message. + *******************************************************************************/ + @Test + void testTamperedAndMalformedValuesFail() throws Exception + { + SlackTokenCipher cipher = cipher(Map.of("SLACK_TOKEN_KEY", KEY_ONE)); + String stored = cipher.encrypt("xoxb-FAKE", BINDING); + byte[] combined = Base64.getDecoder().decode(stored.substring("v1:current:".length())); + combined[combined.length - 1] ^= 1; + String tampered = "v1:current:" + Base64.getEncoder().encodeToString(combined); + + assertThatThrownBy(() -> cipher.decrypt(tampered, BINDING)).isInstanceOf(QException.class).hasMessage("Slack token could not be decrypted"); + for(String bad : new String[] { "", "garbage", "v2:current:AAAA", "v1:other:AAAA", "v1:current:not base64 !!", "v1:current:AAAA", "v1:current", null }) + { + assertThatThrownBy(() -> cipher.decrypt(bad, BINDING)).isInstanceOf(QException.class).hasMessage("Slack token could not be decrypted"); + } + assertThatThrownBy(() -> cipher.encrypt(null, BINDING)).isInstanceOf(QException.class); + assertThatThrownBy(() -> cipher.encrypt("xoxb-FAKE", "")).isInstanceOf(QException.class); + assertThatThrownBy(() -> cipher.fingerprint(null)).isInstanceOf(QException.class); + } + + + + /******************************************************************************* + ** A missing, disallowed, short or non-base64 key fails with a generic message. + *******************************************************************************/ + @Test + void testMissingOrBadKeyFailsGenerically() throws Exception + { + String message = "Slack token key is not configured"; + assertThatThrownBy(() -> cipher(Map.of()).encrypt("xoxb-FAKE", BINDING)).isInstanceOf(QException.class).hasMessage(message); + assertThatThrownBy(() -> cipher(Map.of("SLACK_TOKEN_KEY", " ")).encrypt("xoxb-FAKE", BINDING)).hasMessage(message); + assertThatThrownBy(() -> cipher(Map.of("SLACK_TOKEN_KEY", "not base64 at all!")).encrypt("xoxb-FAKE", BINDING)).hasMessage(message); + assertThatThrownBy(() -> cipher(Map.of("SLACK_TOKEN_KEY", Base64.getEncoder().encodeToString(new byte[16]))).encrypt("xoxb-FAKE", BINDING)).hasMessage(message); + + // a key variable that isn't on the allow-list is never read + SlackTokenCipher notAllowed = new SlackTokenCipher(new SlackQBitConfig().withAllowedEnvNames(Set.of("SLACK_CLIENT_ID")).withSecretResolver(name -> KEY_ONE)); + assertThatThrownBy(() -> notAllowed.encrypt("xoxb-FAKE", BINDING)).hasMessage(message); + + // a missing key on decrypt (a stored value from a key this deployment no longer has) + String stored = cipher(Map.of("SLACK_TOKEN_KEY", KEY_ONE)).encrypt("xoxb-FAKE", BINDING); + assertThatThrownBy(() -> cipher(Map.of()).decrypt(stored, BINDING)).hasMessage(message); + assertThatThrownBy(() -> cipher(Map.of("SLACK_TOKEN_KEY", KEY_TWO)).decrypt(stored, BINDING)).hasMessage("Slack token could not be decrypted"); + + // no resolver and no env names at all + SlackTokenCipher nothing = new SlackTokenCipher(new SlackQBitConfig().withSecretResolver(null)); + assertThatThrownBy(() -> nothing.encrypt("xoxb-FAKE", BINDING)).hasMessage(message); + SlackTokenCipher noNames = new SlackTokenCipher(new SlackQBitConfig().withAllowedEnvNames(null)); + assertThatThrownBy(() -> noNames.encrypt("xoxb-FAKE", BINDING)).hasMessage(message); + } + + + + /******************************************************************************* + ** After a rotation the previous key still decrypts values stored under it, + ** while new values use the current key. + *******************************************************************************/ + @Test + void testPreviousKeyDecrypts() throws Exception + { + String oldValue = cipher(Map.of("SLACK_TOKEN_KEY", KEY_ONE)).encrypt("xoxb-FAKE-OLD", BINDING); + assertThat(oldValue).startsWith("v1:current:"); + + // the old key is renamed "previous" by the rotation: re-label the stored value as the rotation does + String relabelled = oldValue.replaceFirst("^v1:current:", "v1:previous:"); + Map rotated = new HashMap<>(); + rotated.put("SLACK_TOKEN_KEY", KEY_TWO); + rotated.put("SLACK_TOKEN_KEY_PREVIOUS", KEY_ONE); + SlackTokenCipher cipher = cipher(rotated); + + assertThat(cipher.decrypt(relabelled, BINDING)).isEqualTo("xoxb-FAKE-OLD"); + String fresh = cipher.encrypt("xoxb-FAKE-NEW", BINDING); + assertThat(fresh).startsWith("v1:current:"); + assertThat(cipher.decrypt(fresh, BINDING)).isEqualTo("xoxb-FAKE-NEW"); + + // without a previous key configured, a previous-labelled value fails generically + assertThatThrownBy(() -> cipher(Map.of("SLACK_TOKEN_KEY", KEY_TWO)).decrypt(relabelled, BINDING)).hasMessage("Slack token key is not configured"); + } + + + + /******************************************************************************* + ** The fingerprint is the first 8 hex characters of SHA-256, and differs by token. + *******************************************************************************/ + @Test + void testFingerprint() throws Exception + { + SlackTokenCipher cipher = cipher(Map.of()); + // sha256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + assertThat(cipher.fingerprint("abc")).isEqualTo("ba7816bf"); + assertThat(cipher.fingerprint("abd")).isNotEqualTo("ba7816bf").hasSize(8); + } + + + + /******************************************************************************* + ** The scrubber removes tokens, bearer and authorization values, client_secret + ** and code values; leaves ordinary text alone; and caps the length. + *******************************************************************************/ + @Test + void testScrub() + { + assertThat(SlackRedaction.scrub(null)).isNull(); + assertThat(SlackRedaction.scrub("plain text, error_code=5")).isEqualTo("plain text, error_code=5"); + + for(String token : new String[] { "xoxa-1-abc", "xoxb-111-222-abcDEF", "xoxe-xoxp-1-x", "xoxp-1-2-3-abc", "xoxr-abc", "xoxs-abc" }) + { + assertThat(SlackRedaction.scrub("failed with " + token + " here")).doesNotContain(token).contains("[REDACTED]"); + } + + assertThat(SlackRedaction.scrub("sent Authorization: Bearer abc.def-ghi now")).doesNotContain("abc.def-ghi"); + assertThat(SlackRedaction.scrub("header was bearer SECRETVALUE.1")).doesNotContain("SECRETVALUE"); + assertThat(SlackRedaction.scrub("authorization=Basic QUJDREVGRw==")).doesNotContain("QUJDREVGRw"); + assertThat(SlackRedaction.scrub("POST oauth.v2.access?code=1234.5678&client_secret=SEKRET-VALUE&x=1")).doesNotContain("1234.5678").doesNotContain("SEKRET-VALUE").contains("x=1"); + assertThat(SlackRedaction.scrub("body client_id=1&client_secret=ONLY-SECRET")).doesNotContain("ONLY-SECRET"); + + String capped = SlackRedaction.scrub("x".repeat(5000)); + assertThat(capped).hasSize(500); + + // a token near the cap is removed before the cut, so no fragment survives + String nearCap = SlackRedaction.scrub("y".repeat(480) + " xoxb-LONG-TOKEN-ABCDEFGHIJKLMNOP"); + assertThat(nearCap).doesNotContain("xoxb").doesNotContain("LONG-TOKEN"); + } + + + + /******************************************************************************* + ** The canary: a fake token driven through every cipher failure path and the + ** scrubber never shows up in an exception message, a cause, or scrubbed text. + *******************************************************************************/ + @Test + void testCanaryNeverLeaks() throws Exception + { + Map env = Map.of("SLACK_TOKEN_KEY", KEY_ONE); + SlackTokenCipher good = cipher(env); + String stored = good.encrypt(CANARY, BINDING); + byte[] combined = Base64.getDecoder().decode(stored.substring("v1:current:".length())); + combined[15] ^= 1; + String tampered = "v1:current:" + Base64.getEncoder().encodeToString(combined); + + // a resolver that throws with the canary in its message, and one that returns it as the "key" + SlackTokenCipher throwing = new SlackTokenCipher(new SlackQBitConfig().withSecretResolver(name -> + { + throw new IllegalStateException("lookup failed for " + CANARY); + })); + SlackTokenCipher canaryAsKey = new SlackTokenCipher(new SlackQBitConfig().withSecretResolver(name -> CANARY)); + + java.util.List calls = java.util.List.of( + () -> cipher(Map.of()).encrypt(CANARY, BINDING), + () -> throwing.encrypt(CANARY, BINDING), + () -> canaryAsKey.encrypt(CANARY, BINDING), + () -> good.encrypt(CANARY, null), + () -> good.decrypt(stored, "slack_connection:999:" + CANARY), + () -> good.decrypt(tampered, BINDING), + () -> good.decrypt(CANARY, BINDING), + () -> good.decrypt("v1:current:" + CANARY, BINDING), + () -> cipher(Map.of()).decrypt(stored, BINDING), + () -> throwing.decrypt(stored, BINDING), + () -> cipher(Map.of("SLACK_TOKEN_KEY", KEY_TWO)).decrypt(stored, BINDING)); + + for(ThrowingCall call : calls) + { + Throwable thrown = null; + try + { + call.run(); + } + catch(Throwable t) + { + thrown = t; + } + assertThat(thrown).isInstanceOf(QException.class); + for(Throwable t = thrown; t != null; t = t.getCause()) + { + assertThat(String.valueOf(t.getMessage())).doesNotContain(CANARY).doesNotContain("CANARY").doesNotContain(KEY_ONE); + } + assertThat(thrown.getCause()).isNull(); + assertThat(SlackRedaction.scrub(thrown.getMessage())).doesNotContain(CANARY); + } + + // the stored value itself never contains the token, and the scrubber removes it from any text + assertThat(stored).doesNotContain(CANARY).doesNotContain("CANARY"); + String wrapped = "Slack said: invalid_auth for token " + CANARY + " (Authorization: Bearer " + CANARY + ") code=" + CANARY; + assertThat(SlackRedaction.scrub(wrapped)).doesNotContain(CANARY).doesNotContain("CANARY"); + assertThat(SlackRedaction.scrub(new IllegalStateException(wrapped).toString())).doesNotContain(CANARY); + } + + + + /******************************************************************************* + ** A call that may throw, for the canary loop. + *******************************************************************************/ + private interface ThrowingCall + { + /******************************************************************************* + ** Run it. + *******************************************************************************/ + void run() throws Exception; + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackTokenLeakageTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackTokenLeakageTest.java new file mode 100644 index 0000000..d15583b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackTokenLeakageTest.java @@ -0,0 +1,195 @@ +/* + * 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 java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.security.SlackTokens; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qbits.slack.transport.SlackClient; +import com.kingsrook.qbits.slack.transport.SlackResponse; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +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; + + +/******************************************************************************* + ** Token non-leakage: a known fake token is driven through every failure path, + ** with a fake Slack that even echoes the token back in its error bodies, and + ** the test asserts the token never reaches a log line, a table column, an + ** exception message, a refusal reason, an attempt row, a lastError, or an + ** alert or notification. + *******************************************************************************/ +class SlackTokenLeakageTest +{ + private static final String BOT_CANARY = "xoxb-CANARY-LEAK-1234567890"; + private static final String USER_CANARY = "xoxp-CANARY-LEAK-0987654321"; + + private SlackTestEnv env; + private int workspaceId; + + + + /******************************************************************************* + ** One workspace with a bot, one connected user, and channel C1. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + env = SlackTestEnv.create(false, c -> + { + }); + workspaceId = env.workspace("T1", null, BOT_CANARY, "UBOT"); + env.connection("userA", workspaceId, "UA", USER_CANARY); + env.slack().members("C1", "UA", "UBOT"); + } + + + + /******************************************************************************* + ** Restore process-wide state. + *******************************************************************************/ + @AfterEach + void cleanup() + { + env.close(); + } + + + + /******************************************************************************* + ** A Slack error body that contains the token is scrubbed everywhere it could + ** be kept: the client's response, the attempt and message rows, the logs. + *******************************************************************************/ + @Test + void testTokenNeverAppearsInAnyFailurePath() throws Exception + { + List texts = new ArrayList<>(); + try(LogCapture logs = new LogCapture()) + { + String echo = " Authorization: Bearer " + BOT_CANARY + " token=" + BOT_CANARY + " user=" + USER_CANARY; + + /////////////////////////////////////////////////////////////////////////// + // the client: a body that echoes the token // + /////////////////////////////////////////////////////////////////////////// + env.slack().script("auth.test", FakeSlackServer.Reply.of(200, "{\"ok\":false,\"error\":\"invalid_auth" + echo + "\"}")); + SlackResponse direct = new SlackClient(env.config()).authTest(BOT_CANARY); + texts.add(String.valueOf(direct.getError())); + + /////////////////////////////////////////////////////////////////////////// + // the dispatcher: permanent, transient, rate limited, auth, and a token // + // that can't be decrypted // + /////////////////////////////////////////////////////////////////////////// + List ids = new ArrayList<>(); + for(FakeSlackServer.Reply reply : List.of( + FakeSlackServer.Reply.of(200, "{\"ok\":false,\"error\":\"msg_too_long" + echo + "\"}"), + FakeSlackServer.Reply.of(500, "{\"ok\":false,\"error\":\"internal_error" + echo + "\"}"), + new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited" + echo + "\"}", Map.of("Retry-After", "1")), + FakeSlackServer.Reply.of(200, "{\"ok\":false,\"error\":\"invalid_auth" + echo + "\"}"), + FakeSlackServer.Reply.of(200, "this is not json" + echo))) + { + env.slack().script("chat.postMessage", reply); + env.asOwner("userA"); + ids.add(SlackService.enqueue(new SlackSendRequest().withTo("me").withText("hello")).messageId()); + env.clock().advance(Duration.ofSeconds(2)); + env.dispatch(); + env.clock().advance(Duration.ofMinutes(2)); + env.set(SlackTables.WORKSPACE, workspaceId, "status", "ACTIVE"); + } + + /////////////////////////////////////////////////////////////////////////// + // refusals at queue time, and an unreadable token // + /////////////////////////////////////////////////////////////////////////// + env.slack().script("conversations.members", FakeSlackServer.Reply.of(200, "{\"ok\":false,\"error\":\"not_in_channel" + echo + "\"}")); + env.slack().members("C9", "UA"); + env.asOwner("userA"); + SlackSendResult refused = SlackService.enqueue(new SlackSendRequest().withTo("channel:" + workspaceId + "/C9").withText("x")); + texts.addAll(refused.reasons()); + + env.asSystem(); + env.set(SlackTables.WORKSPACE, workspaceId, "botToken", "v1:current:bm90LXJlYWxseS1hLWNpcGhlcnRleHQ="); + try + { + SlackTokens.botToken(env.config(), workspaceId); + } + catch(QException e) + { + texts.add(e.getMessage()); + } + + env.asOwner("userA"); + ids.add(SlackService.enqueue(new SlackSendRequest().withTo("me").withText("unreadable")).messageId()); + env.clock().advance(Duration.ofSeconds(2)); + env.dispatch(); + + assertThat(logs.lines()).as("the capture works").anyMatch(l -> l.contains("Slack attempt completed")); + texts.addAll(logs.lines()); + } + + /////////////////////////////////////////////////////////////////////////// + // every column of every row of every table (tokens are hidden columns, // + // and hold ciphertext in any case), and everything handed to core // + /////////////////////////////////////////////////////////////////////////// + env.asSystem(); + for(String table : SlackTables.ALL) + { + for(QRecord record : SlackStore.query(table, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())) + { + texts.add(record.getValues().values().stream().map(String::valueOf).collect(Collectors.joining(" | "))); + } + } + SlackTestEnv.CapturingAlertSink.RAISED.forEach(a -> texts.add(a.getTitle() + " " + a.getSummary() + " " + a.getDetails() + " " + a.getAttributes() + " " + a.getDedupKey())); + SlackTestEnv.CapturingAlertSink.NOTIFIED.forEach(n -> texts.add(n.getTitle() + " " + n.getBody() + " " + n.getAttributes())); + + assertThat(texts).isNotEmpty(); + assertThat(String.join("\n", texts)).doesNotContain("CANARY").doesNotContain("xoxb-").doesNotContain("xoxp-").doesNotContain("Bearer "); + } + + + + /******************************************************************************* + ** The scrubbed error text still says what went wrong, so the owner and an + ** operator can act on it. + *******************************************************************************/ + @Test + void testScrubbedErrorStillCarriesTheCode() throws Exception + { + env.slack().script("chat.postMessage", FakeSlackServer.Reply.error("is_archived")); + env.asOwner("userA"); + SlackSendResult queued = SlackService.enqueue(new SlackSendRequest().withTo("me").withText("hello")); + env.dispatch(); + + QRecord row = env.row(SlackTables.MESSAGE, queued.messageId()); + assertThat(row.getValueString("lastErrorCode")).isEqualTo("is_archived"); + assertThat(row.getValueString("lastError")).contains("is_archived"); + } +} diff --git a/src/test/resources/slack-h2.sql b/src/test/resources/slack-h2.sql new file mode 100644 index 0000000..a621ebf --- /dev/null +++ b/src/test/resources/slack-h2.sql @@ -0,0 +1,168 @@ +-- TEST ONLY (H2). Phase 1b: slack_workspace, slack_connection, slack_channel, slack_user_group, slack_template, +-- slack_message and slack_message_attempt. Apply before producing the qbit. +-- Host tenant (security) columns, configured through SlackQBitConfig.securityFields, must be +-- added by the host to slack_workspace and slack_template (children inherit them by join). +-- bot_token and user_token hold AES-256-GCM ciphertext (v1:keyId:base64), never plaintext. + +CREATE TABLE slack_workspace ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id INTEGER, + create_date TIMESTAMP, + modify_date TIMESTAMP, + team_id VARCHAR(500) NOT NULL, + team_name VARCHAR(500), + enterprise_id VARCHAR(500), + app_id VARCHAR(500), + tenant_key VARCHAR(500), + installed_by_user_id VARCHAR(500), + installed_at TIMESTAMP, + bot_user_id VARCHAR(500), + bot_scopes TEXT, + bot_token VARCHAR(2000), + bot_token_fingerprint VARCHAR(500), + status VARCHAR(500), + readiness_status VARCHAR(500), + readiness_checked_at TIMESTAMP, + readiness_summary VARCHAR(500), + health VARCHAR(500), + consecutive_failures INTEGER, + rate_per_minute INTEGER, + UNIQUE (team_id, tenant_key) +); + +CREATE TABLE slack_connection ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + user_id VARCHAR(500) NOT NULL, + workspace_id INTEGER NOT NULL, + slack_user_id VARCHAR(500), + slack_user_name VARCHAR(500), + user_token VARCHAR(2000), + user_token_fingerprint VARCHAR(500), + user_scopes TEXT, + status VARCHAR(500), + connected_at TIMESTAMP, + last_verified_at TIMESTAMP, + revoked_at TIMESTAMP, + last_error VARCHAR(500), + UNIQUE (user_id, workspace_id) +); + +CREATE TABLE slack_channel ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500) NOT NULL, + label VARCHAR(500), + workspace_id INTEGER NOT NULL, + slack_channel_id VARCHAR(500), + slack_channel_name VARCHAR(500), + is_private BOOLEAN, + bot_is_member BOOLEAN, + is_archived BOOLEAN, + enabled BOOLEAN, + description TEXT, + checked_at TIMESTAMP, + UNIQUE (workspace_id, name) +); + +CREATE TABLE slack_user_group ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500) NOT NULL, + label VARCHAR(500), + workspace_id INTEGER NOT NULL, + slack_usergroup_id VARCHAR(500), + handle VARCHAR(500), + channel_id VARCHAR(500), + enabled BOOLEAN, + UNIQUE (workspace_id, name) +); + +CREATE TABLE slack_template ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id INTEGER, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500) NOT NULL, + label VARCHAR(500), + description TEXT, + category VARCHAR(500), + enabled BOOLEAN, + tenant_key VARCHAR(500), + text TEXT, + blocks_template TEXT, + variables_schema TEXT, + UNIQUE (name) +); + +CREATE TABLE slack_message ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + owner_user_id VARCHAR(500) NOT NULL, + workspace_id INTEGER NOT NULL, + connection_id INTEGER, + post_as VARCHAR(500), + operation VARCHAR(500), + target_type VARCHAR(500), + target_ref VARCHAR(500), + slack_channel_id VARCHAR(500), + channel_label VARCHAR(500), + status VARCHAR(500), + category VARCHAR(500), + template_name VARCHAR(500), + text TEXT, + blocks_json TEXT, + variables_snapshot TEXT, + idempotency_key VARCHAR(500), + parent_message_id INTEGER, + thread_ts VARCHAR(500), + target_message_id INTEGER, + attachment_storage_ref VARCHAR(500), + attachment_name VARCHAR(500), + attachment_bytes BIGINT, + slack_file_id VARCHAR(500), + slack_ts VARCHAR(500), + permalink TEXT, + attempt_count INTEGER, + next_attempt_date TIMESTAMP, + lease_expires_date TIMESTAMP, + last_error_code VARCHAR(500), + last_error VARCHAR(500), + sent_date TIMESTAMP, + dead_letter_date TIMESTAMP, + linked_table_name VARCHAR(500), + linked_record_id VARCHAR(500), + sender_name VARCHAR(500), + delivery_log_id VARCHAR(500), + UNIQUE (idempotency_key) +); + +CREATE TABLE slack_message_attempt ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + message_id INTEGER NOT NULL, + attempt_number INTEGER NOT NULL, + outcome VARCHAR(500), + error_code VARCHAR(500), + http_status INTEGER, + retry_after_seconds INTEGER, + posted_as VARCHAR(500), + claimed_by VARCHAR(500), + start_date TIMESTAMP, + end_date TIMESTAMP, + error VARCHAR(500), + UNIQUE (message_id, attempt_number) +); + +CREATE INDEX slack_message_due ON slack_message (status, next_attempt_date); +CREATE INDEX slack_message_owner ON slack_message (owner_user_id, status); +CREATE INDEX slack_message_workspace ON slack_message (workspace_id); +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);