feat: phase 1f - Slack app, home dashboard, permissions, integrations, preferences #6

Merged
KofTwentyTwo merged 5 commits from feature/app-home into develop 2026-10-14 18:13:57 +00:00
20 changed files with 1642 additions and 11 deletions

No files matched your search

+6
View File
@@ -74,6 +74,12 @@
</scm>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.19</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
@@ -0,0 +1,96 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Opt-in host ESB, API and Quick Search wiring, with explicit safe projections.
** Hosts must retain the table permissions and record locks in each integration.
*******************************************************************************/
public final class SlackIntegrations
{
/*******************************************************************************
** Quick Search metadata contains no message body or credentials.
*******************************************************************************/
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackIntegrations()
{
}
/*******************************************************************************
** Main tables publish insert, update and delete events with safe fields.
*******************************************************************************/
public static List<String> esbTableNames()
{
return (SlackTables.MAIN);
}
/*******************************************************************************
** Main tables exposed under normal viewer permissions.
*******************************************************************************/
public static List<String> apiTableNames()
{
return (SlackTables.MAIN);
}
/*******************************************************************************
** Explicitly exclude both token columns, even if a host unhides them.
*******************************************************************************/
public static List<String> apiFieldNames(QInstance instance, String table)
{
if(!SlackTables.MAIN.contains(table))
{
return (List.of());
}
return (instance.getTable(table).getFields().keySet().stream()
.filter(name -> !SlackTables.BOT_TOKEN.equals(name) && !SlackTables.USER_TOKEN.equals(name))
.filter(name -> !name.toLowerCase(java.util.Locale.ROOT).contains("token"))
.filter(name -> !instance.getTable(table).getField(name).getIsHidden()).toList());
}
/*******************************************************************************
** ESB must not broadcast credentials or permission-protected message bodies.
*******************************************************************************/
public static List<String> esbFieldNames(QInstance instance, String table)
{
return (apiFieldNames(instance, table).stream().filter(name -> !List.of("text", "blocksJson", "variablesSnapshot").contains(name)).toList());
}
/*******************************************************************************
** Safe, viewer-scoped search on messages and connections.
*******************************************************************************/
public static List<SearchableTable> quickSearchTables()
{
return (List.of(
new SearchableTable(SlackTables.MESSAGE, Map.of("id", 10, "status", 4, "channelLabel", 8), "modifyDate", "%s", List.of("id")),
new SearchableTable(SlackTables.CONNECTION, Map.of("slackUserName", 10, "status", 4), "modifyDate", "%s", List.of("slackUserName"))));
}
}
@@ -147,6 +147,9 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
output.addSelfToInstance(qInstance);
qInstance.addApp(com.kingsrook.qbits.slack.app.SlackApp.app(qInstance.getTables()));
com.kingsrook.qbits.slack.app.SlackApp.widgets().forEach(qInstance::addWidget);
com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter.register(qInstance);
com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance);
try
{
@@ -0,0 +1,105 @@
/*
* 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.app;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Full-width typed home widgets; processes remain record actions.
*******************************************************************************/
public final class SlackApp
{
public static final String HOME_WIDGET = "slackHome";
public static final String DELIVERIES = "slackHomeDeliveries";
public static final String RECENT = "slackHomeRecent";
public static final String READINESS = "slackHomeReadiness";
public static final String OUTBOX = "slackHomeOutbox";
public static final String FAILURES = "slackHomeFailures";
public static final List<String> ADMIN_WIDGETS = List.of(READINESS, OUTBOX, FAILURES);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackApp()
{
}
/*******************************************************************************
** Navigation in owner-requested order.
*******************************************************************************/
public static QAppMetaData app(Map<String, QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData().withName("slack").withLabel("Slack").withIcon(new QIcon("chat"))
.withPermissionRules(SlackTables.permissionRules()).withWidgets(widgets().stream().map(QWidgetMetaData::getName).toList());
SlackTables.MAIN.forEach(name -> app.withChild(tables.get(name)));
return (app);
}
/*******************************************************************************
** Each section occupies all twelve grid columns, including on small screens.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(widget(HOME_WIDGET, "My connections", WidgetType.TABLE),
widget(DELIVERIES, "My deliveries (24 h)", WidgetType.MULTI_STATISTICS),
widget(RECENT, "My recent deliveries", WidgetType.TABLE),
widget(READINESS, "Workspace readiness", WidgetType.TABLE),
widget(OUTBOX, "Outbox", WidgetType.MULTI_STATISTICS),
widget(FAILURES, "Recent failures", WidgetType.TABLE)));
}
/*******************************************************************************
** Labels belong to widget metadata, never duplicated inside their payloads.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType())
.withGridColumns(12).withIsCard(true).withShowReloadButton(true)
.withCodeReference(new QCodeReference(HomeRenderer.class)).withPermissionRules(SlackTables.permissionRules()));
}
/*******************************************************************************
** Render with the request's current context.
*******************************************************************************/
public static class HomeRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Viewer-scoped dashboard.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(SlackHome.render(input.getWidgetMetaData().getName())));
}
}
}
@@ -0,0 +1,199 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.app;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker;
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData.Statistic;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Exact counts and bounded recent lists, always under the viewer's record locks.
*******************************************************************************/
public final class SlackHome
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackHome()
{
}
/*******************************************************************************
** Typed sections fetch only their data, with permissions checked on direct calls.
*******************************************************************************/
public static QWidgetData render(String name) throws QException
{
String permission = SlackApp.ADMIN_WIDGETS.contains(name) ? SlackPermissions.ADMIN : SlackPermissions.VIEW;
if(!SlackPermissionChecker.has(permission))
{
throw (new QPermissionDeniedException("This needs the " + permission + " permission."));
}
String owner = QContext.getQSession().getUser().getIdReference();
return (switch(name)
{
case SlackApp.HOME_WIDGET -> connections(owner);
case SlackApp.DELIVERIES -> deliveries(owner);
case SlackApp.RECENT -> SlackHomeView.messages(messages(SlackStore.equal("ownerUserId", owner)), SlackStore.equal("ownerUserId", owner));
case SlackApp.READINESS -> SlackHomeView.readiness(SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new QQueryFilter()));
case SlackApp.OUTBOX -> outbox();
case SlackApp.FAILURES -> SlackHomeView.messages(messages(failures()), failures());
default -> throw new QException("Unknown Slack home widget.");
});
}
/*******************************************************************************
** Resolve workspace labels without granting access to the workspace admin UI.
*******************************************************************************/
private static QWidgetData connections(String owner) throws QException
{
List<QRecord> connections = rows(SlackTables.CONNECTION, SlackStore.equal("userId", owner), Set.of("id", "workspaceId", "status", "connectedAt"));
QQueryFilter filter = new QQueryFilter().withCriteria("id", QCriteriaOperator.IN,
connections.stream().map(row -> row.getValueInteger("workspaceId")).toList());
List<QRecord> workspaces = connections.isEmpty() ? List.of() : SlackStore.queryAsCurrent(SlackTables.WORKSPACE, filter);
return (SlackHomeView.connections(connections, workspaces));
}
/*******************************************************************************
** Omit empty personal statuses, but always show Sent.
*******************************************************************************/
private static MultiStatisticsData deliveries(String owner) throws QException
{
List<StatisticsGroupData> groups = new ArrayList<>();
for(var status : SlackEnums.MessageStatus.values())
{
QQueryFilter filter = countFilter(status.name(), owner, "createDate");
int total = count(filter);
if(total > 0 || status == SlackEnums.MessageStatus.SENT)
{
groups.add(statistic(SlackHomeView.messageStatus(status.name()), total, filter));
}
}
return (new MultiStatisticsData(null, groups));
}
/*******************************************************************************
** Backlog counts are current; terminal counts cover their event time in 24 h.
*******************************************************************************/
private static MultiStatisticsData outbox() throws QException
{
List<StatisticsGroupData> groups = new ArrayList<>();
String[] statuses = {"QUEUED", "RETRY_WAIT", "SENT", "DEAD_LETTER", "FAILED_AUTH"};
String[] labels = {"Queued", "Retrying", "Sent in 24 h", "Dead letters in 24 h", "Not authorized in 24 h"};
String[] dates = {null, null, "sentDate", "deadLetterDate", "modifyDate"};
for(int i = 0; i < statuses.length; i++)
{
QQueryFilter filter = countFilter(statuses[i], null, dates[i]);
groups.add(statistic(labels[i], count(filter), filter));
}
return (new MultiStatisticsData(null, groups));
}
/*******************************************************************************
** Each tile links to exactly the same filter as its count.
*******************************************************************************/
private static StatisticsGroupData statistic(String label, int total, QQueryFilter filter)
{
return (new StatisticsGroupData().withHeader(label)
.withStatisticList(List.of(new Statistic("Messages", total, SlackHomeView.listUrl(filter)))));
}
/*******************************************************************************
** Count in core, with an inclusive lower and upper bound for the last day.
*******************************************************************************/
public static int count(String status, String owner, String dateField) throws QException
{
return (count(countFilter(status, owner, dateField)));
}
/*******************************************************************************
** One shared filter for counts and their drill-down links.
*******************************************************************************/
private static QQueryFilter countFilter(String status, String owner, String dateField)
{
QQueryFilter filter = SlackStore.equal("status", status);
if(owner != null)
{
filter.withCriteria("ownerUserId", QCriteriaOperator.EQUALS, owner);
}
if(dateField != null)
{
Instant now = SlackRuntime.clock().instant();
filter.withCriteria(dateField, QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400));
filter.withCriteria(dateField, QCriteriaOperator.LESS_THAN_OR_EQUALS, now);
}
return (filter);
}
/*******************************************************************************
** Count under the current viewer's owner and tenant locks.
*******************************************************************************/
private static int count(QQueryFilter filter) throws QException
{
return (new CountAction().execute(new CountInput(SlackTables.MESSAGE).withFilter(filter)).getCount());
}
/*******************************************************************************
** Recoverable and terminal failures share the same list and drill-down filter.
*******************************************************************************/
private static QQueryFilter failures()
{
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("DEAD_LETTER", "FAILED_AUTH", "RETRY_WAIT")));
}
/*******************************************************************************
** Last ten messages, stable ordering even for equal timestamps, without bodies.
*******************************************************************************/
private static List<QRecord> messages(QQueryFilter filter) throws QException
{
filter.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(10);
return (rows(SlackTables.MESSAGE, filter, Set.of("id", "status", "channelLabel", "createDate")));
}
/*******************************************************************************
** Never switch to a system context for dashboard queries.
*******************************************************************************/
private static List<QRecord> rows(String table, QQueryFilter filter, Set<String> fields) throws QException
{
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withFieldNamesToInclude(fields)).getRecords());
}
}
@@ -0,0 +1,188 @@
/*
* 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.app;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Typed tables inherit responsive layout and themes from the QQQ dashboard.
*******************************************************************************/
public final class SlackHomeView
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackHomeView()
{
}
/*******************************************************************************
** Workspace names link to the viewer's connection, never an admin-only record.
*******************************************************************************/
public static TableData connections(List<QRecord> connections, List<QRecord> workspaces)
{
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord row : connections)
{
String workspace = workspaces.stream().filter(w -> w.getValueInteger("id").equals(row.getValueInteger("workspaceId")))
.map(w -> w.getValueString("teamName")).findFirst().orElse("Workspace " + row.getValueInteger("workspaceId"));
rows.add(Map.of("workspace", link(SlackTables.CONNECTION, row, workspace),
"status", label(row.getValueString("status")), "connected", date(row.getValueInstant("connectedAt"))));
}
return (table(List.of(column("Workspace", "workspace", "html"), column("Status", "status", "text"), column("Connected", "connected", "text")),
rows, "No connections yet. Use Connect Slack on Connections."));
}
/*******************************************************************************
** Linked rows contain delivery metadata, never message bodies.
*******************************************************************************/
public static TableData messages(List<QRecord> messages, QQueryFilter filter)
{
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord row : messages)
{
rows.add(Map.of("message", link(SlackTables.MESSAGE, row, "Message " + row.getValueInteger("id")),
"status", messageStatus(row.getValueString("status")), "where", row.getValueString("channelLabel") == null ? "Not recorded" : row.getValueString("channelLabel"),
"when", date(row.getValueInstant("createDate"))));
}
return (table(List.of(column("Message", "message", "html"), column("Status", "status", "text"), column("Where", "where", "text"), column("When", "when", "text")),
rows, "No deliveries.").withLinkText("View all").withLinkURL(listUrl(filter)));
}
/*******************************************************************************
** One row per workspace check; successful checks have no remediation hint.
*******************************************************************************/
public static TableData readiness(List<QRecord> workspaces)
{
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord workspace : workspaces)
{
for(var check : SlackReadiness.workspaceSnapshot(workspace.getValueInteger("id")).checks())
{
boolean channels = "registeredChannels".equals(check.name());
String status = switch(check.status())
{
case "READY" -> "READY";
case "WARN" -> "DEGRADED";
default -> "NOT READY";
};
String fix = "READY".equals(status) ? "" : "UNKNOWN".equals(check.status()) ? "Run Check readiness to refresh."
: channels ? "Check registered channels, unarchive them and invite the bot. Run Check readiness to refresh."
: "Install or reconnect this workspace with the required scopes. Run Check readiness to refresh.";
rows.add(Map.of("workspace", link(SlackTables.WORKSPACE, workspace, workspace.getValueString("teamName")),
"check", channels ? "Registered channels" : "Bot token and scopes", "status", status, "fix", fix));
}
}
return (table(List.of(column("Workspace", "workspace", "html"), column("Check", "check", "text"), column("Status", "status", "text"), column("Fix", "fix", "text")),
rows, "No workspaces installed."));
}
/*******************************************************************************
** Human delivery labels clarify exceptional states instead of exposing enums.
*******************************************************************************/
public static String messageStatus(String status)
{
if(status == null)
{
return ("Not recorded");
}
return (switch(SlackEnums.MessageStatus.valueOf(status))
{
case WAITING_PARENT -> "Waiting for parent";
case FAILED_AUTH -> "Failed: not authorized";
case RETRY_WAIT -> "Retrying";
default -> label(status);
});
}
/*******************************************************************************
** Plain enum display label for connection and remaining delivery states.
*******************************************************************************/
private static String label(String value)
{
return (value == null ? "Not recorded" : value.charAt(0) + value.substring(1).toLowerCase(Locale.ROOT).replace('_', ' '));
}
/*******************************************************************************
** Use the viewer's timezone, falling back to the application's configured zone.
*******************************************************************************/
private static String date(Instant value)
{
return (value == null ? "Not recorded" : DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm a z", Locale.ENGLISH)
.withZone(ValueUtils.getSessionOrInstanceZoneId()).format(value));
}
/*******************************************************************************
** A Messages list filtered to the count or recent list's actual scope.
*******************************************************************************/
public static String listUrl(QQueryFilter filter)
{
return ("/app/" + SlackTables.MESSAGE + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8));
}
/*******************************************************************************
** Only record links use HTML; ordinary text is escaped by the table renderer.
*******************************************************************************/
private static TableData.Column column(String header, String accessor, String type)
{
return (new TableData.Column(type, header, accessor, null, "left").withVerticalAlign("top"));
}
/*******************************************************************************
** Full-width table with an honest empty state and no unnecessary page selector.
*******************************************************************************/
private static TableData table(List<TableData.Column> columns, List<Map<String, Object>> rows, String empty)
{
return (new TableData(null, columns, rows).withRowsPerPage(Math.max(10, rows.size()))
.withHidePaginationDropdown(true).withNoRowsFoundHTML(empty));
}
/*******************************************************************************
** Only integer record ids and internal table names enter URLs.
*******************************************************************************/
private static String link(String table, QRecord row, String label)
{
return ("<a href=\"/app/" + table + "/" + row.getValueInteger("id") + "\">" + esc(label) + "</a>");
}
/*******************************************************************************
** Escape record-sourced labels in HTML cells.
*******************************************************************************/
private static String esc(String value)
{
return (value == null ? "Not recorded" : value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
@@ -71,6 +71,8 @@ public final class SlackTables
public static final String MESSAGE = "slack_message";
public static final String ATTEMPT = "slack_message_attempt";
public static final List<String> MAIN = List.of(MESSAGE, CONNECTION, WORKSPACE, CHANNEL, USER_GROUP, TEMPLATE);
public static final List<String> ALL = List.of(WORKSPACE, CONNECTION, CHANNEL, USER_GROUP, TEMPLATE, MESSAGE, ATTEMPT);
/////////////////////////////////////////////////////////////////////////////
@@ -105,7 +107,8 @@ public final class SlackTables
*******************************************************************************/
public static QPermissionRules permissionRules()
{
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("slack"));
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("slack")
.withCustomPermissionChecker(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.slack.permissions.SlackPermissionChecker.class)));
}
@@ -448,9 +451,9 @@ public final class SlackTables
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("slack_message_status").withDefaultValue("QUEUED").withMaxLength(500))
.withField(field("category", QFieldType.STRING).withMaxLength(500))
.withField(field("templateName", QFieldType.STRING).withMaxLength(500))
.withField(field("text", QFieldType.TEXT).withIsSensitive(true))
.withField(field("blocksJson", QFieldType.TEXT).withIsSensitive(true))
.withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true))
.withField(field("text", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock()))
.withField(field("blocksJson", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock()))
.withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock()))
.withField(field("idempotencyKey", QFieldType.STRING).withMaxLength(500))
.withField(field("parentMessageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE))
.withField(field("threadTs", QFieldType.STRING).withMaxLength(500))
@@ -0,0 +1,125 @@
/*
* 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.permissions;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qbits.slack.security.SlackSecurity;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Table reads retain owner and tenant locks; mutations use explicit actions.
*******************************************************************************/
public class SlackPermissionChecker implements CustomPermissionChecker
{
private static final ThreadLocal<Boolean> WORKSPACE_LOOKUP = ThreadLocal.withInitial(() -> false);
/*******************************************************************************
** Internal workspace resolution retains viewer locks without granting access
** to the administrative workspace table in the UI or host API.
*******************************************************************************/
public static <T> T workspaceLookup(com.kingsrook.qbits.slack.support.SlackStore.Work<T> work) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
boolean previous = WORKSPACE_LOOKUP.get();
try
{
WORKSPACE_LOOKUP.set(true);
return (work.run());
}
finally
{
if(previous)
{
WORKSPACE_LOOKUP.set(true);
}
else
{
WORKSPACE_LOOKUP.remove();
}
}
}
/*******************************************************************************
** Authorize metadata and refresh the permission-derived body field key.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession)
{
return;
}
SlackSecurity.applyBodyPermission(QContext.getQSession());
String needed = SlackPermissions.VIEW;
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData widget
&& com.kingsrook.qbits.slack.app.SlackApp.ADMIN_WIDGETS.contains(widget.getName()))
{
needed = SlackPermissions.ADMIN;
}
if(metadata instanceof QTableMetaData workspace && SlackTables.WORKSPACE.equals(workspace.getName()) && !WORKSPACE_LOOKUP.get())
{
needed = SlackPermissions.ADMIN;
}
if(metadata instanceof QTableMetaData table && (input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput))
{
if(SlackTables.SYSTEM_WRITTEN.contains(table.getName()) || SlackTables.CONNECTION.equals(table.getName()) || SlackTables.WORKSPACE.equals(table.getName()) && input instanceof InsertInput)
{
throw (new QPermissionDeniedException("Use a Slack record action to change this record."));
}
needed = SlackPermissions.ADMIN;
}
if(metadata instanceof QProcessMetaData process)
{
needed = switch(process.getName())
{
case SlackProcesses.CONNECT, SlackProcesses.DISCONNECT -> SlackPermissions.CONNECT;
case SlackProcesses.INSTALL -> SlackPermissions.INSTALL;
case SlackProcesses.TEST_MESSAGE -> SlackPermissions.SEND;
default -> SlackPermissions.ADMIN;
};
if(SlackProcesses.DISCONNECT.equals(process.getName()) && has(SlackPermissions.ADMIN))
{
return;
}
}
if(!has(needed))
{
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
}
}
/*******************************************************************************
** Permissions are independent; an administrator still needs a body grant.
*******************************************************************************/
public static boolean has(String permission)
{
return (QContext.getQSession() != null && QContext.getQSession().hasPermission(permission));
}
}
@@ -0,0 +1,45 @@
/*
* 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.permissions;
import java.util.List;
/*******************************************************************************
** Explicit Slack permission names; body and raw blocks are separate grants.
*******************************************************************************/
public final class SlackPermissions
{
public static final String VIEW = "slack.view";
public static final String CONNECT = "slack.connect";
public static final String INSTALL = "slack.workspace.install";
public static final String ADMIN = "slack.admin";
public static final String SEND = "slack.send";
public static final String BLOCKS = "slack.send.blocks";
public static final String BODY = "slack.message.body";
public static final List<String> ALL = List.of(VIEW, CONNECT, INSTALL, ADMIN, SEND, BLOCKS, BODY);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackPermissions()
{
}
}
@@ -0,0 +1,110 @@
/*
* 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.preferences;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
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.permissions.SlackPermissionChecker;
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
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.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Core-independent Slack preference section and the services behind its actions.
*******************************************************************************/
public class SlackPreferences
{
/*******************************************************************************
** Read-only content and process references, shared with Connections today.
*******************************************************************************/
public record Section(String name, String label, boolean readOnly, List<QRecord> connections, List<String> actions)
{
}
/*******************************************************************************
** Explicit owner filter keeps an admin's preference list personal too.
*******************************************************************************/
public Section section() throws QException
{
List<QRecord> connections = new QueryAction().execute(new QueryInput(SlackTables.CONNECTION)
.withFilter(SlackStore.equal("userId", owner())).withFieldNamesToInclude(Set.of("id", "workspaceId", "status", "connectedAt"))).getRecords();
List<String> actions = new ArrayList<>();
if(SlackPermissionChecker.has(SlackPermissions.CONNECT))
{
actions.add(SlackProcesses.CONNECT);
actions.add(SlackProcesses.DISCONNECT);
}
if(SlackPermissionChecker.has(SlackPermissions.SEND))
{
actions.add(SlackProcesses.TEST_MESSAGE);
}
if(SlackPermissionChecker.has(SlackPermissions.INSTALL) && SlackPermissionChecker.has(SlackPermissions.CONNECT))
{
actions.add(SlackProcesses.INSTALL);
}
return (new Section("slack", "Slack", true, connections, List.copyOf(actions)));
}
/*******************************************************************************
** Start signed, single-use OAuth for the current user only.
*******************************************************************************/
public static String connect(boolean install)
{
return (SlackConnect.startUrl(owner(), install ? "user+bot" : "user"));
}
/*******************************************************************************
** Queue a DM through the service after reloading the selected owner connection.
*******************************************************************************/
public static SlackSendResult testMessage(Integer connectionId) throws QException
{
QRecord connection = SlackConnect.visible(SlackTables.CONNECTION, connectionId);
if(!owner().equals(connection.getValueString("userId")))
{
throw (new QException("Choose your own Slack connection for a test message."));
}
return (SlackService.enqueue(new SlackSendRequest().withOwnerUserId(owner()).withTo("me")
.withWorkspaceId(connection.getValueInteger("workspaceId")).withText("Your Slack connection is working. This is a test message from QQQ.")));
}
/*******************************************************************************
** Preference actions always belong to a signed-in human user.
*******************************************************************************/
private static String owner()
{
if(QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getIdReference() == null)
{
throw (new IllegalStateException("Sign in to manage Slack preferences."));
}
return (QContext.getQSession().getUser().getIdReference());
}
}
@@ -0,0 +1,66 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.preferences;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.8)
** RC.6 has no preferences SPI. Keep its anticipated registration boundary here,
** and fail explicitly on an incompatible future SPI rather than silently losing
** the section. Connections actions remain available on RC.6.
*******************************************************************************/
public final class SlackPreferencesAdapter
{
public static final String CORE_TYPE = "com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackPreferencesAdapter()
{
}
/*******************************************************************************
** No linkage to missing core classes in the RC.6 artifact.
*******************************************************************************/
public static boolean register(QInstance instance) throws QException
{
try
{
Class<?> type = Class.forName(CORE_TYPE);
Object metadata = type.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance);
type.getMethod("withSection", String.class, String.class, QCodeReference.class)
.invoke(metadata, "slack", "Slack", new QCodeReference(SlackPreferences.class));
return (true);
}
catch(ClassNotFoundException e)
{
return (false);
}
catch(ReflectiveOperationException e)
{
throw (new QException("Update SlackPreferencesAdapter for the installed core preferences SPI.", e));
}
}
}
@@ -57,6 +57,9 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith
*******************************************************************************/
public final class SlackProcesses
{
public static final String CONNECT = "slack_connect";
public static final String INSTALL = "slack_install";
public static final String TEST_MESSAGE = "slack_test_message";
public static final String DISCONNECT = "slack_disconnect";
public static final String UNINSTALL = "slack_uninstall";
public static final String READINESS = "slack_check_readiness";
@@ -96,12 +99,40 @@ public final class SlackProcesses
readiness.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName())
.withRepeatSeconds(config.getReadinessIntervalMinutes() * 60).withInitialDelaySeconds(1));
}
return (List.of(dispatch, readiness, action(DISCONNECT, "Disconnect", SlackTables.CONNECTION),
return (List.of(dispatch, readiness, connectAction(CONNECT, "Connect Slack"), connectAction(INSTALL, "Install for my organization"),
action(TEST_MESSAGE, "Send me a test message", SlackTables.CONNECTION).withMinInputRecords(1).withMaxInputRecords(1), action(DISCONNECT, "Disconnect", SlackTables.CONNECTION),
action(UNINSTALL, "Uninstall", SlackTables.WORKSPACE), action(READINESS, "Check readiness", SlackTables.WORKSPACE)));
}
/*******************************************************************************
** OAuth starts without requiring an existing connection or selected record.
*******************************************************************************/
private static QProcessMetaData connectAction(String name, String label)
{
return (new QProcessMetaData().withName(name).withLabel(label).withTableName(SlackTables.CONNECTION)
.withMinInputRecords(0).withPermissionRules(SlackTables.permissionRules())
.withStep(new QBackendStepMetaData().withName("authorize").withCode(new QCodeReference(ConnectStep.class)))
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Connect Slack")
.withViewField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("authorizeUrl", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING).withLabel("Open this Slack authorization URL"))));
}
/*******************************************************************************
** Return the authorize URL through the standard process UI API.
*******************************************************************************/
public static class ConnectStep implements BackendStep
{
/*******************************************************************************
** No remote mutation happens until the user approves at Slack.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output)
{
output.addValue("authorizeUrl", com.kingsrook.qbits.slack.preferences.SlackPreferences.connect(INSTALL.equals(input.getProcessName())));
}
}
/*******************************************************************************
** The scheduler's entry point.
*******************************************************************************/
@@ -164,7 +195,9 @@ public final class SlackProcesses
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())
boolean permitted = TEST_MESSAGE.equals(input.getProcessName())
? session.hasPermission("slack.send") && session.getUser().getIdReference().equals(row.getValueString("userId"))
: 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)
@@ -201,6 +234,15 @@ public final class SlackProcesses
{
switch(input.getProcessName())
{
case TEST_MESSAGE ->
{
var result = com.kingsrook.qbits.slack.preferences.SlackPreferences.testMessage(row.getValueInteger("id"));
if(result.status() != com.kingsrook.qbits.slack.outbox.SlackSendResult.Status.QUEUED)
{
throw (new QException("Slack test message could not be queued: " + String.join("; ", result.reasons())));
}
output.addValue("messageId", result.messageId());
}
case DISCONNECT -> SlackConnect.disconnect(row.getValueInteger("id"));
case UNINSTALL -> SlackConnect.uninstall(row.getValueInteger("id"));
case READINESS ->
@@ -49,6 +49,18 @@ public final class SlackReadiness
{
private static final Map<QInstance, SlackApiReadiness> STORED = Collections.synchronizedMap(new WeakHashMap<>());
private static final Map<QInstance, Map<Integer, SlackApiReadiness>> WORKSPACES = java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>());
/*******************************************************************************
** Last actual checks, scoped to this instance; never invent a successful check.
*******************************************************************************/
public static SlackApiReadiness workspaceSnapshot(Integer id)
{
return (WORKSPACES.getOrDefault(QContext.getQInstance(), Map.of()).getOrDefault(id,
new SlackApiReadiness(false, List.of(new SlackApiReadinessCheck("workspace:" + id, "UNKNOWN", "Checks have not run since startup"),
new SlackApiReadinessCheck("registeredChannels", "UNKNOWN", "Checks have not run since startup")))));
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
@@ -146,6 +158,7 @@ public final class SlackReadiness
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);
WORKSPACES.computeIfAbsent(QContext.getQInstance(), key -> new java.util.concurrent.ConcurrentHashMap<>()).put(id, result);
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);
@@ -49,6 +49,7 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
*******************************************************************************/
public final class SlackSecurity
{
public static final String KEY_BODY_ACCESS = "slack_body_access";
public static final String KEY_USER = "slack_user";
public static final String KEY_USER_ALL_ACCESS = "slack_user_all_access";
public static final String KEY_TOKEN_ACCESS = "slack_token_access";
@@ -77,7 +78,8 @@ public final class SlackSecurity
{
return (List.of(
new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS),
new QSecurityKeyType().withName(KEY_TOKEN_ACCESS)));
new QSecurityKeyType().withName(KEY_TOKEN_ACCESS),
new QSecurityKeyType().withName(KEY_BODY_ACCESS)));
}
@@ -132,6 +134,7 @@ public final class SlackSecurity
return;
}
applyBodyPermission(session);
if(session.getSecurityKeyValues() != null)
{
session.getSecurityKeyValues().remove(KEY_USER);
@@ -150,6 +153,34 @@ public final class SlackSecurity
/*******************************************************************************
** RC.6 field locks use security keys; derive this key from the body grant.
*******************************************************************************/
public static void applyBodyPermission(QSession session)
{
if(session == null)
{
return;
}
if(session.getSecurityKeyValues() != null)
{
session.getSecurityKeyValues().remove(KEY_BODY_ACCESS);
}
if(session.hasPermission("slack.message.body"))
{
session.withSecurityKeyValue(KEY_BODY_ACCESS, true);
}
}
/*******************************************************************************
** Hide body values and prevent filtering, sorting or exporting without access.
*******************************************************************************/
public static FieldSecurityLock bodyFieldLock()
{
return (new FieldSecurityLock().withSecurityKeyType(KEY_BODY_ACCESS)
.withDefaultBehavior(FieldSecurityLock.Behavior.DENY).withOverrideValues(List.of(true)));
}
/*******************************************************************************
** The value the session holds for the owner key, or null.
*******************************************************************************/
@@ -125,6 +125,11 @@ public final class SlackStore
*******************************************************************************/
public static List<QRecord> queryAsCurrent(String table, QQueryFilter filter) throws QException
{
if(com.kingsrook.qbits.slack.metadata.SlackTables.WORKSPACE.equals(table))
{
return (com.kingsrook.qbits.slack.permissions.SlackPermissionChecker.workspaceLookup(
() -> new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()));
}
return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords());
}
@@ -0,0 +1,402 @@
/*
* 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.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.slack.app.SlackApp;
import com.kingsrook.qbits.slack.app.SlackHome;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker;
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.preferences.SlackPreferences;
import com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qbits.slack.security.SlackSecurity;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
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.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Home, integrations, preferences and record actions with real locked H2 data.
*******************************************************************************/
class SlackAppTest
{
/*******************************************************************************
** Main navigation contains tables only, with full-width typed dashboard sections.
*******************************************************************************/
@Test
void navigation() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
{
}))
{
var app = env.instance().getApp("slack");
assertThat(app.getLabel()).isEqualTo("Slack");
assertThat(app.getChildren()).extracting(QAppChildMetaData::getName).containsExactlyElementsOf(SlackTables.MAIN);
assertThat(app.getWidgets()).containsExactly(SlackApp.HOME_WIDGET, SlackApp.DELIVERIES, SlackApp.RECENT,
SlackApp.READINESS, SlackApp.OUTBOX, SlackApp.FAILURES);
assertThat(SlackApp.widgets()).allMatch(widget -> widget.getGridColumns() == 12);
assertThat(SlackApp.widgets()).extracting(widget -> widget.getType())
.containsExactly("table", "multiStatistics", "table", "table", "multiStatistics", "table");
assertThat(env.instance().getWidget(SlackApp.HOME_WIDGET).getGridColumns()).isEqualTo(12);
assertThat(env.instance().getWidget(SlackApp.HOME_WIDGET).getType()).isEqualTo("table");
assertThat(env.instance().getProcess(SlackProcesses.CONNECT).getTableName()).isEqualTo(SlackTables.CONNECTION);
assertThat(env.instance().getProcess(SlackProcesses.TEST_MESSAGE).getTableName()).isEqualTo(SlackTables.CONNECTION);
}
}
/*******************************************************************************
** Every main API/ESB table has an explicit projection with no token columns.
*******************************************************************************/
@Test
void integrationFields() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
{
}))
{
assertThat(SlackIntegrations.apiTableNames()).containsExactlyElementsOf(SlackTables.MAIN);
assertThat(SlackIntegrations.esbTableNames()).containsExactlyElementsOf(SlackTables.MAIN);
env.instance().getTable(SlackTables.WORKSPACE).getField(SlackTables.BOT_TOKEN).setIsHidden(false);
env.instance().getTable(SlackTables.CONNECTION).getField(SlackTables.USER_TOKEN).setIsHidden(false);
for(String table : SlackTables.MAIN)
{
assertThat(SlackIntegrations.apiFieldNames(env.instance(), table)).contains("id").noneMatch(f -> f.toLowerCase().contains("token"));
assertThat(SlackIntegrations.esbFieldNames(env.instance(), table)).doesNotContain("text", "blocksJson", "variablesSnapshot");
}
assertThat(SlackIntegrations.apiFieldNames(env.instance(), SlackTables.ATTEMPT)).isEmpty();
assertThat(SlackIntegrations.quickSearchTables()).extracting(SlackIntegrations.SearchableTable::tableName)
.containsExactly(SlackTables.MESSAGE, SlackTables.CONNECTION);
}
}
/*******************************************************************************
** A personal dashboard cannot see another owner even within the same tenant.
*******************************************************************************/
@Test
void personalCountsAndLastTen() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(true, c ->
{
}))
{
env.tenant("alice", 1);
env.tenant("bob", 1);
int workspace = env.workspace("T1", 1, "xoxb-demo", "BOT");
int connection = env.connection("alice", workspace, "UA", null);
env.set(SlackTables.CONNECTION, connection, "connectedAt", Instant.parse("2026-10-02T17:49:54.182409Z"));
env.connection("bob", workspace, "UB", null);
int hidden = message(env, workspace, "bob", "SENT", env.clock().instant());
List<Integer> ids = new ArrayList<>();
for(int i = 0; i < 12; i++)
{
ids.add(message(env, workspace, "alice", "SENT", env.clock().instant().minusSeconds(60 - i)));
}
env.asOwner("alice");
assertThat(SlackHome.count("SENT", "alice", "createDate")).isEqualTo(12);
QContext.getQSession().withValue(QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago");
TableData connections = (TableData) SlackHome.render(SlackApp.HOME_WIDGET);
assertThat(connections.getRows()).hasSize(1);
assertThat(connections.getRows().getFirst().get("workspace").toString()).contains("Team T1", "/app/slack_connection/");
assertThat(connections.getRows().getFirst().get("status")).isEqualTo("Active");
assertThat(connections.getRows().getFirst().get("connected")).isEqualTo("Oct 2, 2026, 12:49 PM CDT");
MultiStatisticsData deliveries = (MultiStatisticsData) SlackHome.render(SlackApp.DELIVERIES);
assertThat(deliveries.getStatisticsGroupData()).hasSize(1);
var sent = deliveries.getStatisticsGroupData().getFirst();
assertThat(sent.getHeader()).isEqualTo("Sent");
assertThat(sent.getStatisticList().getFirst().getValue()).isEqualTo(12);
assertThat(URLDecoder.decode(sent.getStatisticList().getFirst().getUrl(), StandardCharsets.UTF_8))
.contains("ownerUserId", "alice", "SENT", "createDate", "GREATER_THAN_OR_EQUALS", "LESS_THAN_OR_EQUALS");
TableData recent = (TableData) SlackHome.render(SlackApp.RECENT);
assertThat(recent.getRows()).hasSize(10);
assertThat(recent.getRows().toString()).doesNotContain("/app/slack_message/" + hidden + "\"", "/app/slack_message/" + ids.getFirst() + "\"", "body-canary");
assertThat(recent.getRows()).allSatisfy(row ->
{
assertThat(row.get("status")).isEqualTo("Sent");
assertThat(row.get("when").toString()).containsPattern("[A-Z][a-z]{2} \\d{1,2}, \\d{4}, ").doesNotContain("T00:");
});
assertThat(recent.getLinkText()).isEqualTo("View all");
assertThat(URLDecoder.decode(recent.getLinkURL(), StandardCharsets.UTF_8)).contains("alice", "ownerUserId");
assertThat(new SlackApp.HomeRenderer().render(new RenderWidgetInput().withWidgetMetaData(env.instance().getWidget(SlackApp.RECENT)))).isNotNull();
for(String name : SlackApp.ADMIN_WIDGETS)
{
assertThatThrownBy(() -> SlackHome.render(name)).hasMessageContaining("slack.admin");
}
}
}
/*******************************************************************************
** Tenant admin totals include other owners in their tenant, never tenant two.
*******************************************************************************/
@Test
void adminTenantCounts() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(true, c ->
{
}))
{
env.tenant("alice", 1);
int one = env.workspace("T1", 1, "xoxb-one", "B1");
int two = env.workspace("T2", 2, "xoxb-two", "B2");
message(env, one, "alice", "FAILED_AUTH", env.clock().instant());
message(env, one, "bob", "FAILED_AUTH", env.clock().instant());
int hidden = message(env, two, "carol", "FAILED_AUTH", env.clock().instant());
var session = env.asOwner("alice").withPermissions(SlackPermissions.ADMIN);
SlackSecurity.applyToSession(session, "alice", true);
assertThat(SlackHome.count("FAILED_AUTH", null, "modifyDate")).isEqualTo(2);
assertThat(SlackHome.count("FAILED_AUTH", "alice", "createDate")).isEqualTo(1);
TableData readiness = (TableData) SlackHome.render(SlackApp.READINESS);
assertThat(readiness.getRows()).hasSize(2);
assertThat(readiness.getRows().toString()).contains("Team T1", "NOT READY", "Run Check readiness", "Bot token and scopes", "Registered channels")
.doesNotContain("Team T2", "workspace:", "UNKNOWN");
TableData failures = (TableData) SlackHome.render(SlackApp.FAILURES);
assertThat(failures.getRows()).hasSize(2);
assertThat(failures.getRows().toString()).contains("Failed: not authorized").doesNotContain("FAILED_AUTH", "/app/slack_message/" + hidden + "\"");
MultiStatisticsData outbox = (MultiStatisticsData) SlackHome.render(SlackApp.OUTBOX);
assertThat(outbox.getStatisticsGroupData()).hasSize(5);
assertThat(outbox.getStatisticsGroupData().get(4).getStatisticList().getFirst().getValue()).isEqualTo(2);
assertThat(outbox.getStatisticsGroupData()).allSatisfy(group ->
assertThat(group.getStatisticList().getFirst().getUrl()).startsWith("/app/slack_message?filter="));
}
}
/*******************************************************************************
** Empty states, readable delivery labels and readiness remediation follow status.
*******************************************************************************/
@Test
void typedEmptyAndReadyStates() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
{
}))
{
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
env.asOwner("alice");
assertThat(((TableData) SlackHome.render(SlackApp.HOME_WIDGET)).getRows()).isEmpty();
assertThat(((TableData) SlackHome.render(SlackApp.RECENT)).getNoRowsFoundHTML()).isEqualTo("No deliveries.");
var empty = (MultiStatisticsData) SlackHome.render(SlackApp.DELIVERIES);
assertThat(empty.getStatisticsGroupData()).hasSize(1);
assertThat(empty.getStatisticsGroupData().getFirst().getHeader()).isEqualTo("Sent");
assertThat(empty.getStatisticsGroupData().getFirst().getStatisticList().getFirst().getValue()).isZero();
message(env, workspace, "alice", "WAITING_PARENT", env.clock().instant());
env.asOwner("alice");
assertThat(((TableData) SlackHome.render(SlackApp.RECENT)).getRows().getFirst().get("status")).isEqualTo("Waiting for parent");
for(String scopes : List.of(com.kingsrook.qbits.slack.oauth.SlackConnect.BOT_SCOPES + ",usergroups:read,chat:write.customize",
com.kingsrook.qbits.slack.oauth.SlackConnect.BOT_SCOPES, "channels:read"))
{
env.asSystem();
env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\"}", java.util.Map.of("x-oauth-scopes", scopes)));
com.kingsrook.qbits.slack.readiness.SlackReadiness.workspace(workspace);
env.asOwner("alice").withPermissions(SlackPermissions.ADMIN);
TableData readiness = (TableData) SlackHome.render(SlackApp.READINESS);
String expected = scopes.equals("channels:read") ? "NOT READY" : scopes.contains("usergroups:read") ? "READY" : "DEGRADED";
assertThat(readiness.getRows().getFirst().get("status")).isEqualTo(expected);
assertThat(readiness.getRows()).allSatisfy(row ->
{
if("READY".equals(row.get("status")))
{
assertThat(row.get("fix")).isEqualTo("");
}
else
{
assertThat(row.get("fix").toString()).contains("Run Check readiness");
}
});
}
}
}
/*******************************************************************************
** Last-day counts exclude future and old data and include the exact boundary.
*******************************************************************************/
@Test
void timeBounds() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
{
}))
{
int workspace = env.workspace("T", null, "xoxb-demo", "B");
Instant now = env.clock().instant();
for(long seconds : List.of(-86401L, -86400L, 0L, 1L))
{
message(env, workspace, "alice", "SENT", now.plusSeconds(seconds));
}
env.asOwner("alice");
assertThat(SlackHome.count("SENT", "alice", "sentDate")).isEqualTo(2);
}
}
/*******************************************************************************
** Body locks protect get, query and filter inference, including administrators.
*******************************************************************************/
@Test
void bodyPermission() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
{
}))
{
int workspace = env.workspace("T", null, "xoxb-demo", "B");
int id = message(env, workspace, "alice", "QUEUED", env.clock().instant());
var session = env.asOwner("alice").withPermissions(SlackPermissions.ADMIN);
QRecord row = new GetAction().execute(new GetInput(SlackTables.MESSAGE).withPrimaryKey(id).withShouldFetchHeavyFields(true)).getRecord();
assertThat(row.getValues()).doesNotContainKeys("text", "blocksJson", "variablesSnapshot");
assertThat(new QueryAction().execute(new QueryInput(SlackTables.MESSAGE).withShouldFetchHeavyFields(true)).getRecords().getFirst().getValues())
.doesNotContainKeys("text", "blocksJson", "variablesSnapshot");
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SlackTables.MESSAGE).withFilter(SlackStore.equal("text", "body-canary"))))
.hasMessageContaining("Permission denied");
session.withPermissions(SlackPermissions.BODY);
row = new GetAction().execute(new GetInput(SlackTables.MESSAGE).withPrimaryKey(id).withShouldFetchHeavyFields(true)).getRecord();
assertThat(row.getValueString("text")).isEqualTo("body-canary");
assertThat(row.getValueString("blocksJson")).isEqualTo("[]");
assertThat(row.getValueString("variablesSnapshot")).isEqualTo("{}");
session.setPermissions(Set.of(SlackPermissions.VIEW));
row = new GetAction().execute(new GetInput(SlackTables.MESSAGE).withPrimaryKey(id).withShouldFetchHeavyFields(true)).getRecord();
assertThat(row.getValues()).doesNotContainKey("text");
}
}
/*******************************************************************************
** RC.6 skips registration; personal section exposes only permitted actions.
*******************************************************************************/
@Test
void preferencesAndConnect() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl("http://localhost:8099")
.withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "demo-secret")))
{
assertThat(SlackPreferencesAdapter.register(env.instance())).isFalse();
int workspace = env.workspace("T", null, "xoxb-demo", "B");
env.connection("alice", workspace, "UA", null);
env.connection("bob", workspace, "UB", null);
var session = env.asOwner("alice").withPermissions(SlackPermissions.CONNECT);
var section = new SlackPreferences().section();
assertThat(section.name()).isEqualTo("slack");
assertThat(section.readOnly()).isTrue();
assertThat(section.connections()).hasSize(1);
assertThat(section.actions()).contains(SlackProcesses.CONNECT, SlackProcesses.DISCONNECT, SlackProcesses.TEST_MESSAGE).doesNotContain(SlackProcesses.INSTALL);
var output = new RunBackendStepOutput();
new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.CONNECT), output);
assertThat(output.getValueString("authorizeUrl")).startsWith("https://slack.com/oauth/v2/authorize?").contains("state=");
assertThatThrownBy(() -> SlackPreferences.connect(true)).hasMessageContaining("not permitted");
session.withPermissions(SlackPermissions.INSTALL);
assertThat(new SlackPreferences().section().actions()).contains(SlackProcesses.INSTALL);
new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.INSTALL), output);
assertThat(output.getValueString("authorizeUrl")).contains("scope=");
}
}
/*******************************************************************************
** Record action queues one self DM, never posts inline or sends as another owner.
*******************************************************************************/
@Test
void testMessageAction() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
{
}))
{
int workspace = env.workspace("T", null, "xoxb-demo", "B");
int own = env.connection("alice", workspace, "UA", null);
int other = env.connection("bob", workspace, "UB", null);
var session = env.asOwner("alice").withPermissions(SlackPermissions.ADMIN);
SlackSecurity.applyToSession(session, "alice", true);
var input = new RunBackendStepInput().withProcessName(SlackProcesses.TEST_MESSAGE);
input.setRecords(List.of(new QRecord().withValue("id", own).withValue("userId", "alice")));
var reviewed = new RunBackendStepOutput().withRecords(new ArrayList<>());
var transform = new SlackProcesses.ActionTransform();
transform.runOnePage(input, reviewed);
assertThat(reviewed.getRecords()).hasSize(1);
var output = new RunBackendStepOutput().withRecords(new ArrayList<>());
new SlackProcesses.ActionLoad().runOnePage(input, output);
QRecord message = SlackStore.get(SlackTables.MESSAGE, output.getValueInteger("messageId"));
assertThat(message.getValueString("status")).isEqualTo("QUEUED");
assertThat(message.getValueString("ownerUserId")).isEqualTo("alice");
assertThat(message.getValueString("targetRef")).isEqualTo("me");
assertThat(env.slack().requests()).isEmpty();
assertThatThrownBy(() -> SlackPreferences.testMessage(other)).hasMessageContaining("own Slack connection");
}
}
/*******************************************************************************
** Metadata permission rules reject writes and do not accept the old access grant.
*******************************************************************************/
@Test
void explicitPermissions() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
{
}))
{
var session = env.asOwner("alice");
var checker = new SlackPermissionChecker();
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE)))
.hasMessageContaining("slack.admin");
assertThat(SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE)))
.hasMessageContaining("slack.admin");
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new UpdateInput(SlackTables.CONNECTION), env.instance().getTable(SlackTables.CONNECTION)))
.hasMessageContaining("record action");
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new UpdateInput(SlackTables.TEMPLATE), env.instance().getTable(SlackTables.TEMPLATE)))
.hasMessageContaining("slack.admin");
session.withPermissions(SlackPermissions.ADMIN);
checker.checkPermissionsThrowing(new UpdateInput(SlackTables.TEMPLATE), env.instance().getTable(SlackTables.TEMPLATE));
session.setPermissions(Set.of("slack.hasAccess"));
assertThatThrownBy(() -> SlackHome.render(SlackApp.HOME_WIDGET)).hasMessageContaining("slack.view");
}
}
/*******************************************************************************
** Seed metadata and canary bodies as the system, retaining caller timestamps.
*******************************************************************************/
private int message(SlackTestEnv env, int workspace, String owner, String status, Instant at) throws Exception
{
env.asSystem();
int id = SlackH2Support.insert(SlackTables.MESSAGE, new QRecord().withValue("workspaceId", workspace).withValue("ownerUserId", owner)
.withValue("status", status).withValue("createDate", at).withValue("modifyDate", at).withValue("sentDate", at)
.withValue("deadLetterDate", at).withValue("text", "body-canary").withValue("blocksJson", "[]").withValue("variablesSnapshot", "{}"))
.getValueInteger("id");
SlackH2Support.messageDates(id, at);
return (id);
}
}
@@ -170,6 +170,10 @@ public final class SlackH2Support
{
instance.setEnforcePermissionsInCoreActions(true);
QSession session = new QSession().withPermissions("slack.hasAccess", "slack.view");
if(allUsers)
{
session.withPermissions("slack.admin");
}
SlackSecurity.applyToSession(session, userId, allUsers);
if(tenantId != null)
{
@@ -196,6 +200,22 @@ public final class SlackH2Support
/*******************************************************************************
** Deterministic fixture dates after core applies its current-time behaviors.
*******************************************************************************/
public static void messageDates(Integer id, java.time.Instant at) throws Exception
{
RDBMSBackendMetaData backend = (RDBMSBackendMetaData) QContext.getQInstance().getBackend("rdbms");
try(Connection connection = new ConnectionManager().getConnection(backend);
var statement = connection.prepareStatement("UPDATE slack_message SET create_date = ?, modify_date = ? WHERE id = ?"))
{
statement.setTimestamp(1, java.sql.Timestamp.from(at));
statement.setTimestamp(2, java.sql.Timestamp.from(at));
statement.setInt(3, id);
statement.executeUpdate();
}
}
/*******************************************************************************
** Query a table as the current context.
*******************************************************************************/
@@ -103,7 +103,7 @@ class SlackProviderTest
@Test
void testSendMessageActionQueuesRowForSessionUser() throws Exception
{
env.asOwner("userA");
env.asOwner("userA").withPermissions("slack.message.body");
SendMessageOutput output = new SendMessageAction().execute(input(new Party().withAddress("me")));
assertThat(output).isNotNull();
@@ -191,8 +191,8 @@ class SlackSecurityTest
// the system user's ordinary query does not return them either (hidden fields are omitted by default)
assertThat(SlackH2Support.query(SlackTables.WORKSPACE).get(0).getValues()).doesNotContainKey("botToken");
// a user session: values, fields and metadata
SlackH2Support.asUser(instance, "userA", null, false);
// even an administrator cannot access token values, fields or metadata
SlackH2Support.asUser(instance, "userA", null, false).withPermissions("slack.admin");
for(QRecord record : SlackH2Support.query(SlackTables.WORKSPACE))
{
assertThat(record.getValues()).doesNotContainKey("botToken");
@@ -247,7 +247,7 @@ class SlackSecurityTest
assertThat(cipher.decrypt(row.getValueString("botToken"), binding)).isEqualTo("xoxb-FAKE-ROUNDTRIP");
assertThatThrownBy(() -> cipher.decrypt(row.getValueString("botToken"), "slack_workspace:" + (workspaceId + 1) + ":1")).isInstanceOf(QException.class);
SlackH2Support.asUser(instance, "userA", null, false);
SlackH2Support.asUser(instance, "userA", null, false).withPermissions("slack.admin");
QRecord asUser = SlackH2Support.query(SlackTables.WORKSPACE).stream().filter(r -> r.getValueInteger("id") == workspaceId).findFirst().orElseThrow();
assertThat(asUser.getValues()).doesNotContainKey("botToken");
assertThat(asUser.getValueString("botTokenFingerprint")).hasSize(8);
@@ -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.demo;
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import com.kingsrook.qbits.slack.FakeSlackServer;
import com.kingsrook.qbits.slack.SlackH2Support;
import com.kingsrook.qbits.slack.SlackTestEnv;
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.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
import com.kingsrook.qbits.slack.security.SlackSecurity;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.template.SlackTemplates;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.FullyAnonymousAuthenticationModule;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
/*******************************************************************************
** Local screenshot server: in-memory H2, fake Slack and Next UI. No live tokens.
** Start using exec:java with test classpath and this class, optionally a port.
*******************************************************************************/
public class SlackDemoServer extends AbstractQQQApplication
{
private final QInstance instance;
/*******************************************************************************
** Reuse the seeded instance on every server lifecycle request.
*******************************************************************************/
public SlackDemoServer(QInstance instance)
{
this.instance = instance;
}
/*******************************************************************************
** The demo's already validated instance.
*******************************************************************************/
@Override
public QInstance defineQInstance()
{
return (instance);
}
/*******************************************************************************
** Anonymous access is exclusively for this local screenshot demo.
*******************************************************************************/
public static class DemoAuthenticationModule extends FullyAnonymousAuthenticationModule
{
/*******************************************************************************
** A named tenant administrator, with normal owner and tenant locks.
*******************************************************************************/
@Override
public QSession createSession(QInstance instance, Map<String, String> context)
{
QSession session = super.createSession(instance, context).withPermissions(SlackPermissions.ALL)
.withUser(new QUser().withIdReference("alex").withFullName("Alex Morgan"))
.withSecurityKeyValue(SlackH2Support.TENANT_KEY, 1);
SlackSecurity.applyToSession(session, "alex", true);
return (session);
}
}
/*******************************************************************************
** Realistic fixture states are stable for screenshots; transport stays local.
*******************************************************************************/
public static void seed(SlackTestEnv env) throws Exception
{
SlackRuntime.setClock(Clock.systemUTC());
env.tenant("alex", 1);
env.tenant("jordan", 1);
int operations = env.workspace("T_OPERATIONS", 1, "xoxb-demo-operations", "UBOTOPS");
int community = env.workspace("T_COMMUNITY", 1, "xoxb-demo-community", "UBOTCOMM");
env.set(SlackTables.WORKSPACE, operations, "teamName", "QRun Operations");
env.set(SlackTables.WORKSPACE, community, "teamName", "Community Projects");
int alex = env.connection("alex", operations, "UALEX", "xoxp-demo-alex");
int second = env.connection("alex", community, "UALEXCOMM", "xoxp-demo-alex-community");
env.connection("jordan", operations, "UJORDAN", "xoxp-demo-jordan");
env.set(SlackTables.CONNECTION, alex, "connectedAt", Instant.now().minusSeconds(86400 * 12));
env.set(SlackTables.CONNECTION, second, "connectedAt", Instant.now().minusSeconds(86400 * 3));
env.channel(operations, "operations", "COPS", "operations");
env.channel(community, "project-updates", "CPROJECTS", "project-updates");
env.userGroup(operations, "on-call", "SONCALL", "COPS");
env.slack().members("COPS", "UBOTOPS", "UALEX", "UJORDAN");
env.slack().members("CPROJECTS", "UBOTCOMM", "UALEXCOMM");
SlackTemplates.seed();
int sequence = 0;
for(var status : SlackEnums.MessageStatus.values())
{
for(int i = 0; i < 3; i++)
{
Instant at = Instant.now().minusSeconds(++sequence * 900L);
QRecord message = SlackH2Support.insert(SlackTables.MESSAGE, new QRecord()
.withValue("ownerUserId", i == 2 ? "jordan" : "alex").withValue("workspaceId", operations)
.withValue("connectionId", i == 2 ? null : alex).withValue("postAs", "BOT").withValue("operation", "POST")
.withValue("targetType", "CHANNEL").withValue("targetRef", "channel:" + operations + "/COPS")
.withValue("slackChannelId", "COPS").withValue("channelLabel", "#operations").withValue("status", status.name())
.withValue("text", "Project update " + sequence + ": the weekly operations report is ready.")
.withValue("createDate", at).withValue("modifyDate", at).withValue("attemptCount", 1)
.withValue("sentDate", status == SlackEnums.MessageStatus.SENT ? at : null)
.withValue("deadLetterDate", status == SlackEnums.MessageStatus.DEAD_LETTER ? at : null)
.withValue("lastError", status == SlackEnums.MessageStatus.FAILED_AUTH ? "Reconnect Slack to restore delivery." : null));
SlackH2Support.messageDates(message.getValueInteger("id"), at);
SlackH2Support.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", message.getValueInteger("id"))
.withValue("attemptNumber", 1).withValue("outcome", status == SlackEnums.MessageStatus.SENT ? "SENT" : "TRANSIENT_FAILURE")
.withValue("startDate", at).withValue("endDate", at.plusSeconds(1)));
}
}
env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T_OPERATIONS\"}",
Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES + ",usergroups:read,chat:write.customize")), FakeSlackServer.Reply.error("token_revoked"));
env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{\"is_archived\":false,\"is_member\":true}}"),
FakeSlackServer.Reply.error("not_in_channel"));
SlackReadiness.workspace(operations);
SlackReadiness.workspace(community);
}
/*******************************************************************************
** Start the same Javalin/Next wiring as the email screenshot application.
*******************************************************************************/
public static void main(String[] args) throws Exception
{
int port = args.length == 0 ? 8099 : Integer.parseInt(args[0]);
SlackTestEnv env = SlackTestEnv.create(true, config -> config.withPublicBaseUrl("http://localhost:" + port)
.withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? Base64.getEncoder().encodeToString(new byte[32]) : "local-demo-only"));
Runtime.getRuntime().addShutdownHook(new Thread(env::close));
seed(env);
QInstance instance = env.instance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)
.withCustomizer(new QCodeReference(SlackTestEnv.TestAuthCustomizer.class)));
instance.setHomeAppName("slack");
instance.getProcesses().values().forEach(process -> process.setSchedule(null));
var field = QAuthenticationModuleDispatcher.class.getDeclaredField("authenticationTypeToModuleClassNameMap");
field.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, String> modules = (Map<String, String>) field.get(null);
modules.put(QAuthenticationType.FULLY_ANONYMOUS.getName(), DemoAuthenticationModule.class.getName());
QContext.clear();
new QApplicationJavalinServer(new SlackDemoServer(instance)).withPort(port).withServeFrontendNext(true).start();
System.out.println("Slack demo: http://localhost:" + port + "/app/slack");
}
}