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:
committed by
Bryan Frimin
parent
a71a7bb56f
commit
1e08a23ddc
@@ -24,9 +24,9 @@ import (
|
||||
)
|
||||
|
||||
const archiveMutation = `
|
||||
mutation($input: RemoveUserInput!) {
|
||||
removeUser(input: $input) {
|
||||
deletedProfileId
|
||||
mutation($input: ArchiveUserInput!) {
|
||||
archiveUser(input: $input) {
|
||||
archivedProfileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
114
pkg/cmd/user/remove/remove.go
Normal file
114
pkg/cmd/user/remove/remove.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user