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

@@ -24,9 +24,9 @@ import (
)
const archiveMutation = `
mutation($input: RemoveUserInput!) {
removeUser(input: $input) {
deletedProfileId
mutation($input: ArchiveUserInput!) {
archiveUser(input: $input) {
archivedProfileId
}
}
`

View File

@@ -0,0 +1,114 @@
// 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 remove
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const removeMutation = `
mutation($input: RemoveUserInput!) {
removeUser(input: $input) {
deletedProfileId
}
}
`
func NewCmdRemove(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagYes bool
)
cmd := &cobra.Command{
Use: "remove <id>",
Short: "Remove a user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !flagYes {
if !f.IOStreams.IsInteractive() {
return fmt.Errorf("cannot remove user: confirmation required, use --yes to confirm")
}
var confirmed bool
err := huh.NewConfirm().
Title(fmt.Sprintf("Remove user %s?", args[0])).
Value(&confirmed).
Run()
if err != nil {
return err
}
if !confirmed {
return nil
}
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
_, err = client.Do(
removeMutation,
map[string]any{
"input": map[string]any{
"organizationId": flagOrg,
"profileId": args[0],
},
},
)
if err != nil {
return err
}
_, _ = fmt.Fprintf(f.IOStreams.Out, "Removed user %s\n", args[0])
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt")
return cmd
}

View File

@@ -19,6 +19,7 @@ import (
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/user/archive"
"go.probo.inc/probo/pkg/cmd/user/list"
"go.probo.inc/probo/pkg/cmd/user/remove"
"go.probo.inc/probo/pkg/cmd/user/view"
)
@@ -31,6 +32,7 @@ func NewCmdUser(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(list.NewCmdList(f))
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(archive.NewCmdArchive(f))
cmd.AddCommand(remove.NewCmdRemove(f))
return cmd
}

View File

@@ -157,6 +157,18 @@ func (e ErrUserManagedBySCIM) Error() string {
return fmt.Sprintf("user %q is managed by SCIM and cannot be deleted manually", e.ProfileID)
}
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 ErrLastActiveOwner struct {
MembershipID gid.GID
}

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,

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: