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

@@ -96,6 +96,8 @@ extend type Mutation {
@session(required: PRESENT)
deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload
updateUser(input: UpdateUserInput!): UpdateUserPayload!
archiveUser(input: ArchiveUserInput!): ArchiveUserPayload
@session(required: PRESENT)
removeUser(input: RemoveUserInput!): RemoveUserPayload
@session(required: PRESENT)
}
@@ -137,6 +139,11 @@ input RemoveUserInput {
profileId: ID!
}
input ArchiveUserInput {
organizationId: ID!
profileId: ID!
}
type CreateUserPayload {
profileEdge: ProfileEdge!
}
@@ -152,3 +159,7 @@ type UpdateUserPayload {
type RemoveUserPayload {
deletedProfileId: ID!
}
type ArchiveUserPayload {
archivedProfileId: ID!
}

View File

@@ -104,13 +104,13 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, input types.UpdateUse
}, nil
}
// RemoveUser is the resolver for the removeUser field.
func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
// ArchiveUser is the resolver for the archiveUser field.
func (r *mutationResolver) ArchiveUser(ctx context.Context, input types.ArchiveUserInput) (*types.ArchiveUserPayload, error) {
if _, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete); err != nil {
return nil, err
}
err := r.iam.OrganizationService.RemoveUser(ctx, input.OrganizationID, input.ProfileID)
err := r.iam.OrganizationService.ArchiveUser(ctx, input.OrganizationID, input.ProfileID)
if err != nil {
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
return nil, gqlutils.Conflictf(ctx, "user is managed by SCIM and cannot be archived")
@@ -120,8 +120,32 @@ func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUse
return nil, gqlutils.Conflictf(ctx, "cannot archive last active owner")
}
if errors.Is(err, coredata.ErrResourceInUse) {
return nil, gqlutils.Conflictf(ctx, "cannot archive user")
r.logger.ErrorCtx(ctx, "cannot archive user from organization", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.ArchiveUserPayload{ArchivedProfileID: input.ProfileID}, nil
}
// RemoveUser is the resolver for the removeUser field.
func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
if _, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete); err != nil {
return nil, err
}
err := r.iam.OrganizationService.RemoveUser(ctx, input.OrganizationID, input.ProfileID)
if err != nil {
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
return nil, gqlutils.Conflictf(ctx, "user is managed by SCIM and cannot be removed")
}
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
return nil, gqlutils.Conflictf(ctx, "cannot remove last active owner")
}
if _, ok := errors.AsType[*iam.ErrUserReferencedByRecords](err); ok {
return nil, gqlutils.Conflictf(ctx, "cannot remove user because they are referenced by existing records (for example signatures, tasks, assets, or risks)")
}
r.logger.ErrorCtx(ctx, "cannot remove user from organization", log.Error(err))

View File

@@ -2923,21 +2923,42 @@ func (r *Resolver) RemoveUserTool(ctx context.Context, req *mcp.CallToolRequest,
err := r.iamSvc.OrganizationService.RemoveUser(ctx, input.OrganizationID, input.ProfileID)
if err != nil {
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
return nil, types.RemoveUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be archived: %w", err)
return nil, types.RemoveUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be removed: %w", err)
}
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot archive last active owner: %w", err)
return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot remove last active owner: %w", err)
}
if errors.Is(err, coredata.ErrResourceInUse) {
return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot archive user: %w", err)
if _, ok := errors.AsType[*iam.ErrUserReferencedByRecords](err); ok {
return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot remove user because they are referenced by existing records: %w", err)
}
return nil, types.RemoveUserOutput{}, fmt.Errorf("archive user: %w", err)
return nil, types.RemoveUserOutput{}, fmt.Errorf("remove user: %w", err)
}
return nil, types.RemoveUserOutput{ArchivedUserID: input.ProfileID}, nil
return nil, types.RemoveUserOutput{DeletedUserID: input.ProfileID}, nil
}
func (r *Resolver) ArchiveUserTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ArchiveUserInput) (*mcp.CallToolResult, types.ArchiveUserOutput, error) {
if _, err := r.Authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete); err != nil {
return nil, types.ArchiveUserOutput{}, err
}
err := r.iamSvc.OrganizationService.ArchiveUser(ctx, input.OrganizationID, input.ProfileID)
if err != nil {
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
return nil, types.ArchiveUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be archived: %w", err)
}
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
return nil, types.ArchiveUserOutput{}, fmt.Errorf("cannot archive last active owner: %w", err)
}
return nil, types.ArchiveUserOutput{}, fmt.Errorf("archive user: %w", err)
}
return nil, types.ArchiveUserOutput{ArchivedUserID: input.ProfileID}, nil
}
func (r *Resolver) DeleteDataProtectionImpactAssessmentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteDataProtectionImpactAssessmentInput) (*mcp.CallToolResult, types.DeleteDataProtectionImpactAssessmentOutput, error) {

View File

@@ -1709,9 +1709,31 @@ components:
description: Organization ID
profile_id:
$ref: "#/components/schemas/GID"
description: User (profile) ID to archive
description: User (profile) ID to remove
RemoveUserOutput:
type: object
required:
- deleted_user_id
properties:
deleted_user_id:
$ref: "#/components/schemas/GID"
description: Deleted user (profile) ID
ArchiveUserInput:
type: object
required:
- organization_id
- profile_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
profile_id:
$ref: "#/components/schemas/GID"
description: User (profile) ID to archive
ArchiveUserOutput:
type: object
required:
- archived_user_id
@@ -11934,13 +11956,22 @@ tools:
outputSchema:
$ref: "#/components/schemas/UpdateMembershipOutput"
- name: removeUser
description: Archive a user in the organization
description: Remove a user from the organization
hints:
readonly: false
destructive: true
inputSchema:
$ref: "#/components/schemas/RemoveUserInput"
outputSchema:
$ref: "#/components/schemas/RemoveUserOutput"
- name: archiveUser
description: Archive a user in the organization
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/ArchiveUserInput"
outputSchema:
$ref: "#/components/schemas/ArchiveUserOutput"
- name: addThirdParty
description: Add a new thirdParty to the organization
hints: