diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 571e18c76..fe398b67f 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -169,6 +169,18 @@ func (e ErrLastActiveOwner) Error() string { return fmt.Sprintf("cannot remove profile %q: last active owner of the organization", e.MembershipID) } +type ErrUserReferencedByRecords struct { + ProfileID gid.GID +} + +func NewUserReferencedByRecordsError(profileID gid.GID) error { + return &ErrUserReferencedByRecords{ProfileID: profileID} +} + +func (e ErrUserReferencedByRecords) Error() string { + return "cannot remove user because they are referenced by existing records (for example signatures, tasks, assets, or risks)" +} + type ErrOrganizationNotFound struct{ OrganizationID gid.GID } func NewOrganizationNotFoundError(organizationID gid.GID) error { diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index d04bf5df3..99619c042 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -21,6 +21,7 @@ import ( "io" "time" + "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" "go.probo.inc/probo/packages/emails" @@ -354,10 +355,18 @@ func (s *OrganizationService) RemoveUser( } if err := profile.Delete(ctx, tx, scope, profileID); err != nil { + if isUserRemovalDependencyError(err) { + return NewUserReferencedByRecordsError(profileID) + } + return fmt.Errorf("cannot delete profile: %w", err) } if err := membership.Delete(ctx, tx, scope, membership.ID); err != nil { + if isUserRemovalDependencyError(err) { + return NewUserReferencedByRecordsError(profileID) + } + return fmt.Errorf("cannot delete membership: %w", err) } @@ -366,6 +375,19 @@ func (s *OrganizationService) RemoveUser( ) } +func isUserRemovalDependencyError(err error) bool { + if errors.Is(err, coredata.ErrResourceInUse) { + return true + } + + pgErr, ok := errors.AsType[*pgconn.PgError](err) + if !ok { + return false + } + + return pgErr.Code == "23503" +} + func (s *OrganizationService) InviteUser( ctx context.Context, req *CreateInvitationRequest, diff --git a/pkg/iam/organization_service_test.go b/pkg/iam/organization_service_test.go new file mode 100644 index 000000000..940c62457 --- /dev/null +++ b/pkg/iam/organization_service_test.go @@ -0,0 +1,92 @@ +// 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 iam + +import ( + "errors" + "fmt" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +func TestIsUserRemovalDependencyError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "returns true for sentinel error", + err: coredata.ErrResourceInUse, + want: true, + }, + { + name: "returns true for wrapped sentinel error", + err: fmt.Errorf("wrapped: %w", coredata.ErrResourceInUse), + want: true, + }, + { + name: "returns true for wrapped postgres foreign key error", + err: fmt.Errorf( + "wrapped: %w", + &pgconn.PgError{Code: "23503"}, + ), + want: true, + }, + { + name: "returns false for non foreign key postgres error", + err: fmt.Errorf( + "wrapped: %w", + &pgconn.PgError{Code: "23505"}, + ), + want: false, + }, + { + name: "returns false for unrelated error", + err: errors.New("boom"), + want: false, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, isUserRemovalDependencyError(tt.err)) + }) + } +} + +func TestNewUserReferencedByRecordsError_Message(t *testing.T) { + t.Parallel() + + err := NewUserReferencedByRecordsError(gid.Nil) + resourceErr, ok := errors.AsType[*ErrUserReferencedByRecords](err) + require.True(t, ok) + assert.Equal( + t, + "cannot remove user because they are referenced by existing records (for example signatures, tasks, assets, or risks)", + resourceErr.Error(), + ) +} diff --git a/pkg/server/api/connect/v1/profile_resolvers.go b/pkg/server/api/connect/v1/profile_resolvers.go index 26fe2caa7..ad1839287 100644 --- a/pkg/server/api/connect/v1/profile_resolvers.go +++ b/pkg/server/api/connect/v1/profile_resolvers.go @@ -120,6 +120,10 @@ func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUse return nil, gqlutils.Conflict(ctx, err) } + if _, ok := errors.AsType[*iam.ErrUserReferencedByRecords](err); ok { + return nil, gqlutils.Conflict(ctx, err) + } + if errors.Is(err, coredata.ErrResourceInUse) { return nil, gqlutils.Conflict(ctx, err) } diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 1063e8fef..7022795bc 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -2930,6 +2930,10 @@ func (r *Resolver) RemoveUserTool(ctx context.Context, req *mcp.CallToolRequest, return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot remove last active owner: %w", err) } + if _, ok := errors.AsType[*iam.ErrUserReferencedByRecords](err); ok { + return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot remove user: %w", err) + } + if errors.Is(err, coredata.ErrResourceInUse) { return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot remove user: %w", err) }