Add scim bridge with connector

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-28 23:42:06 -08:00
parent 6d7f64ebf7
commit bc5bbdae81
43 changed files with 3568 additions and 189 deletions

View File

@@ -347,6 +347,8 @@ type SCIMConfiguration implements Node {
updatedAt: Datetime!
organization: Organization @goField(forceResolver: true)
bridge: SCIMBridge @goField(forceResolver: true)
events(
first: Int
after: CursorKey
@@ -360,6 +362,50 @@ type SCIMConfiguration implements Node {
@session(required: PRESENT)
}
type SCIMBridge implements Node {
id: ID!
state: SCIMBridgeState!
scimConfiguration: SCIMConfiguration @goField(forceResolver: true)
connector: Connector @goField(forceResolver: true)
type: SCIMBridgeType!
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean!
@goField(forceResolver: true)
@session(required: PRESENT)
}
type Connector implements Node {
id: ID!
provider: ConnectorProvider!
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean!
@goField(forceResolver: true)
@session(required: PRESENT)
}
enum ConnectorProvider
@goModel(model: "go.probo.inc/probo/pkg/coredata.ConnectorProvider") {
SLACK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSlack")
GOOGLE_WORKSPACE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGoogleWorkspace")
}
enum SCIMBridgeType
@goModel(model: "go.probo.inc/probo/pkg/coredata.SCIMBridgeType") {
GOOGLE_WORKSPACE @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeTypeGoogleWorkspace")
}
enum SCIMBridgeState
@goModel(model: "go.probo.inc/probo/pkg/coredata.SCIMBridgeState") {
PENDING @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStatePending")
ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStateActive")
FAILED @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStateFailed")
}
type SCIMEvent implements Node {
id: ID!
method: String!
@@ -846,6 +892,7 @@ type DeleteSAMLConfigurationPayload {
input CreateSCIMConfigurationInput {
organizationId: ID!
connectorId: ID
}
input DeleteSCIMConfigurationInput {
@@ -860,6 +907,7 @@ input RegenerateSCIMTokenInput {
type CreateSCIMConfigurationPayload {
scimConfiguration: SCIMConfiguration!
scimBridge: SCIMBridge
token: String!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
// 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 types
import "go.probo.inc/probo/pkg/coredata"
func NewSCIMBridge(bridge *coredata.SCIMBridge) *SCIMBridge {
var connector *Connector
if bridge.ConnectorID != nil {
connector = &Connector{
ID: *bridge.ConnectorID,
}
}
return &SCIMBridge{
ID: bridge.ID,
State: bridge.State,
ScimConfiguration: &SCIMConfiguration{
ID: bridge.ScimConfigurationID,
},
Connector: connector,
Type: bridge.Type,
CreatedAt: bridge.CreatedAt,
UpdatedAt: bridge.UpdatedAt,
}
}

View File

@@ -0,0 +1,26 @@
// 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 types
import "go.probo.inc/probo/pkg/coredata"
func NewConnector(connector *coredata.Connector) *Connector {
return &Connector{
ID: connector.ID,
Provider: connector.Provider,
CreatedAt: connector.CreatedAt,
UpdatedAt: connector.UpdatedAt,
}
}

View File

@@ -19,12 +19,18 @@ import (
)
func NewSCIMConfiguration(scimConfiguration *coredata.SCIMConfiguration) *SCIMConfiguration {
var bridge *SCIMBridge
if scimConfiguration.BridgeID != nil {
bridge = &SCIMBridge{
ID: *scimConfiguration.BridgeID,
}
}
return &SCIMConfiguration{
ID: scimConfiguration.ID,
Organization: &Organization{
ID: scimConfiguration.OrganizationID,
},
CreatedAt: scimConfiguration.CreatedAt,
UpdatedAt: scimConfiguration.UpdatedAt,
ID: scimConfiguration.ID,
Organization: &Organization{ID: scimConfiguration.OrganizationID},
Bridge: bridge,
CreatedAt: scimConfiguration.CreatedAt,
UpdatedAt: scimConfiguration.UpdatedAt,
}
}

View File

@@ -60,6 +60,17 @@ type ChangePasswordPayload struct {
Success bool `json:"success"`
}
type Connector struct {
ID gid.GID `json:"id"`
Provider coredata.ConnectorProvider `json:"provider"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Permission bool `json:"permission"`
}
func (Connector) IsNode() {}
func (this Connector) GetID() gid.GID { return this.ID }
type CreateOrganizationInput struct {
Name string `json:"name"`
LogoFile *graphql.Upload `json:"logoFile,omitempty"`
@@ -96,11 +107,13 @@ type CreateSAMLConfigurationPayload struct {
}
type CreateSCIMConfigurationInput struct {
OrganizationID gid.GID `json:"organizationId"`
OrganizationID gid.GID `json:"organizationId"`
ConnectorID *gid.GID `json:"connectorId,omitempty"`
}
type CreateSCIMConfigurationPayload struct {
ScimConfiguration *SCIMConfiguration `json:"scimConfiguration"`
ScimBridge *SCIMBridge `json:"scimBridge,omitempty"`
Token string `json:"token"`
}
@@ -396,12 +409,27 @@ type SAMLConfigurationEdge struct {
Cursor page.CursorKey `json:"cursor"`
}
type SCIMBridge struct {
ID gid.GID `json:"id"`
State coredata.SCIMBridgeState `json:"state"`
ScimConfiguration *SCIMConfiguration `json:"scimConfiguration,omitempty"`
Connector *Connector `json:"connector,omitempty"`
Type coredata.SCIMBridgeType `json:"type"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Permission bool `json:"permission"`
}
func (SCIMBridge) IsNode() {}
func (this SCIMBridge) GetID() gid.GID { return this.ID }
type SCIMConfiguration struct {
ID gid.GID `json:"id"`
EndpointURL string `json:"endpointUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Organization *Organization `json:"organization,omitempty"`
Bridge *SCIMBridge `json:"bridge,omitempty"`
Events *SCIMEventConnection `json:"events,omitempty"`
Permission bool `json:"permission"`
}

View File

@@ -27,6 +27,11 @@ import (
"go.probo.inc/probo/pkg/server/gqlutils/types/cursor"
)
// Permission is the resolver for the permission field.
func (r *connectorResolver) Permission(ctx context.Context, obj *types.Connector, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// Memberships is the resolver for the memberships field.
func (r *identityResolver) Memberships(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MembershipOrderBy) (*types.MembershipConnection, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionMembershipList); err != nil {
@@ -1119,10 +1124,24 @@ func (r *mutationResolver) CreateSCIMConfiguration(ctx context.Context, input ty
return nil, gqlutils.Internal(ctx)
}
return &types.CreateSCIMConfigurationPayload{
var bridge *types.SCIMBridge
if input.ConnectorID != nil {
scimBridge, err := r.iam.OrganizationService.CreateSCIMBridge(ctx, input.OrganizationID, config.ID, *input.ConnectorID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create scim bridge", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
bridge = types.NewSCIMBridge(scimBridge)
}
payload := &types.CreateSCIMConfigurationPayload{
ScimConfiguration: types.NewSCIMConfiguration(config),
ScimBridge: bridge,
Token: token,
}, nil
}
return payload, nil
}
// DeleteSCIMConfiguration is the resolver for the deleteSCIMConfiguration field.
@@ -1578,6 +1597,63 @@ func (r *sAMLConfigurationConnectionResolver) TotalCount(ctx context.Context, ob
return nil, gqlutils.Internal(ctx)
}
// ScimConfiguration is the resolver for the scimConfiguration field.
func (r *sCIMBridgeResolver) ScimConfiguration(ctx context.Context, obj *types.SCIMBridge) (*types.SCIMConfiguration, error) {
if err := r.authorize(ctx, obj.ScimConfiguration.ID, iam.ActionSCIMConfigurationGet); err != nil {
return nil, err
}
if gqlutils.OnlyIDSelected(ctx) {
return &types.SCIMConfiguration{
ID: obj.ScimConfiguration.ID,
}, nil
}
scimConfiguration, err := r.iam.GetSCIMConfiguration(ctx, obj.ScimConfiguration.ID)
if err != nil {
var errNoSCIMConfigurationFound *iam.ErrNoSCIMConfigurationFound
if errors.As(err, &errNoSCIMConfigurationFound) {
return nil, nil
}
return nil, err
}
return types.NewSCIMConfiguration(scimConfiguration), nil
}
// Connector is the resolver for the connector field.
func (r *sCIMBridgeResolver) Connector(ctx context.Context, obj *types.SCIMBridge) (*types.Connector, error) {
if obj.Connector == nil {
return nil, nil
}
// Authorize based on the SCIM configuration (connector accessed via bridge is a sub-resource)
if err := r.authorize(ctx, obj.ScimConfiguration.ID, iam.ActionSCIMConfigurationGet); err != nil {
return nil, err
}
if gqlutils.OnlyIDSelected(ctx) {
return &types.Connector{
ID: obj.Connector.ID,
}, nil
}
// Use metadata-only loading since we don't need the encrypted connection data
connector, err := r.iam.OrganizationService.GetConnectorMetadataByID(ctx, obj.Connector.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewConnector(connector), nil
}
// Permission is the resolver for the permission field.
func (r *sCIMBridgeResolver) Permission(ctx context.Context, obj *types.SCIMBridge, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// EndpointURL is the resolver for the endpointUrl field.
func (r *sCIMConfigurationResolver) EndpointURL(ctx context.Context, obj *types.SCIMConfiguration) (string, error) {
return r.baseURL.WithPath("/api/connect/v1/scim/2.0").MustString(), nil
@@ -1609,6 +1685,31 @@ func (r *sCIMConfigurationResolver) Organization(ctx context.Context, obj *types
return types.NewOrganization(organization), nil
}
// Bridge is the resolver for the bridge field.
func (r *sCIMConfigurationResolver) Bridge(ctx context.Context, obj *types.SCIMConfiguration) (*types.SCIMBridge, error) {
if obj.Bridge == nil {
return nil, nil
}
if err := r.authorize(ctx, obj.ID, iam.ActionSCIMConfigurationGet); err != nil {
return nil, err
}
bridge, err := r.iam.OrganizationService.GetSCIMBridgeByID(ctx, obj.Bridge.ID)
if err != nil {
var errSCIMBridgeNotFound *iam.ErrSCIMBridgeNotFound
if errors.As(err, &errSCIMBridgeNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot get scim bridge", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewSCIMBridge(bridge), nil
}
// Events is the resolver for the events field.
func (r *sCIMConfigurationResolver) Events(ctx context.Context, obj *types.SCIMConfiguration, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SCIMEventOrderBy) (*types.SCIMEventConnection, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionSCIMEventList); err != nil {
@@ -1734,6 +1835,9 @@ func (r *sessionConnectionResolver) TotalCount(ctx context.Context, obj *types.S
return nil, gqlutils.Internal(ctx)
}
// Connector returns schema.ConnectorResolver implementation.
func (r *Resolver) Connector() schema.ConnectorResolver { return &connectorResolver{r} }
// Identity returns schema.IdentityResolver implementation.
func (r *Resolver) Identity() schema.IdentityResolver { return &identityResolver{r} }
@@ -1785,6 +1889,9 @@ func (r *Resolver) SAMLConfigurationConnection() schema.SAMLConfigurationConnect
return &sAMLConfigurationConnectionResolver{r}
}
// SCIMBridge returns schema.SCIMBridgeResolver implementation.
func (r *Resolver) SCIMBridge() schema.SCIMBridgeResolver { return &sCIMBridgeResolver{r} }
// SCIMConfiguration returns schema.SCIMConfigurationResolver implementation.
func (r *Resolver) SCIMConfiguration() schema.SCIMConfigurationResolver {
return &sCIMConfigurationResolver{r}
@@ -1806,6 +1913,7 @@ func (r *Resolver) SessionConnection() schema.SessionConnectionResolver {
return &sessionConnectionResolver{r}
}
type connectorResolver struct{ *Resolver }
type identityResolver struct{ *Resolver }
type invitationResolver struct{ *Resolver }
type invitationConnectionResolver struct{ *Resolver }
@@ -1819,6 +1927,7 @@ type personalAPIKeyConnectionResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type sAMLConfigurationResolver struct{ *Resolver }
type sAMLConfigurationConnectionResolver struct{ *Resolver }
type sCIMBridgeResolver struct{ *Resolver }
type sCIMConfigurationResolver struct{ *Resolver }
type sCIMEventResolver struct{ *Resolver }
type sCIMEventConnectionResolver struct{ *Resolver }

View File

@@ -21,6 +21,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
@@ -76,7 +77,7 @@ func NewMux(
r.Get("/connectors/initiate", func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider != "SLACK" {
if provider != "SLACK" && provider != "GOOGLE_WORKSPACE" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
return
}
@@ -118,9 +119,15 @@ func NewMux(
panic(fmt.Errorf("cannot initiate connector: %w", err))
}
// Allow external redirects for Slack OAuth only for now
slackSafeRedirect := &saferedirect.SafeRedirect{AllowedHost: "slack.com"}
slackSafeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
// Allow external redirects for OAuth providers
var oauthSafeRedirect *saferedirect.SafeRedirect
switch provider {
case "SLACK":
oauthSafeRedirect = &saferedirect.SafeRedirect{AllowedHost: "slack.com"}
case "GOOGLE_WORKSPACE":
oauthSafeRedirect = &saferedirect.SafeRedirect{AllowedHost: "accounts.google.com"}
}
oauthSafeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
})
r.Get("/connectors/complete", func(w http.ResponseWriter, r *http.Request) {
@@ -134,6 +141,8 @@ func NewMux(
switch provider {
case "SLACK":
connectorProvider = coredata.ConnectorProviderSlack
case "GOOGLE_WORKSPACE":
connectorProvider = coredata.ConnectorProviderGoogleWorkspace
default:
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
return
@@ -145,16 +154,14 @@ func NewMux(
return
}
connection, organizationID, err := connectorRegistry.Complete(r.Context(), provider, r)
connection, organizationID, continueURL, err := connectorRegistry.Complete(r.Context(), provider, r)
if err != nil {
panic(fmt.Errorf("cannot complete connector: %w", err))
}
continueURL := r.URL.Query().Get("continue")
svc := proboSvc.WithTenant(organizationID.TenantID())
_, err = svc.Connectors.Create(
connector, err := svc.Connectors.Create(
r.Context(),
probo.CreateConnectorRequest{
OrganizationID: *organizationID,
@@ -167,12 +174,22 @@ func NewMux(
panic(fmt.Errorf("cannot create or update connector: %w", err))
}
if continueURL != "" {
safeRedirect.Redirect(w, r, continueURL, "/", http.StatusSeeOther)
} else {
redirectURL := baseURL.WithPath("/organizations/" + organizationID.String()).MustString()
safeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
// Append connector_id to the redirect URL so frontend can create the bridge
redirectURL := continueURL
if redirectURL == "" {
redirectURL = baseURL.WithPath("/organizations/" + organizationID.String()).MustString()
}
parsedURL, err := url.Parse(redirectURL)
if err != nil {
logger.ErrorCtx(r.Context(), "cannot parse redirect URL", log.Error(err))
parsedURL, _ = url.Parse(baseURL.WithPath("/organizations/" + organizationID.String()).MustString())
}
q := parsedURL.Query()
q.Set("connector_id", connector.ID.String())
parsedURL.RawQuery = q.Encode()
safeRedirect.Redirect(w, r, parsedURL.String(), "/", http.StatusSeeOther)
})
})