Split user remove and archive actions

Restore RemoveUser as a hard delete operation and surface dependency\nconflicts with a dedicated IAM error.\n\nAdd a new ArchiveUser flow that deactivates profiles while keeping the\nmember in the organization, then expose both actions across Connect, MCP,\nCLI, n8n, console UI, and e2e coverage.

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 18:26:33 +00:00
committed by Bryan Frimin
parent a71a7bb56f
commit 1e08a23ddc
15 changed files with 628 additions and 48 deletions

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"
@@ -314,6 +315,73 @@ func (s *OrganizationService) RemoveUser(
) error {
scope := coredata.NewScopeFromObjectID(organizationID)
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
profile := coredata.MembershipProfile{}
if err := profile.LoadByID(ctx, tx, scope, profileID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewProfileNotFoundError(profileID)
}
return fmt.Errorf("cannot load profile: %w", err)
}
if profile.Source == coredata.ProfileSourceSCIM {
return NewUserManagedBySCIMError(profileID)
}
membership := &coredata.Membership{}
if err := membership.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, profile.IdentityID, profile.OrganizationID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.Role == coredata.MembershipRoleOwner && profile.State == coredata.ProfileStateActive {
profiles := coredata.MembershipProfiles{}
count, err := profiles.CountActiveOwnerByOrganizationID(ctx, tx, scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count active owners: %w", err)
}
if count <= 1 {
return NewLastActiveOwnerError(profileID)
}
}
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)
}
if err := webhook.InsertData(ctx, tx, scope, organizationID, coredata.WebhookEventTypeUserDeleted, webhooktypes.NewUser(&profile, membership)); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
}
return nil
},
)
}
func (s *OrganizationService) ArchiveUser(
ctx context.Context,
organizationID gid.GID,
profileID gid.GID,
) error {
scope := coredata.NewScopeFromObjectID(organizationID)
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
@@ -374,6 +442,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,