Return conflict when user has linked records

Map remove-user foreign key failures to a dedicated IAM conflict error so\nconsole users no longer receive a generic internal server error when a\nprofile is still referenced by signed documents, tasks, assets, or\nother records.\n\nThe service now detects wrapped dependency errors across both profile\nand membership deletes, and resolvers explicitly map the new error type\nto conflict responses. Added unit tests cover dependency detection and\nthe user-facing error message.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
This commit is contained in:
Cursor Agent
2026-05-27 00:18:27 +00:00
committed by Bryan Frimin
parent a7639be86b
commit ffa3db3cd2
5 changed files with 134 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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,

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 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 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(),
)
}

View File

@@ -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)
}

View File

@@ -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)
}