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>
302 lines
10 KiB
Go
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"})
|
|
}
|
|
}
|