diff --git a/descope/api/client.go b/descope/api/client.go index a70ce99f..25459ea4 100644 --- a/descope/api/client.go +++ b/descope/api/client.go @@ -277,6 +277,11 @@ var ( outboundApplicationUploadTenantToken: "mgmt/outbound/app/tenant/oauthtoken/upload", outboundApplicationBatchUploadUserTokens: "mgmt/outbound/app/user/oauthtoken/batch/upload", outboundApplicationBatchUploadTenantTokens: "mgmt/outbound/app/tenant/oauthtoken/batch/upload", + outboundSCIMCreate: "mgmt/outbound/scim/create", + outboundSCIMUpdate: "mgmt/outbound/scim/update", + outboundSCIMDelete: "mgmt/outbound/scim/delete", + outboundSCIMLoad: "mgmt/outbound/scim", + outboundSCIMSetEnabled: "mgmt/outbound/scim/enabled/set", thirdPartyApplicationCreate: "mgmt/thirdparty/app/create", thirdPartyApplicationUpdate: "mgmt/thirdparty/app/update", thirdPartyApplicationPatch: "mgmt/thirdparty/app/patch", @@ -623,6 +628,12 @@ type mgmtEndpoints struct { outboundApplicationBatchUploadUserTokens string outboundApplicationBatchUploadTenantTokens string + outboundSCIMCreate string + outboundSCIMUpdate string + outboundSCIMDelete string + outboundSCIMLoad string + outboundSCIMSetEnabled string + thirdPartyApplicationCreate string thirdPartyApplicationUpdate string thirdPartyApplicationPatch string @@ -1668,6 +1679,26 @@ func (e *endpoints) ManagementOutboundApplicationBatchUploadTenantTokens() strin return path.Join(e.version, e.mgmt.outboundApplicationBatchUploadTenantTokens) } +func (e *endpoints) ManagementOutboundSCIMCreate() string { + return path.Join(e.version, e.mgmt.outboundSCIMCreate) +} + +func (e *endpoints) ManagementOutboundSCIMUpdate() string { + return path.Join(e.version, e.mgmt.outboundSCIMUpdate) +} + +func (e *endpoints) ManagementOutboundSCIMDelete() string { + return path.Join(e.version, e.mgmt.outboundSCIMDelete) +} + +func (e *endpoints) ManagementOutboundSCIMLoad() string { + return path.Join(e.version, e.mgmt.outboundSCIMLoad) +} + +func (e *endpoints) ManagementOutboundSCIMSetEnabled() string { + return path.Join(e.version, e.mgmt.outboundSCIMSetEnabled) +} + func (e *endpoints) ManagementThirdPartyApplicationCreate() string { return path.Join(e.version, e.mgmt.thirdPartyApplicationCreate) } diff --git a/descope/internal/mgmt/mgmt.go b/descope/internal/mgmt/mgmt.go index 4d6283f7..e6afe07e 100644 --- a/descope/internal/mgmt/mgmt.go +++ b/descope/internal/mgmt/mgmt.go @@ -39,6 +39,7 @@ type managementService struct { fga sdk.FGA thirdPartyApplication sdk.ThirdPartyApplication outboundApplication sdk.OutboundApplication + outboundSCIM sdk.OutboundSCIM managementKey sdk.ManagementKey descoper sdk.Descoper list sdk.List @@ -54,6 +55,7 @@ func NewManagement(conf ManagementParams, provider *auth.Provider, c *api.Client service.ssoApplication = &ssoApplication{managementBase: base} service.thirdPartyApplication = &thirdPartyApplication{managementBase: base} service.outboundApplication = &outboundApplication{managementBase: base} + service.outboundSCIM = &outboundSCIM{managementBase: base} service.user = &user{managementBase: base} service.accessKey = &accessKey{managementBase: base} service.sso = &sso{managementBase: base} @@ -167,6 +169,11 @@ func (mgmt *managementService) OutboundApplication() sdk.OutboundApplication { return mgmt.outboundApplication } +func (mgmt *managementService) OutboundSCIM() sdk.OutboundSCIM { + mgmt.ensureManagementKey() + return mgmt.outboundSCIM +} + func (mgmt *managementService) ManagementKey() sdk.ManagementKey { mgmt.ensureManagementKey() return mgmt.managementKey diff --git a/descope/internal/mgmt/outbound_scim.go b/descope/internal/mgmt/outbound_scim.go new file mode 100644 index 00000000..65ec961a --- /dev/null +++ b/descope/internal/mgmt/outbound_scim.go @@ -0,0 +1,96 @@ +package mgmt + +import ( + "context" + + "github.com/descope/go-sdk/descope" + "github.com/descope/go-sdk/descope/api" + "github.com/descope/go-sdk/descope/internal/utils" + "github.com/descope/go-sdk/descope/sdk" +) + +type outboundSCIM struct { + managementBase +} + +var _ sdk.OutboundSCIM = &outboundSCIM{} + +func (s *outboundSCIM) CreateConfiguration(ctx context.Context, request *descope.CreateOutboundSCIMConfigurationRequest) (*descope.OutboundSCIMConfiguration, error) { + if request == nil { + return nil, utils.NewInvalidArgumentError("request") + } + if request.AppID == "" { + return nil, utils.NewInvalidArgumentError("request.AppID") + } + + // Descope grpc-gateway rejects unknown JSON request fields — build the body from an explicit + // map so only the proto-declared fields are sent. + body := map[string]any{ + "appId": request.AppID, + "configuration": request.Configuration, + } + httpRes, err := s.client.DoPostRequest(ctx, api.Routes.ManagementOutboundSCIMCreate(), body, nil, "") + if err != nil { + return nil, err + } + return s.unmarshalConfigurationResponse(httpRes) +} + +func (s *outboundSCIM) UpdateConfiguration(ctx context.Context, request *descope.UpdateOutboundSCIMConfigurationRequest) (*descope.OutboundSCIMConfiguration, error) { + if request == nil { + return nil, utils.NewInvalidArgumentError("request") + } + if request.AppID == "" { + return nil, utils.NewInvalidArgumentError("request.AppID") + } + + // Proto int64 Version must serialize as a JSON string — utils.Marshal honors the ",string" + // tag on the request struct, so send the struct directly rather than building a map. + httpRes, err := s.client.DoPostRequest(ctx, api.Routes.ManagementOutboundSCIMUpdate(), request, nil, "") + if err != nil { + return nil, err + } + return s.unmarshalConfigurationResponse(httpRes) +} + +func (s *outboundSCIM) DeleteConfiguration(ctx context.Context, appID string) error { + if appID == "" { + return utils.NewInvalidArgumentError("appID") + } + req := map[string]any{"appId": appID} + _, err := s.client.DoPostRequest(ctx, api.Routes.ManagementOutboundSCIMDelete(), req, nil, "") + return err +} + +func (s *outboundSCIM) LoadConfiguration(ctx context.Context, appID string) (*descope.OutboundSCIMConfiguration, error) { + if appID == "" { + return nil, utils.NewInvalidArgumentError("appID") + } + res, err := s.client.DoGetRequest(ctx, api.Routes.ManagementOutboundSCIMLoad()+"/"+appID, nil, "") + if err != nil { + return nil, err + } + return s.unmarshalConfigurationResponse(res) +} + +func (s *outboundSCIM) SetEnabled(ctx context.Context, appID string, enabled bool) (*descope.OutboundSCIMConfiguration, error) { + if appID == "" { + return nil, utils.NewInvalidArgumentError("appID") + } + body := map[string]any{"appId": appID, "enabled": enabled} + httpRes, err := s.client.DoPostRequest(ctx, api.Routes.ManagementOutboundSCIMSetEnabled(), body, nil, "") + if err != nil { + return nil, err + } + return s.unmarshalConfigurationResponse(httpRes) +} + +func (s *outboundSCIM) unmarshalConfigurationResponse(httpRes *api.HTTPResponse) (*descope.OutboundSCIMConfiguration, error) { + res := &struct { + Configuration *descope.OutboundSCIMConfiguration `json:"configuration"` + }{} + if err := utils.Unmarshal([]byte(httpRes.BodyStr), res); err != nil { + return nil, err + } + return res.Configuration, nil +} diff --git a/descope/internal/mgmt/outbound_scim_test.go b/descope/internal/mgmt/outbound_scim_test.go new file mode 100644 index 00000000..176b4d0c --- /dev/null +++ b/descope/internal/mgmt/outbound_scim_test.go @@ -0,0 +1,269 @@ +package mgmt + +import ( + "context" + "net/http" + "testing" + + "github.com/descope/go-sdk/descope" + "github.com/descope/go-sdk/descope/tests/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOutboundSCIMCreateSuccess(t *testing.T) { + // Version arrives as a JSON string in proto3 — verify the SDK unmarshals it back into int64. + response := map[string]any{"configuration": map[string]any{ + "appId": "app-1", + "configuration": map[string]any{ + "baseUrl": "https://scim.example.com", + "ignoreUnverifiedPhones": true, + "authentication": map[string]any{"method": "bearerToken", "bearerToken": "sekret"}, + }, + "enabled": true, + "version": "42", + }} + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + assert.Equal(t, "/v1/mgmt/outbound/scim/create", r.URL.Path) + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + assert.Equal(t, "app-1", req["appId"]) + cfg, ok := req["configuration"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://scim.example.com", cfg["baseUrl"]) + assert.Equal(t, true, cfg["ignoreUnverifiedPhones"]) + auth, ok := cfg["authentication"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "bearerToken", auth["method"]) + assert.Equal(t, "sekret", auth["bearerToken"]) + // id/name/version/enabled must NOT be sent on create (unknown-field-rejecting gateway). + for _, k := range []string{"id", "name", "version", "enabled"} { + _, has := req[k] + assert.False(t, has, "unexpected key %q on create body", k) + } + }, response)) + + cfg, err := mgmt.OutboundSCIM().CreateConfiguration(context.Background(), &descope.CreateOutboundSCIMConfigurationRequest{ + AppID: "app-1", + Configuration: &descope.OutboundSCIMConfigurationData{ + BaseURL: "https://scim.example.com", + IgnoreUnverifiedPhones: true, + Authentication: &descope.OutboundSCIMHTTPAuth{ + Method: descope.OutboundSCIMAuthMethodBearerToken, + BearerToken: "sekret", + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, "app-1", cfg.AppID) + assert.True(t, cfg.Enabled) + assert.Equal(t, int64(42), cfg.Version) + require.NotNil(t, cfg.Configuration) + assert.Equal(t, "https://scim.example.com", cfg.Configuration.BaseURL) + assert.True(t, cfg.Configuration.IgnoreUnverifiedPhones) + require.NotNil(t, cfg.Configuration.Authentication) + assert.Equal(t, descope.OutboundSCIMAuthMethodBearerToken, cfg.Configuration.Authentication.Method) + assert.Equal(t, "sekret", cfg.Configuration.Authentication.BearerToken) +} + +func TestOutboundSCIMCreateError(t *testing.T) { + called := false + mgmt := newTestMgmt(nil, helpers.DoOk(func(_ *http.Request) { + called = true + })) + + // nil request + cfg, err := mgmt.OutboundSCIM().CreateConfiguration(context.Background(), nil) + require.Error(t, err) + require.Nil(t, cfg) + + // missing AppID + cfg, err = mgmt.OutboundSCIM().CreateConfiguration(context.Background(), &descope.CreateOutboundSCIMConfigurationRequest{}) + require.Error(t, err) + require.Nil(t, cfg) + require.False(t, called) +} + +func TestOutboundSCIMUpdateSuccess(t *testing.T) { + response := map[string]any{"configuration": map[string]any{ + "appId": "app-1", + "version": "43", + }} + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + assert.Equal(t, "/v1/mgmt/outbound/scim/update", r.URL.Path) + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + assert.Equal(t, "app-1", req["appId"]) + // Version int64 must serialize as a JSON string. + assert.Equal(t, "42", req["version"]) + cfg, ok := req["configuration"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://scim.example.com/v2", cfg["baseUrl"]) + }, response)) + + cfg, err := mgmt.OutboundSCIM().UpdateConfiguration(context.Background(), &descope.UpdateOutboundSCIMConfigurationRequest{ + AppID: "app-1", + Configuration: &descope.OutboundSCIMConfigurationData{BaseURL: "https://scim.example.com/v2"}, + Version: 42, + }) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, "app-1", cfg.AppID) + assert.Equal(t, int64(43), cfg.Version) +} + +func TestOutboundSCIMUpdateError(t *testing.T) { + called := false + mgmt := newTestMgmt(nil, helpers.DoOk(func(_ *http.Request) { + called = true + })) + + // nil request + cfg, err := mgmt.OutboundSCIM().UpdateConfiguration(context.Background(), nil) + require.Error(t, err) + require.Nil(t, cfg) + + // missing AppID + cfg, err = mgmt.OutboundSCIM().UpdateConfiguration(context.Background(), &descope.UpdateOutboundSCIMConfigurationRequest{}) + require.Error(t, err) + require.Nil(t, cfg) + require.False(t, called) +} + +func TestOutboundSCIMDeleteSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { + assert.Equal(t, "/v1/mgmt/outbound/scim/delete", r.URL.Path) + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + assert.Equal(t, "app-1", req["appId"]) + })) + err := mgmt.OutboundSCIM().DeleteConfiguration(context.Background(), "app-1") + require.NoError(t, err) +} + +func TestOutboundSCIMDeleteError(t *testing.T) { + called := false + mgmt := newTestMgmt(nil, helpers.DoOk(func(_ *http.Request) { + called = true + })) + + err := mgmt.OutboundSCIM().DeleteConfiguration(context.Background(), "") + require.Error(t, err) + require.False(t, called) +} + +func TestOutboundSCIMLoadSuccess(t *testing.T) { + // Load exercises the concrete configuration shape end-to-end — baseUrl, + // ignoreUnverifiedEmails, userMapping, authentication, headers, and awsAuthType + // all round-trip into the typed struct. + response := map[string]any{"configuration": map[string]any{ + "appId": "app-1", + "configuration": map[string]any{ + "baseUrl": "https://scim.example.com", + "ignoreUnverifiedEmails": true, + "userMapping": []map[string]any{ + {"srcKey": "customAttributes.foo", "namespace": "urn:lulu", "destKey": "cstm"}, + }, + "authentication": map[string]any{ + "method": "basicAuth", + "basicAuth": map[string]any{"username": "u", "password": "p"}, + }, + "headers": []map[string]any{{"key": "X-Trace", "value": "1", "secret": false}}, + "awsAuthType": "none", + }, + "lastExportTime": 1720000000, + "lastProcessingTime": 1720000500, + "failures": 3, + "version": "7", + }} + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + assert.Contains(t, r.URL.Path, "/v1/mgmt/outbound/scim/app-1") + }, response)) + + cfg, err := mgmt.OutboundSCIM().LoadConfiguration(context.Background(), "app-1") + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, "app-1", cfg.AppID) + assert.Equal(t, int32(1720000000), cfg.LastExportTime) + assert.Equal(t, int32(1720000500), cfg.LastProcessingTime) + assert.Equal(t, int32(3), cfg.Failures) + assert.Equal(t, int64(7), cfg.Version) + require.NotNil(t, cfg.Configuration) + assert.Equal(t, "https://scim.example.com", cfg.Configuration.BaseURL) + assert.True(t, cfg.Configuration.IgnoreUnverifiedEmails) + assert.Equal(t, "none", cfg.Configuration.AWSAuthType) + require.Len(t, cfg.Configuration.UserMapping, 1) + assert.Equal(t, "customAttributes.foo", cfg.Configuration.UserMapping[0].SrcKey) + assert.Equal(t, "urn:lulu", cfg.Configuration.UserMapping[0].Namespace) + assert.Equal(t, "cstm", cfg.Configuration.UserMapping[0].DestKey) + require.NotNil(t, cfg.Configuration.Authentication) + assert.Equal(t, descope.OutboundSCIMAuthMethodBasic, cfg.Configuration.Authentication.Method) + require.NotNil(t, cfg.Configuration.Authentication.BasicAuth) + assert.Equal(t, "u", cfg.Configuration.Authentication.BasicAuth.Username) + assert.Equal(t, "p", cfg.Configuration.Authentication.BasicAuth.Password) + require.Len(t, cfg.Configuration.Headers, 1) + assert.Equal(t, "X-Trace", cfg.Configuration.Headers[0].Key) + assert.Equal(t, "1", cfg.Configuration.Headers[0].Value) + assert.False(t, cfg.Configuration.Headers[0].Secret) +} + +func TestOutboundSCIMLoadError(t *testing.T) { + called := false + mgmt := newTestMgmt(nil, helpers.DoOk(func(_ *http.Request) { + called = true + })) + + cfg, err := mgmt.OutboundSCIM().LoadConfiguration(context.Background(), "") + require.Error(t, err) + require.Nil(t, cfg) + require.False(t, called) +} + +func TestOutboundSCIMSetEnabledSuccess(t *testing.T) { + response := map[string]any{"configuration": map[string]any{ + "appId": "app-1", + "enabled": true, + "version": "8", + }} + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + assert.Equal(t, "/v1/mgmt/outbound/scim/enabled/set", r.URL.Path) + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + assert.Equal(t, "app-1", req["appId"]) + assert.Equal(t, true, req["enabled"]) + }, response)) + + cfg, err := mgmt.OutboundSCIM().SetEnabled(context.Background(), "app-1", true) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, "app-1", cfg.AppID) + assert.True(t, cfg.Enabled) + assert.Equal(t, int64(8), cfg.Version) +} + +func TestOutboundSCIMSetEnabledFalse(t *testing.T) { + // Disable — verify enabled:false is transmitted. + response := map[string]any{"configuration": map[string]any{"appId": "app-1"}} + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + assert.Equal(t, false, req["enabled"]) + }, response)) + + cfg, err := mgmt.OutboundSCIM().SetEnabled(context.Background(), "app-1", false) + require.NoError(t, err) + require.NotNil(t, cfg) +} + +func TestOutboundSCIMSetEnabledError(t *testing.T) { + called := false + mgmt := newTestMgmt(nil, helpers.DoOk(func(_ *http.Request) { + called = true + })) + + cfg, err := mgmt.OutboundSCIM().SetEnabled(context.Background(), "", true) + require.Error(t, err) + require.Nil(t, cfg) + require.False(t, called) +} diff --git a/descope/sdk/mgmt.go b/descope/sdk/mgmt.go index bf0fef10..9ece1e32 100644 --- a/descope/sdk/mgmt.go +++ b/descope/sdk/mgmt.go @@ -1268,6 +1268,32 @@ type OutboundApplication interface { DeleteTokenByID(ctx context.Context, id string) error } +// Provides functions for managing outbound SCIM configurations in a project. +// A project has at most one outbound SCIM configuration per federated SSO application, +// so every operation is keyed by the federated app id. +type OutboundSCIM interface { + // Create a new outbound SCIM configuration for a federated SSO application. The connector + // name is derived server-side from the app. + CreateConfiguration(ctx context.Context, request *descope.CreateOutboundSCIMConfigurationRequest) (*descope.OutboundSCIMConfiguration, error) + + // Update the outbound SCIM configuration attached to a federated SSO app. Version is + // optimistic-concurrency versioned — pass the value returned by the last Load/Create/Update + // so the backend can reject stale writes. + UpdateConfiguration(ctx context.Context, request *descope.UpdateOutboundSCIMConfigurationRequest) (*descope.OutboundSCIMConfiguration, error) + + // Delete the outbound SCIM configuration attached to the given federated SSO app. + // + // IMPORTANT: This action is irreversible. Use carefully. + DeleteConfiguration(ctx context.Context, appID string) error + + // Load the outbound SCIM configuration attached to the given federated SSO app. + LoadConfiguration(ctx context.Context, appID string) (*descope.OutboundSCIMConfiguration, error) + + // SetEnabled enables or disables the outbound SCIM configuration attached to the given + // federated SSO app. Returns the updated configuration. + SetEnabled(ctx context.Context, appID string, enabled bool) (*descope.OutboundSCIMConfiguration, error) +} + // Provides functions for managing engines in a project. type Engine interface { // Create a new engine with the given name. The returned engine includes its generated @@ -1492,6 +1518,9 @@ type Management interface { // Provides functions for managing outbound applications in a project. OutboundApplication() OutboundApplication + // Provides functions for managing outbound SCIM configurations in a project. + OutboundSCIM() OutboundSCIM + // Provides functions for management key management. ManagementKey() ManagementKey diff --git a/descope/tests/mocks/mgmt/managementmock.go b/descope/tests/mocks/mgmt/managementmock.go index 3d9a0a63..3d89d503 100644 --- a/descope/tests/mocks/mgmt/managementmock.go +++ b/descope/tests/mocks/mgmt/managementmock.go @@ -27,6 +27,7 @@ type MockManagement struct { *MockFGA *MockThirdPartyApplication *MockOutboundApplication + *MockOutboundSCIM *MockManagementKey *MockDescoper *MockList @@ -107,6 +108,10 @@ func (m *MockManagement) OutboundApplication() sdk.OutboundApplication { return m.MockOutboundApplication } +func (m *MockManagement) OutboundSCIM() sdk.OutboundSCIM { + return m.MockOutboundSCIM +} + func (m *MockManagement) ManagementKey() sdk.ManagementKey { return m.MockManagementKey } @@ -2512,6 +2517,64 @@ func (m *MockOutboundApplication) DeleteTokenByID(_ context.Context, id string) return m.DeleteTokenByIDError } +// Mock OutboundSCIM + +type MockOutboundSCIM struct { + CreateConfigurationAssert func(request *descope.CreateOutboundSCIMConfigurationRequest) + CreateConfigurationResponse *descope.OutboundSCIMConfiguration + CreateConfigurationError error + + UpdateConfigurationAssert func(request *descope.UpdateOutboundSCIMConfigurationRequest) + UpdateConfigurationResponse *descope.OutboundSCIMConfiguration + UpdateConfigurationError error + + DeleteConfigurationAssert func(appID string) + DeleteConfigurationError error + + LoadConfigurationAssert func(appID string) + LoadConfigurationResponse *descope.OutboundSCIMConfiguration + LoadConfigurationError error + + SetEnabledAssert func(appID string, enabled bool) + SetEnabledResponse *descope.OutboundSCIMConfiguration + SetEnabledError error +} + +func (m *MockOutboundSCIM) CreateConfiguration(_ context.Context, request *descope.CreateOutboundSCIMConfigurationRequest) (*descope.OutboundSCIMConfiguration, error) { + if m.CreateConfigurationAssert != nil { + m.CreateConfigurationAssert(request) + } + return m.CreateConfigurationResponse, m.CreateConfigurationError +} + +func (m *MockOutboundSCIM) UpdateConfiguration(_ context.Context, request *descope.UpdateOutboundSCIMConfigurationRequest) (*descope.OutboundSCIMConfiguration, error) { + if m.UpdateConfigurationAssert != nil { + m.UpdateConfigurationAssert(request) + } + return m.UpdateConfigurationResponse, m.UpdateConfigurationError +} + +func (m *MockOutboundSCIM) DeleteConfiguration(_ context.Context, appID string) error { + if m.DeleteConfigurationAssert != nil { + m.DeleteConfigurationAssert(appID) + } + return m.DeleteConfigurationError +} + +func (m *MockOutboundSCIM) LoadConfiguration(_ context.Context, appID string) (*descope.OutboundSCIMConfiguration, error) { + if m.LoadConfigurationAssert != nil { + m.LoadConfigurationAssert(appID) + } + return m.LoadConfigurationResponse, m.LoadConfigurationError +} + +func (m *MockOutboundSCIM) SetEnabled(_ context.Context, appID string, enabled bool) (*descope.OutboundSCIMConfiguration, error) { + if m.SetEnabledAssert != nil { + m.SetEnabledAssert(appID, enabled) + } + return m.SetEnabledResponse, m.SetEnabledError +} + // Mock ManagementKey type MockManagementKey struct { diff --git a/descope/types.go b/descope/types.go index 7322aaf2..345dcff0 100644 --- a/descope/types.go +++ b/descope/types.go @@ -1600,6 +1600,150 @@ type BatchUploadOutboundAppTokensResponse struct { Failures []*OutboundAppTokenUploadFailure `json:"failures"` } +// OutboundSCIMConfiguration represents an outbound SCIM configuration attached to a federated +// SSO application. The configuration is identified end-to-end by AppID; there is no separate +// id/name because both are derived from the SSO app. +// LastExportTime and LastProcessingTime are epoch seconds. Version is optimistic-concurrency +// versioning maintained by the backend — pass it back unchanged on update to detect conflicting +// concurrent writes. Version serializes as a JSON string in proto3, so the ",string" tag is required. +type OutboundSCIMConfiguration struct { + AppID string `json:"appId,omitempty"` + Configuration *OutboundSCIMConfigurationData `json:"configuration,omitempty"` + Enabled bool `json:"enabled,omitempty"` + LastExportTime int32 `json:"lastExportTime,omitempty"` + LastProcessingTime int32 `json:"lastProcessingTime,omitempty"` + Failures int32 `json:"failures,omitempty"` + Version int64 `json:"version,string,omitempty"` +} + +// OutboundSCIMConfigurationData is the provider-specific configuration blob for an +// outbound SCIM connector. Field names mirror the SCIM connector template +// (content/connectors/templates/scim/metadata.json). Secret-typed fields +// (hmacSecret, awsAccessKeyId, awsSecretAccessKey, rfc9421PrivateKey) are stored +// encrypted server-side; the backend returns them masked on Load — never plaintext. +type OutboundSCIMConfigurationData struct { + // BaseURL is the SCIM SP root, e.g. "https://scim.example.com". Required. + BaseURL string `json:"baseUrl"` + // IgnoreUnverifiedPhones drops phone numbers that aren't verified from outgoing SCIM payloads. + IgnoreUnverifiedPhones bool `json:"ignoreUnverifiedPhones,omitempty"` + // IgnoreUnverifiedEmails drops emails that aren't verified from outgoing SCIM payloads. + IgnoreUnverifiedEmails bool `json:"ignoreUnverifiedEmails,omitempty"` + // UserMapping maps Descope user attributes to SCIM attributes. + UserMapping []OutboundSCIMUserMapping `json:"userMapping,omitempty"` + // Authentication carries HTTP auth used for every SCIM request. + Authentication *OutboundSCIMHTTPAuth `json:"authentication,omitempty"` + // Headers are extra HTTP headers sent with every SCIM request. Values may be secret-typed. + Headers []OutboundSCIMHeader `json:"headers,omitempty"` + // HMACSecret signs the base64-encoded payload; the signature is delivered in + // the "x-descope-webhook-s256" header. Secret-typed — returned masked on Load. + HMACSecret string `json:"hmacSecret,omitempty"` + // AWSAuthType enables AWS Signature V4 signing. One of "none" (default) | "credentials". + AWSAuthType string `json:"awsAuthType,omitempty"` + // AWSAccessKeyID is required when AWSAuthType == "credentials". Secret-typed. + AWSAccessKeyID string `json:"awsAccessKeyId,omitempty"` + // AWSSecretAccessKey is required when AWSAuthType == "credentials". Secret-typed. + AWSSecretAccessKey string `json:"awsSecretAccessKey,omitempty"` + // AWSService is the AWS service to target (e.g. "lambda", "execute-api"). Required when AWSAuthType == "credentials". + AWSService string `json:"awsService,omitempty"` + // RFC9421SigningEnabled turns on RFC 9421 HTTP Message Signatures. + RFC9421SigningEnabled bool `json:"rfc9421SigningEnabled,omitempty"` + // RFC9421PrivateKey is a PEM private key (ECDSA/Ed25519/RSA) or HMAC secret. Secret-typed. + RFC9421PrivateKey string `json:"rfc9421PrivateKey,omitempty"` + // RFC9421KeyID is the key id included in the signature metadata. + RFC9421KeyID string `json:"rfc9421KeyId,omitempty"` + // RFC9421Components lists HTTP message components covered by the signature + // (comma-separated, e.g. "@method,@target-uri,@authority"). Empty means defaults. + RFC9421Components string `json:"rfc9421Components,omitempty"` + // RFC9421SignatureTTL is how long the signature is valid, in seconds. Default 300. + RFC9421SignatureTTL int32 `json:"rfc9421SignatureTTL,omitempty"` + // Insecure disables TLS certificate verification. Do not use in production. + Insecure bool `json:"insecure,omitempty"` +} + +// OutboundSCIMUserMapping maps one Descope user attribute to a SCIM SP attribute. +// SrcKey is the Descope side (dot-path allowed, e.g. "customAttributes.foo"). +type OutboundSCIMUserMapping struct { + SrcKey string `json:"srcKey"` + Namespace string `json:"namespace"` + DestKey string `json:"destKey"` +} + +// OutboundSCIMHeader is a single HTTP header sent with every SCIM request. Secret +// headers are stored encrypted server-side and returned masked on Load. +type OutboundSCIMHeader struct { + Key string `json:"key"` + Value string `json:"value"` + Secret bool `json:"secret,omitempty"` +} + +// OutboundSCIMHTTPAuthMethod enumerates supported HTTP auth methods. +type OutboundSCIMHTTPAuthMethod string + +const ( + OutboundSCIMAuthMethodNone OutboundSCIMHTTPAuthMethod = "none" + OutboundSCIMAuthMethodBearerToken OutboundSCIMHTTPAuthMethod = "bearerToken" + OutboundSCIMAuthMethodAPIKey OutboundSCIMHTTPAuthMethod = "apiKey" + OutboundSCIMAuthMethodBasic OutboundSCIMHTTPAuthMethod = "basicAuth" + OutboundSCIMAuthMethodOAuth2ClientCredentials OutboundSCIMHTTPAuthMethod = "oauth2ClientCredentials" //nolint:gosec // enum discriminator value, not a credential +) + +// OutboundSCIMHTTPAuth is a flat auth-config with a Method discriminator and the +// method-specific credentials under the matching sub-field. Only the field +// matching Method is used at request time; others are ignored server-side. +type OutboundSCIMHTTPAuth struct { + Method OutboundSCIMHTTPAuthMethod `json:"method"` + BearerToken string `json:"bearerToken,omitempty"` + APIKey *OutboundSCIMAPIKeyAuth `json:"apiKey,omitempty"` + BasicAuth *OutboundSCIMBasicAuth `json:"basicAuth,omitempty"` + OAuth2ClientCredentials *OutboundSCIMOAuth2ClientCredentials `json:"oauth2ClientCredentials,omitempty"` +} + +// OutboundSCIMAPIKeyAuth carries an API key credential. Key is the header name, Token is the value. +type OutboundSCIMAPIKeyAuth struct { + Key string `json:"key"` + Token string `json:"token"` +} + +// OutboundSCIMBasicAuth carries HTTP basic-auth credentials. +type OutboundSCIMBasicAuth struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// OutboundSCIMOAuth2ClientCredentials carries an OAuth2 client-credentials grant configuration. +// Scopes is space-separated. AuthStyle is one of "header" (default) or "body". +type OutboundSCIMOAuth2ClientCredentials struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + AuthURL string `json:"authUrl"` + Scopes string `json:"scopes,omitempty"` + AuthStyle string `json:"authStyle,omitempty"` + TokenRequestHeaders []OutboundSCIMOAuth2RequestHeader `json:"tokenRequestHeaders,omitempty"` +} + +// OutboundSCIMOAuth2RequestHeader is one extra header sent to the OAuth2 token endpoint. +type OutboundSCIMOAuth2RequestHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// CreateOutboundSCIMConfigurationRequest is the request body for creating an outbound SCIM +// configuration on the federated SSO app identified by AppID. The connector name is derived +// server-side from the app. +type CreateOutboundSCIMConfigurationRequest struct { + AppID string `json:"appId"` + Configuration *OutboundSCIMConfigurationData `json:"configuration,omitempty"` +} + +// UpdateOutboundSCIMConfigurationRequest is the request body for updating an outbound SCIM +// configuration. AppID identifies which app's SCIM configuration to update. Version must be +// the value returned by the last Load/Create/Update so the backend can reject stale writes. +type UpdateOutboundSCIMConfigurationRequest struct { + AppID string `json:"appId"` + Configuration *OutboundSCIMConfigurationData `json:"configuration,omitempty"` + Version int64 `json:"version,string,omitempty"` +} + type ThirdPartyApplicationScope struct { Name string `json:"name"` Description string `json:"description"`