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:
@@ -18,7 +18,7 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package archive
|
||||
package deactivate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -29,34 +29,34 @@ import (
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
)
|
||||
|
||||
const archiveMutation = `
|
||||
mutation($input: ArchiveUserInput!) {
|
||||
archiveUser(input: $input) {
|
||||
archivedProfileId
|
||||
const deactivateMutation = `
|
||||
mutation($input: DeactivateUserInput!) {
|
||||
deactivateUser(input: $input) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func NewCmdArchive(f *cmdutil.Factory) *cobra.Command {
|
||||
func NewCmdDeactivate(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagOrg string
|
||||
flagYes bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "archive <id>",
|
||||
Short: "Archive a user",
|
||||
Use: "deactivate <id>",
|
||||
Short: "Deactivate a user",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !flagYes {
|
||||
if !f.IOStreams.IsInteractive() {
|
||||
return fmt.Errorf("cannot archive user: confirmation required, use --yes to confirm")
|
||||
return fmt.Errorf("cannot deactivate user: confirmation required, use --yes to confirm")
|
||||
}
|
||||
|
||||
var confirmed bool
|
||||
|
||||
err := huh.NewConfirm().
|
||||
Title(fmt.Sprintf("Archive user %s?", args[0])).
|
||||
Title(fmt.Sprintf("Deactivate user %s?", args[0])).
|
||||
Value(&confirmed).
|
||||
Run()
|
||||
if err != nil {
|
||||
@@ -95,7 +95,7 @@ func NewCmdArchive(f *cmdutil.Factory) *cobra.Command {
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
archiveMutation,
|
||||
deactivateMutation,
|
||||
map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": flagOrg,
|
||||
@@ -107,7 +107,7 @@ func NewCmdArchive(f *cmdutil.Factory) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(f.IOStreams.Out, "Archived user %s\n", args[0])
|
||||
_, _ = fmt.Fprintf(f.IOStreams.Out, "Deactivated user %s\n", args[0])
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -23,7 +23,7 @@ package user
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
"go.probo.inc/probo/pkg/cmd/user/archive"
|
||||
"go.probo.inc/probo/pkg/cmd/user/deactivate"
|
||||
"go.probo.inc/probo/pkg/cmd/user/list"
|
||||
"go.probo.inc/probo/pkg/cmd/user/remove"
|
||||
"go.probo.inc/probo/pkg/cmd/user/view"
|
||||
@@ -37,7 +37,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(deactivate.NewCmdDeactivate(f))
|
||||
cmd.AddCommand(remove.NewCmdRemove(f))
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -374,7 +374,7 @@ func (s *OrganizationService) RemoveUser(
|
||||
)
|
||||
}
|
||||
|
||||
func (s *OrganizationService) ArchiveUser(
|
||||
func (s *OrganizationService) DeactivateUser(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
organizationID gid.GID,
|
||||
@@ -1183,55 +1183,6 @@ func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserReq
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *OrganizationService) UpdateUserState(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
state coredata.ProfileState,
|
||||
) (*coredata.MembershipProfile, error) {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(userID)
|
||||
profile = &coredata.MembershipProfile{}
|
||||
)
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
if err := profile.LoadByID(ctx, tx, scope, userID); err != nil {
|
||||
return fmt.Errorf("cannot load profile: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
switch state {
|
||||
case coredata.ProfileStatePending:
|
||||
profile.MarkPending(now)
|
||||
case coredata.ProfileStateActive:
|
||||
profile.MarkActive(now)
|
||||
case coredata.ProfileStateDeactivated:
|
||||
profile.MarkDeactivated(now)
|
||||
}
|
||||
|
||||
if err := profile.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update profile: %w", err)
|
||||
}
|
||||
|
||||
if state == coredata.ProfileStateDeactivated {
|
||||
signatures := &coredata.DocumentVersionSignatures{}
|
||||
if err := signatures.DeleteRequestedBySignatory(ctx, tx, scope, profile.ID); err != nil {
|
||||
return fmt.Errorf("cannot delete requested signatures: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *OrganizationService) GetProfile(ctx context.Context, profileID gid.GID) (*coredata.MembershipProfile, error) {
|
||||
profile := &coredata.MembershipProfile{}
|
||||
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user