satellite/{payment,console,analytics} extend freeze functionality for legal freeze

This change extends the account freeze functionality account for legal
freezes as well. This is a freeze event for accounts to be put on hold
for legal review. It also sets the LegalHold status on the affected
user.

Issue: storj/storj-private#492

Change-Id: I8c733269b5cfb647c840379a6bb033da120c8280
This commit is contained in:
Wilfred Asomani 2023-11-09 16:00:27 +00:00
parent a053b210a5
commit cd8e9bd044
7 changed files with 400 additions and 32 deletions

View File

@ -88,6 +88,7 @@ const (
eventUnpaidLargeInvoice = "Large Invoice Unpaid"
eventUnpaidStorjscanInvoice = "Storjscan Invoice Unpaid"
eventPendingDeletionUnpaidInvoice = "Pending Deletion Invoice Open"
eventLegalHoldUnpaidInvoice = "Legal Hold Invoice Open"
eventExpiredCreditNeedsRemoval = "Expired Credit Needs Removal"
eventExpiredCreditRemoved = "Expired Credit Removed"
eventProjectInvitationAccepted = "Project Invitation Accepted"
@ -454,7 +455,7 @@ func (service *Service) TrackLargeUnpaidInvoice(invID string, userID uuid.UUID,
})
}
// TrackViolationFrozenUnpaidInvoice sends an event to Segment indicating that a user has not paid a large invoice.
// TrackViolationFrozenUnpaidInvoice sends an event to Segment indicating that a violation frozen user has not paid an invoice.
func (service *Service) TrackViolationFrozenUnpaidInvoice(invID string, userID uuid.UUID, email string) {
if !service.config.Enabled {
return
@ -471,6 +472,24 @@ func (service *Service) TrackViolationFrozenUnpaidInvoice(invID string, userID u
})
}
// TrackLegalHoldUnpaidInvoice sends an event to Segment indicating that a user has not paid an invoice
// but is in legal hold.
func (service *Service) TrackLegalHoldUnpaidInvoice(invID string, userID uuid.UUID, email string) {
if !service.config.Enabled {
return
}
props := segment.NewProperties()
props.Set("email", email)
props.Set("invoice", invID)
service.enqueueMessage(segment.Track{
UserId: userID.String(),
Event: service.satelliteName + " " + eventLegalHoldUnpaidInvoice,
Properties: props,
})
}
// TrackStorjscanUnpaidInvoice sends an event to Segment indicating that a user has not paid an invoice, but has storjscan transaction history.
func (service *Service) TrackStorjscanUnpaidInvoice(invID string, userID uuid.UUID, email string) {
if !service.config.Enabled {

View File

@ -74,7 +74,7 @@ type FreezeEventsPage struct {
// UserFreezeEvents holds the freeze events for a user.
type UserFreezeEvents struct {
BillingFreeze, BillingWarning, ViolationFreeze *AccountFreezeEvent
BillingFreeze, BillingWarning, ViolationFreeze, LegalFreeze *AccountFreezeEvent
}
// AccountFreezeEventType is used to indicate the account freeze event's type.
@ -88,6 +88,8 @@ const (
BillingWarning AccountFreezeEventType = 1
// ViolationFreeze signifies that the user has been frozen due to ToS violation.
ViolationFreeze AccountFreezeEventType = 2
// LegalFreeze signifies that the user has been frozen for legal review.
LegalFreeze AccountFreezeEventType = 3
)
// String returns a string representation of this event.
@ -99,6 +101,8 @@ func (et AccountFreezeEventType) String() string {
return "Billing Warning"
case ViolationFreeze:
return "Violation Freeze"
case LegalFreeze:
return "Legal Freeze"
default:
return ""
}
@ -133,24 +137,19 @@ func NewAccountFreezeService(freezeEventsDB AccountFreezeEvents, usersDB Users,
// IsUserBillingFrozen returns whether the user specified by the given ID is frozen
// due to nonpayment of invoices.
func (s *AccountFreezeService) IsUserBillingFrozen(ctx context.Context, userID uuid.UUID) (_ bool, err error) {
defer mon.Task()(&ctx)(&err)
_, err = s.freezeEventsDB.Get(ctx, userID, BillingFreeze)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, ErrAccountFreeze.Wrap(err)
default:
return true, nil
}
return s.IsUserFrozen(ctx, userID, BillingFreeze)
}
// IsUserViolationFrozen returns whether the user specified by the given ID is frozen.
func (s *AccountFreezeService) IsUserViolationFrozen(ctx context.Context, userID uuid.UUID) (_ bool, err error) {
return s.IsUserFrozen(ctx, userID, ViolationFreeze)
}
// IsUserFrozen returns whether the user specified by the given ID has an eventType freeze.
func (s *AccountFreezeService) IsUserFrozen(ctx context.Context, userID uuid.UUID, eventType AccountFreezeEventType) (_ bool, err error) {
defer mon.Task()(&ctx)(&err)
_, err = s.freezeEventsDB.Get(ctx, userID, ViolationFreeze)
_, err = s.freezeEventsDB.Get(ctx, userID, eventType)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
@ -177,6 +176,9 @@ func (s *AccountFreezeService) BillingFreezeUser(ctx context.Context, userID uui
if freezes.ViolationFreeze != nil {
return ErrAccountFreeze.New("User is already frozen due to ToS violation")
}
if freezes.LegalFreeze != nil {
return ErrAccountFreeze.New("User is already frozen for legal review")
}
userLimits := UsageLimits{
Storage: user.ProjectStorageLimit,
@ -315,7 +317,7 @@ func (s *AccountFreezeService) BillingWarnUser(ctx context.Context, userID uuid.
return ErrAccountFreeze.Wrap(err)
}
if freezes.ViolationFreeze != nil || freezes.BillingFreeze != nil {
if freezes.ViolationFreeze != nil || freezes.BillingFreeze != nil || freezes.LegalFreeze != nil {
return ErrAccountFreeze.New("User is already frozen")
}
@ -374,6 +376,10 @@ func (s *AccountFreezeService) ViolationFreezeUser(ctx context.Context, userID u
return ErrAccountFreeze.Wrap(err)
}
if freezes.LegalFreeze != nil {
return ErrAccountFreeze.New("User is already frozen for legal review")
}
var limits *AccountFreezeEventLimits
if freezes.BillingFreeze != nil {
limits = freezes.BillingFreeze.Limits
@ -509,6 +515,150 @@ func (s *AccountFreezeService) ViolationUnfreezeUser(ctx context.Context, userID
return nil
}
// LegalFreezeUser freezes the user specified by the given ID for legal review.
func (s *AccountFreezeService) LegalFreezeUser(ctx context.Context, userID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.usersDB.Get(ctx, userID)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
freezes, err := s.freezeEventsDB.GetAll(ctx, userID)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
if freezes.ViolationFreeze != nil {
return ErrAccountFreeze.New("User is already frozen due to ToS violation")
}
userLimits := UsageLimits{
Storage: user.ProjectStorageLimit,
Bandwidth: user.ProjectBandwidthLimit,
Segment: user.ProjectSegmentLimit,
}
legalFreeze := freezes.LegalFreeze
if legalFreeze == nil {
legalFreeze = &AccountFreezeEvent{
UserID: userID,
Type: LegalFreeze,
Limits: &AccountFreezeEventLimits{
User: userLimits,
Projects: make(map[uuid.UUID]UsageLimits),
},
}
}
// If user limits have been zeroed already, we should not override what is in the freeze table.
if userLimits != (UsageLimits{}) {
legalFreeze.Limits.User = userLimits
}
projects, err := s.projectsDB.GetOwn(ctx, userID)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
for _, p := range projects {
projLimits := UsageLimits{}
if p.StorageLimit != nil {
projLimits.Storage = p.StorageLimit.Int64()
}
if p.BandwidthLimit != nil {
projLimits.Bandwidth = p.BandwidthLimit.Int64()
}
if p.SegmentLimit != nil {
projLimits.Segment = *p.SegmentLimit
}
// If project limits have been zeroed already, we should not override what is in the freeze table.
if projLimits != (UsageLimits{}) {
legalFreeze.Limits.Projects[p.ID] = projLimits
}
}
_, err = s.freezeEventsDB.Upsert(ctx, legalFreeze)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
err = s.usersDB.UpdateUserProjectLimits(ctx, userID, UsageLimits{})
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
for _, proj := range projects {
err := s.projectsDB.UpdateUsageLimits(ctx, proj.ID, UsageLimits{})
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
}
if freezes.BillingWarning != nil {
err = s.freezeEventsDB.DeleteByUserIDAndEvent(ctx, userID, BillingWarning)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
}
status := LegalHold
err = s.usersDB.Update(ctx, userID, UpdateUserRequest{
Status: &status,
})
if err != nil {
return ErrAccountFreeze.Wrap(errs.Combine(ErrFreezeUserStatusUpdate, err))
}
return nil
}
// LegalUnfreezeUser reverses the legal freeze placed on the user specified by the given ID.
func (s *AccountFreezeService) LegalUnfreezeUser(ctx context.Context, userID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.usersDB.Get(ctx, userID)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
event, err := s.freezeEventsDB.Get(ctx, userID, LegalFreeze)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccountFreeze.New("user is not legal-frozen")
}
if event.Limits == nil {
return ErrAccountFreeze.New("freeze event limits are nil")
}
for id, limits := range event.Limits.Projects {
err = s.projectsDB.UpdateUsageLimits(ctx, id, limits)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
}
err = s.usersDB.UpdateUserProjectLimits(ctx, userID, event.Limits.User)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
err = ErrAccountFreeze.Wrap(s.freezeEventsDB.DeleteByUserIDAndEvent(ctx, userID, LegalFreeze))
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
if user.Status == LegalHold {
status := Active
err = s.usersDB.Update(ctx, userID, UpdateUserRequest{
Status: &status,
})
if err != nil {
return ErrAccountFreeze.Wrap(errs.Combine(ErrFreezeUserStatusUpdate, err))
}
}
return nil
}
// GetAll returns all events for a user.
func (s *AccountFreezeService) GetAll(ctx context.Context, userID uuid.UUID) (freezes *UserFreezeEvents, err error) {
defer mon.Task()(&ctx)(&err)

View File

@ -71,6 +71,11 @@ func TestAccountBillingFreeze(t *testing.T) {
require.Error(t, service.BillingFreezeUser(ctx, user.ID))
require.NoError(t, service.ViolationUnfreezeUser(ctx, user.ID))
require.NoError(t, service.LegalFreezeUser(ctx, user.ID))
// cannot billing freeze a legal-frozen user.
require.Error(t, service.BillingFreezeUser(ctx, user.ID))
require.NoError(t, service.LegalUnfreezeUser(ctx, user.ID))
require.NoError(t, service.BillingFreezeUser(ctx, user.ID))
user, err = usersDB.Get(ctx, user.ID)
@ -238,6 +243,88 @@ func TestAccountViolationFreeze(t *testing.T) {
})
}
func TestAccountLegalFreeze(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
usersDB := sat.DB.Console().Users()
projectsDB := sat.DB.Console().Projects()
service := console.NewAccountFreezeService(sat.DB.Console().AccountFreezeEvents(), usersDB, projectsDB, sat.API.Analytics.Service, sat.Config.Console.AccountFreeze)
userLimits := randUsageLimits()
user, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test User",
Email: "user@mail.test",
}, 2)
require.NoError(t, err)
require.NoError(t, usersDB.UpdateUserProjectLimits(ctx, user.ID, userLimits))
projLimits := randUsageLimits()
proj, err := sat.AddProject(ctx, user.ID, "")
require.NoError(t, err)
require.NoError(t, projectsDB.UpdateUsageLimits(ctx, proj.ID, projLimits))
checkLimits := func(testT *testing.T) {
user, err = usersDB.Get(ctx, user.ID)
require.NoError(t, err)
require.Zero(t, getUserLimits(user))
proj, err = projectsDB.Get(ctx, proj.ID)
require.NoError(t, err)
require.Zero(t, getProjectLimits(proj))
}
frozen, err := service.IsUserFrozen(ctx, user.ID, console.LegalFreeze)
require.NoError(t, err)
require.False(t, frozen)
require.NoError(t, service.LegalFreezeUser(ctx, user.ID))
frozen, err = service.IsUserFrozen(ctx, user.ID, console.LegalFreeze)
require.NoError(t, err)
require.True(t, frozen)
user, err = usersDB.Get(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, console.LegalHold, user.Status)
checkLimits(t)
require.NoError(t, service.LegalUnfreezeUser(ctx, user.ID))
frozen, err = service.IsUserFrozen(ctx, user.ID, console.LegalFreeze)
require.NoError(t, err)
require.False(t, frozen)
require.NoError(t, service.BillingWarnUser(ctx, user.ID))
frozen, err = service.IsUserFrozen(ctx, user.ID, console.LegalFreeze)
require.NoError(t, err)
require.False(t, frozen)
// legal freezing a warned user should be possible.
require.NoError(t, service.LegalFreezeUser(ctx, user.ID))
frozen, err = service.IsUserFrozen(ctx, user.ID, console.LegalFreeze)
require.NoError(t, err)
require.True(t, frozen)
require.NoError(t, service.LegalUnfreezeUser(ctx, user.ID))
require.NoError(t, service.BillingFreezeUser(ctx, user.ID))
frozen, err = service.IsUserFrozen(ctx, user.ID, console.LegalFreeze)
require.NoError(t, err)
require.False(t, frozen)
// legal freezing a billing frozen user should be possible.
require.NoError(t, service.LegalFreezeUser(ctx, user.ID))
frozen, err = service.IsUserFrozen(ctx, user.ID, console.LegalFreeze)
require.NoError(t, err)
require.True(t, frozen)
freezes, err := service.GetAll(ctx, user.ID)
require.NoError(t, err)
require.NotNil(t, freezes.LegalFreeze)
require.Nil(t, freezes.LegalFreeze.DaysTillEscalation)
checkLimits(t)
})
}
func TestRemoveAccountBillingWarning(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
@ -286,6 +373,11 @@ func TestRemoveAccountBillingWarning(t *testing.T) {
require.Error(t, service.BillingWarnUser(ctx, user.ID))
require.NoError(t, service.BillingUnfreezeUser(ctx, user.ID))
require.NoError(t, service.LegalFreezeUser(ctx, user.ID))
// cannot warn a legal-frozen user.
require.Error(t, service.BillingWarnUser(ctx, user.ID))
require.NoError(t, service.LegalUnfreezeUser(ctx, user.ID))
require.NoError(t, service.BillingWarnUser(ctx, user.ID))
require.NoError(t, service.ViolationFreezeUser(ctx, user.ID))
// cannot warn a violation-frozen user.
@ -391,12 +483,6 @@ func TestAccountFreezeAlreadyFrozen(t *testing.T) {
require.NoError(t, err)
require.Equal(t, userLimits, getUserLimits(user))
})
// Billing freezing a violation frozen user should not be possible.
t.Run("ViolationFrozen user", func(t *testing.T) {
require.NoError(t, service.ViolationFreezeUser(ctx, user.ID))
require.Error(t, service.BillingFreezeUser(ctx, user.ID))
})
})
}
@ -463,32 +549,42 @@ func TestFreezeEffects(t *testing.T) {
t.Run("BillingFreeze effect on project owner", func(t *testing.T) {
shouldUploadAndDownload(t)
err = freezeService.BillingWarnUser(ctx, user1.ID)
require.NoError(t, err)
require.NoError(t, freezeService.BillingWarnUser(ctx, user1.ID))
// Should be able to download because account is not frozen.
shouldUploadAndDownload(t)
err = freezeService.BillingFreezeUser(ctx, user1.ID)
require.NoError(t, err)
require.NoError(t, freezeService.BillingFreezeUser(ctx, user1.ID))
shouldNotUploadAndDownload(t)
shouldListAndDelete(t)
err = freezeService.BillingUnfreezeUser(ctx, user1.ID)
require.NoError(t, err)
require.NoError(t, freezeService.BillingUnfreezeUser(ctx, user1.ID))
})
t.Run("ViolationFreeze effect on project owner", func(t *testing.T) {
shouldUploadAndDownload(t)
err = freezeService.ViolationFreezeUser(ctx, user1.ID)
require.NoError(t, err)
require.NoError(t, freezeService.ViolationFreezeUser(ctx, user1.ID))
shouldNotUploadAndDownload(t)
shouldListAndDelete(t)
require.NoError(t, freezeService.ViolationUnfreezeUser(ctx, user1.ID))
})
t.Run("LegalFreeze effect on project owner", func(t *testing.T) {
shouldUploadAndDownload(t)
require.NoError(t, freezeService.LegalFreezeUser(ctx, user1.ID))
shouldNotUploadAndDownload(t)
shouldListAndDelete(t)
require.NoError(t, freezeService.LegalUnfreezeUser(ctx, user1.ID))
})
})
}

View File

@ -192,6 +192,12 @@ func (chore *Chore) attemptBillingFreezeWarn(ctx context.Context) {
continue
}
if freezes.LegalFreeze != nil {
infoLog("Ignoring invoice; account already frozen for legal review")
chore.analytics.TrackLegalHoldUnpaidInvoice(invoice.ID, userID, user.Email)
continue
}
shouldEscalate := func(event *console.AccountFreezeEvent) bool {
if event == nil || event.DaysTillEscalation == nil {
return false
@ -352,6 +358,11 @@ func (chore *Chore) attemptBillingUnfreezeUnwarn(ctx context.Context) {
continue
}
if event.Type == console.LegalFreeze {
infoLog("Skipping legal freeze event")
continue
}
user, err := chore.usersDB.Get(ctx, event.UserID)
if err != nil {
errorLog("Could not get user", err)

View File

@ -58,6 +58,91 @@ func TestAutoFreezeChore(t *testing.T) {
amount := int64(100)
curr := string(stripe.CurrencyUSD)
t.Run("No billing event for legal frozen user", func(t *testing.T) {
// AnalyticsMock tests that events are sent once.
service.TestChangeFreezeTracker(newFreezeTrackerMock(t))
violatingUser, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Violating User",
Email: "legalhold@mail.test",
}, 1)
require.NoError(t, err)
cus2, err := customerDB.GetCustomerID(ctx, violatingUser.ID)
require.NoError(t, err)
inv, err := stripeClient.Invoices().New(&stripe.InvoiceParams{
Params: stripe.Params{Context: ctx},
Customer: &cus2,
})
require.NoError(t, err)
_, err = stripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
Params: stripe.Params{Context: ctx},
Amount: &amount,
Currency: &curr,
Customer: &cus2,
Invoice: &inv.ID,
})
require.NoError(t, err)
paymentMethod := stripe1.MockInvoicesPayFailure
inv, err = stripeClient.Invoices().Pay(inv.ID, &stripe.InvoicePayParams{
Params: stripe.Params{Context: ctx},
PaymentMethod: &paymentMethod,
})
require.Error(t, err)
require.Equal(t, stripe.InvoiceStatusOpen, inv.Status)
failed, err := invoicesDB.ListFailed(ctx, nil)
require.NoError(t, err)
require.Equal(t, 1, len(failed))
require.NoError(t, service.LegalFreezeUser(ctx, violatingUser.ID))
chore.Loop.TriggerWait()
// user should not be billing warned or frozen.
freezes, err := service.GetAll(ctx, violatingUser.ID)
require.NoError(t, err)
require.NotNil(t, freezes)
require.Nil(t, freezes.BillingWarning)
require.Nil(t, freezes.BillingFreeze)
require.NotNil(t, freezes.LegalFreeze)
// forward date to after the grace period
chore.TestSetNow(func() time.Time {
return time.Now().Add(sat.Config.Console.AccountFreeze.BillingWarnGracePeriod).Add(24 * time.Hour)
})
chore.Loop.TriggerWait()
// user should still not be billing warned or frozen.
freezes, err = service.GetAll(ctx, violatingUser.ID)
require.NoError(t, err)
require.NotNil(t, freezes)
require.Nil(t, freezes.BillingFreeze)
require.Nil(t, freezes.BillingWarning)
require.NotNil(t, freezes.LegalFreeze)
paymentMethod = stripe1.MockInvoicesPaySuccess
_, err = stripeClient.Invoices().Pay(inv.ID, &stripe.InvoicePayParams{
Params: stripe.Params{Context: ctx},
PaymentMethod: &paymentMethod,
})
require.NoError(t, err)
require.Equal(t, stripe.InvoiceStatusPaid, inv.Status)
chore.Loop.TriggerWait()
// paying for the invoice does not remove the legal freeze
freezes, err = service.GetAll(ctx, violatingUser.ID)
require.NoError(t, err)
require.NotNil(t, freezes)
require.Nil(t, freezes.BillingFreeze)
require.Nil(t, freezes.BillingWarning)
require.NotNil(t, freezes.LegalFreeze)
})
t.Run("No billing event for violation frozen user", func(t *testing.T) {
// AnalyticsMock tests that events are sent once.
service.TestChangeFreezeTracker(newFreezeTrackerMock(t))

View File

@ -126,8 +126,8 @@ func (events *accountFreezeEvents) GetAllEvents(ctx context.Context, cursor cons
func (events *accountFreezeEvents) GetAll(ctx context.Context, userID uuid.UUID) (freezes *console.UserFreezeEvents, err error) {
defer mon.Task()(&ctx)(&err)
// dbxEvents will have a max length of 3.
// because there's at most 1 instance each of 3 types of events for a user.
// dbxEvents will have a max length of 4.
// because there's at most 1 instance each of 4 types of events for a user.
dbxEvents, err := events.db.All_AccountFreezeEvent_By_UserId(ctx,
dbx.AccountFreezeEvent_UserId(userID.Bytes()),
)
@ -151,6 +151,13 @@ func (events *accountFreezeEvents) GetAll(ctx context.Context, userID uuid.UUID)
}
continue
}
if console.AccountFreezeEventType(event.Event) == console.LegalFreeze {
freezes.LegalFreeze, err = fromDBXAccountFreezeEvent(event)
if err != nil {
return nil, err
}
continue
}
freezes.BillingWarning, err = fromDBXAccountFreezeEvent(event)
if err != nil {
return nil, err

View File

@ -206,7 +206,7 @@ model account_freeze_event (
// user_id refers to user.id column.
field user_id blob
// event indicates the console.AccountFreezeEventType. BillingFreeze=0, BillingWarning=1, ViolationFreeze=2.
// event indicates the console.AccountFreezeEventType. BillingFreeze=0, BillingWarning=1, ViolationFreeze=2, LegalFreeze=3.
field event int
// limits are the limits before the freeze begun.
field limits json ( nullable, updatable )