diff --git a/pkg/iam/scim/bridge/bridge_test.go b/pkg/iam/scim/bridge/bridge_test.go new file mode 100644 index 000000000..1c59c9513 --- /dev/null +++ b/pkg/iam/scim/bridge/bridge_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 bridge_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + scimbridge "go.probo.inc/probo/pkg/iam/scim/bridge" + scimclient "go.probo.inc/probo/pkg/iam/scim/bridge/client" +) + +type mockProvider struct { + users scimclient.Users +} + +func (p *mockProvider) Name() string { + return "mock" +} + +func (p *mockProvider) ListUsers(_ context.Context) (scimclient.Users, error) { + return p.users, nil +} + +func TestBridge_Run_DeletesInactiveExcludedUsers(t *testing.T) { + t.Parallel() + + deleteCalled := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/Users": + w.Header().Set("Content-Type", "application/scim+json") + _, _ = w.Write([]byte(`{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 1, + "startIndex": 1, + "itemsPerPage": 100, + "Resources": [{ + "id": "gid://probo/MembershipProfile/abc", + "userName": "excluded@example.com", + "displayName": "Excluded User", + "active": false, + "externalId": "ext-1" + }] + }`)) + case r.Method == http.MethodDelete: + deleteCalled = true + + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + provider := &mockProvider{users: scimclient.Users{}} + client := scimclient.NewClient(server.Client(), server.URL, "token") + bridge := scimbridge.NewBridge( + provider, + client, + scimbridge.WithExcludedUserNames([]string{"excluded@example.com"}), + ) + + created, updated, deleted, deactivated, skipped, err := bridge.Run(context.Background()) + + require.NoError(t, err) + assert.True(t, deleteCalled) + assert.Equal(t, 0, created) + assert.Equal(t, 0, updated) + assert.Equal(t, 1, deleted) + assert.Equal(t, 0, deactivated) + assert.Equal(t, 0, skipped) +} diff --git a/pkg/iam/scim/service.go b/pkg/iam/scim/service.go index 59018c373..5a3391a70 100644 --- a/pkg/iam/scim/service.go +++ b/pkg/iam/scim/service.go @@ -836,19 +836,6 @@ func (s *Service) DeleteUser( return scimerrors.ScimErrorResourceNotFound(profileID.String()) } - invitations := &coredata.Invitations{} - - onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) - if err := invitations.ExpireByUserID( - ctx, - tx, - scope, - profile.ID, - onlyPending, - ); err != nil { - return fmt.Errorf("cannot expire pending invitations: %w", err) - } - var membership *coredata.Membership m := &coredata.Membership{} @@ -862,12 +849,39 @@ func (s *Service) DeleteUser( membership = m } - if err := webhook.InsertData(ctx, tx, scope, config.OrganizationID, coredata.WebhookEventTypeUserDeleted, webhooktypes.NewUser(profile, membership)); err != nil { - return fmt.Errorf("cannot insert webhook event: %w", err) + if err := profile.Delete(ctx, tx, scope, profile.ID); err != nil { + if errors.Is(err, coredata.ErrResourceInUse) { + s.logger.WarnCtx( + ctx, + "SCIM user delete skipped, profile is in use", + log.String("profile_id", profileID.String()), + ) + + if err := s.deactivateProfileInTx(ctx, tx, scope, config, profile, membership); err != nil { + return fmt.Errorf("cannot deactivate profile: %w", err) + } + + return nil + } + + return fmt.Errorf("cannot delete profile: %w", err) } - if err := profile.Delete(ctx, tx, scope, profile.ID); err != nil { - return fmt.Errorf("cannot delete profile: %w", err) + invitations := &coredata.Invitations{} + + onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) + if err := invitations.ExpireByUserID( + ctx, + tx, + scope, + profile.ID, + onlyPending, + ); err != nil { + return fmt.Errorf("cannot expire pending invitations: %w", err) + } + + if err := webhook.InsertData(ctx, tx, scope, config.OrganizationID, coredata.WebhookEventTypeUserDeleted, webhooktypes.NewUser(profile, membership)); err != nil { + return fmt.Errorf("cannot insert webhook event: %w", err) } if membership != nil { @@ -881,6 +895,58 @@ func (s *Service) DeleteUser( ) } +func (s *Service) deactivateProfileInTx( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + config *coredata.SCIMConfiguration, + profile *coredata.MembershipProfile, + membership *coredata.Membership, +) error { + if profile.State == coredata.ProfileStateInactive { + return nil + } + + now := time.Now() + profile.State = coredata.ProfileStateInactive + profile.UpdatedAt = now + + if err := profile.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot deactivate profile: %w", err) + } + + invitations := &coredata.Invitations{} + + onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) + if err := invitations.ExpireByUserID( + ctx, + tx, + scope, + profile.ID, + onlyPending, + ); err != nil { + return fmt.Errorf("cannot expire pending invitations: %w", err) + } + + signatures := &coredata.DocumentVersionSignatures{} + if err := signatures.DeleteRequestedBySignatory(ctx, tx, scope, profile.ID); err != nil { + return fmt.Errorf("cannot delete requested signatures: %w", err) + } + + if membership != nil { + membership.UpdatedAt = now + if err := membership.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update membership: %w", err) + } + } + + if err := webhook.InsertData(ctx, tx, scope, config.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile, membership)); err != nil { + return fmt.Errorf("cannot insert webhook event: %w", err) + } + + return nil +} + func (s *Service) LogEvent( ctx context.Context, config *coredata.SCIMConfiguration,