feat: phase 1c/1d - Connect Slack (OAuth), SlackApi, readiness and health, templates, PHI guard #5
No files matched your search
@@ -74,6 +74,16 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-health</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
|
||||
@@ -42,6 +42,12 @@ public class SlackQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
|
||||
|
||||
private String clientIdEnvName = "SLACK_CLIENT_ID";
|
||||
private String clientSecretEnvName = "SLACK_CLIENT_SECRET";
|
||||
private String signingSecretEnvName = "SLACK_SIGNING_SECRET";
|
||||
private String oauthCallbackPath = "/qqq/hooks/slack/oauth/callback";
|
||||
private Integer readinessIntervalMinutes = 15;
|
||||
private List<String> phiPatterns = List.of("\\b\\d{3}-\\d{2}-\\d{4}\\b", "(?i)\\bMRN[:# ]?\\d{6,}\\b");
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private String bridgeProviderName = "slack";
|
||||
@@ -76,6 +82,9 @@ public class SlackQBitConfig implements QBitConfig
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
assertCondition(positive(readinessIntervalMinutes), "Slack qbit: readiness interval must be positive", errors);
|
||||
assertCondition(oauthCallbackPath != null && oauthCallbackPath.matches("/qqq/hooks/slack/[a-zA-Z0-9/_-]+"), "Slack qbit: invalid OAuth callback path", errors);
|
||||
assertCondition(java.util.Arrays.asList(clientIdEnvName, clientSecretEnvName, signingSecretEnvName).stream().allMatch(n -> n != null && ENV_NAME.matcher(n).matches()), "Slack qbit: invalid OAuth environment name", errors);
|
||||
assertCondition(backendName != null && !backendName.isBlank(), "Slack qbit: backendName is required", errors);
|
||||
assertCondition(schedulerName != null && !schedulerName.isBlank(), "Slack qbit: schedulerName is required", errors);
|
||||
assertCondition(bridgeProviderName != null && bridgeProviderName.matches("[a-zA-Z][a-zA-Z0-9_]*"), "Slack qbit: bridgeProviderName must be a simple name", errors);
|
||||
@@ -660,4 +669,106 @@ public class SlackQBitConfig implements QBitConfig
|
||||
this.requestTimeoutSeconds = requestTimeoutSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientIdEnvName.
|
||||
*******************************************************************************/
|
||||
public String getClientIdEnvName()
|
||||
{
|
||||
return (clientIdEnvName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientIdEnvName.
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withClientIdEnvName(String value)
|
||||
{
|
||||
clientIdEnvName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientSecretEnvName.
|
||||
*******************************************************************************/
|
||||
public String getClientSecretEnvName()
|
||||
{
|
||||
return (clientSecretEnvName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientSecretEnvName.
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withClientSecretEnvName(String value)
|
||||
{
|
||||
clientSecretEnvName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for signingSecretEnvName.
|
||||
*******************************************************************************/
|
||||
public String getSigningSecretEnvName()
|
||||
{
|
||||
return (signingSecretEnvName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for signingSecretEnvName.
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withSigningSecretEnvName(String value)
|
||||
{
|
||||
signingSecretEnvName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for oauthCallbackPath.
|
||||
*******************************************************************************/
|
||||
public String getOauthCallbackPath()
|
||||
{
|
||||
return (oauthCallbackPath);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for oauthCallbackPath.
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withOauthCallbackPath(String value)
|
||||
{
|
||||
oauthCallbackPath = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for readinessIntervalMinutes.
|
||||
*******************************************************************************/
|
||||
public Integer getReadinessIntervalMinutes()
|
||||
{
|
||||
return (readinessIntervalMinutes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for readinessIntervalMinutes.
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withReadinessIntervalMinutes(Integer value)
|
||||
{
|
||||
readinessIntervalMinutes = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for phiPatterns.
|
||||
*******************************************************************************/
|
||||
public List<String> getPhiPatterns()
|
||||
{
|
||||
return (phiPatterns);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for phiPatterns.
|
||||
*******************************************************************************/
|
||||
public SlackQBitConfig withPhiPatterns(List<String> value)
|
||||
{
|
||||
phiPatterns = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,13 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
{
|
||||
table = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
|
||||
}
|
||||
if(SlackTables.TEMPLATE.equals(table.getName()))
|
||||
{
|
||||
table.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
|
||||
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.slack.template.SlackTemplateCustomizer.class));
|
||||
table.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
|
||||
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.slack.template.SlackTemplateCustomizer.class));
|
||||
}
|
||||
table.setSourceQBitName(getQBitMetaData().getName());
|
||||
output.add(table);
|
||||
}
|
||||
@@ -140,6 +147,16 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
|
||||
output.addSelfToInstance(qInstance);
|
||||
com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance);
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckMetaData");
|
||||
com.kingsrook.qbits.slack.readiness.SlackHealth.register(qInstance);
|
||||
}
|
||||
catch(ClassNotFoundException e)
|
||||
{
|
||||
// Optional health module is absent in this host.
|
||||
}
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.api;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackEnums;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackConnect;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendRequest;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendResult;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackService;
|
||||
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
|
||||
import com.kingsrook.qbits.slack.security.SlackAuthorizer;
|
||||
import com.kingsrook.qbits.slack.security.SlackOwnerSession;
|
||||
import com.kingsrook.qbits.slack.security.SlackTokens;
|
||||
import com.kingsrook.qbits.slack.support.SlackRuntime;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplates;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
/*******************************************************************************
|
||||
** The only public surface for other qbits. Changes within a 0.x minor release
|
||||
** line are additive only; implementation packages are not API and may change.
|
||||
** Database operations use the current QContext and its tenant/owner locks.
|
||||
** Projections never include credentials, bodies or variable snapshots. Sends
|
||||
** retain permission, owner, tenant, template and PHI checks and always use the
|
||||
** outbox. QUEUED acknowledges storage, not delivery.
|
||||
*******************************************************************************/
|
||||
public final class SlackApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackApi()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** True when produced into the supplied instance. Null is not installed.
|
||||
*******************************************************************************/
|
||||
public static boolean isInstalled(QInstance instance)
|
||||
{
|
||||
return (SlackRuntime.config(instance) != null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue through the same service as core messaging; null is refused.
|
||||
*******************************************************************************/
|
||||
public static SlackApiResult send(SlackApiRequest request) throws QException
|
||||
{
|
||||
return (result(SlackService.enqueue(request(request))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One committed durable handoff. Refuse caller transactions and conflicting
|
||||
** keys; success means committed storage, not delivery. Database failures
|
||||
** propagate as QException. The immutable request is never modified.
|
||||
*******************************************************************************/
|
||||
public static SlackApiEffectResult sendOnce(SlackApiRequest request, String effectKey) throws QException
|
||||
{
|
||||
if(request == null || effectKey == null || effectKey.isBlank() || effectKey.length() > 500
|
||||
|| request.idempotencyKey() != null && !request.idempotencyKey().equals(effectKey))
|
||||
{
|
||||
return (new SlackApiEffectResult("REFUSED", null));
|
||||
}
|
||||
SlackSendResult result = SlackService.sendOnce(request(request), effectKey);
|
||||
return (new SlackApiEffectResult(result.status().name(), result.messageId() == null ? null : getMessage(result.messageId()).orElse(null)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue a thread reply owned by the same user; waits for an unsent parent.
|
||||
*******************************************************************************/
|
||||
public static SlackApiResult reply(Integer parentMessageId, SlackApiRequest request) throws QException
|
||||
{
|
||||
if(parentMessageId == null || request == null)
|
||||
{
|
||||
return (result(SlackSendResult.refused("A parent and request are required")));
|
||||
}
|
||||
return (send(request.withThreadOfMessageId(parentMessageId)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue an update without changing the original posting identity.
|
||||
*******************************************************************************/
|
||||
public static SlackApiResult update(Integer messageId, SlackApiRequest request) throws QException
|
||||
{
|
||||
return (result(SlackService.update(messageId, request(request))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Visible message metadata. Null and unknown ids return empty.
|
||||
*******************************************************************************/
|
||||
public static Optional<SlackApiMessage> getMessage(Integer id) throws QException
|
||||
{
|
||||
return (id == null ? Optional.empty() : first(SlackStore.equal("id", id)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Visible message metadata by exact key. Unknown keys return empty.
|
||||
*******************************************************************************/
|
||||
public static Optional<SlackApiMessage> getMessageByIdempotencyKey(String key) throws QException
|
||||
{
|
||||
return (key == null ? Optional.empty() : first(SlackStore.equal("idempotencyKey", key)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Literal prefix matching, bounded to 1..1000 visible projections.
|
||||
*******************************************************************************/
|
||||
public static List<SlackApiMessage> findMessagesByIdempotencyKeyPrefix(String prefix, int limit) throws QException
|
||||
{
|
||||
if(prefix == null || prefix.isBlank() || limit < 1 || limit > 1000)
|
||||
{
|
||||
throw (new IllegalArgumentException("A prefix and limit from 1 to 1000 are required"));
|
||||
}
|
||||
return (SlackStore.queryAsCurrent(SlackTables.MESSAGE, new QQueryFilter()).stream()
|
||||
.filter(r -> r.getValueString("idempotencyKey") != null && r.getValueString("idempotencyKey").startsWith(prefix))
|
||||
.limit(limit).map(SlackApi::message).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deployment app and outbox readiness. Requires this instance's QContext.
|
||||
*******************************************************************************/
|
||||
public static SlackApiReadiness readiness(QInstance instance) throws QException
|
||||
{
|
||||
return (SlackReadiness.deployment(instance));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The user's connected workspaces and own token checks. A user cannot name
|
||||
** another user; a system caller explicitly assumes that owner's session.
|
||||
*******************************************************************************/
|
||||
public static SlackApiReadiness readinessFor(String userId) throws QException
|
||||
{
|
||||
requireUser(userId);
|
||||
return (SlackOwnerSession.call(userId, () ->
|
||||
{
|
||||
List<SlackApiReadinessCheck> checks = new ArrayList<>();
|
||||
for(SlackApiConnection connection : connectionsFor(userId))
|
||||
{
|
||||
checks.addAll(SlackReadiness.workspace(connection.workspaceId()).checks());
|
||||
checks.add(SlackReadiness.connection(connection.id()));
|
||||
}
|
||||
if(checks.isEmpty())
|
||||
{
|
||||
checks.add(new SlackApiReadinessCheck("connections", "ERROR", "Connect Slack first"));
|
||||
}
|
||||
return (SlackReadiness.result(checks));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Connections visible to their owner, with workspace labels and no tokens.
|
||||
*******************************************************************************/
|
||||
public static List<SlackApiConnection> connectionsFor(String userId) throws QException
|
||||
{
|
||||
requireUser(userId);
|
||||
return (SlackOwnerSession.call(userId, () ->
|
||||
{
|
||||
List<SlackApiConnection> result = new ArrayList<>();
|
||||
for(QRecord row : SlackStore.queryAsCurrent(SlackTables.CONNECTION, SlackStore.equal("userId", userId)))
|
||||
{
|
||||
QRecord workspace = SlackConnect.visible(SlackTables.WORKSPACE, row.getValueInteger("workspaceId"));
|
||||
result.add(new SlackApiConnection(row.getValueInteger("id"), row.getValueInteger("workspaceId"), workspace.getValueString("teamName"),
|
||||
workspace.getValueString("teamId"), row.getValueString("status")));
|
||||
}
|
||||
return (List.copyOf(result));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Picker options from the user's own token, reauthorized for membership.
|
||||
** Pagination is bounded; no token from another connection is a fallback.
|
||||
*******************************************************************************/
|
||||
public static List<SlackApiTarget> listTargets(String userId, Integer workspaceId) throws QException
|
||||
{
|
||||
requireUser(userId);
|
||||
return (SlackOwnerSession.call(userId, () ->
|
||||
{
|
||||
SlackApiConnection connection = connectionsFor(userId).stream().filter(c -> c.workspaceId().equals(workspaceId) && "ACTIVE".equals(c.status())).findFirst().orElse(null);
|
||||
if(connection == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
List<SlackApiTarget> result = new ArrayList<>();
|
||||
result.add(new SlackApiTarget("me", "Direct message to me"));
|
||||
String token = SlackTokens.userToken(SlackRuntime.config(), connection.id());
|
||||
String cursor = null;
|
||||
for(int page = 0; page < 100; page++)
|
||||
{
|
||||
var response = SlackRuntime.client(SlackRuntime.config()).usersConversations(token, cursor);
|
||||
if(!response.isOk())
|
||||
{
|
||||
throw (new QException("Slack targets unavailable"));
|
||||
}
|
||||
var channels = response.getJson().optJSONArray("channels");
|
||||
if(channels != null)
|
||||
{
|
||||
for(int i = 0; i < channels.length(); i++)
|
||||
{
|
||||
var channel = channels.getJSONObject(i);
|
||||
String address = "channel:" + workspaceId + "/" + channel.getString("id");
|
||||
var allowed = new SlackAuthorizer().authorize(new SlackAuthorizer.Request().withOwnerUserId(userId).withWorkspaceId(workspaceId).withAddress(address));
|
||||
if(allowed.isAuthorized())
|
||||
{
|
||||
result.add(new SlackApiTarget(address, channel.optString("name", "Channel")));
|
||||
}
|
||||
}
|
||||
}
|
||||
var metadata = response.getJson().optJSONObject("response_metadata");
|
||||
cursor = metadata == null ? null : metadata.optString("next_cursor", null);
|
||||
if(cursor == null || cursor.isBlank())
|
||||
{
|
||||
return (List.copyOf(result));
|
||||
}
|
||||
}
|
||||
throw (new QException("Slack target pagination exceeded"));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed once without overwriting edits. System or tenant admin only.
|
||||
*******************************************************************************/
|
||||
public static boolean ensureTemplate(SlackApiTemplate template) throws QException
|
||||
{
|
||||
if(template == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("A template is required"));
|
||||
}
|
||||
return (SlackTemplates.ensure(new QRecord().withValue("name", template.name()).withValue("label", template.label())
|
||||
.withValue("text", template.text()).withValue("blocksTemplate", template.blocksTemplate())
|
||||
.withValue("variablesSchema", template.variablesSchema()).withValue("category", template.category())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never switch a signed-in caller to someone else's session.
|
||||
*******************************************************************************/
|
||||
private static void requireUser(String user)
|
||||
{
|
||||
var session = QContext.getQSession();
|
||||
if(user == null || session == null || !(session instanceof QSystemUserSession)
|
||||
&& (session.getUser() == null || !user.equals(session.getUser().getIdReference())))
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack user is not available"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Translate the stable request into the implementation request.
|
||||
*******************************************************************************/
|
||||
private static SlackSendRequest request(SlackApiRequest request)
|
||||
{
|
||||
if(request == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (new SlackSendRequest().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())
|
||||
.withContentClassification(request.contentClassification()).withTitle(request.title()).withLink(request.link()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy service result into the stable contract.
|
||||
*******************************************************************************/
|
||||
private static SlackApiResult result(SlackSendResult result)
|
||||
{
|
||||
return (new SlackApiResult(result.status().name(), result.messageId(), result.reasons()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a single metadata projection through current-session record locks.
|
||||
*******************************************************************************/
|
||||
private static Optional<SlackApiMessage> first(QQueryFilter filter) throws QException
|
||||
{
|
||||
var rows = SlackStore.queryAsCurrent(SlackTables.MESSAGE, filter.withLimit(1));
|
||||
return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit allow-list of public message fields.
|
||||
*******************************************************************************/
|
||||
private static SlackApiMessage message(QRecord row)
|
||||
{
|
||||
return (new SlackApiMessage(row.getValueInteger("id"), row.getValueString("status"), row.getValueString("ownerUserId"),
|
||||
row.getValueInteger("workspaceId"), row.getValueString("idempotencyKey"), row.getValueString("slackTs"), row.getValueString("permalink"), row.getValueString("category")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Visible connection identity; no credential fields.
|
||||
*******************************************************************************/
|
||||
public record SlackApiConnection(Integer id, Integer workspaceId, String workspaceLabel, String teamId, String status)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** A durable handoff acknowledgement, not a delivery acknowledgement.
|
||||
*******************************************************************************/
|
||||
public record SlackApiEffectResult(String outcome, SlackApiMessage message)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata projection. Never contains bodies, variables or credentials.
|
||||
*******************************************************************************/
|
||||
public record SlackApiMessage(Integer id, String status, String ownerUserId, Integer workspaceId, String idempotencyKey, String slackTs, String permalink, String category)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable result with a defensive collection snapshot.
|
||||
*******************************************************************************/
|
||||
public record SlackApiReadiness(boolean ready, java.util.List<SlackApiReadinessCheck> checks)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Retain an immutable snapshot.
|
||||
*******************************************************************************/
|
||||
public SlackApiReadiness
|
||||
{
|
||||
checks = java.util.List.copyOf(checks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed diagnostic; never includes provider response or credential text.
|
||||
*******************************************************************************/
|
||||
public record SlackApiReadinessCheck(String name, String status, String summary)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** 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)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot the variable map at construction.
|
||||
*******************************************************************************/
|
||||
public SlackApiRequest
|
||||
{
|
||||
java.util.Map<String, java.io.Serializable> copy = new java.util.LinkedHashMap<>();
|
||||
if(variables != null)
|
||||
{
|
||||
variables.forEach((name, value) -> copy.put(name, freeze(value)));
|
||||
}
|
||||
variables = java.util.Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with ownerUserId.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with to.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with 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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with text.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with blocksJson.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with templateName.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with variables.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with idempotencyKey.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with category.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with postAs.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with threadOfMessageId.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with linkedTableName.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with linkedRecordId.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with senderName.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with contentClassification.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with title.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy with link.
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recursively snapshot structured values. Immutable scalar values are retained.
|
||||
*******************************************************************************/
|
||||
private static java.io.Serializable freeze(java.io.Serializable value)
|
||||
{
|
||||
if(value instanceof java.util.Map<?, ?> map)
|
||||
{
|
||||
java.util.Map<String, java.io.Serializable> copy = new java.util.LinkedHashMap<>();
|
||||
map.forEach((key, item) -> copy.put(String.valueOf(key), freeze((java.io.Serializable) item)));
|
||||
return ((java.io.Serializable) java.util.Collections.unmodifiableMap(copy));
|
||||
}
|
||||
if(value instanceof java.util.List<?> list)
|
||||
{
|
||||
java.util.List<java.io.Serializable> copy = new java.util.ArrayList<>();
|
||||
list.forEach(item -> copy.add(freeze((java.io.Serializable) item)));
|
||||
return ((java.io.Serializable) java.util.Collections.unmodifiableList(copy));
|
||||
}
|
||||
if(value instanceof SlackApiVariable variable)
|
||||
{
|
||||
return (new SlackApiVariable(freeze(variable.value()), variable.classification(), variable.table(), variable.field()));
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable result with a defensive collection snapshot.
|
||||
*******************************************************************************/
|
||||
public record SlackApiResult(String status, Integer messageId, java.util.List<String> reasons)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Retain an immutable snapshot.
|
||||
*******************************************************************************/
|
||||
public SlackApiResult
|
||||
{
|
||||
reasons = java.util.List.copyOf(reasons);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** One authorized picker option.
|
||||
*******************************************************************************/
|
||||
public record SlackApiTarget(String address, String label)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable template definition. ensureTemplate never overwrites administrator edits.
|
||||
*******************************************************************************/
|
||||
public record SlackApiTemplate(String name, String label, String text, String blocksTemplate, String variablesSchema, String category)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/*******************************************************************************
|
||||
** Provenance for a runtime variable. Bound-field classification comes from
|
||||
** core metadata; a declaration can increase but never decrease sensitivity.
|
||||
*******************************************************************************/
|
||||
public record SlackApiVariable(Serializable value, String classification, String table, String field) implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Values must not appear in diagnostic formatting.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("[classified variable]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* 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.oauth;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecrets;
|
||||
import com.kingsrook.qbits.slack.security.SlackTokenCipher;
|
||||
import com.kingsrook.qbits.slack.security.SlackTokens;
|
||||
import com.kingsrook.qbits.slack.support.SlackRuntime;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qbits.slack.transport.SlackResponse;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/*******************************************************************************
|
||||
** Session-authorized OAuth lifecycle. Every token write is encrypted and bound
|
||||
** to an existing row. Installation privileges are rechecked at callback time.
|
||||
*******************************************************************************/
|
||||
public final class SlackConnect
|
||||
{
|
||||
public static final String BOT_SCOPES = "chat:write,im:write,channels:read,groups:read,users:read,files:write";
|
||||
public static final String USER_SCOPES = "chat:write,channels:read,groups:read,im:write,files:write,users:read";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackConnect()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Start only for the signed-in user, never an automated or system session.
|
||||
*******************************************************************************/
|
||||
public static String startUrl(String userId, String mode)
|
||||
{
|
||||
QSession session = session();
|
||||
if(!session.getUser().getIdReference().equals(userId) || !session.hasPermission("slack.connect")
|
||||
|| !("user".equals(mode) || "user+bot".equals(mode)) || "user+bot".equals(mode) && !session.hasPermission("slack.workspace.install"))
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack connect is not permitted"));
|
||||
}
|
||||
SlackQBitConfig config = SlackRuntime.config();
|
||||
String redirect = redirectUri(config);
|
||||
String state = SlackOAuthState.issue(config, session, userId, tenant(config, session), mode);
|
||||
return ("https://slack.com/oauth/v2/authorize?client_id=" + encode(SlackSecrets.get(config, config.getClientIdEnvName()))
|
||||
+ "&scope=" + encode("user+bot".equals(mode) ? BOT_SCOPES : "") + "&user_scope=" + encode(USER_SCOPES)
|
||||
+ "&redirect_uri=" + encode(redirect) + "&state=" + encode(state));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete a one-use state. Errors are fixed codes, never provider text.
|
||||
*******************************************************************************/
|
||||
public static String callback(String state, String code)
|
||||
{
|
||||
SlackQBitConfig config = SlackRuntime.config();
|
||||
try
|
||||
{
|
||||
QSession session = session();
|
||||
String user = session.getUser().getIdReference();
|
||||
String tenant = tenant(config, session);
|
||||
SlackOAuthState.State verified = SlackOAuthState.consume(config, session, state, user, tenant);
|
||||
if(!session.hasPermission("slack.connect") || "user+bot".equals(verified.mode()) && !session.hasPermission("slack.workspace.install"))
|
||||
{
|
||||
return ("not_permitted");
|
||||
}
|
||||
if(code == null || code.isBlank() || code.length() > 4096)
|
||||
{
|
||||
return ("authorization_denied");
|
||||
}
|
||||
SlackResponse response = SlackRuntime.client(config).oauthAccess(SlackSecrets.get(config, config.getClientIdEnvName()),
|
||||
SlackSecrets.get(config, config.getClientSecretEnvName()), code, redirectUri(config));
|
||||
if(!response.isOk())
|
||||
{
|
||||
return ("exchange_failed");
|
||||
}
|
||||
save(config, session, verified, response.getJson());
|
||||
return (null);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return ("connect_failed");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve an unambiguous tenant from the host's record locks. Multiple tenant
|
||||
** selections and join-based root locks cannot safely identify an install.
|
||||
*******************************************************************************/
|
||||
public static String tenant(SlackQBitConfig config, QSession session)
|
||||
{
|
||||
String tenant = null;
|
||||
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
|
||||
{
|
||||
List<Serializable> values = session.getSecurityKeyValues(lock.getSecurityKeyType());
|
||||
if(values == null || values.size() != 1 || values.getFirst() == null || lock.getFieldName() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Select one tenant before connecting Slack"));
|
||||
}
|
||||
String value = String.valueOf(values.getFirst());
|
||||
if(tenant != null && !tenant.equals(value))
|
||||
{
|
||||
throw (new IllegalArgumentException("Ambiguous Slack tenant"));
|
||||
}
|
||||
tenant = value;
|
||||
}
|
||||
return (tenant);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist identity first, then encrypt credentials with the generated ids.
|
||||
** Synchronization serializes callbacks on this node; database unique keys
|
||||
** remain authoritative across pods and a conflict fails closed.
|
||||
*******************************************************************************/
|
||||
private static synchronized void save(SlackQBitConfig config, QSession session, SlackOAuthState.State state, JSONObject json) throws QException
|
||||
{
|
||||
JSONObject team = json.getJSONObject("team");
|
||||
JSONObject user = json.getJSONObject("authed_user");
|
||||
String teamId = team.getString("id");
|
||||
String slackUser = user.getString("id");
|
||||
if(teamId.isBlank() || slackUser.isBlank())
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid Slack identity"));
|
||||
}
|
||||
List<QRecord> workspaces = SlackStore.queryAsCurrent(SlackTables.WORKSPACE, SlackStore.equal("teamId", teamId));
|
||||
String tenant = state.tenantKey().isEmpty() ? null : state.tenantKey();
|
||||
QRecord workspace = workspaces.stream().filter(row -> java.util.Objects.equals(tenant, row.getValueString("tenantKey"))).findFirst().orElse(null);
|
||||
if(workspace == null)
|
||||
{
|
||||
workspace = new QRecord().withValue("teamId", teamId).withValue("teamName", team.optString("name", "Slack"))
|
||||
.withValue("tenantKey", tenant).withValue("status", "ACTIVE");
|
||||
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
|
||||
{
|
||||
workspace.withValue(lock.getFieldName(), session.getSecurityKeyValues(lock.getSecurityKeyType()).getFirst());
|
||||
}
|
||||
workspace = inserted(SlackTables.WORKSPACE, workspace);
|
||||
}
|
||||
Integer workspaceId = workspace.getValueInteger("id");
|
||||
SlackTokenCipher cipher = new SlackTokenCipher(config);
|
||||
if("user+bot".equals(state.mode()) && json.has("access_token"))
|
||||
{
|
||||
String token = json.getString("access_token");
|
||||
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", workspaceId)
|
||||
.withValue("botToken", cipher.encrypt(token, SlackTokens.workspaceBinding(workspaceId, tenant)))
|
||||
.withValue("botTokenFingerprint", cipher.fingerprint(token))
|
||||
.withValue("botUserId", json.optString("bot_user_id", null)).withValue("botScopes", json.optString("scope", ""))
|
||||
.withValue("appId", json.optString("app_id", null)).withValue("installedByUserId", state.userId())
|
||||
.withValue("installedAt", SlackRuntime.clock().instant()).withValue("status", "ACTIVE"), null);
|
||||
}
|
||||
List<QRecord> connections = SlackStore.queryAsCurrent(SlackTables.CONNECTION, SlackStore.equal("workspaceId", workspaceId));
|
||||
QRecord connection = connections.stream().filter(row -> state.userId().equals(row.getValueString("userId"))).findFirst().orElse(null);
|
||||
if(connection == null)
|
||||
{
|
||||
connection = inserted(SlackTables.CONNECTION, new QRecord().withValue("workspaceId", workspaceId).withValue("userId", state.userId()).withValue("status", "ACTIVE"));
|
||||
}
|
||||
Integer id = connection.getValueInteger("id");
|
||||
String token = user.optString("access_token", null);
|
||||
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", id).withValue("slackUserId", slackUser)
|
||||
.withValue("userScopes", user.optString("scope", "")).withValue("status", "ACTIVE").withValue("revokedAt", null)
|
||||
.withValue("userToken", token == null ? null : cipher.encrypt(token, SlackTokens.connectionBinding(id, state.userId())))
|
||||
.withValue("userTokenFingerprint", token == null ? null : cipher.fingerprint(token))
|
||||
.withValue("connectedAt", SlackRuntime.clock().instant()), null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never continue token writes after a failed identity insert.
|
||||
*******************************************************************************/
|
||||
private static QRecord inserted(String table, QRecord row) throws QException
|
||||
{
|
||||
QRecord saved = SlackStore.insert(table, row, null);
|
||||
if(saved.getValueInteger("id") == null || saved.getErrors() != null && !saved.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Slack identity storage failed"));
|
||||
}
|
||||
return (saved);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Revoke a visible owned connection, or an admin's tenant-visible connection.
|
||||
*******************************************************************************/
|
||||
public static void disconnect(Integer id) throws QException
|
||||
{
|
||||
QSession session = session();
|
||||
QRecord row = visible(SlackTables.CONNECTION, id);
|
||||
if(!session.hasPermission("slack.admin") && (!session.hasPermission("slack.connect") || !session.getUser().getIdReference().equals(row.getValueString("userId"))))
|
||||
{
|
||||
throw (new QException("Slack disconnect is not permitted"));
|
||||
}
|
||||
SlackQBitConfig config = SlackRuntime.config();
|
||||
if(SlackStore.getWithToken(SlackTables.CONNECTION, id).getValueString("userToken") != null)
|
||||
{
|
||||
SlackResponse response = SlackRuntime.client(config).authRevoke(SlackTokens.userToken(config, id));
|
||||
if(!response.isOk() && !List.of("token_revoked", "invalid_auth").contains(response.getError()))
|
||||
{
|
||||
throw (new QException("Slack revocation failed"));
|
||||
}
|
||||
}
|
||||
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", id).withValue("status", "REVOKED")
|
||||
.withValue("userToken", null).withValue("userTokenFingerprint", null).withValue("revokedAt", SlackRuntime.clock().instant()), null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Uninstall only within the administrator's visible tenant.
|
||||
*******************************************************************************/
|
||||
public static void uninstall(Integer id) throws QException
|
||||
{
|
||||
if(!session().hasPermission("slack.admin"))
|
||||
{
|
||||
throw (new QException("Slack uninstall is not permitted"));
|
||||
}
|
||||
visible(SlackTables.WORKSPACE, id);
|
||||
SlackQBitConfig config = SlackRuntime.config();
|
||||
SlackResponse response = SlackRuntime.client(config).appsUninstall(SlackTokens.botToken(config, id),
|
||||
SlackSecrets.get(config, config.getClientIdEnvName()), SlackSecrets.get(config, config.getClientSecretEnvName()));
|
||||
if(!response.isOk())
|
||||
{
|
||||
throw (new QException("Slack uninstall failed"));
|
||||
}
|
||||
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("status", "UNINSTALLED")
|
||||
.withValue("botToken", null).withValue("botTokenFingerprint", null), null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read through normal record locks before any privileged operation.
|
||||
*******************************************************************************/
|
||||
public static QRecord visible(String table, Integer id) throws QException
|
||||
{
|
||||
List<QRecord> rows = SlackStore.queryAsCurrent(table, SlackStore.equal("id", id));
|
||||
if(rows.size() != 1)
|
||||
{
|
||||
throw (new QException("Slack record is unavailable"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Require a real signed-in user.
|
||||
*******************************************************************************/
|
||||
private static QSession session()
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null || session instanceof QSystemUserSession || session.getUser() == null || session.getUser().getIdReference() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Sign in to connect Slack"));
|
||||
}
|
||||
return (session);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact redirect URI shared by start and exchange.
|
||||
*******************************************************************************/
|
||||
private static String redirectUri(SlackQBitConfig config)
|
||||
{
|
||||
if(config.getPublicBaseUrl() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack public URL is required"));
|
||||
}
|
||||
return (config.getPublicBaseUrl().replaceAll("/+$", "") + config.getOauthCallbackPath());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Encode one query parameter.
|
||||
*******************************************************************************/
|
||||
private static String encode(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.oauth;
|
||||
|
||||
import java.util.Map;
|
||||
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.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
|
||||
/*******************************************************************************
|
||||
** Browser callback through the RC.6 qbit route extension point.
|
||||
*******************************************************************************/
|
||||
public class SlackOAuthRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private QInstance instance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotent provider registration.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
QJavalinMetaData metadata = QJavalinMetaData.ofOrWithNew(instance);
|
||||
if(metadata.getAdditionalRouteProviderReferences() == null || metadata.getAdditionalRouteProviderReferences().stream()
|
||||
.noneMatch(r -> SlackOAuthRouteProvider.class.getName().equals(r.getName())))
|
||||
{
|
||||
metadata.withAdditionalRouteProviderReference(new QCodeReference(SlackOAuthRouteProvider.class));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Receive the host instance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the configured callback path only.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.get(SlackRuntime.config(instance).getOauthCallbackPath(), this::callback);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed templates when the host starts, after its storage is initialized.
|
||||
** The scheduler repeats this idempotently for non-Javalin hosts.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void acceptJavalinService(io.javalin.Javalin service)
|
||||
{
|
||||
CapturedContext previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession(instance));
|
||||
com.kingsrook.qbits.slack.template.SlackTemplates.seed();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException("Slack template startup failed"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticate from the existing cookie only. The generic session helper
|
||||
** prioritizes code/state query parameters, which here belong to Slack,
|
||||
** not the host identity provider. Restore thread context on every exit.
|
||||
*******************************************************************************/
|
||||
private void callback(Context context)
|
||||
{
|
||||
CapturedContext previous = QContext.capture();
|
||||
String error = "sign_in_required";
|
||||
try
|
||||
{
|
||||
QContext.init(instance, null);
|
||||
String cookie = context.cookie("sessionUUID");
|
||||
String key = "sessionUUID";
|
||||
if(cookie == null)
|
||||
{
|
||||
cookie = context.cookie("sessionId");
|
||||
key = "sessionId";
|
||||
}
|
||||
if(cookie != null && !cookie.isBlank())
|
||||
{
|
||||
var module = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication());
|
||||
var session = module.createSession(instance, Map.of(key, cookie));
|
||||
if(module.isSessionValid(instance, session))
|
||||
{
|
||||
QContext.init(instance, session);
|
||||
error = SlackConnect.callback(context.queryParam("state"), context.queryParam("code"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
error = "connect_failed";
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
context.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer");
|
||||
context.redirect(SlackRuntime.config(instance).getPublicBaseUrl().replaceAll("/+$", "")
|
||||
+ "/preferences?section=slack" + (error == null ? "&result=connected" : "&error=" + error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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.oauth;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecrets;
|
||||
import com.kingsrook.qbits.slack.support.SlackRuntime;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/*******************************************************************************
|
||||
** Ten-minute state verified statelessly across pods, bound to the user, tenant
|
||||
** and host session UUID. The bounded map tracks consumed nonces only, providing
|
||||
** best-effort replay rejection on this pod. Slack authorization codes are
|
||||
** single-use and provide the cross-pod replay barrier.
|
||||
*******************************************************************************/
|
||||
public final class SlackOAuthState
|
||||
{
|
||||
private static final Map<String, Long> NONCES = new HashMap<>();
|
||||
private static final int CAPACITY = 10_000;
|
||||
|
||||
/*******************************************************************************
|
||||
** Verified state contains identities, never credentials.
|
||||
*******************************************************************************/
|
||||
public record State(String userId, String tenantKey, String nonce, String mode, long expiry)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackOAuthState()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Issue signed state without requiring pod-local or session nonce storage.
|
||||
*******************************************************************************/
|
||||
public static synchronized String issue(SlackQBitConfig config, QSession session, String user, String tenant, String mode)
|
||||
{
|
||||
long now = SlackRuntime.clock().instant().getEpochSecond();
|
||||
NONCES.values().removeIf(expiry -> expiry <= now);
|
||||
if(!java.util.List.of("user", "user+bot").contains(mode))
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack connect unavailable"));
|
||||
}
|
||||
String nonce = UUID.randomUUID().toString();
|
||||
long expiry = now + 600;
|
||||
String payload = encode(new JSONObject().put("user", user).put("tenant", Objects.toString(tenant, ""))
|
||||
.put("session", session.getUuid()).put("nonce", nonce).put("mode", mode).put("expiry", expiry).toString().getBytes(StandardCharsets.UTF_8));
|
||||
String signed = payload + "." + encode(sign(config, payload));
|
||||
return (signed);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify identity and signature before atomically consuming the nonce.
|
||||
*******************************************************************************/
|
||||
public static synchronized State consume(SlackQBitConfig config, QSession session, String token, String user, String tenant)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(token == null || token.length() > 8192 || session == null)
|
||||
{
|
||||
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));
|
||||
State state = new State(json.getString("user"), json.getString("tenant"), json.getString("nonce"), json.getString("mode"), json.getLong("expiry"));
|
||||
long now = SlackRuntime.clock().instant().getEpochSecond();
|
||||
NONCES.values().removeIf(expiry -> expiry <= now);
|
||||
if(!state.userId().equals(user) || !state.tenantKey().equals(Objects.toString(tenant, "")) || state.expiry() <= now
|
||||
|| !Objects.equals(json.getString("session"), session.getUuid()) || NONCES.containsKey(state.nonce()))
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
if(NONCES.size() >= CAPACITY)
|
||||
{
|
||||
NONCES.remove(NONCES.keySet().iterator().next());
|
||||
}
|
||||
NONCES.put(state.nonce(), state.expiry());
|
||||
return (state);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid or expired Slack state"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** HKDF-style domain separation prevents token-key reuse across protocols.
|
||||
*******************************************************************************/
|
||||
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-oauth-state".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 encoding without padding.
|
||||
*******************************************************************************/
|
||||
private static String encode(byte[] bytes)
|
||||
{
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,11 @@ import com.kingsrook.qbits.slack.metadata.SlackEnums;
|
||||
*******************************************************************************/
|
||||
public class SlackSendRequest
|
||||
{
|
||||
private String templateName = null;
|
||||
private java.util.Map<String, java.io.Serializable> variables = java.util.Map.of();
|
||||
private String contentClassification = null;
|
||||
private String title = null;
|
||||
private String link = null;
|
||||
private String ownerUserId;
|
||||
private String tenantKey;
|
||||
private String to;
|
||||
@@ -309,4 +314,89 @@ public class SlackSendRequest
|
||||
this.senderName = senderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for templateName.
|
||||
*******************************************************************************/
|
||||
public String getTemplateName()
|
||||
{
|
||||
return (templateName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for templateName.
|
||||
*******************************************************************************/
|
||||
public SlackSendRequest withTemplateName(String value)
|
||||
{
|
||||
templateName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for variables.
|
||||
*******************************************************************************/
|
||||
public java.util.Map<String, java.io.Serializable> getVariables()
|
||||
{
|
||||
return (variables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for variables.
|
||||
*******************************************************************************/
|
||||
public SlackSendRequest withVariables(java.util.Map<String, java.io.Serializable> value)
|
||||
{
|
||||
variables = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for contentClassification.
|
||||
*******************************************************************************/
|
||||
public String getContentClassification()
|
||||
{
|
||||
return (contentClassification);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for contentClassification.
|
||||
*******************************************************************************/
|
||||
public SlackSendRequest withContentClassification(String value)
|
||||
{
|
||||
contentClassification = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title.
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (title);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title.
|
||||
*******************************************************************************/
|
||||
public SlackSendRequest withTitle(String value)
|
||||
{
|
||||
title = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for link.
|
||||
*******************************************************************************/
|
||||
public String getLink()
|
||||
{
|
||||
return (link);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for link.
|
||||
*******************************************************************************/
|
||||
public SlackSendRequest withLink(String value)
|
||||
{
|
||||
link = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.slack.security.SlackAuthorizer;
|
||||
import com.kingsrook.qbits.slack.security.SlackOwnerSession;
|
||||
import com.kingsrook.qbits.slack.support.SlackRuntime;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplates;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -111,7 +112,7 @@ public final class SlackService
|
||||
boolean ownerless = owner == null;
|
||||
String storedOwner = ownerless ? SlackAuthorizer.systemOwner(request.getTenantKey()) : owner;
|
||||
|
||||
String denied = permissionProblem(owner, request.getBlocksJson() != null && !request.getBlocksJson().isBlank());
|
||||
String denied = permissionProblem(owner, !"SECURITY".equals(request.getCategory()) && request.getBlocksJson() != null && !request.getBlocksJson().isBlank());
|
||||
if(denied != null)
|
||||
{
|
||||
return (SlackSendResult.refused(denied));
|
||||
@@ -156,7 +157,15 @@ public final class SlackService
|
||||
}
|
||||
AuthorizedSend send = authorization.getSend();
|
||||
|
||||
QRecord row = baseRow(request, storedOwner, send, SlackEnums.Operation.POST);
|
||||
QRecord row;
|
||||
try
|
||||
{
|
||||
row = baseRow(request, storedOwner, send, SlackEnums.Operation.POST);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (SlackSendResult.refused(e instanceof com.kingsrook.qbits.slack.phi.SlackPayloadGuard.RestrictedVariableException ? e.getMessage() : "Invalid or restricted Slack content"));
|
||||
}
|
||||
if(parent != null)
|
||||
{
|
||||
boolean parentSent = "SENT".equals(parent.getValueString("status")) && parent.getValueString("slackTs") != null;
|
||||
@@ -234,13 +243,22 @@ public final class SlackService
|
||||
{
|
||||
return (SlackSendResult.refused("Only the owner can update a Slack message"));
|
||||
}
|
||||
String denied = permissionProblem(owner, request.getBlocksJson() != null && !request.getBlocksJson().isBlank());
|
||||
String denied = permissionProblem(owner, !"SECURITY".equals(request.getCategory()) && request.getBlocksJson() != null && !request.getBlocksJson().isBlank());
|
||||
if(denied != null)
|
||||
{
|
||||
return (SlackSendResult.refused(denied));
|
||||
}
|
||||
|
||||
QRecord target = ownerMessage(owner, messageId);
|
||||
QRecord target;
|
||||
if(QContext.getQSession().hasPermission("slack.admin"))
|
||||
{
|
||||
List<QRecord> visible = SlackStore.queryAsCurrent(SlackTables.MESSAGE, SlackStore.equal("id", messageId));
|
||||
target = visible.isEmpty() ? null : visible.getFirst();
|
||||
}
|
||||
else
|
||||
{
|
||||
target = ownerMessage(owner, messageId);
|
||||
}
|
||||
if(target == null)
|
||||
{
|
||||
return (SlackSendResult.refused("That message wasn't found"));
|
||||
@@ -250,6 +268,15 @@ public final class SlackService
|
||||
return (SlackSendResult.refused("Only a message that was sent can be updated"));
|
||||
}
|
||||
|
||||
String originalOwner = target.getValueString("ownerUserId");
|
||||
if(!owner.equals(originalOwner))
|
||||
{
|
||||
if(!QContext.getQSession().hasPermission("slack.admin") || SlackAuthorizer.isOwnerless(originalOwner))
|
||||
{
|
||||
return (SlackSendResult.refused("Only the owner or tenant admin can update a Slack message"));
|
||||
}
|
||||
owner = originalOwner;
|
||||
}
|
||||
SlackEnums.PostAs postAs = SlackEnums.PostAs.valueOf(target.getValueString("postAs"));
|
||||
SlackAuthorization authorization = new SlackAuthorizer().authorize(new SlackAuthorizer.Request()
|
||||
.withOwnerUserId(owner).withAddress(target.getValueString("targetRef")).withWorkspaceId(target.getValueInteger("workspaceId")).withPostAs(postAs));
|
||||
@@ -258,7 +285,15 @@ public final class SlackService
|
||||
return (SlackSendResult.refused(authorization.getRefusal().message()));
|
||||
}
|
||||
|
||||
QRecord row = baseRow(request, owner, authorization.getSend(), SlackEnums.Operation.UPDATE);
|
||||
QRecord row;
|
||||
try
|
||||
{
|
||||
row = baseRow(request, owner, authorization.getSend(), SlackEnums.Operation.UPDATE);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (SlackSendResult.refused(e instanceof com.kingsrook.qbits.slack.phi.SlackPayloadGuard.RestrictedVariableException ? e.getMessage() : "Invalid or restricted Slack content"));
|
||||
}
|
||||
row.withValue("targetMessageId", messageId);
|
||||
row.withValue("slackTs", target.getValueString("slackTs"));
|
||||
row.withValue("slackChannelId", target.getValueString("slackChannelId"));
|
||||
@@ -285,9 +320,9 @@ public final class SlackService
|
||||
*******************************************************************************/
|
||||
private static String validate(SlackSendRequest request, boolean needsAddress)
|
||||
{
|
||||
boolean hasText = request.getText() != null && !request.getText().isBlank();
|
||||
boolean hasBlocks = request.getBlocksJson() != null && !request.getBlocksJson().isBlank();
|
||||
if(!hasText && !hasBlocks)
|
||||
boolean hasText = !"SECURITY".equals(request.getCategory()) && request.getText() != null && !request.getText().isBlank();
|
||||
boolean hasBlocks = !"SECURITY".equals(request.getCategory()) && request.getBlocksJson() != null && !request.getBlocksJson().isBlank();
|
||||
if(!hasText && !hasBlocks && request.getTemplateName() == null && !"SECURITY".equals(request.getCategory()))
|
||||
{
|
||||
return ("A Slack message needs text or blocks");
|
||||
}
|
||||
@@ -423,9 +458,15 @@ public final class SlackService
|
||||
/*******************************************************************************
|
||||
** The row common to a post and an update.
|
||||
*******************************************************************************/
|
||||
private static QRecord baseRow(SlackSendRequest request, String storedOwner, AuthorizedSend send, SlackEnums.Operation operation)
|
||||
private static QRecord baseRow(SlackSendRequest request, String storedOwner, AuthorizedSend send, SlackEnums.Operation operation) throws QException
|
||||
{
|
||||
String text = SlackService.escape(request.getText());
|
||||
SlackTemplates.Content content = SlackAuthorizer.isOwnerless(storedOwner) ? SlackTemplates.render(request)
|
||||
: SlackOwnerSession.call(storedOwner, () -> SlackTemplates.render(request));
|
||||
String text = content.text();
|
||||
if(text != null && text.length() > MAX_TEXT_LENGTH || content.blocks() != null && content.blocks().length() >= MAX_TEXT_LENGTH)
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack content too large"));
|
||||
}
|
||||
if(text != null && send.mentionPrefix() != null)
|
||||
{
|
||||
text = send.mentionPrefix() + text;
|
||||
@@ -445,7 +486,8 @@ public final class SlackService
|
||||
.withValue("status", SlackEnums.MessageStatus.QUEUED.name())
|
||||
.withValue("category", request.getCategory())
|
||||
.withValue("text", text)
|
||||
.withValue("blocksJson", request.getBlocksJson() == null || request.getBlocksJson().isBlank() ? null : request.getBlocksJson())
|
||||
.withValue("blocksJson", content.blocks())
|
||||
.withValue("templateName", request.getTemplateName())
|
||||
.withValue("idempotencyKey", request.getIdempotencyKey())
|
||||
.withValue("attemptCount", 0)
|
||||
.withValue("nextAttemptDate", now)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* 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.phi;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.slack.support.SlackRuntime;
|
||||
import com.kingsrook.qbits.slack.template.VariableSchema;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
|
||||
/*******************************************************************************
|
||||
** Refuse restricted content before persistence. Uses RC.6 field classifications.
|
||||
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.8): ClassifiedValues and
|
||||
** ClassifiedValueTokens are absent in RC.6. Restricted tokens use changed:v1,
|
||||
** a deliberately non-reversible marker, never a hash of a low-entropy value.
|
||||
*******************************************************************************/
|
||||
public final class SlackPayloadGuard
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Safe diagnostic containing only a declared schema variable name.
|
||||
*******************************************************************************/
|
||||
public static class RestrictedVariableException extends IllegalArgumentException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Never include the supplied value in diagnostics.
|
||||
*******************************************************************************/
|
||||
public RestrictedVariableException(String name)
|
||||
{
|
||||
super(name == null ? "Restricted template variable" : "Restricted template variable: " + name);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Runtime provenance accompanies a value without exposing it in diagnostics.
|
||||
*******************************************************************************/
|
||||
public record ClassifiedValue(Serializable value, String classification, String table, String field) implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Do not expose restricted values through accidental diagnostic formatting.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("[classified value]");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackPayloadGuard()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the most restrictive declaration and core field classification.
|
||||
*******************************************************************************/
|
||||
public static boolean restricted(String declared, String tableName, String field)
|
||||
{
|
||||
DataClassification classification = declared == null ? null : DataClassification.valueOf(declared);
|
||||
if(tableName != null)
|
||||
{
|
||||
var table = QContext.getQInstance().getTable(tableName);
|
||||
if(table == null || table.getField(field) == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Unknown classified field"));
|
||||
}
|
||||
classification = DataClassification.max(classification, AiDataClassifications.ofField(table, field));
|
||||
}
|
||||
return (classification != null && classification.isAtLeast(DataClassification.PII));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A restricted schema cannot be stored, even when marked for tokenization.
|
||||
*******************************************************************************/
|
||||
public static void validateSchema(VariableSchema schema)
|
||||
{
|
||||
for(VariableSchema.Entry entry : schema.entries().values())
|
||||
{
|
||||
if(restricted(entry.classification(), entry.table(), entry.field()))
|
||||
{
|
||||
throw (new IllegalArgumentException("Restricted template variable"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sanitize all supplied values before validation, rendering or snapshotting.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Serializable> variables(VariableSchema schema, Map<String, Serializable> values)
|
||||
{
|
||||
Map<String, Serializable> safe = new LinkedHashMap<>();
|
||||
for(Map.Entry<String, Serializable> entry : values.entrySet())
|
||||
{
|
||||
VariableSchema.Entry declaration = schema.entries().get(entry.getKey());
|
||||
boolean tokenize = declaration != null && declaration.tokenize();
|
||||
if(declaration != null && restricted(declaration.classification(), declaration.table(), declaration.field()))
|
||||
{
|
||||
if(!tokenize)
|
||||
{
|
||||
throw (new RestrictedVariableException(declaration.name()));
|
||||
}
|
||||
safe.put(entry.getKey(), "changed:v1");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
safe.put(entry.getKey(), sanitize(entry.getValue(), tokenize));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new RestrictedVariableException(declaration == null ? null : declaration.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(VariableSchema.Entry declaration : schema.entries().values())
|
||||
{
|
||||
if(restricted(declaration.classification(), declaration.table(), declaration.field()))
|
||||
{
|
||||
if(!declaration.tokenize())
|
||||
{
|
||||
throw (new RestrictedVariableException(declaration.name()));
|
||||
}
|
||||
safe.put(declaration.name(), "changed:v1");
|
||||
}
|
||||
}
|
||||
return (safe);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Traverse nested maps and lists; no classified wrapper can stringify raw data.
|
||||
*******************************************************************************/
|
||||
private static Serializable sanitize(Object value, boolean tokenize)
|
||||
{
|
||||
if(value instanceof com.kingsrook.qbits.slack.api.SlackApiVariable variable)
|
||||
{
|
||||
return (sanitize(new ClassifiedValue(variable.value(), variable.classification(), variable.table(), variable.field()), tokenize));
|
||||
}
|
||||
if(value instanceof ClassifiedValue classified)
|
||||
{
|
||||
if(restricted(classified.classification(), classified.table(), classified.field()))
|
||||
{
|
||||
if(!tokenize)
|
||||
{
|
||||
throw (new IllegalArgumentException("Restricted template variable"));
|
||||
}
|
||||
return ("changed:v1");
|
||||
}
|
||||
return (sanitize(classified.value(), tokenize));
|
||||
}
|
||||
if(value instanceof Map<?, ?> map)
|
||||
{
|
||||
LinkedHashMap<String, Serializable> result = new LinkedHashMap<>();
|
||||
map.forEach((key, item) -> result.put(String.valueOf(key), sanitize(item, tokenize)));
|
||||
return (result);
|
||||
}
|
||||
if(value instanceof List<?> list)
|
||||
{
|
||||
java.util.ArrayList<Serializable> result = new java.util.ArrayList<>();
|
||||
list.forEach(item -> result.add(sanitize(item, tokenize)));
|
||||
return (result);
|
||||
}
|
||||
if(value != null)
|
||||
{
|
||||
text(value.toString(), null);
|
||||
}
|
||||
return ((Serializable) value);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect decoded JSON strings as well as the source, so unicode escapes
|
||||
** cannot disguise a restricted free-text pattern.
|
||||
*******************************************************************************/
|
||||
public static void blocks(String source)
|
||||
{
|
||||
if(source == null || source.isBlank())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
inspectJson(new com.fasterxml.jackson.databind.ObjectMapper().readTree(source));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid or restricted Slack blocks"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recursively inspect textual values without rendering or logging them.
|
||||
*******************************************************************************/
|
||||
private static void inspectJson(com.fasterxml.jackson.databind.JsonNode node)
|
||||
{
|
||||
if(node.isTextual())
|
||||
{
|
||||
text(node.textValue(), null);
|
||||
}
|
||||
else
|
||||
{
|
||||
node.forEach(SlackPayloadGuard::inspectJson);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declared classification and configured patterns refuse free text. No value
|
||||
** or pattern match appears in the diagnostic.
|
||||
*******************************************************************************/
|
||||
public static void text(String text, String classification)
|
||||
{
|
||||
if(restricted(classification, null, null))
|
||||
{
|
||||
throw (new IllegalArgumentException("Restricted Slack content"));
|
||||
}
|
||||
if(text != null)
|
||||
{
|
||||
for(String pattern : SlackRuntime.config().getPhiPatterns())
|
||||
{
|
||||
if(Pattern.compile(pattern).matcher(text).find())
|
||||
{
|
||||
throw (new IllegalArgumentException("Restricted Slack content"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,18 +21,34 @@
|
||||
package com.kingsrook.qbits.slack.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
import com.kingsrook.qbits.slack.delivery.SlackDispatcher;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackConnect;
|
||||
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplates;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
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.actions.processes.Status;
|
||||
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.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -41,6 +57,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaDa
|
||||
*******************************************************************************/
|
||||
public final class SlackProcesses
|
||||
{
|
||||
public static final String DISCONNECT = "slack_disconnect";
|
||||
public static final String UNINSTALL = "slack_uninstall";
|
||||
public static final String READINESS = "slack_check_readiness";
|
||||
public static final String MAINTAIN = "slack_readiness";
|
||||
public static final String DISPATCH = "slack_dispatch";
|
||||
|
||||
|
||||
@@ -68,7 +88,16 @@ public final class SlackProcesses
|
||||
dispatch.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName())
|
||||
.withRepeatSeconds(config.getDispatchIntervalSeconds()).withInitialDelaySeconds(Math.min(config.getDispatchIntervalSeconds(), 30)));
|
||||
}
|
||||
return (List.of(dispatch));
|
||||
QProcessMetaData readiness = new QProcessMetaData().withName(MAINTAIN).withLabel("Check Slack Readiness").withIsHidden(true)
|
||||
.withPermissionRules(SlackTables.permissionRules())
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(ReadinessStep.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
{
|
||||
readiness.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName())
|
||||
.withRepeatSeconds(config.getReadinessIntervalMinutes() * 60).withInitialDelaySeconds(1));
|
||||
}
|
||||
return (List.of(dispatch, readiness, action(DISCONNECT, "Disconnect", SlackTables.CONNECTION),
|
||||
action(UNINSTALL, "Uninstall", SlackTables.WORKSPACE), action(READINESS, "Check readiness", SlackTables.WORKSPACE)));
|
||||
}
|
||||
|
||||
|
||||
@@ -87,4 +116,115 @@ public final class SlackProcesses
|
||||
new SlackDispatcher().runOnce();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard streamed ETL Review and Result, never a navigation entry.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData action(String name, String label, String table)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label + " for selected records.");
|
||||
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, ActionTransform.class, ActionLoad.class, values)
|
||||
.withName(name).withLabel(label).withTableName(table).withPermissionRules(SlackTables.permissionRules()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Readiness runs after storage startup; seeding preserves existing edits.
|
||||
*******************************************************************************/
|
||||
public static class ReadinessStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Seed built-ins and refresh deployment and workspace snapshots.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
SlackTemplates.seed();
|
||||
SlackReadiness.run();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review selected rows without remote calls or mutation.
|
||||
*******************************************************************************/
|
||||
public static class ActionTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate permissions before offering the action.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
var session = com.kingsrook.qqq.backend.core.context.QContext.getQSession();
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
boolean permitted = session.hasPermission("slack.admin") || DISCONNECT.equals(input.getProcessName())
|
||||
&& session.hasPermission("slack.connect") && session.getUser().getIdReference().equals(row.getValueString("userId"));
|
||||
lines.add(new ProcessSummaryLine(permitted ? Status.OK : Status.INFO, 1, permitted ? "Record will be processed" : "Record not permitted"));
|
||||
if(permitted)
|
||||
{
|
||||
output.getRecords().add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute only after review, with fresh row authorization.
|
||||
*******************************************************************************/
|
||||
public static class ActionLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Service methods recheck current permissions and tenant visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
switch(input.getProcessName())
|
||||
{
|
||||
case DISCONNECT -> SlackConnect.disconnect(row.getValueInteger("id"));
|
||||
case UNINSTALL -> SlackConnect.uninstall(row.getValueInteger("id"));
|
||||
case READINESS ->
|
||||
{
|
||||
if(!com.kingsrook.qqq.backend.core.context.QContext.getQSession().hasPermission("slack.admin"))
|
||||
{
|
||||
throw (new QException("Slack readiness action is not permitted"));
|
||||
}
|
||||
SlackReadiness.workspace(row.getValueInteger("id"));
|
||||
}
|
||||
default -> throw (new QException("Unknown Slack action"));
|
||||
}
|
||||
lines.add(new ProcessSummaryLine(Status.OK, 1, "Record processed"));
|
||||
output.getRecords().add(row);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Result summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.readiness;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.health.HealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional RC.6 health integration, loaded only when its module is present.
|
||||
*******************************************************************************/
|
||||
public class SlackHealth implements HealthIndicator
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Register once without replacing the host's existing health metadata.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
HealthCheckMetaData metadata = (HealthCheckMetaData) instance.getSupplementalMetaData(HealthCheckMetaData.METADATA_KEY);
|
||||
if(metadata == null)
|
||||
{
|
||||
metadata = new HealthCheckMetaData();
|
||||
metadata.addSelfToInstance(instance);
|
||||
}
|
||||
if(metadata.getIndicators().stream().noneMatch(i -> "slack".equals(i.getName())))
|
||||
{
|
||||
metadata.getIndicators().add(new SlackHealth());
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Health component name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("slack");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot only: tenant failures never make a pod unhealthy.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public HealthCheckResult check(QInstance instance)
|
||||
{
|
||||
var readiness = SlackReadiness.stored(instance);
|
||||
boolean appFailed = readiness.checks().stream().anyMatch(c -> "app".equals(c.name()) && "ERROR".equals(c.status()));
|
||||
HealthStatus status = appFailed ? HealthStatus.DOWN : readiness.ready() ? HealthStatus.UP : HealthStatus.DEGRADED;
|
||||
return (new HealthCheckResult().withStatus(status)
|
||||
.withDetail("checks", readiness.checks()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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.readiness;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
import com.kingsrook.qbits.slack.api.SlackApiReadiness;
|
||||
import com.kingsrook.qbits.slack.api.SlackApiReadinessCheck;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackConnect;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecrets;
|
||||
import com.kingsrook.qbits.slack.security.SlackTokens;
|
||||
import com.kingsrook.qbits.slack.support.SlackRuntime;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed readiness checks and stored deployment snapshots. Health probes never
|
||||
** call Slack. Tenant failures are details, not deployment health failures.
|
||||
*******************************************************************************/
|
||||
public final class SlackReadiness
|
||||
{
|
||||
private static final Map<QInstance, SlackApiReadiness> STORED = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackReadiness()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Presence-only application check and current outbox condition.
|
||||
*******************************************************************************/
|
||||
public static SlackApiReadiness deployment(QInstance instance) throws QException
|
||||
{
|
||||
SlackQBitConfig config = SlackRuntime.config(instance);
|
||||
boolean app = false;
|
||||
try
|
||||
{
|
||||
SlackSecrets.get(config, config.getClientIdEnvName());
|
||||
SlackSecrets.get(config, config.getClientSecretEnvName());
|
||||
SlackSecrets.get(config, config.getSigningSecretEnvName());
|
||||
app = Base64.getDecoder().decode(SlackSecrets.get(config, config.getTokenKeyEnvName())).length == 32;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
app = false;
|
||||
}
|
||||
List<SlackApiReadinessCheck> checks = new ArrayList<>();
|
||||
checks.add(check("app", app, "Application configuration"));
|
||||
boolean outbox = true;
|
||||
if(config != null && QContext.getQInstance() == instance)
|
||||
{
|
||||
for(QRecord row : SlackStore.query(SlackTables.MESSAGE, new QQueryFilter()))
|
||||
{
|
||||
java.time.Instant created = row.getValueInstant("createDate");
|
||||
java.time.Instant dead = row.getValueInstant("deadLetterDate");
|
||||
if("QUEUED".equals(row.getValueString("status")) && created != null && created.isBefore(SlackRuntime.clock().instant().minusSeconds(600))
|
||||
|| "DEAD_LETTER".equals(row.getValueString("status")) && dead != null && dead.isAfter(SlackRuntime.clock().instant().minusSeconds(3600)))
|
||||
{
|
||||
outbox = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
outbox = false;
|
||||
}
|
||||
checks.add(check("outbox", outbox, "Recent outbox progress"));
|
||||
SlackApiReadiness result = result(checks);
|
||||
STORED.put(instance, result);
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Last scheduled deployment result, without network or database work.
|
||||
*******************************************************************************/
|
||||
public static SlackApiReadiness stored(QInstance instance)
|
||||
{
|
||||
return (STORED.getOrDefault(instance, new SlackApiReadiness(false, List.of(new SlackApiReadinessCheck("app", "UNKNOWN", "Readiness has not run")))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check a workspace already visible to this caller and store fixed results.
|
||||
*******************************************************************************/
|
||||
public static SlackApiReadiness workspace(Integer id) throws QException
|
||||
{
|
||||
QRecord workspace = SlackConnect.visible(SlackTables.WORKSPACE, id);
|
||||
SlackQBitConfig config = SlackRuntime.config();
|
||||
List<SlackApiReadinessCheck> checks = new ArrayList<>();
|
||||
boolean good = false;
|
||||
boolean channels = true;
|
||||
boolean optionalScopes = false;
|
||||
try
|
||||
{
|
||||
String token = SlackTokens.botToken(config, id);
|
||||
var response = SlackRuntime.client(config).authTest(token);
|
||||
good = "ACTIVE".equals(workspace.getValueString("status")) && response.isOk()
|
||||
&& workspace.getValueString("teamId").equals(response.getString("team_id"))
|
||||
&& response.getScopes() != null && List.of(response.getScopes().split(",")).containsAll(List.of(SlackConnect.BOT_SCOPES.split(",")));
|
||||
optionalScopes = response.getScopes() != null && List.of(response.getScopes().split(",")).containsAll(List.of("usergroups:read", "chat:write.customize"));
|
||||
for(QRecord channel : SlackStore.queryAsCurrent(SlackTables.CHANNEL, SlackStore.equal("workspaceId", id)))
|
||||
{
|
||||
if(Boolean.TRUE.equals(channel.getValueBoolean("enabled")))
|
||||
{
|
||||
var info = SlackRuntime.client(config).conversationsInfo(token, channel.getValueString("slackChannelId"));
|
||||
var json = info.getJson() == null ? null : info.getJson().optJSONObject("channel");
|
||||
channels &= info.isOk() && json != null && !json.optBoolean("is_archived", true) && json.optBoolean("is_member", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
good = false;
|
||||
channels = false;
|
||||
}
|
||||
checks.add(new SlackApiReadinessCheck("workspace:" + id, good ? optionalScopes ? "READY" : "WARN" : "ERROR",
|
||||
good && !optionalScopes ? "Workspace ready; optional scopes are missing" : "Workspace identity and required scopes"));
|
||||
checks.add(check("registeredChannels", channels, "Enabled channels and bot membership"));
|
||||
SlackApiReadiness result = result(checks);
|
||||
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("readinessStatus", result.ready() ? "OK" : "FAIL")
|
||||
.withValue("readinessCheckedAt", SlackRuntime.clock().instant()).withValue("readinessSummary", result.ready() ? "Workspace ready" : "Workspace needs attention"), null);
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify only a visible connection's own token and Slack identity.
|
||||
*******************************************************************************/
|
||||
public static SlackApiReadinessCheck connection(Integer id) throws QException
|
||||
{
|
||||
QRecord row = SlackConnect.visible(SlackTables.CONNECTION, id);
|
||||
boolean good = false;
|
||||
try
|
||||
{
|
||||
SlackQBitConfig config = SlackRuntime.config();
|
||||
QRecord workspace = SlackConnect.visible(SlackTables.WORKSPACE, row.getValueInteger("workspaceId"));
|
||||
var response = SlackRuntime.client(config).authTest(SlackTokens.userToken(config, id));
|
||||
good = "ACTIVE".equals(row.getValueString("status")) && response.isOk()
|
||||
&& workspace.getValueString("teamId").equals(response.getString("team_id"))
|
||||
&& row.getValueString("slackUserId").equals(response.getString("user_id"))
|
||||
&& response.getScopes() != null && List.of(response.getScopes().split(",")).containsAll(List.of(SlackConnect.USER_SCOPES.split(",")));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
good = false;
|
||||
}
|
||||
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", id).withValue("lastVerifiedAt", SlackRuntime.clock().instant()), null);
|
||||
return (check("connection:" + id, good, "Connection identity and required scopes"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled maintenance runs as the system across all workspace rows.
|
||||
*******************************************************************************/
|
||||
public static void run() throws QException
|
||||
{
|
||||
deployment(QContext.getQInstance());
|
||||
for(QRecord workspace : SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new QQueryFilter()))
|
||||
{
|
||||
workspace(workspace.getValueInteger("id"));
|
||||
}
|
||||
for(QRecord connection : SlackStore.queryAsCurrent(SlackTables.CONNECTION, new QQueryFilter()))
|
||||
{
|
||||
java.time.Instant checked = connection.getValueInstant("lastVerifiedAt");
|
||||
if("ACTIVE".equals(connection.getValueString("status")) && (checked == null || checked.isBefore(SlackRuntime.clock().instant().minusSeconds(86400))))
|
||||
{
|
||||
connection(connection.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed diagnostics only.
|
||||
*******************************************************************************/
|
||||
private static SlackApiReadinessCheck check(String name, boolean ready, String summary)
|
||||
{
|
||||
return (new SlackApiReadinessCheck(name, ready ? "READY" : "ERROR", summary));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate without discarding individual checks.
|
||||
*******************************************************************************/
|
||||
public static SlackApiReadiness result(List<SlackApiReadinessCheck> checks)
|
||||
{
|
||||
return (new SlackApiReadiness(checks.stream().allMatch(c -> "READY".equals(c.status()) || "WARN".equals(c.status())), checks));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.security;
|
||||
|
||||
import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
|
||||
/*******************************************************************************
|
||||
** Allow-listed secret lookup. Diagnostics never contain configuration values.
|
||||
*******************************************************************************/
|
||||
public final class SlackSecrets
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackSecrets()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a permitted environment name, failing closed.
|
||||
*******************************************************************************/
|
||||
public static String get(SlackQBitConfig config, String name)
|
||||
{
|
||||
if(config == null || name == null || !config.getAllowedEnvNames().contains(name))
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack secret unavailable"));
|
||||
}
|
||||
String value = config.getSecretResolver().apply(name);
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack secret unavailable"));
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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.template;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate all template writes, including sparse updates through the UI API.
|
||||
*******************************************************************************/
|
||||
public class SlackTemplateCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Merge existing content before validation; never echo malformed input.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord effective = new QRecord();
|
||||
oldRecords.ifPresent(old -> old.stream().filter(r -> java.util.Objects.equals(r.getValue("id"), record.getValue("id")))
|
||||
.findFirst().ifPresent(r -> effective.getValues().putAll(r.getValues())));
|
||||
effective.getValues().putAll(record.getValues());
|
||||
try
|
||||
{
|
||||
SlackTemplates.validate(effective);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Invalid or restricted Slack template"));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* 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.template;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small parsed template language: values, fallback, if and each, without code.
|
||||
*******************************************************************************/
|
||||
public class SlackTemplateEngine
|
||||
{
|
||||
/*******************************************************************************
|
||||
** One budget shared by all parts and nested blocks of a render.
|
||||
*******************************************************************************/
|
||||
public static class Budget
|
||||
{
|
||||
private int iterations;
|
||||
private int bytes;
|
||||
|
||||
/*******************************************************************************
|
||||
** Count work before entering a loop body.
|
||||
*******************************************************************************/
|
||||
private void iterate()
|
||||
{
|
||||
if(++iterations > 10_000)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template output is too large"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound UTF-8 output before appending it.
|
||||
*******************************************************************************/
|
||||
private void append(StringBuilder out, String value)
|
||||
{
|
||||
if(value.length() > 1_048_576 || (bytes += value.getBytes(StandardCharsets.UTF_8).length) > 1_048_576)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template output is too large"));
|
||||
}
|
||||
out.append(value);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rendering context. Markdown uses entities for all punctuation so values
|
||||
** cannot inject Markdown links, images or block syntax.
|
||||
*******************************************************************************/
|
||||
public enum Context
|
||||
{
|
||||
MRKDWN, JSON_STRING
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A parsed literal, expression or block.
|
||||
*******************************************************************************/
|
||||
private record Node(String kind, String value, String fallback, int line, List<Node> children, String attribute)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validated immutable parse result.
|
||||
*******************************************************************************/
|
||||
public static class Parsed
|
||||
{
|
||||
private final List<Node> nodes;
|
||||
private final Set<String> references;
|
||||
|
||||
/*******************************************************************************
|
||||
** Store parsed nodes.
|
||||
*******************************************************************************/
|
||||
private Parsed(List<Node> nodes, Set<String> references)
|
||||
{
|
||||
this.nodes = nodes;
|
||||
this.references = Set.copyOf(references);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the already validated syntax tree.
|
||||
*******************************************************************************/
|
||||
public String render(Map<String, ?> values, Context context)
|
||||
{
|
||||
return (render(values, context, new Budget(), new ArrayList<>()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bindings referenced by values and block conditions, including nested blocks.
|
||||
*******************************************************************************/
|
||||
public Set<String> references()
|
||||
{
|
||||
return (references);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render using the message-wide budget and safe author-facing warnings.
|
||||
*******************************************************************************/
|
||||
public String render(Map<String, ?> values, Context context, Budget budget, List<String> warnings)
|
||||
{
|
||||
StringBuilder out = new StringBuilder();
|
||||
renderNodes(nodes, values, null, -1, context, budget, warnings, out);
|
||||
return (out.toString());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse once, checking balanced blocks and every variable, with line numbers.
|
||||
*******************************************************************************/
|
||||
public Parsed parse(String source, VariableSchema schema)
|
||||
{
|
||||
source = source == null ? "" : source;
|
||||
List<Node> root = new ArrayList<>();
|
||||
Deque<Node> stack = new ArrayDeque<>();
|
||||
Set<String> references = new LinkedHashSet<>();
|
||||
int position = 0;
|
||||
int line = 1;
|
||||
while(position < source.length())
|
||||
{
|
||||
List<Node> current = stack.isEmpty() ? root : stack.peek().children();
|
||||
int start = source.indexOf("{{", position);
|
||||
if(start < 0)
|
||||
{
|
||||
current.add(new Node("literal", source.substring(position), null, 0, List.of(), null));
|
||||
break;
|
||||
}
|
||||
current.add(new Node("literal", source.substring(position, start), null, 0, List.of(), null));
|
||||
for(int i = position; i < start; i++)
|
||||
{
|
||||
if(source.charAt(i) == '\n')
|
||||
{
|
||||
line++;
|
||||
}
|
||||
}
|
||||
int end = source.indexOf("}}", start + 2);
|
||||
if(end < 0)
|
||||
{
|
||||
throw (error(line, "Unclosed expression"));
|
||||
}
|
||||
String expression = source.substring(start + 2, end).trim();
|
||||
if(expression.startsWith("/"))
|
||||
{
|
||||
if(stack.isEmpty() || !stack.peek().kind().equals(expression.substring(1)))
|
||||
{
|
||||
throw (error(line, "Block nesting error"));
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
else
|
||||
{
|
||||
String kind = expression.startsWith("#if ") ? "if" : expression.startsWith("#each ") ? "each" : "value";
|
||||
String binding = kind.equals("value") ? expression : expression.substring(kind.length() + 2).trim();
|
||||
String[] parts = binding.split("\\|", 2);
|
||||
String name = parts[0].trim();
|
||||
boolean inEach = stack.stream().anyMatch(n -> n.kind().equals("each"));
|
||||
boolean local = name.equals("this") || name.startsWith("this.") || name.equals("@index");
|
||||
if(!name.matches("(?:[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*|@index)")
|
||||
|| (local ? !inEach : !schema.knows(name)) || !kind.equals("value") && parts.length > 1)
|
||||
{
|
||||
throw (error(line, "Unknown or unsupported variable"));
|
||||
}
|
||||
if(!local)
|
||||
{
|
||||
references.add(name);
|
||||
}
|
||||
Node node = new Node(kind, name, parts.length == 2 ? parts[1] : "", line, new ArrayList<>(), null);
|
||||
current.add(node);
|
||||
if(!kind.equals("value"))
|
||||
{
|
||||
if(stack.size() >= 8)
|
||||
{
|
||||
throw (error(line, "Blocks exceed 8 levels"));
|
||||
}
|
||||
stack.push(node);
|
||||
}
|
||||
}
|
||||
for(int i = start; i < end + 2; i++)
|
||||
{
|
||||
if(source.charAt(i) == '\n')
|
||||
{
|
||||
line++;
|
||||
}
|
||||
}
|
||||
position = end + 2;
|
||||
}
|
||||
if(!stack.isEmpty())
|
||||
{
|
||||
throw (error(stack.peek().line(), "Unclosed " + stack.peek().kind() + " block"));
|
||||
}
|
||||
return (new Parsed(root, references));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render syntax nodes; loop scope is restored on every recursive return.
|
||||
*******************************************************************************/
|
||||
private static void renderNodes(List<Node> nodes, Map<String, ?> values, Object item, int index, Context context, Budget budget, List<String> warnings, StringBuilder out)
|
||||
{
|
||||
for(Node node : nodes)
|
||||
{
|
||||
Object value = node.value().equals("this") ? item : node.value().startsWith("this.") ? lookup(item, node.value().substring(5))
|
||||
: node.value().equals("@index") ? index : lookup(values, node.value());
|
||||
switch(node.kind())
|
||||
{
|
||||
case "literal" -> budget.append(out, node.value());
|
||||
case "value" ->
|
||||
{
|
||||
String text = value == null || value.toString().isEmpty() ? node.fallback() : value.toString();
|
||||
if(text.length() > 1_048_576)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template output is too large"));
|
||||
}
|
||||
budget.append(out, encode(text, context));
|
||||
}
|
||||
case "if" ->
|
||||
{
|
||||
if(truthy(value))
|
||||
{
|
||||
renderNodes(node.children(), values, item, index, context, budget, warnings, out);
|
||||
}
|
||||
}
|
||||
case "each" ->
|
||||
{
|
||||
if(value instanceof List<?> list)
|
||||
{
|
||||
for(int i = 0; i < list.size(); i++)
|
||||
{
|
||||
budget.iterate();
|
||||
renderNodes(node.children(), values, list.get(i), i, context, budget, warnings, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
default -> throw (new IllegalStateException("Invalid template node"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Dotted paths traverse maps only; no reflection, methods or arbitrary code.
|
||||
*******************************************************************************/
|
||||
public static Object lookup(Object values, String path)
|
||||
{
|
||||
if(values instanceof Map<?, ?> map)
|
||||
{
|
||||
if(map.containsKey(path))
|
||||
{
|
||||
return (map.get(path));
|
||||
}
|
||||
int dot = path.indexOf('.');
|
||||
return (dot < 0 ? map.get(path) : lookup(map.get(path.substring(0, dot)), path.substring(dot + 1)));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit language truthiness.
|
||||
*******************************************************************************/
|
||||
public static boolean truthy(Object value)
|
||||
{
|
||||
if(value == null || Boolean.FALSE.equals(value) || value instanceof CharSequence s && s.isEmpty()
|
||||
|| value instanceof Collection<?> c && c.isEmpty() || value instanceof Map<?, ?> m && m.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (!(value instanceof Number n) || new BigDecimal(n.toString()).compareTo(BigDecimal.ZERO) != 0);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape values exactly once for the consuming grammar.
|
||||
*******************************************************************************/
|
||||
public static String encode(String value, Context context)
|
||||
{
|
||||
String escaped = escape(value);
|
||||
if(context == Context.JSON_STRING)
|
||||
{
|
||||
String quoted = org.json.JSONObject.quote(escaped);
|
||||
return (quoted.substring(1, quoted.length() - 1));
|
||||
}
|
||||
return (escaped);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Slack interprets only these three characters as special markup.
|
||||
*******************************************************************************/
|
||||
public static String escape(Object value)
|
||||
{
|
||||
return (value == null ? "" : value.toString().replace("&", "&").replace("<", "<").replace(">", ">"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** JSON must remain an array and expressions may occur only in string values,
|
||||
** never keys or structural positions. Parse strings individually so loops
|
||||
** cannot cross JSON syntax boundaries or create additional blocks.
|
||||
*******************************************************************************/
|
||||
public String blocks(String source, VariableSchema schema, Map<String, ?> values, Budget budget)
|
||||
{
|
||||
if(source == null || source.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
if(source.length() >= 40_000)
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
if(!new com.fasterxml.jackson.databind.ObjectMapper().readTree(source).isArray())
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
org.json.JSONArray array = new org.json.JSONArray(source);
|
||||
if(array.length() > 50)
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
visit(array, schema, values, budget);
|
||||
String result = array.toString();
|
||||
if(result.length() >= 40_000)
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid Slack blocks template"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Walk JSON values, preserving their type and escaping variables once.
|
||||
*******************************************************************************/
|
||||
private void visit(Object node, VariableSchema schema, Map<String, ?> values, Budget budget)
|
||||
{
|
||||
if(node instanceof org.json.JSONObject object)
|
||||
{
|
||||
for(String key : object.keySet())
|
||||
{
|
||||
if(key.contains("{{"))
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
Object value = object.get(key);
|
||||
if(value instanceof String text)
|
||||
{
|
||||
object.put(key, parse(text, schema).render(values, Context.MRKDWN, budget, new ArrayList<>()));
|
||||
}
|
||||
else
|
||||
{
|
||||
visit(value, schema, values, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(node instanceof org.json.JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
Object value = array.get(i);
|
||||
if(value instanceof String text)
|
||||
{
|
||||
array.put(i, parse(text, schema).render(values, Context.MRKDWN, budget, new ArrayList<>()));
|
||||
}
|
||||
else
|
||||
{
|
||||
visit(value, schema, values, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable author-facing parse diagnostic.
|
||||
*******************************************************************************/
|
||||
private static IllegalArgumentException error(int line, String message)
|
||||
{
|
||||
return (new IllegalArgumentException("Line " + line + ": " + message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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.template;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendRequest;
|
||||
import com.kingsrook.qbits.slack.phi.SlackPayloadGuard;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Template validation, idempotent seeding and queue-time rendering.
|
||||
*******************************************************************************/
|
||||
public final class SlackTemplates
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Rendered content only: raw variables never reach the outbox.
|
||||
*******************************************************************************/
|
||||
public record Content(String text, String blocks)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackTemplates()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate syntax, declared variables, classification and JSON structure.
|
||||
*******************************************************************************/
|
||||
public static void validate(QRecord template)
|
||||
{
|
||||
VariableSchema schema = new VariableSchema(template.getValueString("variablesSchema"));
|
||||
SlackPayloadGuard.validateSchema(schema);
|
||||
SlackTemplateEngine engine = new SlackTemplateEngine();
|
||||
String text = template.getValueString("text");
|
||||
if(text != null && text.length() >= 40_000)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template too large"));
|
||||
}
|
||||
engine.parse(text, schema);
|
||||
engine.blocks(template.getValueString("blocksTemplate"), schema, Map.of(), new SlackTemplateEngine.Budget());
|
||||
SlackPayloadGuard.text(text, null);
|
||||
SlackPayloadGuard.text(template.getValueString("blocksTemplate"), null);
|
||||
SlackPayloadGuard.blocks(template.getValueString("blocksTemplate"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed once, preserving administrator edits. Caller must be a system or admin.
|
||||
*******************************************************************************/
|
||||
public static synchronized boolean ensure(QRecord template) throws QException
|
||||
{
|
||||
var session = com.kingsrook.qqq.backend.core.context.QContext.getQSession();
|
||||
if(!(session instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession) && !session.hasPermission("slack.admin"))
|
||||
{
|
||||
throw (new QException("Template administration is not permitted"));
|
||||
}
|
||||
boolean system = session instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
if(!system)
|
||||
{
|
||||
var config = com.kingsrook.qbits.slack.support.SlackRuntime.config();
|
||||
template.withValue("tenantKey", com.kingsrook.qbits.slack.oauth.SlackConnect.tenant(config, session));
|
||||
for(var lock : config.getRecordSecurityLocks())
|
||||
{
|
||||
template.withValue(lock.getFieldName(), session.getSecurityKeyValues(lock.getSecurityKeyType()).getFirst());
|
||||
}
|
||||
}
|
||||
validate(template);
|
||||
List<QRecord> existing = SlackStore.queryAsCurrent(SlackTables.TEMPLATE, SlackStore.equal("name", template.getValueString("name")));
|
||||
if(!existing.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QRecord result = SlackStore.insert(SlackTables.TEMPLATE, template.withValue("enabled", true), null);
|
||||
if(result.getValueInteger("id") == null || result.getErrors() != null && !result.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Template creation failed"));
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Built-in templates are seeded after storage is available at startup.
|
||||
*******************************************************************************/
|
||||
public static void seed() throws QException
|
||||
{
|
||||
String schema = "[{\"name\":\"title\",\"type\":\"STRING\"},{\"name\":\"body\",\"type\":\"STRING\"},"
|
||||
+ "{\"name\":\"link\",\"type\":\"URL\",\"required\":true},{\"name\":\"severity\",\"type\":\"STRING\"},"
|
||||
+ "{\"name\":\"context\",\"type\":\"STRING\"},{\"name\":\"items\",\"type\":\"LIST\"}]";
|
||||
String blocks = "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"{{severity|:information_source:}} *{{title}}*\\n{{body}}\"}},"
|
||||
+ "{\"type\":\"actions\",\"elements\":[{\"type\":\"button\",\"text\":{\"type\":\"plain_text\",\"text\":\"View\"},\"url\":\"{{link}}\"}]},"
|
||||
+ "{\"type\":\"context\",\"elements\":[{\"type\":\"mrkdwn\",\"text\":\"{{context|QQQ notification}}\"}]}]";
|
||||
for(String name : List.of("notification", "digest", "delivery"))
|
||||
{
|
||||
String text = "digest".equals(name) ? "*{{title}}*\n{{#each items}}{{@index}}. {{this.title}}\n{{/each}}{{link}}"
|
||||
: "*{{title}}*\n{{body}}\n{{link}}";
|
||||
com.kingsrook.qbits.slack.api.SlackApi.ensureTemplate(new com.kingsrook.qbits.slack.api.SlackApiTemplate(name, name, text,
|
||||
"notification".equals(name) ? blocks : null, schema, "TRANSACTIONAL"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard and render both posts and updates before constructing any stored row.
|
||||
*******************************************************************************/
|
||||
public static Content render(SlackSendRequest request) throws QException
|
||||
{
|
||||
String text = request.getText();
|
||||
String blocks = request.getBlocksJson();
|
||||
if("SECURITY".equals(request.getCategory()))
|
||||
{
|
||||
text = java.util.Objects.toString(request.getTitle(), "") + "\n" + java.util.Objects.toString(request.getLink(), "");
|
||||
SlackPayloadGuard.text(text, null);
|
||||
return (new Content(SlackTemplateEngine.escape(text), null));
|
||||
}
|
||||
SlackPayloadGuard.text(text, request.getContentClassification());
|
||||
SlackPayloadGuard.text(blocks, request.getContentClassification());
|
||||
SlackPayloadGuard.blocks(blocks);
|
||||
if(request.getTemplateName() == null)
|
||||
{
|
||||
if(request.getLinkedTableName() != null && request.getLinkedRecordId() != null)
|
||||
{
|
||||
String base = com.kingsrook.qbits.slack.support.SlackRuntime.config().getPublicBaseUrl();
|
||||
if(base != null)
|
||||
{
|
||||
text = java.util.Objects.toString(text, "") + "\n" + base.replaceAll("/+$", "") + "/"
|
||||
+ java.net.URLEncoder.encode(request.getLinkedTableName(), java.nio.charset.StandardCharsets.UTF_8) + "/"
|
||||
+ java.net.URLEncoder.encode(request.getLinkedRecordId(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return (new Content(SlackTemplateEngine.escape(text), blocks));
|
||||
}
|
||||
List<QRecord> templates = SlackStore.queryAsCurrent(SlackTables.TEMPLATE, SlackStore.equal("name", request.getTemplateName()));
|
||||
if(templates.size() != 1 || !Boolean.TRUE.equals(templates.getFirst().getValueBoolean("enabled")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack template unavailable"));
|
||||
}
|
||||
QRecord template = templates.getFirst();
|
||||
VariableSchema schema = new VariableSchema(template.getValueString("variablesSchema"));
|
||||
var values = schema.validate(SlackPayloadGuard.variables(schema, request.getVariables()));
|
||||
SlackTemplateEngine engine = new SlackTemplateEngine();
|
||||
SlackTemplateEngine.Budget budget = new SlackTemplateEngine.Budget();
|
||||
text = engine.parse(template.getValueString("text"), schema).render(values, SlackTemplateEngine.Context.MRKDWN, budget, new java.util.ArrayList<>());
|
||||
blocks = engine.blocks(template.getValueString("blocksTemplate"), schema, values, budget);
|
||||
SlackPayloadGuard.text(text, null);
|
||||
SlackPayloadGuard.text(blocks, null);
|
||||
SlackPayloadGuard.blocks(blocks);
|
||||
return (new Content(text, blocks));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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.template;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declarative variable contract. Validation errors never include input values.
|
||||
*******************************************************************************/
|
||||
public class VariableSchema
|
||||
{
|
||||
private final Map<String, Entry> entries = new LinkedHashMap<>();
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/*******************************************************************************
|
||||
** One schema binding, including optional record provenance.
|
||||
*******************************************************************************/
|
||||
public record Entry(String name, String label, String type, boolean required, String classification, String table, String field, boolean tokenize)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse the JSON list and reject ambiguous or unsupported declarations.
|
||||
*******************************************************************************/
|
||||
public VariableSchema(String json)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<Entry> parsed = JSON.readValue(json == null || json.isBlank() ? "[]" : json, new TypeReference<List<Entry>>() {});
|
||||
for(Entry entry : parsed)
|
||||
{
|
||||
if(entry.name() == null || !entry.name().matches("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*")
|
||||
|| !List.of("STRING", "NUMBER", "DATE", "DATE_TIME", "BOOLEAN", "URL", "LIST", "RECORD").contains(entry.type())
|
||||
|| entries.putIfAbsent(entry.name(), entry) != null
|
||||
|| entry.classification() != null && !List.of("PUBLIC", "CONFIDENTIAL", "PII", "PHI").contains(entry.classification())
|
||||
|| (entry.table() == null) != (entry.field() == null))
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid variables schema"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declared bindings in stable order.
|
||||
*******************************************************************************/
|
||||
public Map<String, Entry> entries()
|
||||
{
|
||||
return (Map.copyOf(entries));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Built-ins and paths under declared records are known to the parser.
|
||||
*******************************************************************************/
|
||||
public boolean knows(String name)
|
||||
{
|
||||
return (entries.containsKey(name) || entries.values().stream().anyMatch(e ->
|
||||
("RECORD".equals(e.type()) || "LIST".equals(e.type())) && name.startsWith(e.name() + ".")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate and coerce declared values without discarding record structure.
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> validate(Map<String, Serializable> values)
|
||||
{
|
||||
Map<String, Serializable> result = new LinkedHashMap<>(values);
|
||||
for(Entry entry : entries.values())
|
||||
{
|
||||
Object value = SlackTemplateEngine.lookup(values, entry.name());
|
||||
if(value == null || value instanceof String s && s.isEmpty())
|
||||
{
|
||||
if(entry.required())
|
||||
{
|
||||
throw (new IllegalArgumentException("Required variable " + entry.name() + " is missing"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
Serializable coerced = switch(entry.type())
|
||||
{
|
||||
case "STRING" -> value.toString();
|
||||
case "NUMBER" -> new BigDecimal(value.toString());
|
||||
case "DATE" -> LocalDate.parse(value.toString());
|
||||
case "DATE_TIME" -> Instant.parse(value.toString());
|
||||
case "BOOLEAN" -> booleanValue(value);
|
||||
case "URL" -> url(value.toString());
|
||||
case "LIST" -> (Serializable) (List<?>) value;
|
||||
case "RECORD" -> (Serializable) (Map<?, ?>) value;
|
||||
default -> throw (new IllegalArgumentException());
|
||||
};
|
||||
result.put(entry.name(), coerced);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Variable " + entry.name() + " must be " + entry.type()));
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict boolean coercion avoids silently turning misspellings into false.
|
||||
*******************************************************************************/
|
||||
private static Boolean booleanValue(Object value)
|
||||
{
|
||||
if(!List.of("true", "false").contains(value.toString().toLowerCase(java.util.Locale.ROOT)))
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
return (Boolean.valueOf(value.toString()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only absolute web URLs are allowed in variable and brand URL fields.
|
||||
*******************************************************************************/
|
||||
public static String url(String value)
|
||||
{
|
||||
return (url(value, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The same URL policy, optionally allowing mailto only for link destinations.
|
||||
*******************************************************************************/
|
||||
public static String url(String value, boolean allowMailto)
|
||||
{
|
||||
URI uri = URI.create(value);
|
||||
if(allowMailto && "mailto".equalsIgnoreCase(uri.getScheme()) && !uri.getRawSchemeSpecificPart().isBlank()
|
||||
&& !value.matches("(?is).*%(?:0a|0d).*"))
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
if(uri.getScheme() == null || !List.of("http", "https").contains(uri.getScheme().toLowerCase(java.util.Locale.ROOT)) || uri.getHost() == null || uri.getUserInfo() != null)
|
||||
{
|
||||
throw (new IllegalArgumentException("URL must use http or https"));
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse process inputs and sample values; never echo malformed JSON.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Serializable> values(String json)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, Serializable> result = JSON.readValue(json == null || json.isBlank() ? "{}" : json, new TypeReference<LinkedHashMap<String, Serializable>>() {});
|
||||
if(result == null)
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Variables must be a JSON object"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize safe snapshots using the existing Jackson dependency.
|
||||
*******************************************************************************/
|
||||
public static String json(Object value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JSON.writeValueAsString(value));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Could not serialize Slack variables"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,41 @@ public class SlackClient
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** OAuth exchange uses form encoding; secrets never enter a URL.
|
||||
*******************************************************************************/
|
||||
public SlackResponse oauthAccess(String clientId, String clientSecret, String code, String redirectUri)
|
||||
{
|
||||
return (form("oauth.v2.access", Map.of("client_id", clientId, "client_secret", clientSecret, "code", code, "redirect_uri", redirectUri)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Revoke only the selected user's token.
|
||||
*******************************************************************************/
|
||||
public SlackResponse authRevoke(String token)
|
||||
{
|
||||
return (post(token, "auth.revoke", new JSONObject()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove the selected workspace's installation.
|
||||
*******************************************************************************/
|
||||
public SlackResponse appsUninstall(String token, String clientId, String clientSecret)
|
||||
{
|
||||
return (form("apps.uninstall", Map.of("token", token, "client_id", clientId, "client_secret", clientSecret)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Credentials are sent in a form body, never in query strings.
|
||||
*******************************************************************************/
|
||||
private SlackResponse form(String method, Map<String, String> values)
|
||||
{
|
||||
String body = values.entrySet().stream().map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "="
|
||||
+ URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)).collect(java.util.stream.Collectors.joining("&"));
|
||||
return (send(HttpRequest.newBuilder().uri(URI.create(baseUrl + "/" + method)).timeout(requestTimeout)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofString(body))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor: the base URL and the connect and request timeouts come from
|
||||
** the config.
|
||||
|
||||
@@ -295,6 +295,11 @@ public class FakeSlackServer implements AutoCloseable
|
||||
|
||||
switch(request.method())
|
||||
{
|
||||
case "oauth.v2.access":
|
||||
return (Reply.error("invalid_code"));
|
||||
case "auth.revoke":
|
||||
case "apps.uninstall":
|
||||
return (Reply.of(200, "{\"ok\":true}"));
|
||||
case "chat.postMessage":
|
||||
return (Reply.of(200, new JSONObject().put("ok", true).put("channel", request.json().optString("channel")).put("ts", "1700000000." + String.format("%06d", tsCount.incrementAndGet())).toString()));
|
||||
case "chat.update":
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.api.SlackApi;
|
||||
import com.kingsrook.qbits.slack.api.SlackApiRequest;
|
||||
import com.kingsrook.qbits.slack.api.SlackApiTemplate;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackConnect;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackOAuthState;
|
||||
import com.kingsrook.qbits.slack.readiness.SlackHealth;
|
||||
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** OAuth identity, replay, scope, revocation and public API integration checks.
|
||||
*******************************************************************************/
|
||||
class SlackConnectApiTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Complete configured environment without real credentials.
|
||||
*******************************************************************************/
|
||||
private SlackTestEnv environment(boolean tenants) throws Exception
|
||||
{
|
||||
return (SlackTestEnv.create(tenants, c -> c.withPublicBaseUrl("https://example.test").withSecretResolver(n ->
|
||||
"SLACK_TOKEN_KEY".equals(n) ? SlackTokenCipherTest.KEY_ONE : "fake-application-secret")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract the signed state parameter without modifying its bytes.
|
||||
*******************************************************************************/
|
||||
private String state(String url)
|
||||
{
|
||||
return (java.util.Arrays.stream(URI.create(url).getRawQuery().split("&")).filter(p -> p.startsWith("state="))
|
||||
.map(p -> URLDecoder.decode(p.substring(6), StandardCharsets.UTF_8)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact user/tenant binding, expiry, signature rejection and single use.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stateBinding() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment(false))
|
||||
{
|
||||
env.asOwner("alice");
|
||||
var session = QContext.getQSession();
|
||||
String signed = SlackOAuthState.issue(env.config(), session, "alice", "1", "user");
|
||||
assertThatThrownBy(() -> SlackOAuthState.consume(env.config(), session, signed, "bob", "1")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> SlackOAuthState.consume(env.config(), session, signed, "alice", "2")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> SlackOAuthState.consume(env.config(), session, signed + "x", "alice", "1")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(SlackOAuthState.consume(env.config(), session, signed, "alice", "1").mode()).isEqualTo("user");
|
||||
assertThatThrownBy(() -> SlackOAuthState.consume(env.config(), session, signed, "alice", "1")).isInstanceOf(IllegalArgumentException.class);
|
||||
String expired = SlackOAuthState.issue(env.config(), session, "alice", "1", "user");
|
||||
env.clock().advance(Duration.ofMinutes(10));
|
||||
assertThatThrownBy(() -> SlackOAuthState.consume(env.config(), session, expired, "alice", "1")).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The exchange creates row-bound encrypted tokens, rejects replay, and
|
||||
** disconnect/uninstall clear only the selected credentials.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void connectAndRevoke() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment(true); LogCapture logs = new LogCapture())
|
||||
{
|
||||
SlackTestEnv.TestAuthCustomizer.TENANTS.put("alice", 1);
|
||||
env.asOwner("alice");
|
||||
QContext.getQSession().withPermissions("slack.connect", "slack.workspace.install", "slack.admin");
|
||||
String url = SlackConnect.startUrl("alice", "user+bot");
|
||||
assertThat(url).contains("user_scope=", "scope=", "redirect_uri=");
|
||||
env.slack().script("oauth.v2.access", FakeSlackServer.Reply.of(200,
|
||||
"{\"ok\":true,\"team\":{\"id\":\"T1\",\"name\":\"Workspace\"},\"access_token\":\"xoxb-OAUTH-CANARY\",\"bot_user_id\":\"UBOT\",\"scope\":\"chat:write\",\"authed_user\":{\"id\":\"UA\",\"access_token\":\"xoxp-OAUTH-CANARY\"}}"));
|
||||
String signed = state(url);
|
||||
assertThat(SlackConnect.callback(signed, "authorization-code")).isNull();
|
||||
assertThat(SlackConnect.callback(signed, "authorization-code")).isEqualTo("connect_failed");
|
||||
List<QRecord> connections = SlackStore.queryAsCurrent(SlackTables.CONNECTION, new QQueryFilter());
|
||||
assertThat(connections).hasSize(1);
|
||||
Integer id = connections.getFirst().getValueInteger("id");
|
||||
Integer workspaceId = connections.getFirst().getValueInteger("workspaceId");
|
||||
assertThat(SlackStore.getWithToken(SlackTables.CONNECTION, id).getValueString("userToken")).startsWith("v1:").doesNotContain("OAUTH-CANARY");
|
||||
assertThat(SlackStore.getWithToken(SlackTables.WORKSPACE, workspaceId).getValueString("botToken")).startsWith("v1:");
|
||||
assertThat(SlackStore.get(SlackTables.WORKSPACE, workspaceId).getValueInteger("tenantId")).isEqualTo(1);
|
||||
assertThat(env.slack().requests("oauth.v2.access")).hasSize(1);
|
||||
assertThat(env.slack().requests("oauth.v2.access").getFirst().query()).isNull();
|
||||
assertThat(SlackApi.connectionsFor("alice")).hasSize(1);
|
||||
assertThatThrownBy(() -> SlackApi.connectionsFor("bob")).isInstanceOf(IllegalArgumentException.class);
|
||||
SlackConnect.disconnect(id);
|
||||
assertThat(SlackStore.getWithToken(SlackTables.CONNECTION, id).getValueString("userToken")).isNull();
|
||||
SlackConnect.uninstall(workspaceId);
|
||||
assertThat(SlackStore.getWithToken(SlackTables.WORKSPACE, workspaceId).getValueString("botToken")).isNull();
|
||||
assertThat(logs.lines().toString()).doesNotContain("OAUTH-CANARY", "fake-application-secret");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Denied installation and scope/identity failures never report ready.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readinessAndHealth() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment(false))
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int connection = env.connection("alice", workspace, "UA", "xoxp-test");
|
||||
env.asOwner("alice");
|
||||
assertThatThrownBy(() -> SlackConnect.startUrl("alice", "user+bot")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(SlackApi.readiness(env.instance()).ready()).isTrue();
|
||||
assertThat(new SlackHealth().getName()).isEqualTo("slack");
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("UP");
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\"}", Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES)));
|
||||
assertThat(SlackReadiness.workspace(workspace).ready()).isTrue();
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\",\"user_id\":\"UA\"}", Map.of("x-oauth-scopes", SlackConnect.USER_SCOPES)));
|
||||
assertThat(SlackReadiness.connection(connection).status()).isEqualTo("READY");
|
||||
env.channel(workspace, "registered", "C1", "general");
|
||||
env.asOwner("alice");
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\"}", Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES)));
|
||||
env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{\"is_archived\":false,\"is_member\":false}}"));
|
||||
assertThat(SlackReadiness.workspace(workspace).checks()).anyMatch(c -> c.name().equals("registeredChannels") && c.status().equals("ERROR"));
|
||||
env.slack().script("auth.test", FakeSlackServer.Reply.error("token_revoked"));
|
||||
assertThat(SlackReadiness.connection(connection).status()).isEqualTo("ERROR");
|
||||
assertThat(SlackReadiness.workspace(workspace).ready()).isFalse();
|
||||
assertThat(SlackApi.readinessFor("alice").ready()).isFalse();
|
||||
env.asSystem();
|
||||
SlackReadiness.run();
|
||||
assertThat(SlackStore.get(SlackTables.WORKSPACE, workspace).getValueString("readinessStatus")).isEqualTo("FAIL");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable requests preserve outbox semantics and metadata-only reads.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void publicApi() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment(false))
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
env.connection("alice", workspace, "UA", "xoxp-test");
|
||||
assertThat(SlackApi.isInstalled(env.instance())).isTrue();
|
||||
assertThat(SlackApi.isInstalled(null)).isFalse();
|
||||
assertThat(SlackApi.ensureTemplate(new SlackApiTemplate("greeting", "Greeting", "Hello {{name}}", null,
|
||||
"[{\"name\":\"name\",\"type\":\"STRING\"}]", "TRANSACTIONAL"))).isTrue();
|
||||
env.asOwner("alice");
|
||||
var request = new SlackApiRequest().withTo("me").withWorkspaceId(workspace).withOwnerUserId("alice")
|
||||
.withText("private-body").withIdempotencyKey("test:1").withCategory("TRANSACTIONAL").withPostAs("BOT")
|
||||
.withSenderName("test");
|
||||
var sent = SlackApi.send(request);
|
||||
assertThat(sent.status()).isEqualTo("QUEUED");
|
||||
assertThat(SlackApi.getMessage(sent.messageId()).orElseThrow().toString()).doesNotContain("private-body", "xoxb");
|
||||
assertThat(SlackApi.getMessageByIdempotencyKey("test:1")).isPresent();
|
||||
assertThat(SlackApi.findMessagesByIdempotencyKeyPrefix("test:", 5)).hasSize(1);
|
||||
assertThat(SlackApi.reply(sent.messageId(), new SlackApiRequest().withText("reply")).status()).isEqualTo("QUEUED");
|
||||
assertThat(SlackApi.sendOnce(request, "conflict").outcome()).isEqualTo("REFUSED");
|
||||
assertThat(SlackApi.sendOnce(request.withIdempotencyKey(null), "effect:1").outcome()).isEqualTo("QUEUED_OR_DUPLICATE");
|
||||
env.dispatch();
|
||||
env.asOwner("alice");
|
||||
assertThat(SlackApi.update(sent.messageId(), new SlackApiRequest().withText("updated")).status()).isEqualTo("QUEUED");
|
||||
assertThat(SlackApi.getMessage(null)).isEmpty();
|
||||
assertThat(SlackApi.getMessageByIdempotencyKey(null)).isEmpty();
|
||||
assertThatThrownBy(() -> SlackApi.findMessagesByIdempotencyKeyPrefix("", 0)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(SlackApi.send(null).status()).isEqualTo("REFUSED");
|
||||
assertThat(SlackApi.reply(null, null).status()).isEqualTo("REFUSED");
|
||||
assertThat(SlackApi.sendOnce(null, null).outcome()).isEqualTo("REFUSED");
|
||||
env.slack().members("C1", "UA", "UBOT");
|
||||
env.slack().members("C2", "OTHER", "UBOT");
|
||||
env.slack().script("users.conversations", FakeSlackServer.Reply.of(200,
|
||||
"{\"ok\":true,\"channels\":[{\"id\":\"C1\",\"name\":\"mine\"},{\"id\":\"C2\",\"name\":\"other\"}]}"));
|
||||
assertThat(SlackApi.listTargets("alice", workspace)).extracting("address").containsExactly("me", "channel:" + workspace + "/C1");
|
||||
assertThat(SlackApi.listTargets("alice", -1)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A callback on another pod needs only the signed state and host session.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stateAcrossPods() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment(false))
|
||||
{
|
||||
var session = env.asOwner("alice");
|
||||
String signed = SlackOAuthState.issue(env.config(), session, "alice", "1", "user");
|
||||
var field = SlackOAuthState.class.getDeclaredField("NONCES");
|
||||
field.setAccessible(true);
|
||||
((Map<?, ?>) field.get(null)).clear();
|
||||
var other = env.asOwner("alice");
|
||||
assertThatThrownBy(() -> SlackOAuthState.consume(env.config(), other, signed, "alice", "1")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(SlackOAuthState.consume(env.config(), session, signed, "alice", "1").userId()).isEqualTo("alice");
|
||||
assertThatThrownBy(() -> SlackOAuthState.consume(env.config(), session, signed, "alice", "1")).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefix validation independently rejects every invalid argument.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invalidPrefixes() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment(false))
|
||||
{
|
||||
assertThatThrownBy(() -> SlackApi.findMessagesByIdempotencyKeyPrefix(null, 1)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> SlackApi.findMessagesByIdempotencyKeyPrefix(" ", 1)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> SlackApi.findMessagesByIdempotencyKeyPrefix("a", 0)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> SlackApi.findMessagesByIdempotencyKeyPrefix("a", 1001)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(SlackApi.isInstalled(new com.kingsrook.qqq.backend.core.model.metadata.QInstance())).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Seeding twice preserves an administrator's edited template.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void templatePreservesEdits() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment(false))
|
||||
{
|
||||
var seed = new SlackApiTemplate("seed", "Seed", "original", null, "[]", "TRANSACTIONAL");
|
||||
assertThat(SlackApi.ensureTemplate(seed)).isTrue();
|
||||
var row = SlackStore.query(SlackTables.TEMPLATE, SlackStore.equal("name", "seed")).getFirst();
|
||||
SlackStore.update(SlackTables.TEMPLATE, new QRecord().withValue("id", row.getValueInteger("id")).withValue("text", "admin edit"), null);
|
||||
assertThat(SlackApi.ensureTemplate(seed)).isFalse();
|
||||
assertThat(SlackStore.get(SlackTables.TEMPLATE, row.getValueInteger("id")).getValueString("text")).isEqualTo("admin edit");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Another tenant's real workspace cannot be used as a target picker.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void targetsRespectTenants() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment(true))
|
||||
{
|
||||
SlackTestEnv.TestAuthCustomizer.TENANTS.put("alice", 1);
|
||||
int own = env.workspace("T1", 1, "xoxb-own", "UBOT");
|
||||
int other = env.workspace("T2", 2, "xoxb-other", "UBOT");
|
||||
env.connection("alice", own, "UA", "xoxp-own");
|
||||
env.asOwner("alice");
|
||||
assertThat(SlackApi.listTargets("alice", other)).isEmpty();
|
||||
assertThat(SlackApi.connectionsFor("alice")).hasSize(1);
|
||||
assertThat(SlackApi.connectionsFor("alice").toString()).doesNotContain("xoxp", "xoxb", "Token");
|
||||
assertThat(java.util.Arrays.stream(com.kingsrook.qbits.slack.api.SlackApiConnection.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
|
||||
.containsExactly("id", "workspaceId", "workspaceLabel", "teamId", "status");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
import java.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.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackOAuthState;
|
||||
import com.kingsrook.qbits.slack.processes.SlackProcesses;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Real HTTP callback routing and streamed action Review/Result boundaries.
|
||||
*******************************************************************************/
|
||||
class SlackOAuthRouteTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Test host resumes a session identity, rebuilding the QSession object.
|
||||
*******************************************************************************/
|
||||
public static class ResumeSession implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
static QSession original;
|
||||
|
||||
/*******************************************************************************
|
||||
** Rehydrate only the matching session cookie.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
if(original != null && original.getUuid().equals(session.getUuid()))
|
||||
{
|
||||
session.setUser(original.getUser());
|
||||
session.withPermissions(original.getPermissions());
|
||||
session.setSecurityKeyValues(original.getSecurityKeyValues());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** GET callback requires a cookie, consumes the state once and redirects
|
||||
** with fixed messages. Slack credentials never appear in response headers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void browserCallback() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl("https://example.test")
|
||||
.withSecretResolver(n -> "SLACK_TOKEN_KEY".equals(n) ? SlackTokenCipherTest.KEY_ONE : "fake-secret")))
|
||||
{
|
||||
env.asOwner("alice");
|
||||
QSession session = QContext.getQSession().withPermissions("slack.connect");
|
||||
ResumeSession.original = session;
|
||||
env.instance().getAuthentication().withCustomizer(new QCodeReference(ResumeSession.class));
|
||||
String signed = SlackOAuthState.issue(env.config(), session, "alice", null, "user");
|
||||
SlackOAuthRouteProvider provider = new SlackOAuthRouteProvider();
|
||||
provider.setQInstance(env.instance());
|
||||
provider.acceptJavalinService(null);
|
||||
Javalin app = Javalin.create(provider::acceptJavalinConfig).start(0);
|
||||
try
|
||||
{
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
URI uri = URI.create("http://127.0.0.1:" + app.port() + env.config().getOauthCallbackPath() + "?code=code&state=" + URLEncoder.encode(signed, StandardCharsets.UTF_8));
|
||||
var unsigned = client.send(HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(unsigned.statusCode()).isEqualTo(302);
|
||||
assertThat(unsigned.headers().firstValue("location").orElseThrow()).endsWith("&error=sign_in_required");
|
||||
env.slack().script("oauth.v2.access", FakeSlackServer.Reply.of(200,
|
||||
"{\"ok\":true,\"team\":{\"id\":\"T1\"},\"authed_user\":{\"id\":\"UA\",\"access_token\":\"xoxp-route-token\"}}"));
|
||||
var request = HttpRequest.newBuilder(uri).header("Cookie", "sessionUUID=" + session.getUuid()).GET().build();
|
||||
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/preferences?section=slack&result=connected");
|
||||
assertThat(response.headers().map().toString()).doesNotContain("xoxp-route-token", "fake-secret");
|
||||
assertThat(client.send(request, HttpResponse.BodyHandlers.ofString()).headers().firstValue("location").orElseThrow()).endsWith("&error=connect_failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
app.stop();
|
||||
ResumeSession.original = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review never revokes; Result rechecks and revokes the selected owner row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recordAction() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
|
||||
{
|
||||
}))
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int connection = env.connection("alice", workspace, "UA", "xoxp-test");
|
||||
env.asOwner("alice");
|
||||
QContext.getQSession().withPermissions("slack.connect");
|
||||
RunBackendStepInput input = new RunBackendStepInput().withProcessName(SlackProcesses.DISCONNECT);
|
||||
input.setRecords(List.of(new QRecord().withValue("id", connection).withValue("userId", "alice")));
|
||||
RunBackendStepOutput review = new RunBackendStepOutput().withRecords(new ArrayList<>());
|
||||
SlackProcesses.ActionTransform transform = new SlackProcesses.ActionTransform();
|
||||
transform.runOnePage(input, review);
|
||||
assertThat(review.getRecords()).hasSize(1);
|
||||
assertThat(transform.getProcessSummary(review, false)).hasSize(1);
|
||||
assertThat(env.slack().requests("auth.revoke")).isEmpty();
|
||||
RunBackendStepOutput result = new RunBackendStepOutput().withRecords(new ArrayList<>());
|
||||
SlackProcesses.ActionLoad load = new SlackProcesses.ActionLoad();
|
||||
load.runOnePage(input, result);
|
||||
assertThat(load.getProcessSummary(result, true)).hasSize(1);
|
||||
assertThat(SlackStore.get(SlackTables.CONNECTION, connection).getValueString("status")).isEqualTo("REVOKED");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackConnect;
|
||||
import com.kingsrook.qbits.slack.processes.SlackProcesses;
|
||||
import com.kingsrook.qbits.slack.readiness.SlackHealth;
|
||||
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Focused readiness failures and record actions against real database rows.
|
||||
*******************************************************************************/
|
||||
class SlackReadinessTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Configure application secrets without real credentials.
|
||||
*******************************************************************************/
|
||||
private SlackTestEnv environment() throws Exception
|
||||
{
|
||||
return (SlackTestEnv.create(false, c -> c.withSecretResolver(n -> "SLACK_TOKEN_KEY".equals(n) ? SlackTokenCipherTest.KEY_ONE : "test")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing encryption key fails the application check.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingKey() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
env.config().withSecretResolver(n -> "SLACK_TOKEN_KEY".equals(n) ? null : "test");
|
||||
assertThat(SlackReadiness.deployment(env.instance()).checks()).anyMatch(c -> c.name().equals("app") && c.status().equals("ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A valid Base64 string still needs exactly 32 decoded bytes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void wrongKeyLength() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
env.config().withSecretResolver(n -> "SLACK_TOKEN_KEY".equals(n) ? "YQ==" : "test");
|
||||
assertThat(SlackReadiness.deployment(env.instance()).ready()).isFalse();
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DOWN");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Required scopes fail, optional scopes warn, and full scopes are ready.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void workspaceScopes() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int id = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
for(String scopes : List.of(SlackConnect.BOT_SCOPES + ",usergroups:read,chat:write.customize", SlackConnect.BOT_SCOPES, "channels:read"))
|
||||
{
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\"}", Map.of("x-oauth-scopes", scopes)));
|
||||
String expected = scopes.equals("channels:read") ? "ERROR" : scopes.equals(SlackConnect.BOT_SCOPES) ? "WARN" : "READY";
|
||||
assertThat(SlackReadiness.workspace(id).checks()).anyMatch(c -> c.name().equals("workspace:" + id) && c.status().equals(expected));
|
||||
assertThat(SlackStore.get(SlackTables.WORKSPACE, id).getValueString("readinessStatus")).isEqualTo(expected.equals("ERROR") ? "FAIL" : "OK");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid authentication marks the persisted workspace readiness as failed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invalidAuth() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int id = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
env.slack().script("auth.test", FakeSlackServer.Reply.error("invalid_auth"));
|
||||
assertThat(SlackReadiness.workspace(id).ready()).isFalse();
|
||||
assertThat(SlackStore.get(SlackTables.WORKSPACE, id).getValueString("readinessStatus")).isEqualTo("FAIL");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Archived channels and absent bot membership each fail independently.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableChannels() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int id = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
env.channel(id, "registered", "C1", "general");
|
||||
for(String flags : List.of("\"is_archived\":true,\"is_member\":true", "\"is_archived\":false,\"is_member\":false"))
|
||||
{
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\"}", Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES)));
|
||||
env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{" + flags + "}}"));
|
||||
assertThat(SlackReadiness.workspace(id).checks()).anyMatch(c -> c.name().equals("registeredChannels") && c.status().equals("ERROR"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A recent dead letter degrades deployment health until the window passes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recentDeadLetter() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
assertThat(SlackReadiness.deployment(env.instance()).ready()).isTrue();
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("UP");
|
||||
SlackH2Support.insert(SlackTables.MESSAGE, new QRecord().withValue("workspaceId", env.workspace("T1", null, "xoxb-test", "UBOT")).withValue("ownerUserId", "alice").withValue("status", "DEAD_LETTER")
|
||||
.withValue("deadLetterDate", env.clock().instant()));
|
||||
assertThat(SlackReadiness.deployment(env.instance()).checks()).anyMatch(c -> c.name().equals("outbox") && c.status().equals("ERROR"));
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DEGRADED");
|
||||
env.clock().advance(java.time.Duration.ofHours(2));
|
||||
assertThat(SlackReadiness.deployment(env.instance()).ready()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue age, not merely the presence of queued messages, determines readiness.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void staleQueue() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
QRecord queued = SlackH2Support.insert(SlackTables.MESSAGE, new QRecord().withValue("workspaceId", env.workspace("T1", null, "xoxb-test", "UBOT")).withValue("ownerUserId", "alice").withValue("status", "QUEUED"));
|
||||
java.time.Instant created = SlackStore.get(SlackTables.MESSAGE, queued.getValueInteger("id")).getValueInstant("createDate");
|
||||
env.clock().advance(java.time.Duration.between(env.clock().instant(), created));
|
||||
assertThat(SlackReadiness.deployment(env.instance()).ready()).isTrue();
|
||||
env.clock().advance(java.time.Duration.ofMinutes(11));
|
||||
assertThat(SlackReadiness.deployment(env.instance()).checks()).anyMatch(c -> c.name().equals("outbox") && c.status().equals("ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Uninitialized readiness maps to degraded health without network access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notReadyHealth() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
assertThat(SlackReadiness.stored(env.instance()).ready()).isFalse();
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DEGRADED");
|
||||
assertThat(env.slack().requests()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record actions persist readiness and revoke only their selected tokens.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void actions() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int connection = env.connection("alice", workspace, "UA", "xoxp-test");
|
||||
env.asOwner("alice").withPermissions("slack.admin", "slack.connect");
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\"}", Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES)));
|
||||
action(SlackProcesses.READINESS, workspace);
|
||||
QRecord row = SlackStore.get(SlackTables.WORKSPACE, workspace);
|
||||
assertThat(row.getValueString("readinessStatus")).isEqualTo("OK");
|
||||
assertThat(row.getValueInstant("readinessCheckedAt")).isEqualTo(env.clock().instant());
|
||||
assertThat(row.getValueString("readinessSummary")).isNotBlank();
|
||||
action(SlackProcesses.DISCONNECT, connection);
|
||||
assertThat(env.slack().requests("auth.revoke")).hasSize(1);
|
||||
assertThat(SlackStore.getWithToken(SlackTables.CONNECTION, connection).getValueString("userToken")).isNull();
|
||||
assertThat(SlackStore.get(SlackTables.CONNECTION, connection).getValueString("status")).isEqualTo("REVOKED");
|
||||
action(SlackProcesses.UNINSTALL, workspace);
|
||||
assertThat(SlackStore.getWithToken(SlackTables.WORKSPACE, workspace).getValueString("botToken")).isNull();
|
||||
assertThat(SlackStore.get(SlackTables.WORKSPACE, workspace).getValueString("status")).isEqualTo("UNINSTALLED");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the same load step invoked after the record-action review.
|
||||
*******************************************************************************/
|
||||
private void action(String name, int id) throws Exception
|
||||
{
|
||||
var input = new RunBackendStepInput().withProcessName(name);
|
||||
input.setRecords(List.of(new QRecord().withValue("id", id)));
|
||||
new SlackProcesses.ActionLoad().runOnePage(input, new RunBackendStepOutput().withRecords(new ArrayList<>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.api.SlackApi;
|
||||
import com.kingsrook.qbits.slack.api.SlackApiRequest;
|
||||
import com.kingsrook.qbits.slack.api.SlackApiTemplate;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendRequest;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackService;
|
||||
import com.kingsrook.qbits.slack.phi.SlackPayloadGuard;
|
||||
import com.kingsrook.qbits.slack.processes.SlackProcesses;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplateEngine;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplates;
|
||||
import com.kingsrook.qbits.slack.template.VariableSchema;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
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.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Parser budgets, schema validation, Block Kit safety and the PHI canary.
|
||||
*******************************************************************************/
|
||||
class SlackTemplateGuardTest
|
||||
{
|
||||
private static final String CANARY = "CANARY-PHI-7f3a";
|
||||
|
||||
/*******************************************************************************
|
||||
** Same language as email, with Slack escaping rather than HTML escaping.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void languageAndBudgets()
|
||||
{
|
||||
SlackTemplateEngine engine = new SlackTemplateEngine();
|
||||
VariableSchema schema = new VariableSchema("[{\"name\":\"name\",\"type\":\"STRING\"},{\"name\":\"items\",\"type\":\"LIST\"},{\"name\":\"record\",\"type\":\"RECORD\"}]");
|
||||
var parsed = engine.parse("{{name|friend}} {{#if record.ok}}{{record.name}}{{/if}} {{#each items}}{{@index}}:{{this}};{{/each}}", schema);
|
||||
assertThat(parsed.render(Map.of("record", Map.of("ok", true, "name", "<&>"), "items", List.of("a", "b")), SlackTemplateEngine.Context.MRKDWN))
|
||||
.isEqualTo("friend <&> 0:a;1:b;");
|
||||
assertThat(parsed.references()).contains("name", "record.ok", "items");
|
||||
assertThatThrownBy(() -> engine.parse("{{unknown}}", schema)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.parse("{{this}}", schema)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.parse("{{#if name}}", schema)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.parse("{{/if}}", schema)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.parse("{{name", schema)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.parse("{{#if name}}".repeat(9) + "{{/if}}".repeat(9), schema)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.parse("{{#each items}}x{{/each}}", schema)
|
||||
.render(Map.of("items", java.util.Collections.nCopies(10001, "x")), SlackTemplateEngine.Context.MRKDWN)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.parse("{{name}}", schema).render(Map.of("name", "x".repeat(1_048_577)), SlackTemplateEngine.Context.MRKDWN)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(engine.blocks("[{\"type\":\"section\",\"text\":\"{{name}}\"}]", schema, Map.of("name", "\"<@all>"), new SlackTemplateEngine.Budget()))
|
||||
.contains("<@all>");
|
||||
assertThatThrownBy(() -> engine.blocks("[{\"{{name}}\":1}]", schema, Map.of(), new SlackTemplateEngine.Budget())).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.blocks("[{\"text\":{{name}}}]", schema, Map.of(), new SlackTemplateEngine.Budget())).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.blocks("[" + "{},".repeat(50) + "{}]", schema, Map.of(), new SlackTemplateEngine.Budget())).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed coercion and malformed schema diagnostics never echo supplied data.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void schemaValidation()
|
||||
{
|
||||
VariableSchema schema = new VariableSchema("[{\"name\":\"n\",\"type\":\"NUMBER\",\"required\":true},{\"name\":\"b\",\"type\":\"BOOLEAN\"},{\"name\":\"u\",\"type\":\"URL\"}]");
|
||||
assertThat(schema.validate(Map.of("n", "12.5", "b", "true", "u", "https://example.test")).get("b")).isEqualTo(true);
|
||||
assertThatThrownBy(() -> schema.validate(Map.of())).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> schema.validate(Map.of("n", "bad"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> schema.validate(Map.of("n", "1", "b", "maybe"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> VariableSchema.url("javascript:alert(1)")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new VariableSchema("[{}]")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(VariableSchema.values("{\"a\":1}")).containsKey("a");
|
||||
assertThat(VariableSchema.json(Map.of("a", 1))).isEqualTo("{\"a\":1}");
|
||||
assertThatThrownBy(() -> VariableSchema.values("[]")).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A real fixture PHI column is never copied to Slack tables, wire or logs
|
||||
** through variables, linked records, free text, SECURITY or updates.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canaryNeverLeavesFixture() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl("https://example.test")); LogCapture logs = new LogCapture())
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
env.connection("alice", workspace, "UA", "xoxp-test");
|
||||
QFieldMetaData phi = new QFieldMetaData("phi", QFieldType.STRING)
|
||||
.withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
env.instance().addTable(new QTableMetaData().withName("patient_fixture").withBackendName("rdbms").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(phi));
|
||||
try(var connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) env.instance().getBackend("rdbms")))
|
||||
{
|
||||
connection.createStatement().execute("CREATE TABLE patient_fixture (id INT PRIMARY KEY, phi VARCHAR(200))");
|
||||
try(var insert = connection.prepareStatement("INSERT INTO patient_fixture VALUES (1, ?)"))
|
||||
{
|
||||
insert.setString(1, CANARY);
|
||||
insert.executeUpdate();
|
||||
}
|
||||
}
|
||||
assertThatThrownBy(() -> SlackPayloadGuard.blocks("[{\"text\":\"" + "\\u0031".repeat(3) + "-45-6789\"}]"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
String value = SlackStore.query("patient_fixture", new QQueryFilter()).getFirst().getValueString("phi");
|
||||
assertThat(value).isEqualTo(CANARY);
|
||||
SlackPayloadGuard.ClassifiedValue classified = new SlackPayloadGuard.ClassifiedValue(value, null, "patient_fixture", "phi");
|
||||
String bound = "[{\"name\":\"v\",\"type\":\"STRING\",\"table\":\"patient_fixture\",\"field\":\"phi\"}]";
|
||||
assertThatThrownBy(() -> SlackTemplates.validate(new QRecord().withValue("text", "{{v}}").withValue("variablesSchema", bound)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
QRecord rejected = SlackStore.insert(SlackTables.TEMPLATE, new QRecord().withValue("name", "bad").withValue("text", "{{v}}")
|
||||
.withValue("variablesSchema", bound), null);
|
||||
assertThat(rejected.getErrors()).isNotEmpty();
|
||||
String schema = "[{\"name\":\"v\",\"type\":\"STRING\",\"tokenize\":true}]";
|
||||
assertThat(SlackApi.ensureTemplate(new SlackApiTemplate("safe", "Safe", "{{v}}", null, schema, "TRANSACTIONAL"))).isTrue();
|
||||
assertThat(SlackApi.ensureTemplate(new SlackApiTemplate("safe", "Safe", "{{v}}", null, schema, "TRANSACTIONAL"))).isFalse();
|
||||
SlackApi.ensureTemplate(new SlackApiTemplate("refuse", "Refuse", "{{v}}", null, schema.replace("true", "false"), "TRANSACTIONAL"));
|
||||
new SlackProcesses.ReadinessStep().run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
env.asOwner("alice");
|
||||
var tokenized = SlackApi.send(new SlackApiRequest().withTo("me").withTemplateName("safe").withVariables(Map.of("v", classified)));
|
||||
assertThat(tokenized.status()).isEqualTo("QUEUED");
|
||||
var refused = SlackApi.send(new SlackApiRequest().withTo("me").withTemplateName("refuse").withVariables(Map.of("v", classified)));
|
||||
assertThat(refused.status()).isEqualTo("REFUSED");
|
||||
assertThat(refused.reasons().toString()).contains("variable: v");
|
||||
assertThat(SlackApi.send(new SlackApiRequest().withTo("me").withText(value).withContentClassification("PHI")).status()).isEqualTo("REFUSED");
|
||||
assertThat(SlackApi.send(new SlackApiRequest().withTo("me").withText("MRN:123456")).status()).isEqualTo("REFUSED");
|
||||
assertThat(SlackApi.send(new SlackApiRequest().withTo("me").withText("123-45-6789")).status()).isEqualTo("REFUSED");
|
||||
assertThat(SlackService.enqueue(new SlackSendRequest().withTo("me").withText("View record")
|
||||
.withLinkedRecord("patient_fixture", "1")).status().name()).isEqualTo("QUEUED");
|
||||
var security = SlackApi.send(new SlackApiRequest().withTo("me").withText(value).withBlocksJson("[]").withCategory("SECURITY")
|
||||
.withTitle("Security event").withLink("https://example.test/patient/1"));
|
||||
assertThat(security.status()).isEqualTo("QUEUED");
|
||||
QRecord securityPayload = SlackStore.get(SlackTables.MESSAGE, security.messageId());
|
||||
assertThat(securityPayload.getValueString("text")).endsWith("Security event\nhttps://example.test/patient/1").doesNotContain(CANARY);
|
||||
assertThat(securityPayload.getValueString("blocksJson")).isNull();
|
||||
env.dispatch();
|
||||
env.asOwner("alice");
|
||||
assertThat(SlackApi.update(tokenized.messageId(), new SlackApiRequest().withText(value).withContentClassification("PHI")).status()).isEqualTo("REFUSED");
|
||||
assertThat(SlackApi.update(tokenized.messageId(), new SlackApiRequest().withTemplateName("refuse").withVariables(Map.of("v", classified))).status()).isEqualTo("REFUSED");
|
||||
env.asSystem();
|
||||
try(var connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) env.instance().getBackend("rdbms")))
|
||||
{
|
||||
for(String table : env.instance().getTables().keySet())
|
||||
{
|
||||
if(table.startsWith("slack_"))
|
||||
{
|
||||
try(var rows = connection.createStatement().executeQuery("SELECT * FROM " + table))
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
for(int column = 1; column <= rows.getMetaData().getColumnCount(); column++)
|
||||
{
|
||||
assertThat(String.valueOf(rows.getObject(column))).doesNotContain(CANARY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(env.slack().requests().toString()).doesNotContain(CANARY).contains("changed:v1");
|
||||
assertThat(logs.lines().toString()).doesNotContain(CANARY);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Mention text remains inert and both source and rendered blocks are bounded.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mentionsAndBlockSize()
|
||||
{
|
||||
var engine = new SlackTemplateEngine();
|
||||
var schema = new VariableSchema("[{\"name\":\"v\",\"type\":\"STRING\"}]");
|
||||
assertThat(engine.parse("{{v}}", schema).render(Map.of("v", "& < > <!channel>"), SlackTemplateEngine.Context.MRKDWN))
|
||||
.isEqualTo("& < > <!channel>");
|
||||
assertThatThrownBy(() -> engine.blocks("[{\"text\":\"" + "x".repeat(40001) + "\"}]", schema, Map.of(), new SlackTemplateEngine.Budget()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.blocks("[{\"text\":\"{{v}}\"}]", schema, Map.of("v", "x".repeat(40001)), new SlackTemplateEngine.Budget()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Nested loops share an iteration budget and UTF-8 bytes share a size budget.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cumulativeBudgets()
|
||||
{
|
||||
var engine = new SlackTemplateEngine();
|
||||
var schema = new VariableSchema("[{\"name\":\"items\",\"type\":\"LIST\"},{\"name\":\"v\",\"type\":\"STRING\"}]");
|
||||
assertThatThrownBy(() -> engine.parse("{{#each items}}{{#each items}}{{/each}}{{/each}}", schema)
|
||||
.render(Map.of("items", java.util.Collections.nCopies(100, "x")), SlackTemplateEngine.Context.MRKDWN)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> engine.parse("{{v}}{{v}}", schema).render(Map.of("v", "é".repeat(300000)), SlackTemplateEngine.Context.MRKDWN))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user