Files
probo/pkg/server/api/slack/v1/slack_handler.go
Bryan Frimin ce1b64b529 Update GraphQL, MCP, and CLI for the portal
Rewire the console and visitor resolvers onto the management and visitor
services with compliance-portal authorization. Rename the GraphQL and MCP
ComplianceExternalURL type to ComplianceCustomLink, expose trust center
profile fields, default and custom domains, public URL, and the managed
flag, and drop the profile fields from the organization surface.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-21 15:44:08 +02:00

302 lines
10 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package slack_v1
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
trust "go.probo.inc/probo/pkg/complianceportal/visitor"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/slack"
)
type (
SlackInteractivePayload struct {
ResponseURL string `json:"response_url"`
Actions []struct {
ActionID string `json:"action_id"`
Value string `json:"value"`
SelectedOption struct {
Value string `json:"value"`
} `json:"selected_option"`
} `json:"actions"`
Container struct {
MessageTS string `json:"message_ts"`
ChannelID string `json:"channel_id"`
} `json:"container"`
}
SlackInteractiveResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
)
const (
StatusAccept = "accept"
StatusReject = "reject"
)
func SlackHandler(slackSvc *slack.Service, slackSigningSecret string, logger *log.Logger, trustSvc *trust.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "cannot read request body"})
return
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
timestamp := r.Header.Get("X-Slack-Request-Timestamp")
signature := r.Header.Get("X-Slack-Signature")
if timestamp == "" || signature == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing Slack signature headers"})
return
}
if err := slack.VerifySignature(slackSigningSecret, timestamp, signature, bodyBytes); err != nil {
logger.ErrorCtx(ctx, "invalid Slack signature", log.Error(err))
httpserver.RenderJSON(w, http.StatusUnauthorized, SlackInteractiveResponse{Success: false, Message: "invalid Slack signature"})
return
}
var slackPayload SlackInteractivePayload
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "unsupported content type"})
return
}
if err := r.ParseForm(); err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "cannot parse form"})
return
}
raw := r.FormValue("payload")
if raw == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "empty payload field"})
return
}
if err := json.NewDecoder(strings.NewReader(raw)).Decode(&slackPayload); err != nil {
logger.ErrorCtx(ctx, "cannot parse Slack payload", log.Error(err))
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "cannot parse Slack payload"})
return
}
// Slack sends empty action for url button clicks
if len(slackPayload.Actions) == 0 {
httpserver.RenderJSON(w, http.StatusOK, SlackInteractiveResponse{Success: true, Message: "no action required"})
return
}
action := slackPayload.Actions[0]
if action.Value == "" && action.SelectedOption.Value == "" {
httpserver.RenderJSON(w, http.StatusOK, SlackInteractiveResponse{Success: true, Message: "no action required"})
return
}
if slackPayload.Container.MessageTS == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing message_ts"})
return
}
if slackPayload.Container.ChannelID == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing channel_id"})
return
}
if slackPayload.ResponseURL == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing response_url"})
return
}
initialSlackMessage, err := slackSvc.GetInitialSlackMessageByChannelAndTS(ctx, slackPayload.Container.ChannelID, slackPayload.Container.MessageTS)
if err != nil {
logger.ErrorCtx(ctx, "cannot load slack message", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
//TODO: Update the message when it is too old to be updated
fourteenDaysAgo := time.Now().Add(-14 * 24 * time.Hour)
if initialSlackMessage.CreatedAt.Before(fourteenDaysAgo) {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "this message is too old to be updated (older than 14 days)"})
return
}
if initialSlackMessage.RequesterEmail == nil {
logger.ErrorCtx(ctx, "missing requester email", log.String("slack_message_id", initialSlackMessage.ID.String()))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
requesterEmail := *initialSlackMessage.RequesterEmail
scope := coredata.NewScopeFromObjectID(initialSlackMessage.OrganizationID)
var (
documentIDs []gid.GID
reportIDs []gid.GID
fileIDs []gid.GID
statusAction string
)
// accept_all, reject_all
if strings.HasSuffix(action.ActionID, "_all") {
currentMessageId, err := gid.ParseGID(action.Value)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid message ID"})
return
}
documentIDs, reportIDs, fileIDs, err = slackSvc.GetSlackMessageDocumentIDs(ctx, scope, currentMessageId)
if err != nil {
logger.ErrorCtx(ctx, "cannot load slack message document ids", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
if strings.HasPrefix(action.ActionID, "accept_") {
statusAction = StatusAccept
} else {
statusAction = StatusReject
}
} else {
var gID gid.GID
// handle_<document|report|file> is used in an overflow menu (a select) to choose between grant and reject on requested accesses
if strings.HasPrefix(action.ActionID, "handle_") {
// action value is the select option value. <accept|reject>-<ID>
params := strings.Split(action.SelectedOption.Value, "/")
if len(params) < 2 {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid selected option format"})
return
}
statusAction = params[0]
gID, err = gid.ParseGID(params[1])
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid ID"})
return
}
} else {
// accept_<document|report|file>, reject_<document|report|file>, revoke_<document|report|file>
gID, err = gid.ParseGID(action.Value)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid ID"})
return
}
if strings.HasPrefix(action.ActionID, "accept_") {
statusAction = StatusAccept
} else {
statusAction = StatusReject
}
}
switch gID.EntityType() {
case coredata.DocumentEntityType:
documentIDs = []gid.GID{gID}
case coredata.FileEntityType:
reportIDs = []gid.GID{gID}
case coredata.TrustCenterFileEntityType:
fileIDs = []gid.GID{gID}
default:
logger.ErrorCtx(ctx, "unknown entity type", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
}
switch statusAction {
case StatusAccept:
if err := trustSvc.GrantPortalAccessByIDs(
ctx,
scope,
initialSlackMessage.OrganizationID,
requesterEmail,
documentIDs,
reportIDs,
fileIDs,
); err != nil {
logger.ErrorCtx(ctx, "cannot grant access", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
case StatusReject:
if err := trustSvc.RejectOrRevokePortalAccessByIDs(
ctx,
scope,
initialSlackMessage.OrganizationID,
requesterEmail,
documentIDs,
reportIDs,
fileIDs,
); err != nil {
logger.ErrorCtx(ctx, "cannot reject access", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
default:
logger.ErrorCtx(ctx, "unknown status action", log.String("status_action", statusAction))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
if err := slackSvc.UpdateSlackAccessMessage(
ctx,
scope,
initialSlackMessage.ID,
slackPayload.ResponseURL,
requesterEmail,
); err != nil {
logger.ErrorCtx(ctx, "cannot update Slack message", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
httpserver.RenderJSON(w, http.StatusOK, SlackInteractiveResponse{Success: true, Message: "Access granted"})
}
}