Rename user archive action to deactivate

"Archive" was misleading for users: the action sets a profile to
DEACTIVATED while keeping the person in the organization. Rename it to
"deactivate" across the API, CLI, MCP, n8n, and console UI.

Consolidate the two overlapping operations into a single deactivateUser
backed by the fuller, guarded logic (SCIM guard, last-active-owner
guard, invitation expiry, signature cancellation, membership update,
webhook) and authorized via iam:membership-profile:deactivate. Remove
the archiveUser surface and the thin state-only deactivate path.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-28 17:30:14 +02:00
parent 99c3235b46
commit b10fc55b7f
14 changed files with 130 additions and 208 deletions

View File

@@ -104,9 +104,8 @@ extend type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload
@authentication(required: PRESENT)
deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload
updateUser(input: UpdateUserInput!): UpdateUserPayload!
archiveUser(input: ArchiveUserInput!): ArchiveUserPayload
@authentication(required: PRESENT)
updateUser(input: UpdateUserInput!): UpdateUserPayload!
removeUser(input: RemoveUserInput!): RemoveUserPayload
@authentication(required: PRESENT)
}
@@ -148,11 +147,6 @@ input RemoveUserInput {
profileId: ID!
}
input ArchiveUserInput {
organizationId: ID!
profileId: ID!
}
type CreateUserPayload {
profileEdge: ProfileEdge!
}
@@ -168,7 +162,3 @@ type UpdateUserPayload {
type RemoveUserPayload {
deletedProfileId: ID!
}
type ArchiveUserPayload {
archivedProfileId: ID!
}

View File

@@ -64,17 +64,23 @@ func (r *mutationResolver) CreateUser(ctx context.Context, input types.CreateUse
// DeactivateUser is the resolver for the deactivateUser field.
func (r *mutationResolver) DeactivateUser(ctx context.Context, input types.DeactivateUserInput) (*types.DeactivateUserPayload, error) {
if _, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDeactivate); err != nil {
scope, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDeactivate)
if err != nil {
return nil, err
}
_, err := r.iam.OrganizationService.UpdateUserState(
ctx,
input.ProfileID,
coredata.ProfileStateDeactivated,
)
err = r.iam.OrganizationService.DeactivateUser(ctx, scope, input.OrganizationID, input.ProfileID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot deactivate profile", log.Error(err))
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
return nil, gqlutils.Conflictf(ctx, "user is managed by SCIM and cannot be deactivated")
}
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
return nil, gqlutils.Conflictf(ctx, "cannot deactivate last active owner")
}
r.logger.ErrorCtx(ctx, "cannot deactivate user", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -111,31 +117,6 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, input types.UpdateUse
}, nil
}
// ArchiveUser is the resolver for the archiveUser field.
func (r *mutationResolver) ArchiveUser(ctx context.Context, input types.ArchiveUserInput) (*types.ArchiveUserPayload, error) {
scope, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete)
if err != nil {
return nil, err
}
err = r.iam.OrganizationService.ArchiveUser(ctx, scope, 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")
}
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
return nil, gqlutils.Conflictf(ctx, "cannot archive last active owner")
}
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) {
scope, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipDelete)

View File

@@ -2994,26 +2994,26 @@ func (r *Resolver) RemoveUserTool(ctx context.Context, req *mcp.CallToolRequest,
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) {
scope, err := r.Authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete)
func (r *Resolver) DeactivateUserTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeactivateUserInput) (*mcp.CallToolResult, types.DeactivateUserOutput, error) {
scope, err := r.Authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDeactivate)
if err != nil {
return nil, types.ArchiveUserOutput{}, err
return nil, types.DeactivateUserOutput{}, err
}
err = r.iamSvc.OrganizationService.ArchiveUser(ctx, scope, input.OrganizationID, input.ProfileID)
err = r.iamSvc.OrganizationService.DeactivateUser(ctx, scope, 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)
return nil, types.DeactivateUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be deactivated: %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.DeactivateUserOutput{}, fmt.Errorf("cannot deactivate last active owner: %w", err)
}
return nil, types.ArchiveUserOutput{}, fmt.Errorf("archive user: %w", err)
return nil, types.DeactivateUserOutput{}, fmt.Errorf("deactivate user: %w", err)
}
return nil, types.ArchiveUserOutput{ArchivedUserID: input.ProfileID}, nil
return nil, types.DeactivateUserOutput{DeactivatedUserID: input.ProfileID}, nil
}
func (r *Resolver) DeleteDataProtectionImpactAssessmentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteDataProtectionImpactAssessmentInput) (*mcp.CallToolResult, types.DeleteDataProtectionImpactAssessmentOutput, error) {

View File

@@ -1698,7 +1698,7 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted user (profile) ID
ArchiveUserInput:
DeactivateUserInput:
type: object
required:
- organization_id
@@ -1709,16 +1709,16 @@ components:
description: Organization ID
profile_id:
$ref: "#/components/schemas/GID"
description: User (profile) ID to archive
description: User (profile) ID to deactivate
ArchiveUserOutput:
DeactivateUserOutput:
type: object
required:
- archived_user_id
- deactivated_user_id
properties:
archived_user_id:
deactivated_user_id:
$ref: "#/components/schemas/GID"
description: Archived user (profile) ID
description: Deactivated user (profile) ID
GetProfileInput:
type: object
@@ -12679,14 +12679,14 @@ tools:
$ref: "#/components/schemas/RemoveUserInput"
outputSchema:
$ref: "#/components/schemas/RemoveUserOutput"
- name: archiveUser
description: Archive a user in the organization
- name: deactivateUser
description: Deactivate a user in the organization
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/ArchiveUserInput"
$ref: "#/components/schemas/DeactivateUserInput"
outputSchema:
$ref: "#/components/schemas/ArchiveUserOutput"
$ref: "#/components/schemas/DeactivateUserOutput"
- name: addThirdParty
description: Add a new thirdParty to the organization
hints: