Add slack integration

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-10-15 23:41:59 +02:00
parent 8302b11614
commit de004ce8d7
38 changed files with 2621 additions and 1578 deletions

View File

@@ -44,6 +44,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
)
@@ -218,7 +219,12 @@ func NewMux(
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
connectorID := r.URL.Query().Get("connector_id")
provider := r.URL.Query().Get("provider")
if provider != "SLACK" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
return
}
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
if err != nil {
panic(fmt.Errorf("failed to parse organization id: %w", err))
@@ -226,34 +232,53 @@ func NewMux(
_ = GetTenantService(r.Context(), proboSvc, organizationID.TenantID())
redirectURL, err := connectorRegistry.Initiate(r.Context(), connectorID, organizationID, r)
redirectURL, err := connectorRegistry.Initiate(r.Context(), provider, organizationID, r)
if err != nil {
panic(fmt.Errorf("cannot initiate connector: %w", err))
}
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
// Allow external redirects for Slack OAuth only for now
slackSafeRedirect := &saferedirect.SafeRedirect{AllowedHost: "slack.com"}
slackSafeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
}))
r.Get("/connectors/complete", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
connectorID := r.URL.Query().Get("connector_id")
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
if err != nil {
panic(fmt.Errorf("failed to parse organization id: %w", err))
r.Get("/connectors/complete", func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing provider parameter"))
return
}
connection, err := connectorRegistry.Complete(r.Context(), connectorID, organizationID, r)
var connectorProvider coredata.ConnectorProvider
switch provider {
case "SLACK":
connectorProvider = coredata.ConnectorProviderSlack
default:
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
return
}
stateToken := r.URL.Query().Get("state")
if stateToken == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing state parameter"))
return
}
connection, organizationID, err := connectorRegistry.Complete(r.Context(), provider, r)
if err != nil {
panic(fmt.Errorf("failed to complete connector: %w", err))
}
svc := GetTenantService(r.Context(), proboSvc, organizationID.TenantID())
continueURL := r.URL.Query().Get("continue")
_, err = svc.Connectors.CreateOrUpdate(
svc := proboSvc.WithTenant(organizationID.TenantID())
_, err = svc.Connectors.Create(
r.Context(),
probo.CreateOrUpdateConnectorRequest{
OrganizationID: organizationID,
Name: connectorID,
Type: connector.ProtocolType(connection.Type()),
probo.CreateConnectorRequest{
OrganizationID: *organizationID,
Provider: connectorProvider,
Protocol: coredata.ConnectorProtocol(connection.Type()),
Connection: connection,
},
)
@@ -261,8 +286,13 @@ func NewMux(
panic(fmt.Errorf("failed to create or update connector: %w", err))
}
safeRedirect.RedirectFromQuery(w, r, "continue", "/", http.StatusSeeOther)
}))
if continueURL != "" {
safeRedirect.Redirect(w, r, continueURL, "/", http.StatusSeeOther)
} else {
redirectURL := fmt.Sprintf("/organizations/%s", organizationID.String())
safeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
}
})
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, authCfg, customDomainCname))

View File

@@ -555,20 +555,6 @@ enum OrganizationOrderField
)
}
enum ConnectorOrderField
@goModel(
model: "github.com/getprobo/probo/pkg/coredata.ConnectorOrderField"
) {
CREATED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.ConnectorOrderFieldCreatedAt"
)
NAME
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.ConnectorOrderFieldName"
)
}
enum DataSensitivity
@goModel(model: "github.com/getprobo/probo/pkg/coredata.DataSensitivity") {
NONE
@@ -1473,11 +1459,6 @@ input OrganizationOrder {
field: OrganizationOrderField!
}
input ConnectorOrder {
field: ConnectorOrderField!
direction: OrderDirection!
}
input DocumentVersionOrder
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.DocumentVersionOrderBy"
@@ -1621,13 +1602,12 @@ type Organization implements Node {
filter: InvitationFilter
): InvitationConnection! @goField(forceResolver: true)
connectors(
slackConnections(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ConnectorOrder
): ConnectorConnection! @goField(forceResolver: true)
): SlackConnectionConnection! @goField(forceResolver: true)
frameworks(
first: Int
@@ -1808,14 +1788,24 @@ type Invitation implements Node {
organization: Organization! @goField(forceResolver: true)
}
type Connector implements Node {
type SlackConnection {
id: ID!
name: String!
type: String!
channel: String
channelId: String
createdAt: Datetime!
updatedAt: Datetime!
}
type SlackConnectionConnection {
edges: [SlackConnectionEdge!]!
pageInfo: PageInfo!
}
type SlackConnectionEdge {
cursor: CursorKey!
node: SlackConnection!
}
type People implements Node {
id: ID!
fullName: String!
@@ -2635,15 +2625,6 @@ type VendorServiceEdge {
node: VendorService!
}
type ConnectorConnection {
edges: [ConnectorEdge!]!
pageInfo: PageInfo!
}
type ConnectorEdge {
cursor: CursorKey!
node: Connector!
}
type VendorRiskAssessmentConnection {
edges: [VendorRiskAssessmentEdge!]!

File diff suppressed because it is too large Load Diff

View File

@@ -19,36 +19,42 @@ import (
"github.com/getprobo/probo/pkg/page"
)
type (
ConnectorOrderBy OrderBy[coredata.ConnectorOrderField]
)
func NewConnectorConnection(p *page.Page[*coredata.Connector, coredata.ConnectorOrderField]) *ConnectorConnection {
var edges = make([]*ConnectorEdge, len(p.Data))
func NewSlackConnectionConnection(p *page.Page[*coredata.Connector, coredata.ConnectorOrderField]) *SlackConnectionConnection {
var edges = make([]*SlackConnectionEdge, len(p.Data))
for i := range edges {
edges[i] = NewConnectorEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges[i] = NewSlackConnectionEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &ConnectorConnection{
return &SlackConnectionConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewConnectorEdge(c *coredata.Connector, orderBy coredata.ConnectorOrderField) *ConnectorEdge {
return &ConnectorEdge{
func NewSlackConnectionEdge(c *coredata.Connector, orderBy coredata.ConnectorOrderField) *SlackConnectionEdge {
return &SlackConnectionEdge{
Cursor: c.CursorKey(orderBy),
Node: NewConnector(c),
Node: NewSlackConnection(c),
}
}
func NewConnector(c *coredata.Connector) *Connector {
return &Connector{
func NewSlackConnection(c *coredata.Connector) *SlackConnection {
conn := &SlackConnection{
ID: c.ID,
Name: c.Name,
Type: string(c.Type),
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
// Extract channel information from settings
if len(c.Settings) > 0 {
if channel, ok := c.Settings["channel"].(string); ok && channel != "" {
conn.Channel = &channel
}
if channelID, ok := c.Settings["channel_id"].(string); ok && channelID != "" {
conn.ChannelID = &channelID
}
}
return conn
}

View File

@@ -146,32 +146,6 @@ type ConfirmEmailPayload struct {
Success bool `json:"success"`
}
type Connector struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Connector) IsNode() {}
func (this Connector) GetID() gid.GID { return this.ID }
type ConnectorConnection struct {
Edges []*ConnectorEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type ConnectorEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Connector `json:"node"`
}
type ConnectorOrder struct {
Field coredata.ConnectorOrderField `json:"field"`
Direction page.OrderDirection `json:"direction"`
}
type ContinualImprovement struct {
ID gid.GID `json:"id"`
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
@@ -1374,7 +1348,7 @@ type Organization struct {
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
Memberships *MembershipConnection `json:"memberships"`
Invitations *InvitationConnection `json:"invitations"`
Connectors *ConnectorConnection `json:"connectors"`
SlackConnections *SlackConnectionConnection `json:"slackConnections"`
Frameworks *FrameworkConnection `json:"frameworks"`
Controls *ControlConnection `json:"controls"`
Vendors *VendorConnection `json:"vendors"`
@@ -1590,6 +1564,24 @@ type Session struct {
ExpiresAt time.Time `json:"expiresAt"`
}
type SlackConnection struct {
ID gid.GID `json:"id"`
Channel *string `json:"channel,omitempty"`
ChannelID *string `json:"channelId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SlackConnectionConnection struct {
Edges []*SlackConnectionEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type SlackConnectionEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *SlackConnection `json:"node"`
}
type Snapshot struct {
ID gid.GID `json:"id"`
Organization *Organization `json:"organization"`

View File

@@ -3666,29 +3666,27 @@ func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organ
return types.NewInvitationConnection(page, r, obj.ID, filter), nil
}
// Connectors is the resolver for the connectors field.
func (r *organizationResolver) Connectors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ConnectorOrder) (*types.ConnectorConnection, error) {
// SlackConnections is the resolver for the slackConnections field.
func (r *organizationResolver) SlackConnections(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.SlackConnectionConnection, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
// Filter for Slack connectors only
slackProvider := coredata.ConnectorProviderSlack
filter := coredata.NewConnectorProviderFilter(&slackProvider)
pageOrderBy := page.OrderBy[coredata.ConnectorOrderField]{
Field: coredata.ConnectorOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.ConnectorOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := prb.Connectors.ListForOrganizationID(ctx, obj.ID, cursor)
page, err := prb.Connectors.ListForOrganizationID(ctx, obj.ID, cursor, filter)
if err != nil {
panic(fmt.Errorf("cannot list organization connectors: %w", err))
panic(fmt.Errorf("cannot list organization slack connections: %w", err))
}
return types.NewConnectorConnection(page), nil
return types.NewSlackConnectionConnection(page), nil
}
// Frameworks is the resolver for the frameworks field.