Files
probo/pkg/server/api/trust/v1/slack_handler.go
Émile Ré 1ed291aa0e Fix cubic review
Signed-off-by: Émile Ré <nemile.re@gmail.com>
2025-12-11 16:24:36 +01:00

278 lines
9.9 KiB
Go

// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package trust_v1
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/trust"
)
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(trustSvc *trust.Service, slackSigningSecret string, logger *log.Logger) 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 := trustSvc.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 || *initialSlackMessage.RequesterEmail == "" {
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
tenantSvc := trustSvc.WithTenant(initialSlackMessage.OrganizationID.TenantID())
var documentIDs []gid.GID
var reportIDs []gid.GID
var fileIDs []gid.GID
var 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 = tenantSvc.SlackMessages.GetSlackMessageDocumentIDs(ctx, 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 = "accept"
} else {
statusAction = "reject"
}
} 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.ReportEntityType:
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 := tenantSvc.TrustCenterAccesses.GrantByIDs(
ctx,
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 := tenantSvc.TrustCenterAccesses.RejectOrRevokeByIDs(
ctx,
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 access", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
if err := tenantSvc.SlackMessages.UpdateSlackAccessMessage(
ctx,
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"})
}
}