storj/satellite/console/accountfreezes.go
Wilfred Asomani 6308da2cc0 satellite/{payment,console,analytics} extend freeze functionality for violation freeze
This change extends the account freeze functionality account for
violation freezes as well.
Also, debug level logs in the freeze chore have been changed to info.
It adds an analytics event for when an invoice is found that belongs to
a user frozen for violation.
And finally adds whether a user is frozen for violation to the
/account/freezestatus response.

Issue: https://github.com/storj/storj-private/issues/386

Change-Id: Id8e40282dc8fd8f242da52791ab8ddbbef3da2bc
2023-10-10 18:39:29 +00:00

517 lines
15 KiB
Go

// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package console
import (
"context"
"database/sql"
"errors"
"time"
"github.com/zeebo/errs"
"storj.io/common/uuid"
"storj.io/storj/satellite/analytics"
)
// ErrAccountFreeze is the class for errors that occur during operation of the account freeze service.
var ErrAccountFreeze = errs.Class("account freeze service")
// ErrFreezeUserStatusUpdate is error returned if updating the user status as part of violation (un)freeze
// fails.
var ErrFreezeUserStatusUpdate = errs.New("user status update failed")
// AccountFreezeEvents exposes methods to manage the account freeze events table in database.
//
// architecture: Database
type AccountFreezeEvents interface {
// Upsert is a method for updating an account freeze event if it exists and inserting it otherwise.
Upsert(ctx context.Context, event *AccountFreezeEvent) (*AccountFreezeEvent, error)
// Get is a method for querying account freeze event from the database by user ID and event type.
Get(ctx context.Context, userID uuid.UUID, eventType AccountFreezeEventType) (*AccountFreezeEvent, error)
// GetAllEvents is a method for querying all account freeze events from the database.
GetAllEvents(ctx context.Context, cursor FreezeEventsCursor) (events *FreezeEventsPage, err error)
// GetAll is a method for querying all account freeze events from the database by user ID.
GetAll(ctx context.Context, userID uuid.UUID) (freezes *UserFreezeEvents, err error)
// DeleteAllByUserID is a method for deleting all account freeze events from the database by user ID.
DeleteAllByUserID(ctx context.Context, userID uuid.UUID) error
// DeleteByUserIDAndEvent is a method for deleting all account `eventType` events from the database by user ID.
DeleteByUserIDAndEvent(ctx context.Context, userID uuid.UUID, eventType AccountFreezeEventType) error
}
// AccountFreezeEvent represents an event related to account freezing.
type AccountFreezeEvent struct {
UserID uuid.UUID
Type AccountFreezeEventType
Limits *AccountFreezeEventLimits
CreatedAt time.Time
}
// AccountFreezeEventLimits represents the usage limits for a user's account and projects before they were frozen.
type AccountFreezeEventLimits struct {
User UsageLimits `json:"user"`
Projects map[uuid.UUID]UsageLimits `json:"projects"`
}
// FreezeEventsCursor holds info for freeze events
// cursor pagination.
type FreezeEventsCursor struct {
Limit int
// StartingAfter is the last user ID of the previous page.
// The next page will start after this user ID.
StartingAfter *uuid.UUID
}
// FreezeEventsPage returns paginated freeze events.
type FreezeEventsPage struct {
Events []AccountFreezeEvent
// Next indicates whether there are more events to retrieve.
Next bool
}
// UserFreezeEvents holds the freeze events for a user.
type UserFreezeEvents struct {
BillingFreeze, BillingWarning, ViolationFreeze *AccountFreezeEvent
}
// AccountFreezeEventType is used to indicate the account freeze event's type.
type AccountFreezeEventType int
const (
// BillingFreeze signifies that the user has been frozen due to nonpayment of invoices.
BillingFreeze AccountFreezeEventType = 0
// BillingWarning signifies that the user has been warned that they may be frozen soon
// due to nonpayment of invoices.
BillingWarning AccountFreezeEventType = 1
// ViolationFreeze signifies that the user has been frozen due to ToS violation.
ViolationFreeze AccountFreezeEventType = 2
)
// String returns a string representation of this event.
func (et AccountFreezeEventType) String() string {
switch et {
case BillingFreeze:
return "Billing Freeze"
case BillingWarning:
return "Billing Warning"
case ViolationFreeze:
return "Violation Freeze"
default:
return ""
}
}
// AccountFreezeService encapsulates operations concerning account freezes.
type AccountFreezeService struct {
freezeEventsDB AccountFreezeEvents
usersDB Users
projectsDB Projects
tracker analytics.FreezeTracker
}
// NewAccountFreezeService creates a new account freeze service.
func NewAccountFreezeService(freezeEventsDB AccountFreezeEvents, usersDB Users, projectsDB Projects, tracker analytics.FreezeTracker) *AccountFreezeService {
return &AccountFreezeService{
freezeEventsDB: freezeEventsDB,
usersDB: usersDB,
projectsDB: projectsDB,
tracker: tracker,
}
}
// 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
}
}
// 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) {
defer mon.Task()(&ctx)(&err)
_, err = s.freezeEventsDB.Get(ctx, userID, ViolationFreeze)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, ErrAccountFreeze.Wrap(err)
default:
return true, nil
}
}
// BillingFreezeUser freezes the user specified by the given ID due to nonpayment of invoices.
func (s *AccountFreezeService) BillingFreezeUser(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,
}
billingFreeze := freezes.BillingFreeze
if billingFreeze == nil {
billingFreeze = &AccountFreezeEvent{
UserID: userID,
Type: BillingFreeze,
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{}) {
billingFreeze.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{}) {
billingFreeze.Limits.Projects[p.ID] = projLimits
}
}
_, err = s.freezeEventsDB.Upsert(ctx, billingFreeze)
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)
}
}
s.tracker.TrackAccountFrozen(userID, user.Email)
return nil
}
// BillingUnfreezeUser reverses the billing freeze placed on the user specified by the given ID.
func (s *AccountFreezeService) BillingUnfreezeUser(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, BillingFreeze)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccountFreeze.New("user is not frozen due to nonpayment of invoices")
}
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, BillingFreeze))
if err != nil {
return err
}
s.tracker.TrackAccountUnfrozen(userID, user.Email)
return nil
}
// BillingWarnUser adds a billing warning event to the freeze events table.
func (s *AccountFreezeService) BillingWarnUser(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 || freezes.BillingFreeze != nil {
return ErrAccountFreeze.New("User is already frozen")
}
if freezes.BillingWarning != nil {
return nil
}
_, err = s.freezeEventsDB.Upsert(ctx, &AccountFreezeEvent{
UserID: userID,
Type: BillingWarning,
})
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
s.tracker.TrackAccountFreezeWarning(userID, user.Email)
return nil
}
// BillingUnWarnUser reverses the warning placed on the user specified by the given ID.
func (s *AccountFreezeService) BillingUnWarnUser(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)
}
_, err = s.freezeEventsDB.Get(ctx, userID, BillingWarning)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccountFreeze.New("user is not warned")
}
err = ErrAccountFreeze.Wrap(s.freezeEventsDB.DeleteByUserIDAndEvent(ctx, userID, BillingWarning))
if err != nil {
return err
}
s.tracker.TrackAccountUnwarned(userID, user.Email)
return nil
}
// ViolationFreezeUser freezes the user specified by the given ID due to ToS violation.
func (s *AccountFreezeService) ViolationFreezeUser(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)
}
var limits *AccountFreezeEventLimits
if freezes.BillingFreeze != nil {
limits = freezes.BillingFreeze.Limits
}
userLimits := UsageLimits{
Storage: user.ProjectStorageLimit,
Bandwidth: user.ProjectBandwidthLimit,
Segment: user.ProjectSegmentLimit,
}
violationFreeze := freezes.ViolationFreeze
if violationFreeze == nil {
if limits == nil {
limits = &AccountFreezeEventLimits{
User: userLimits,
Projects: make(map[uuid.UUID]UsageLimits),
}
}
violationFreeze = &AccountFreezeEvent{
UserID: userID,
Type: ViolationFreeze,
Limits: limits,
}
}
// If user limits have been zeroed already, we should not override what is in the freeze table.
if userLimits != (UsageLimits{}) {
violationFreeze.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{}) {
violationFreeze.Limits.Projects[p.ID] = projLimits
}
}
_, err = s.freezeEventsDB.Upsert(ctx, violationFreeze)
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)
}
}
status := PendingDeletion
err = s.usersDB.Update(ctx, userID, UpdateUserRequest{
Status: &status,
})
if err != nil {
return ErrAccountFreeze.Wrap(errs.Combine(ErrFreezeUserStatusUpdate, err))
}
if freezes.BillingWarning != nil {
err = s.freezeEventsDB.DeleteByUserIDAndEvent(ctx, userID, BillingWarning)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
}
if freezes.BillingFreeze != nil {
err = s.freezeEventsDB.DeleteByUserIDAndEvent(ctx, userID, BillingFreeze)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
}
return nil
}
// ViolationUnfreezeUser reverses the violation freeze placed on the user specified by the given ID.
func (s *AccountFreezeService) ViolationUnfreezeUser(ctx context.Context, userID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
event, err := s.freezeEventsDB.Get(ctx, userID, ViolationFreeze)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccountFreeze.New("user is not violation 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 = s.freezeEventsDB.DeleteByUserIDAndEvent(ctx, userID, ViolationFreeze)
if err != nil {
return ErrAccountFreeze.Wrap(err)
}
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)
freezes, err = s.freezeEventsDB.GetAll(ctx, userID)
if err != nil {
return nil, ErrAccountFreeze.Wrap(err)
}
return freezes, nil
}
// GetAllEvents returns all events.
func (s *AccountFreezeService) GetAllEvents(ctx context.Context, cursor FreezeEventsCursor) (events *FreezeEventsPage, err error) {
defer mon.Task()(&ctx)(&err)
events, err = s.freezeEventsDB.GetAllEvents(ctx, cursor)
if err != nil {
return nil, ErrAccountFreeze.Wrap(err)
}
return events, nil
}
// TestChangeFreezeTracker changes the freeze tracker service for tests.
func (s *AccountFreezeService) TestChangeFreezeTracker(t analytics.FreezeTracker) {
s.tracker = t
}