diff --git a/pom.xml b/pom.xml index 3053fa4..5ce5333 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,16 @@ + + com.kingsrook.qqq + qqq-middleware-javalin + provided + + + com.kingsrook.qqq + qqq-middleware-health + true + com.kingsrook.qqq qqq-backend-core diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java index a7fe6a6..1ac7c80 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java @@ -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 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 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 getPhiPatterns() + { + return (phiPatterns); + } + + /******************************************************************************* + ** Fluent setter for phiPatterns. + *******************************************************************************/ + public SlackQBitConfig withPhiPatterns(List value) + { + phiPatterns = value; + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index 7c0b3cd..ec27632 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -124,6 +124,13 @@ public class SlackQBitProducer implements QBitMetaDataProducer { 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 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()); } diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java new file mode 100644 index 0000000..6cc790a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java @@ -0,0 +1,313 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.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 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 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 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 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 connectionsFor(String userId) throws QException + { + requireUser(userId); + return (SlackOwnerSession.call(userId, () -> + { + List 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 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 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 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"))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiConnection.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiConnection.java new file mode 100644 index 0000000..c190904 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiConnection.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiEffectResult.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiEffectResult.java new file mode 100644 index 0000000..860ad69 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiEffectResult.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiMessage.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiMessage.java new file mode 100644 index 0000000..b993527 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiMessage.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiReadiness.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiReadiness.java new file mode 100644 index 0000000..54002fd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiReadiness.java @@ -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 checks) +{ + /******************************************************************************* + ** Retain an immutable snapshot. + *******************************************************************************/ + public SlackApiReadiness + { + checks = java.util.List.copyOf(checks); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiReadinessCheck.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiReadinessCheck.java new file mode 100644 index 0000000..969cc64 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiReadinessCheck.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiRequest.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiRequest.java new file mode 100644 index 0000000..6e9d86c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiRequest.java @@ -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 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 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 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 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 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiResult.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiResult.java new file mode 100644 index 0000000..1712ac7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiResult.java @@ -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 reasons) +{ + /******************************************************************************* + ** Retain an immutable snapshot. + *******************************************************************************/ + public SlackApiResult + { + reasons = java.util.List.copyOf(reasons); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiTarget.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiTarget.java new file mode 100644 index 0000000..2f511db --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiTarget.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiTemplate.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiTemplate.java new file mode 100644 index 0000000..d86cb52 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiTemplate.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiVariable.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiVariable.java new file mode 100644 index 0000000..570a5e6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiVariable.java @@ -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]"); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java new file mode 100644 index 0000000..f9a931a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java @@ -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 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 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 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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java new file mode 100644 index 0000000..8e718c9 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java @@ -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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthState.java b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthState.java new file mode 100644 index 0000000..35b2073 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthState.java @@ -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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java index e4106de..535f543 100644 --- a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java @@ -32,6 +32,11 @@ import com.kingsrook.qbits.slack.metadata.SlackEnums; *******************************************************************************/ public class SlackSendRequest { + private String templateName = null; + private java.util.Map 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 getVariables() + { + return (variables); + } + + /******************************************************************************* + ** Fluent setter for variables. + *******************************************************************************/ + public SlackSendRequest withVariables(java.util.Map 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); + } } diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java index 6f5877b..061f94b 100644 --- a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java @@ -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 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) diff --git a/src/main/java/com/kingsrook/qbits/slack/phi/SlackPayloadGuard.java b/src/main/java/com/kingsrook/qbits/slack/phi/SlackPayloadGuard.java new file mode 100644 index 0000000..11b720e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/phi/SlackPayloadGuard.java @@ -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 variables(VariableSchema schema, Map values) + { + Map safe = new LinkedHashMap<>(); + for(Map.Entry 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 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 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")); + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java index 0f00183..68d8931 100644 --- a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java +++ b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java @@ -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 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 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 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 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 getProcessSummary(RunBackendStepOutput output, boolean result) + { + return (lines); + } + } } diff --git a/src/main/java/com/kingsrook/qbits/slack/readiness/SlackHealth.java b/src/main/java/com/kingsrook/qbits/slack/readiness/SlackHealth.java new file mode 100644 index 0000000..c36ecf1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/readiness/SlackHealth.java @@ -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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java b/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java new file mode 100644 index 0000000..f173016 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java @@ -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 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 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 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 checks) + { + return (new SlackApiReadiness(checks.stream().allMatch(c -> "READY".equals(c.status()) || "WARN".equals(c.status())), checks)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackSecrets.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackSecrets.java new file mode 100644 index 0000000..1a2b0e7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackSecrets.java @@ -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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplateCustomizer.java b/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplateCustomizer.java new file mode 100644 index 0000000..2414cde --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplateCustomizer.java @@ -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 preInsertOrUpdate(AbstractActionInput input, List records, boolean preview, Optional> 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplateEngine.java b/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplateEngine.java new file mode 100644 index 0000000..caa9d3c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplateEngine.java @@ -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 children, String attribute) + { + } + + /******************************************************************************* + ** Validated immutable parse result. + *******************************************************************************/ + public static class Parsed + { + private final List nodes; + private final Set references; + + /******************************************************************************* + ** Store parsed nodes. + *******************************************************************************/ + private Parsed(List nodes, Set references) + { + this.nodes = nodes; + this.references = Set.copyOf(references); + } + + /******************************************************************************* + ** Render the already validated syntax tree. + *******************************************************************************/ + public String render(Map values, Context context) + { + return (render(values, context, new Budget(), new ArrayList<>())); + } + + /******************************************************************************* + ** Bindings referenced by values and block conditions, including nested blocks. + *******************************************************************************/ + public Set references() + { + return (references); + } + + /******************************************************************************* + ** Render using the message-wide budget and safe author-facing warnings. + *******************************************************************************/ + public String render(Map values, Context context, Budget budget, List 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 root = new ArrayList<>(); + Deque stack = new ArrayDeque<>(); + Set references = new LinkedHashSet<>(); + int position = 0; + int line = 1; + while(position < source.length()) + { + List 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 nodes, Map values, Object item, int index, Context context, Budget budget, List warnings, StringBuilder out) + { + for(Node node : nodes) + { + Object value = node.value().equals("this") ? item : node.value().startsWith("this.") ? lookup(item, node.value().substring(5)) + : node.value().equals("@index") ? index : lookup(values, node.value()); + switch(node.kind()) + { + case "literal" -> budget.append(out, node.value()); + case "value" -> + { + String text = value == null || value.toString().isEmpty() ? node.fallback() : value.toString(); + if(text.length() > 1_048_576) + { + throw (new IllegalArgumentException("Template output is too large")); + } + budget.append(out, encode(text, context)); + } + case "if" -> + { + if(truthy(value)) + { + renderNodes(node.children(), values, item, index, context, budget, warnings, out); + } + } + case "each" -> + { + if(value instanceof List list) + { + for(int i = 0; i < list.size(); i++) + { + budget.iterate(); + renderNodes(node.children(), values, list.get(i), i, context, budget, warnings, out); + } + } + } + default -> throw (new IllegalStateException("Invalid template node")); + } + } + } + + /******************************************************************************* + ** Dotted paths traverse maps only; no reflection, methods or arbitrary code. + *******************************************************************************/ + public static Object lookup(Object values, String path) + { + if(values instanceof Map map) + { + if(map.containsKey(path)) + { + return (map.get(path)); + } + int dot = path.indexOf('.'); + return (dot < 0 ? map.get(path) : lookup(map.get(path.substring(0, dot)), path.substring(dot + 1))); + } + return (null); + } + + /******************************************************************************* + ** Explicit language truthiness. + *******************************************************************************/ + public static boolean truthy(Object value) + { + if(value == null || Boolean.FALSE.equals(value) || value instanceof CharSequence s && s.isEmpty() + || value instanceof Collection c && c.isEmpty() || value instanceof Map m && m.isEmpty()) + { + return (false); + } + return (!(value instanceof Number n) || new BigDecimal(n.toString()).compareTo(BigDecimal.ZERO) != 0); + } + + /******************************************************************************* + ** Escape values exactly once for the consuming grammar. + *******************************************************************************/ + public static String encode(String value, Context context) + { + String escaped = escape(value); + if(context == Context.JSON_STRING) + { + String quoted = org.json.JSONObject.quote(escaped); + return (quoted.substring(1, quoted.length() - 1)); + } + return (escaped); + } + + /******************************************************************************* + ** Slack interprets only these three characters as special markup. + *******************************************************************************/ + public static String escape(Object value) + { + return (value == null ? "" : value.toString().replace("&", "&").replace("<", "<").replace(">", ">")); + } + + /******************************************************************************* + ** JSON must remain an array and expressions may occur only in string values, + ** never keys or structural positions. Parse strings individually so loops + ** cannot cross JSON syntax boundaries or create additional blocks. + *******************************************************************************/ + public String blocks(String source, VariableSchema schema, Map 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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplates.java b/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplates.java new file mode 100644 index 0000000..abe99e6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplates.java @@ -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 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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/template/VariableSchema.java b/src/main/java/com/kingsrook/qbits/slack/template/VariableSchema.java new file mode 100644 index 0000000..62f9775 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/template/VariableSchema.java @@ -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 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 parsed = JSON.readValue(json == null || json.isBlank() ? "[]" : json, new TypeReference>() {}); + 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 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 validate(Map values) + { + Map 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 values(String json) + { + try + { + Map result = JSON.readValue(json == null || json.isBlank() ? "{}" : json, new TypeReference>() {}); + 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")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java index b5465fb..af52d02 100644 --- a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java +++ b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java @@ -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 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. diff --git a/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java b/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java index 6d391f5..f4b4c89 100644 --- a/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java +++ b/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java @@ -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": diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackConnectApiTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackConnectApiTest.java new file mode 100644 index 0000000..b08c9e5 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackConnectApiTest.java @@ -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 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"); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackOAuthRouteTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackOAuthRouteTest.java new file mode 100644 index 0000000..ab92f25 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackOAuthRouteTest.java @@ -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 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"); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackReadinessTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackReadinessTest.java new file mode 100644 index 0000000..d80a6e9 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackReadinessTest.java @@ -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<>())); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackTemplateGuardTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackTemplateGuardTest.java new file mode 100644 index 0000000..ec25f0f --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackTemplateGuardTest.java @@ -0,0 +1,222 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.slack.api.SlackApi; +import com.kingsrook.qbits.slack.api.SlackApiRequest; +import com.kingsrook.qbits.slack.api.SlackApiTemplate; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.phi.SlackPayloadGuard; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qbits.slack.template.SlackTemplateEngine; +import com.kingsrook.qbits.slack.template.SlackTemplates; +import com.kingsrook.qbits.slack.template.VariableSchema; +import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Parser budgets, schema validation, Block Kit safety and the PHI canary. + *******************************************************************************/ +class SlackTemplateGuardTest +{ + private static final String CANARY = "CANARY-PHI-7f3a"; + + /******************************************************************************* + ** Same language as email, with Slack escaping rather than HTML escaping. + *******************************************************************************/ + @Test + void languageAndBudgets() + { + SlackTemplateEngine engine = new SlackTemplateEngine(); + VariableSchema schema = new VariableSchema("[{\"name\":\"name\",\"type\":\"STRING\"},{\"name\":\"items\",\"type\":\"LIST\"},{\"name\":\"record\",\"type\":\"RECORD\"}]"); + var parsed = engine.parse("{{name|friend}} {{#if record.ok}}{{record.name}}{{/if}} {{#each items}}{{@index}}:{{this}};{{/each}}", schema); + assertThat(parsed.render(Map.of("record", Map.of("ok", true, "name", "<&>"), "items", List.of("a", "b")), SlackTemplateEngine.Context.MRKDWN)) + .isEqualTo("friend <&> 0:a;1:b;"); + assertThat(parsed.references()).contains("name", "record.ok", "items"); + assertThatThrownBy(() -> engine.parse("{{unknown}}", schema)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.parse("{{this}}", schema)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.parse("{{#if name}}", schema)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.parse("{{/if}}", schema)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.parse("{{name", schema)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.parse("{{#if name}}".repeat(9) + "{{/if}}".repeat(9), schema)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.parse("{{#each items}}x{{/each}}", schema) + .render(Map.of("items", java.util.Collections.nCopies(10001, "x")), SlackTemplateEngine.Context.MRKDWN)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.parse("{{name}}", schema).render(Map.of("name", "x".repeat(1_048_577)), SlackTemplateEngine.Context.MRKDWN)).isInstanceOf(IllegalArgumentException.class); + assertThat(engine.blocks("[{\"type\":\"section\",\"text\":\"{{name}}\"}]", schema, Map.of("name", "\"<@all>"), new SlackTemplateEngine.Budget())) + .contains("<@all>"); + assertThatThrownBy(() -> engine.blocks("[{\"{{name}}\":1}]", schema, Map.of(), new SlackTemplateEngine.Budget())).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.blocks("[{\"text\":{{name}}}]", schema, Map.of(), new SlackTemplateEngine.Budget())).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.blocks("[" + "{},".repeat(50) + "{}]", schema, Map.of(), new SlackTemplateEngine.Budget())).isInstanceOf(IllegalArgumentException.class); + } + + /******************************************************************************* + ** Typed coercion and malformed schema diagnostics never echo supplied data. + *******************************************************************************/ + @Test + void schemaValidation() + { + VariableSchema schema = new VariableSchema("[{\"name\":\"n\",\"type\":\"NUMBER\",\"required\":true},{\"name\":\"b\",\"type\":\"BOOLEAN\"},{\"name\":\"u\",\"type\":\"URL\"}]"); + assertThat(schema.validate(Map.of("n", "12.5", "b", "true", "u", "https://example.test")).get("b")).isEqualTo(true); + assertThatThrownBy(() -> schema.validate(Map.of())).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> schema.validate(Map.of("n", "bad"))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> schema.validate(Map.of("n", "1", "b", "maybe"))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> VariableSchema.url("javascript:alert(1)")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new VariableSchema("[{}]")).isInstanceOf(IllegalArgumentException.class); + assertThat(VariableSchema.values("{\"a\":1}")).containsKey("a"); + assertThat(VariableSchema.json(Map.of("a", 1))).isEqualTo("{\"a\":1}"); + assertThatThrownBy(() -> VariableSchema.values("[]")).isInstanceOf(IllegalArgumentException.class); + } + + /******************************************************************************* + ** A real fixture PHI column is never copied to Slack tables, wire or logs + ** through variables, linked records, free text, SECURITY or updates. + *******************************************************************************/ + @Test + void canaryNeverLeavesFixture() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl("https://example.test")); LogCapture logs = new LogCapture()) + { + int workspace = env.workspace("T1", null, "xoxb-test", "UBOT"); + env.connection("alice", workspace, "UA", "xoxp-test"); + QFieldMetaData phi = new QFieldMetaData("phi", QFieldType.STRING) + .withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI)); + env.instance().addTable(new QTableMetaData().withName("patient_fixture").withBackendName("rdbms").withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(phi)); + try(var connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) env.instance().getBackend("rdbms"))) + { + connection.createStatement().execute("CREATE TABLE patient_fixture (id INT PRIMARY KEY, phi VARCHAR(200))"); + try(var insert = connection.prepareStatement("INSERT INTO patient_fixture VALUES (1, ?)")) + { + insert.setString(1, CANARY); + insert.executeUpdate(); + } + } + assertThatThrownBy(() -> SlackPayloadGuard.blocks("[{\"text\":\"" + "\\u0031".repeat(3) + "-45-6789\"}]")) + .isInstanceOf(IllegalArgumentException.class); + String value = SlackStore.query("patient_fixture", new QQueryFilter()).getFirst().getValueString("phi"); + assertThat(value).isEqualTo(CANARY); + SlackPayloadGuard.ClassifiedValue classified = new SlackPayloadGuard.ClassifiedValue(value, null, "patient_fixture", "phi"); + String bound = "[{\"name\":\"v\",\"type\":\"STRING\",\"table\":\"patient_fixture\",\"field\":\"phi\"}]"; + assertThatThrownBy(() -> SlackTemplates.validate(new QRecord().withValue("text", "{{v}}").withValue("variablesSchema", bound))) + .isInstanceOf(IllegalArgumentException.class); + QRecord rejected = SlackStore.insert(SlackTables.TEMPLATE, new QRecord().withValue("name", "bad").withValue("text", "{{v}}") + .withValue("variablesSchema", bound), null); + assertThat(rejected.getErrors()).isNotEmpty(); + String schema = "[{\"name\":\"v\",\"type\":\"STRING\",\"tokenize\":true}]"; + assertThat(SlackApi.ensureTemplate(new SlackApiTemplate("safe", "Safe", "{{v}}", null, schema, "TRANSACTIONAL"))).isTrue(); + assertThat(SlackApi.ensureTemplate(new SlackApiTemplate("safe", "Safe", "{{v}}", null, schema, "TRANSACTIONAL"))).isFalse(); + SlackApi.ensureTemplate(new SlackApiTemplate("refuse", "Refuse", "{{v}}", null, schema.replace("true", "false"), "TRANSACTIONAL")); + new SlackProcesses.ReadinessStep().run(new RunBackendStepInput(), new RunBackendStepOutput()); + env.asOwner("alice"); + var tokenized = SlackApi.send(new SlackApiRequest().withTo("me").withTemplateName("safe").withVariables(Map.of("v", classified))); + assertThat(tokenized.status()).isEqualTo("QUEUED"); + var refused = SlackApi.send(new SlackApiRequest().withTo("me").withTemplateName("refuse").withVariables(Map.of("v", classified))); + assertThat(refused.status()).isEqualTo("REFUSED"); + assertThat(refused.reasons().toString()).contains("variable: v"); + assertThat(SlackApi.send(new SlackApiRequest().withTo("me").withText(value).withContentClassification("PHI")).status()).isEqualTo("REFUSED"); + assertThat(SlackApi.send(new SlackApiRequest().withTo("me").withText("MRN:123456")).status()).isEqualTo("REFUSED"); + assertThat(SlackApi.send(new SlackApiRequest().withTo("me").withText("123-45-6789")).status()).isEqualTo("REFUSED"); + assertThat(SlackService.enqueue(new SlackSendRequest().withTo("me").withText("View record") + .withLinkedRecord("patient_fixture", "1")).status().name()).isEqualTo("QUEUED"); + var security = SlackApi.send(new SlackApiRequest().withTo("me").withText(value).withBlocksJson("[]").withCategory("SECURITY") + .withTitle("Security event").withLink("https://example.test/patient/1")); + assertThat(security.status()).isEqualTo("QUEUED"); + QRecord securityPayload = SlackStore.get(SlackTables.MESSAGE, security.messageId()); + assertThat(securityPayload.getValueString("text")).endsWith("Security event\nhttps://example.test/patient/1").doesNotContain(CANARY); + assertThat(securityPayload.getValueString("blocksJson")).isNull(); + env.dispatch(); + env.asOwner("alice"); + assertThat(SlackApi.update(tokenized.messageId(), new SlackApiRequest().withText(value).withContentClassification("PHI")).status()).isEqualTo("REFUSED"); + assertThat(SlackApi.update(tokenized.messageId(), new SlackApiRequest().withTemplateName("refuse").withVariables(Map.of("v", classified))).status()).isEqualTo("REFUSED"); + env.asSystem(); + try(var connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) env.instance().getBackend("rdbms"))) + { + for(String table : env.instance().getTables().keySet()) + { + if(table.startsWith("slack_")) + { + try(var rows = connection.createStatement().executeQuery("SELECT * FROM " + table)) + { + while(rows.next()) + { + for(int column = 1; column <= rows.getMetaData().getColumnCount(); column++) + { + assertThat(String.valueOf(rows.getObject(column))).doesNotContain(CANARY); + } + } + } + } + } + } + assertThat(env.slack().requests().toString()).doesNotContain(CANARY).contains("changed:v1"); + assertThat(logs.lines().toString()).doesNotContain(CANARY); + } + } + + /******************************************************************************* + ** Mention text remains inert and both source and rendered blocks are bounded. + *******************************************************************************/ + @Test + void mentionsAndBlockSize() + { + var engine = new SlackTemplateEngine(); + var schema = new VariableSchema("[{\"name\":\"v\",\"type\":\"STRING\"}]"); + assertThat(engine.parse("{{v}}", schema).render(Map.of("v", "& < > "), SlackTemplateEngine.Context.MRKDWN)) + .isEqualTo("& < > <!channel>"); + assertThatThrownBy(() -> engine.blocks("[{\"text\":\"" + "x".repeat(40001) + "\"}]", schema, Map.of(), new SlackTemplateEngine.Budget())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.blocks("[{\"text\":\"{{v}}\"}]", schema, Map.of("v", "x".repeat(40001)), new SlackTemplateEngine.Budget())) + .isInstanceOf(IllegalArgumentException.class); + } + + /******************************************************************************* + ** Nested loops share an iteration budget and UTF-8 bytes share a size budget. + *******************************************************************************/ + @Test + void cumulativeBudgets() + { + var engine = new SlackTemplateEngine(); + var schema = new VariableSchema("[{\"name\":\"items\",\"type\":\"LIST\"},{\"name\":\"v\",\"type\":\"STRING\"}]"); + assertThatThrownBy(() -> engine.parse("{{#each items}}{{#each items}}{{/each}}{{/each}}", schema) + .render(Map.of("items", java.util.Collections.nCopies(100, "x")), SlackTemplateEngine.Context.MRKDWN)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> engine.parse("{{v}}{{v}}", schema).render(Map.of("v", "é".repeat(300000)), SlackTemplateEngine.Context.MRKDWN)) + .isInstanceOf(IllegalArgumentException.class); + } +}