feat: phase 1c/1d - Connect Slack (OAuth), SlackApi, readiness and health, templates, PHI guard #5

Merged
KofTwentyTwo merged 4 commits from feature/api-connect into develop 2026-10-14 17:37:42 +00:00
34 changed files with 4143 additions and 12 deletions

No files matched your search

+10
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"));
}
/*******************************************************************************
** 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 &lt;&amp;&gt; 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("&lt;@all&gt;");
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("&amp; &lt; &gt; &lt;!channel&gt;");
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);
}
}