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