Split inactive profile state

Replace the binary profile ACTIVE/INACTIVE model with PENDING, ACTIVE,
and DEACTIVATED so invited-but-not-yet-activated members remain
assignable to assets, data, and risks instead of being treated like
deactivated users.

Add activated_at/deactivated_at timestamps and Mark* lifecycle helpers,
and update every transition (create, invite/re-invite, activation,
archive, SCIM, SAML, sessions, compliance-portal grant) to the new
states. Expose a multi-state states[] filter across coredata, GraphQL,
MCP, and the console owner pickers, which now request ACTIVE and
PENDING members.

A migration renames the membership_state enum, classifies existing
inactive profiles as PENDING from recent invitation activity, and
backfills the new timestamp columns.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-28 17:03:52 +02:00
parent 6b3913b189
commit 4a276e3ef7
29 changed files with 261 additions and 64 deletions

View File

@@ -179,9 +179,8 @@ func (s *AuthService) ActivateAccount(
return NewUserManagedBySCIMError(profile.ID)
}
if profile.State == coredata.ProfileStateInactive {
profile.State = coredata.ProfileStateActive
profile.UpdatedAt = now
if profile.State == coredata.ProfileStatePending {
profile.MarkActive(now)
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update user: %w", err)

View File

@@ -438,9 +438,8 @@ func (s *OrganizationService) ArchiveUser(
now := time.Now()
if profile.State != coredata.ProfileStateInactive {
profile.State = coredata.ProfileStateInactive
profile.UpdatedAt = now
if profile.State != coredata.ProfileStateDeactivated {
profile.MarkDeactivated(now)
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update profile state: %w", err)
@@ -505,6 +504,14 @@ func (s *OrganizationService) InviteUser(
return NewUserManagedBySCIMError(profile.ID)
}
if profile.State == coredata.ProfileStateDeactivated {
profile.MarkPending(now)
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update profile state: %w", err)
}
}
err = invitation.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert invitation: %w", err)
@@ -583,6 +590,7 @@ func (s *OrganizationService) CreateOrganization(
OrganizationID: organization.ID,
Source: coredata.ProfileSourceManual,
State: coredata.ProfileStateActive,
ActivatedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
@@ -1038,8 +1046,8 @@ func (s *OrganizationService) CreateUser(ctx context.Context, scope coredata.Sco
Kind: req.Kind,
AdditionalEmailAddresses: req.AdditionalEmailAddresses,
Position: req.Position,
// User is created inactive
State: coredata.ProfileStateInactive,
// User is pending until they accept an invitation.
State: coredata.ProfileStatePending,
CreatedAt: now,
UpdatedAt: now,
}
@@ -1192,14 +1200,22 @@ func (s *OrganizationService) UpdateUserState(
return fmt.Errorf("cannot load profile: %w", err)
}
profile.State = state
profile.UpdatedAt = time.Now()
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.ProfileStateInactive {
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)

View File

@@ -322,6 +322,7 @@ func (s *Service) HandleAssertion(
OrganizationID: config.OrganizationID,
Source: coredata.ProfileSourceSAML,
State: coredata.ProfileStateActive,
ActivatedAt: &now,
FullName: fullname,
CreatedAt: now,
UpdatedAt: now,
@@ -332,7 +333,7 @@ func (s *Service) HandleAssertion(
return fmt.Errorf("cannot insert membership profile: %w", err)
}
} else {
if profile.State == coredata.ProfileStateInactive {
if profile.State == coredata.ProfileStateDeactivated {
return NewUserInactiveError(profile.ID)
}
}

View File

@@ -150,7 +150,7 @@ func (s *Service) CreateUser(
profileState := coredata.ProfileStateActive
if !attrs.Active {
profileState = coredata.ProfileStateInactive
profileState = coredata.ProfileStateDeactivated
}
var externalIdPtr *string
@@ -536,8 +536,8 @@ func (s *Service) updateUser(
previousMembership := *membership
previousUser := webhooktypes.NewUser(&previousProfile, &previousMembership)
shouldReactivate := attrs.Active != nil && *attrs.Active && profile.State == coredata.ProfileStateInactive
shouldDeactivate := attrs.Active != nil && !*attrs.Active && profile.State == coredata.ProfileStateActive
shouldReactivate := attrs.Active != nil && *attrs.Active && profile.State == coredata.ProfileStateDeactivated
shouldDeactivate := attrs.Active != nil && !*attrs.Active && profile.State != coredata.ProfileStateDeactivated
if attrs.FullName != "" {
profile.FullName = attrs.FullName
@@ -748,11 +748,9 @@ func (s *Service) updateUser(
}
if shouldReactivate {
profile.State = coredata.ProfileStateActive
profile.UpdatedAt = now
profile.MarkActive(now)
} else if shouldDeactivate {
profile.State = coredata.ProfileStateInactive
profile.UpdatedAt = now
profile.MarkDeactivated(now)
}
if profile.Source != coredata.ProfileSourceSCIM {
@@ -826,7 +824,15 @@ func applyUserAttributes(
now time.Time,
) {
profile.Source = coredata.ProfileSourceSCIM
profile.State = state
switch {
case state == coredata.ProfileStateActive && profile.State != coredata.ProfileStateActive:
profile.MarkActive(now)
case state == coredata.ProfileStateDeactivated && profile.State != coredata.ProfileStateDeactivated:
profile.MarkDeactivated(now)
default:
profile.State = state
}
profile.FullName = attrs.FullName
profile.Position = &attrs.Title
profile.UserName = &attrs.UserName
@@ -956,15 +962,14 @@ func (s *Service) deactivateProfileInTx(
profile *coredata.MembershipProfile,
membership *coredata.Membership,
) error {
if profile.State == coredata.ProfileStateInactive {
if profile.State == coredata.ProfileStateDeactivated {
return nil
}
previousUser := webhooktypes.NewUser(profile, membership)
now := time.Now()
profile.State = coredata.ProfileStateInactive
profile.UpdatedAt = now
profile.MarkDeactivated(now)
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot deactivate profile: %w", err)

View File

@@ -364,7 +364,7 @@ func (s SessionService) OpenPasswordChildSessionForOrganization(
return fmt.Errorf("cannot load profile: %w", err)
}
if profile.State == coredata.ProfileStateInactive {
if profile.State == coredata.ProfileStateDeactivated {
return NewUserInactiveError(profile.ID)
}
@@ -469,7 +469,7 @@ func (s SessionService) OpenSAMLChildSessionForOrganization(
return fmt.Errorf("cannot load profile: %w", err)
}
if profile.State == coredata.ProfileStateInactive {
if profile.State == coredata.ProfileStateDeactivated {
return NewUserInactiveError(profile.ID)
}
@@ -562,7 +562,7 @@ func (s SessionService) OpenOIDCChildSessionForOrganization(
return fmt.Errorf("cannot load profile: %w", err)
}
if profile.State == coredata.ProfileStateInactive {
if profile.State == coredata.ProfileStateDeactivated {
return NewUserInactiveError(profile.ID)
}
@@ -651,7 +651,7 @@ func (s SessionService) AssumeOrganizationSession(
return fmt.Errorf("cannot load profile: %w", err)
}
if profile.State == coredata.ProfileStateInactive {
if profile.State == coredata.ProfileStateDeactivated {
return NewUserInactiveError(profile.ID)
}