From ab38448c51729527257d7d9c02d6bae693dfbb49 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:18:50 -0500 Subject: [PATCH 1/7] feat(config): phase 1 settings, secret env-name allow list and validation Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/SlackQBitConfig.java | 493 ++++++++++++++++++ 1 file changed, 493 insertions(+) diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java index c3f5aab..087f7e2 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,31 @@ 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; @@ -47,6 +77,49 @@ 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(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 +195,424 @@ 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); + } } -- 2.54.0 From 82c13f2ff5d92fada193ba68c2debf125bdeb1e2 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:18:51 -0500 Subject: [PATCH 2/7] feat(security): AES-GCM token cipher bound to its row, and error scrubbing Keys from named environment variables (current and previous), generic errors with no cause, a canary test that a fake token never escapes. Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/security/SlackRedaction.java | 77 +++++ .../slack/security/SlackTokenCipher.java | 230 +++++++++++++ .../qbits/slack/SlackTokenCipherTest.java | 313 ++++++++++++++++++ 3 files changed, 620 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackRedaction.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackTokenCipher.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackTokenCipherTest.java 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/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/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; + } +} -- 2.54.0 From ac2427220e24dae531c2237100b43be891c86d11 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:18:52 -0500 Subject: [PATCH 3/7] feat(metadata): slack_ tables with owner and tenant locks and hidden tokens Seven tables (workspace, connection, channel, user group, template, message, attempt), enums, joins, child lists and Postgres DDL. Owner locks on connection and message; the host tenant lock on workspace and inherited by join; token fields PASSWORD, hidden, and DENY-locked for every user session. Tests: validator, owner isolation, two-tenant isolation, token invisibility. Co-Authored-By: Claude Opus 5.5 --- docs/ddl/slack-postgres.sql | 166 +++++ .../qbits/slack/SlackQBitProducer.java | 60 +- .../qbits/slack/metadata/SlackEnums.java | 198 ++++++ .../qbits/slack/metadata/SlackTables.java | 568 ++++++++++++++++++ .../qbits/slack/security/SlackSecurity.java | 165 +++++ .../kingsrook/qbits/slack/SlackH2Support.java | 204 +++++++ .../qbits/slack/SlackMetadataTest.java | 289 +++++++++ .../qbits/slack/SlackSecurityTest.java | 315 ++++++++++ src/test/resources/slack-h2.sql | 168 ++++++ 9 files changed, 2132 insertions(+), 1 deletion(-) create mode 100644 docs/ddl/slack-postgres.sql create mode 100644 src/main/java/com/kingsrook/qbits/slack/metadata/SlackEnums.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackSecurity.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackMetadataTest.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackSecurityTest.java create mode 100644 src/test/resources/slack-h2.sql 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/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index 156893c..070aa7a 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -21,6 +21,13 @@ package com.kingsrook.qbits.slack; +import java.util.ArrayList; +import java.util.List; +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.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 +78,58 @@ 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); + + output.addSelfToInstance(qInstance); + return (new MetaDataProducerMultiOutput()); } 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/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/test/java/com/kingsrook/qbits/slack/SlackH2Support.java b/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java new file mode 100644 index 0000000..d4a361d --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java @@ -0,0 +1,204 @@ +/* + * 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.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.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/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/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); -- 2.54.0 From a45bb8a75e352f8c7bb13177226d156a4c1990ea Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:51:01 -0500 Subject: [PATCH 4/7] feat(transport): Slack Web API client, error classifier and fake test server Bearer token per call (never stored), JSON writes, paged reads, Retry-After and x-oauth-scopes, scrubbed errors, configurable timeouts. Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/SlackQBitConfig.java | 45 +++ .../qbits/slack/transport/SlackClient.java | 348 ++++++++++++++++++ .../slack/transport/SlackErrorClassifier.java | 129 +++++++ .../qbits/slack/transport/SlackResponse.java | 136 +++++++ .../qbits/slack/FakeSlackServer.java | 331 +++++++++++++++++ .../qbits/slack/SlackClientTest.java | 278 ++++++++++++++ 6 files changed, 1267 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/transport/SlackErrorClassifier.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/transport/SlackResponse.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackClientTest.java diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java index 087f7e2..a7fe6a6 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java @@ -65,6 +65,8 @@ public class SlackQBitConfig implements QBitConfig private Long maxAttachmentBytes = 50L * 1024 * 1024; private String slackApiBaseUrl = "https://slack.com/api"; private String publicBaseUrl; + private Integer connectTimeoutSeconds = 5; + private Integer requestTimeoutSeconds = 15; @@ -84,6 +86,7 @@ public class SlackQBitConfig implements QBitConfig 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); @@ -615,4 +618,46 @@ public class SlackQBitConfig implements QBitConfig 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/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/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(); + } +} -- 2.54.0 From efc7a7c82b83fbcc5665b10abedbb15d5f9df543 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:51:02 -0500 Subject: [PATCH 5/7] feat(security): owner-session authorizer with membership check Workspace and connection lookups run as the owner (createAutomatedSessionForUser) so tenant and owner locks apply; conversations.members must include the owner; bounded membership cache; only the chosen row's token is ever decrypted; ownerless sends limited to registered channels with a matching tenant. Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/security/AuthorizedSend.java | 49 ++ .../slack/security/SlackAuthorization.java | 102 +++ .../qbits/slack/security/SlackAuthorizer.java | 781 ++++++++++++++++++ .../slack/security/SlackOwnerSession.java | 113 +++ .../qbits/slack/security/SlackRefusal.java | 60 ++ .../qbits/slack/security/SlackTokens.java | 101 +++ .../slack/support/SlackMembershipCache.java | 133 +++ .../qbits/slack/support/SlackRateLimiter.java | 163 ++++ .../qbits/slack/support/SlackRuntime.java | 167 ++++ .../qbits/slack/support/SlackStore.java | 199 +++++ .../com/kingsrook/qbits/slack/LogCapture.java | 101 +++ .../qbits/slack/SlackAuthorizerTest.java | 382 +++++++++ .../kingsrook/qbits/slack/SlackH2Support.java | 2 + .../qbits/slack/SlackSupportTest.java | 217 +++++ .../kingsrook/qbits/slack/SlackTestEnv.java | 515 ++++++++++++ 15 files changed, 3085 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/AuthorizedSend.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorization.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorizer.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackOwnerSession.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackRefusal.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackTokens.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/support/SlackMembershipCache.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/support/SlackRateLimiter.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/support/SlackRuntime.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/support/SlackStore.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/LogCapture.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackAuthorizerTest.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackSupportTest.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackTestEnv.java 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/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/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/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/SlackH2Support.java b/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java index d4a361d..0fd7970 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java @@ -40,6 +40,7 @@ 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; @@ -124,6 +125,7 @@ public final class SlackH2Support 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"); 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/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(); + } +} -- 2.54.0 From 8d966e9bbe4fa310096072ff8ba18d840549240c Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:51:03 -0500 Subject: [PATCH 6/7] feat(outbox): durable outbox and dispatcher with exactly-once posting Claim and lease with attempt rows; re-authorization as the owner at send time; message-metadata reconcile of expired leases; 429 Retry-After without consuming attempts; retries, dead letters, FAILED_AUTH with owner notification and alert; threads, updates, rate limiter, workspace health. Two-tenant and token-leak tests. Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/delivery/SlackAlerts.java | 135 +++ .../qbits/slack/delivery/SlackDispatcher.java | 795 ++++++++++++++++++ .../qbits/slack/outbox/SlackSendRequest.java | 312 +++++++ .../qbits/slack/outbox/SlackSendResult.java | 55 ++ .../qbits/slack/outbox/SlackService.java | 490 +++++++++++ .../qbits/slack/processes/SlackProcesses.java | 90 ++ .../qbits/slack/SlackOutboxTest.java | 794 +++++++++++++++++ .../qbits/slack/SlackTenantIsolationTest.java | 301 +++++++ .../qbits/slack/SlackTokenLeakageTest.java | 195 +++++ 9 files changed, 3167 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/SlackAlerts.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/SlackDispatcher.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendResult.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackOutboxTest.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackTenantIsolationTest.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackTokenLeakageTest.java 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/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/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/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/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"); + } +} -- 2.54.0 From 2bb5fa7bc2b5ae6501a4634e5db702f91cc17e7e Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:51:03 -0500 Subject: [PATCH 7/7] feat(bridge): QBIT_SLACK core messaging provider Registered under bridgeProviderName (default slack) so callers use core SendMessageAction only. STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8) for output status. Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/SlackQBitProducer.java | 11 ++ .../bridge/SlackOutboxMessagingProvider.java | 171 ++++++++++++++++ .../SlackOutboxMessagingProviderMetaData.java | 51 +++++ .../qbits/slack/SlackProviderTest.java | 184 ++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProvider.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProviderMetaData.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index 070aa7a..7c0b3cd 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -23,7 +23,10 @@ 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; @@ -128,6 +131,14 @@ public class SlackQBitProducer implements QBitMetaDataProducer 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/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"); + } +} -- 2.54.0