feat: phase 1b - tables, token cipher, owner/tenant authorization, outbox, dispatcher, core provider #2
No files matched your search
@@ -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);
|
||||
@@ -21,9 +21,17 @@
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -32,9 +40,33 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
*******************************************************************************/
|
||||
public class SlackQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
|
||||
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private String bridgeProviderName = "slack";
|
||||
private transient Function<String, String> secretResolver = System::getenv;
|
||||
private Set<String> 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<QFieldMetaData> securityFields = List.of();
|
||||
private List<RecordSecurityLock> recordSecurityLocks = List.of();
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
private String appName = "slack";
|
||||
private String appLabel = "Slack";
|
||||
private Integer bodyRetentionDays = 30;
|
||||
private Integer messageRetentionDays = 0;
|
||||
private Integer maxAttempts = 6;
|
||||
private List<Integer> retryMinutes = List.of(1, 5, 15, 60, 240, 720);
|
||||
private Integer leaseSeconds = 120;
|
||||
private Integer batchSize = 50;
|
||||
private Integer dispatchIntervalSeconds = 10;
|
||||
private Integer membershipCacheMinutes = 10;
|
||||
private Long maxAttachmentBytes = 50L * 1024 * 1024;
|
||||
private String slackApiBaseUrl = "https://slack.com/api";
|
||||
private String publicBaseUrl;
|
||||
private Integer connectTimeoutSeconds = 5;
|
||||
private Integer requestTimeoutSeconds = 15;
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +79,50 @@ public class SlackQBitConfig implements QBitConfig
|
||||
assertCondition(backendName != null && !backendName.isBlank(), "Slack qbit: backendName is required", errors);
|
||||
assertCondition(schedulerName != null && !schedulerName.isBlank(), "Slack qbit: schedulerName is required", errors);
|
||||
assertCondition(bridgeProviderName != null && bridgeProviderName.matches("[a-zA-Z][a-zA-Z0-9_]*"), "Slack qbit: bridgeProviderName must be a simple name", errors);
|
||||
assertCondition(secretResolver != null, "Slack qbit: secretResolver is required", errors);
|
||||
assertCondition(allowedEnvNames != null && allowedEnvNames.stream().allMatch(n -> n != null && ENV_NAME.matcher(n).matches()), "Slack qbit: allowedEnvNames must be valid environment variable names", errors);
|
||||
assertCondition(tokenKeyEnvName != null && ENV_NAME.matcher(tokenKeyEnvName).matches(), "Slack qbit: tokenKeyEnvName must be a valid environment variable name", errors);
|
||||
assertCondition(previousTokenKeyEnvName == null || ENV_NAME.matcher(previousTokenKeyEnvName).matches(), "Slack qbit: previousTokenKeyEnvName must be a valid environment variable name", errors);
|
||||
assertCondition(appName != null && !appName.isBlank() && appLabel != null && !appLabel.isBlank(), "Slack qbit: appName and appLabel are required", errors);
|
||||
assertCondition(securityFields != null && recordSecurityLocks != null, "Slack qbit: securityFields and recordSecurityLocks must not be null", errors);
|
||||
assertCondition(positive(maxAttempts) && positive(batchSize) && positive(dispatchIntervalSeconds) && positive(membershipCacheMinutes), "Slack qbit: attempts, batch size, dispatch interval and membership cache must be positive", errors);
|
||||
assertCondition(positive(connectTimeoutSeconds) && positive(requestTimeoutSeconds), "Slack qbit: HTTP timeouts must be positive", errors);
|
||||
assertCondition(leaseSeconds != null && leaseSeconds >= 60, "Slack qbit: leaseSeconds must be at least 60", errors);
|
||||
assertCondition(retryMinutes != null && !retryMinutes.isEmpty() && retryMinutes.stream().allMatch(n -> n != null && n >= 1), "Slack qbit: retryMinutes must contain positive delays", errors);
|
||||
assertCondition(bodyRetentionDays != null && bodyRetentionDays >= 0 && messageRetentionDays != null && messageRetentionDays >= 0, "Slack qbit: retention days must not be negative", errors);
|
||||
assertCondition(maxAttachmentBytes != null && maxAttachmentBytes > 0, "Slack qbit: maxAttachmentBytes must be positive", errors);
|
||||
assertCondition(slackApiBaseUrl != null && secureOrLoopback(slackApiBaseUrl), "Slack qbit: slackApiBaseUrl must be https (or loopback http)", errors);
|
||||
assertCondition(publicBaseUrl == null || secureOrLoopback(publicBaseUrl), "Slack qbit: publicBaseUrl must be https (or loopback http)", errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for a non-null positive number.
|
||||
*******************************************************************************/
|
||||
private static boolean positive(Integer value)
|
||||
{
|
||||
return (value != null && value >= 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for an https URL, or an http URL to a loopback host (tests).
|
||||
*******************************************************************************/
|
||||
private static boolean secureOrLoopback(String url)
|
||||
{
|
||||
return (url.startsWith("https://") || url.startsWith("http://localhost") || url.startsWith("http://127.0.0.1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retry delay in minutes after a one-based attempt number, repeating the last.
|
||||
*******************************************************************************/
|
||||
public int retryMinutesAfterAttempt(int attemptNumber)
|
||||
{
|
||||
return (retryMinutes.get(Math.max(0, Math.min(attemptNumber - 1, retryMinutes.size() - 1))));
|
||||
}
|
||||
|
||||
|
||||
@@ -122,4 +198,466 @@ public class SlackQBitConfig implements QBitConfig
|
||||
this.bridgeProviderName = bridgeProviderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for secretResolver
|
||||
*******************************************************************************/
|
||||
public Function<String, String> getSecretResolver()
|
||||
{
|
||||
return (this.secretResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for secretResolver
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withSecretResolver(Function<String, String> secretResolver)
|
||||
{
|
||||
this.secretResolver = secretResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedEnvNames
|
||||
*******************************************************************************/
|
||||
public Set<String> getAllowedEnvNames()
|
||||
{
|
||||
return (this.allowedEnvNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowedEnvNames
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withAllowedEnvNames(Set<String> 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<QFieldMetaData> getSecurityFields()
|
||||
{
|
||||
return (this.securityFields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for securityFields
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withSecurityFields(List<QFieldMetaData> securityFields)
|
||||
{
|
||||
this.securityFields = securityFields;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordSecurityLocks
|
||||
*******************************************************************************/
|
||||
public List<RecordSecurityLock> getRecordSecurityLocks()
|
||||
{
|
||||
return (this.recordSecurityLocks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordSecurityLocks
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withRecordSecurityLocks(List<RecordSecurityLock> recordSecurityLocks)
|
||||
{
|
||||
this.recordSecurityLocks = recordSecurityLocks;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableMetaDataCustomizer
|
||||
*******************************************************************************/
|
||||
public MetaDataCustomizerInterface<QTableMetaData> getTableMetaDataCustomizer()
|
||||
{
|
||||
return (this.tableMetaDataCustomizer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableMetaDataCustomizer
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> 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<Integer> getRetryMinutes()
|
||||
{
|
||||
return (this.retryMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for retryMinutes
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withRetryMinutes(List<Integer> retryMinutes)
|
||||
{
|
||||
this.retryMinutes = retryMinutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for leaseSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getLeaseSeconds()
|
||||
{
|
||||
return (this.leaseSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for leaseSeconds
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withLeaseSeconds(Integer leaseSeconds)
|
||||
{
|
||||
this.leaseSeconds = leaseSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for batchSize
|
||||
*******************************************************************************/
|
||||
public Integer getBatchSize()
|
||||
{
|
||||
return (this.batchSize);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for batchSize
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withBatchSize(Integer batchSize)
|
||||
{
|
||||
this.batchSize = batchSize;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dispatchIntervalSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getDispatchIntervalSeconds()
|
||||
{
|
||||
return (this.dispatchIntervalSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dispatchIntervalSeconds
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withDispatchIntervalSeconds(Integer dispatchIntervalSeconds)
|
||||
{
|
||||
this.dispatchIntervalSeconds = dispatchIntervalSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for membershipCacheMinutes
|
||||
*******************************************************************************/
|
||||
public Integer getMembershipCacheMinutes()
|
||||
{
|
||||
return (this.membershipCacheMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for membershipCacheMinutes
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withMembershipCacheMinutes(Integer membershipCacheMinutes)
|
||||
{
|
||||
this.membershipCacheMinutes = membershipCacheMinutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxAttachmentBytes
|
||||
*******************************************************************************/
|
||||
public Long getMaxAttachmentBytes()
|
||||
{
|
||||
return (this.maxAttachmentBytes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxAttachmentBytes
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withMaxAttachmentBytes(Long maxAttachmentBytes)
|
||||
{
|
||||
this.maxAttachmentBytes = maxAttachmentBytes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackApiBaseUrl
|
||||
*******************************************************************************/
|
||||
public String getSlackApiBaseUrl()
|
||||
{
|
||||
return (this.slackApiBaseUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackApiBaseUrl
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withSlackApiBaseUrl(String slackApiBaseUrl)
|
||||
{
|
||||
this.slackApiBaseUrl = slackApiBaseUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publicBaseUrl
|
||||
*******************************************************************************/
|
||||
public String getPublicBaseUrl()
|
||||
{
|
||||
return (this.publicBaseUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publicBaseUrl
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withPublicBaseUrl(String publicBaseUrl)
|
||||
{
|
||||
this.publicBaseUrl = publicBaseUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for connectTimeoutSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getConnectTimeoutSeconds()
|
||||
{
|
||||
return (this.connectTimeoutSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for connectTimeoutSeconds
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withConnectTimeoutSeconds(Integer connectTimeoutSeconds)
|
||||
{
|
||||
this.connectTimeoutSeconds = connectTimeoutSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requestTimeoutSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getRequestTimeoutSeconds()
|
||||
{
|
||||
return (this.requestTimeoutSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for requestTimeoutSeconds
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withRequestTimeoutSeconds(Integer requestTimeoutSeconds)
|
||||
{
|
||||
this.requestTimeoutSeconds = requestTimeoutSeconds;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,16 @@
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData;
|
||||
import com.kingsrook.qbits.slack.delivery.SlackAlerts;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.processes.SlackProcesses;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecurity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
|
||||
@@ -71,7 +81,66 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
.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<String> errors = new ArrayList<>();
|
||||
qBitConfig.validate(qInstance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QException("Slack qbit configuration is invalid: " + String.join("; ", errors)));
|
||||
}
|
||||
|
||||
qInstance.addQBit(getQBitMetaData());
|
||||
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
SlackSecurity.buildSecurityKeyTypes().forEach(output::add);
|
||||
SlackTables.possibleValueSources().forEach(output::add);
|
||||
|
||||
for(com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table : SlackTables.tables(qBitConfig))
|
||||
{
|
||||
if(qBitConfig.getTableMetaDataCustomizer() != null)
|
||||
{
|
||||
table = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
|
||||
}
|
||||
table.setSourceQBitName(getQBitMetaData().getName());
|
||||
output.add(table);
|
||||
}
|
||||
|
||||
SlackTables.joins().forEach(output::add);
|
||||
SlackTables.childWidgets().forEach(output::add);
|
||||
|
||||
SlackProcesses.processes(qBitConfig).forEach(process ->
|
||||
{
|
||||
process.setSourceQBitName(getQBitMetaData().getName());
|
||||
output.add(process);
|
||||
});
|
||||
SlackAlerts.types().forEach(output::add);
|
||||
output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
|
||||
output.addSelfToInstance(qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<String> addresses = new ArrayList<>();
|
||||
collect(input.getTo(), addresses);
|
||||
if(addresses.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("A Slack recipient is required"));
|
||||
}
|
||||
|
||||
String text = compose(input);
|
||||
|
||||
List<String> 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<String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<QAlertTypeMetaData> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<QRecord> 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<Runnable> 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<Runnable> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
List<QPossibleValueSource> 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<QTableMetaData> tables(SlackQBitConfig config)
|
||||
{
|
||||
List<QTableMetaData> 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<String> 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<QJoinMetaData> 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<QWidgetMetaData> childWidgets()
|
||||
{
|
||||
List<QWidgetMetaData> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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)));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<QRecord> 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<QRecord> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:<tenant>" 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:<workspace>/<channel>, channel:<name>, #<name> or group:<name>.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<QRecord> rows = SlackStore.queryAsCurrent(table, filter);
|
||||
List<Pick> picks = new ArrayList<>();
|
||||
List<QRecord> 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 = "<!subteam^" + row.getValueString("slackUsergroupId") + "> ";
|
||||
}
|
||||
}
|
||||
|
||||
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<QRecord> connections = SlackStore.queryAsCurrent(SlackTables.CONNECTION, filter);
|
||||
if(connections.isEmpty())
|
||||
{
|
||||
return (new Pick(null, null, SlackAuthorization.refuse(SlackRefusal.Reason.NO_CONNECTION, "Connect Slack first")));
|
||||
}
|
||||
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
connections.forEach(c -> ids.add(c.getValueInteger("workspaceId")));
|
||||
List<QRecord> 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<SlackEnums.PostAs> 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<String> 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<String> 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<QRecord> workspaces = SlackStore.query(SlackTables.WORKSPACE, workspaceFilter);
|
||||
List<Serializable> 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<QRecord> 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());
|
||||
}
|
||||
}
|
||||
@@ -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> T call(String ownerUserId, SlackStore.Work<T> 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> T run(QSession session, SlackStore.Work<T> work) throws QException
|
||||
{
|
||||
Object[] holder = new Object[1];
|
||||
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session), () -> holder[0] = work.run());
|
||||
return ((T) holder[0]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<QSecurityKeyType> 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<String> 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<Serializable> values = session.getSecurityKeyValues(KEY_USER);
|
||||
return (values == null || values.isEmpty() || values.get(0) == null ? null : String.valueOf(values.get(0)));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))));
|
||||
}
|
||||
}
|
||||
@@ -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<String, Entry> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Instant> lastPostByChannel = new HashMap<>();
|
||||
private final Map<Integer, ArrayDeque<Instant>> minuteWindows = new HashMap<>();
|
||||
private final Map<Integer, Instant> 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<Instant> 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<Map.Entry<String, Instant>> 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<Instant> 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<Instant> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<SlackQBitConfig, SlackClient> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** 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> T asSystem(Work<T> 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<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (query(table, filter, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query as the system, inside a transaction.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> 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<QRecord> 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<QRecord> 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<QRecord> 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<QRecord> 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> query)
|
||||
{
|
||||
StringBuilder url = new StringBuilder(baseUrl).append("/").append(method);
|
||||
String sep = "?";
|
||||
for(Map.Entry<String, String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> TRANSIENT_CODES = Set.of("ratelimited", "service_unavailable", "internal_error", "fatal_error", "request_timeout");
|
||||
private static final Set<String> 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<String> AUTH_CODES = Set.of("not_in_channel", "invalid_auth", "token_revoked", "account_inactive", "missing_scope", "not_authed", "token_expired");
|
||||
private static final Set<String> 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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Recorded> requests = new CopyOnWriteArrayList<>();
|
||||
private final Map<String, Deque<Reply>> scripts = new ConcurrentHashMap<>();
|
||||
private final Map<String, List<String>> members = new ConcurrentHashMap<>();
|
||||
private final Map<String, String> names = new ConcurrentHashMap<>();
|
||||
private final Map<String, JSONArray> 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<String, String> 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<Reply> 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<Recorded> requests()
|
||||
{
|
||||
return (List.copyOf(requests));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The requests for one Slack method.
|
||||
*******************************************************************************/
|
||||
public List<Recorded> requests(String method)
|
||||
{
|
||||
return (requests.stream().filter(r -> r.method().equals(method)).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every distinct Authorization header seen.
|
||||
*******************************************************************************/
|
||||
public java.util.Set<String> 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<Reply> 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<String, String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> lines()
|
||||
{
|
||||
return (List.copyOf(lines));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop capturing.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
context.getConfiguration().getRootLogger().removeAppender(appender.getName());
|
||||
appender.stop();
|
||||
context.updateLoggers();
|
||||
}
|
||||
}
|
||||
@@ -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:<own id>, #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 <me>")).messageId());
|
||||
assertThat(group.getValueString("targetType")).isEqualTo("USER_GROUP");
|
||||
assertThat(group.getValueString("slackChannelId")).isEqualTo("COPS");
|
||||
assertThat(group.getValueString("text")).isEqualTo("<!subteam^S123> 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("<!subteam^S123> 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");
|
||||
}
|
||||
}
|
||||
@@ -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, "<html>bad gateway</html>"),
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecurity;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test support: an in-memory H2 database loaded from slack-h2.sql, an instance
|
||||
** with the qbit produced into it, a host tenant lock, and session helpers.
|
||||
*******************************************************************************/
|
||||
public final class SlackH2Support
|
||||
{
|
||||
public static final String TENANT_KEY = "tenant";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackH2Support()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh in-memory backend.
|
||||
*******************************************************************************/
|
||||
public static RDBMSBackendMetaData backend()
|
||||
{
|
||||
RDBMSBackendMetaData backend = new RDBMSBackendMetaData()
|
||||
.withName("rdbms")
|
||||
.withVendor("h2")
|
||||
.withHostName("mem")
|
||||
.withDatabaseName("slack_" + UUID.randomUUID())
|
||||
.withUsername("sa");
|
||||
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000");
|
||||
return (backend);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the tables.
|
||||
*******************************************************************************/
|
||||
public static void prime(RDBMSBackendMetaData backend) throws Exception
|
||||
{
|
||||
try(Connection connection = new ConnectionManager().getConnection(backend))
|
||||
{
|
||||
String sql = new String(SlackH2Support.class.getResourceAsStream("/slack-h2.sql").readAllBytes(), StandardCharsets.UTF_8);
|
||||
for(String statement : sql.split(";"))
|
||||
{
|
||||
if(!statement.isBlank())
|
||||
{
|
||||
connection.createStatement().execute(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Config that adds a host tenant column and lock (tenantId, key "tenant").
|
||||
*******************************************************************************/
|
||||
public static Consumer<SlackQBitConfig> 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<SlackQBitConfig> tweak) throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(backend);
|
||||
instance.addScheduler(new SimpleSchedulerMetaData().withName("scheduler"));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName(TENANT_KEY).withAllAccessKeyName(TENANT_KEY + "_all_access"));
|
||||
|
||||
SlackQBitConfig config = new SlackQBitConfig().withBackendName("rdbms").withSchedulerName("scheduler");
|
||||
tweak.accept(config);
|
||||
new SlackQBitProducer().withQBitConfig(config).produce(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prime a database, produce the instance, and run as the system user.
|
||||
*******************************************************************************/
|
||||
public static QInstance setup(Consumer<SlackQBitConfig> 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<QRecord> query(String table) throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter())).getRecords());
|
||||
}
|
||||
}
|
||||
@@ -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<String> errors(SlackQBitConfig config)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(new QInstance(), errors);
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The field-name lists of a table's unique keys.
|
||||
*******************************************************************************/
|
||||
private static List<List<String>> uniqueKeys(QTableMetaData table)
|
||||
{
|
||||
return (table.getUniqueKeys().stream().map(k -> k.getFieldNames()).toList());
|
||||
}
|
||||
}
|
||||
@@ -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 & <world>");
|
||||
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<QRecord> 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 <b>"));
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <failures> & 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<QRecord> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<String, ?> workspaceFields = new TableMetaDataAction().execute(tableInput(SlackTables.WORKSPACE)).getTable().getFields();
|
||||
assertThat(workspaceFields).containsKey("teamId").doesNotContainKey("botToken");
|
||||
Map<String, ?> 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<String> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<SlackSendResult.Status> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<AlertSignal> RAISED = new CopyOnWriteArrayList<>();
|
||||
public static final List<AlertResolution> RESOLVED = new CopyOnWriteArrayList<>();
|
||||
public static final List<NotificationSignal> 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<String, Integer> TENANTS = new ConcurrentHashMap<>();
|
||||
public static final Set<String> BLOCK_USERS = ConcurrentHashMap.newKeySet();
|
||||
public static final Set<String> 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<SlackQBitConfig> 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<QRecord> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<String, String> 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<String, String> 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<ThrowingCall> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<Integer> 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user