From 33fb21c8e0178b2d3a580b961d6aa692cbbc7680 Mon Sep 17 00:00:00 2001 From: Wilfred Asomani Date: Fri, 10 Nov 2023 17:29:48 +0000 Subject: [PATCH] satellite/admin: add endpoints to legal freeze/unfreeze users This change adds two new admin endpoints to freeze users for legal review and to remove them from that state Issue: storj/storj-private#492 Change-Id: I6c8e3ffcb80375e81e78bc6ecc785c1047328cf7 --- satellite/admin/README.md | 13 ++++ satellite/admin/server.go | 2 + satellite/admin/ui/src/lib/api.ts | 16 +++++ satellite/admin/user.go | 92 ++++++++++++++++++++++++++++- satellite/admin/user_test.go | 74 +++++++++++++++++++++-- satellite/console/accountfreezes.go | 11 ++-- 6 files changed, 196 insertions(+), 12 deletions(-) diff --git a/satellite/admin/README.md b/satellite/admin/README.md index 6785c83fc..1bfdd2d17 100644 --- a/satellite/admin/README.md +++ b/satellite/admin/README.md @@ -23,6 +23,8 @@ Requires setting `Authorization` header for requests. * [DELETE /api/users/{user-email}/billing-freeze](#delete-apiusersuser-emailbilling-freeze) * [PUT /api/users/{user-email}/violation-freeze](#put-apiusersuser-emailviolation-freeze) * [DELETE /api/users/{user-email}/violation-freeze](#delete-apiusersuser-emailviolation-freeze) + * [PUT /api/users/{user-email}/legal-freeze](#put-apiusersuser-emaillegal-freeze) + * [DELETE /api/users/{user-email}/legal-freeze](#delete-apiusersuser-emaillegal-freeze) * [DELETE /api/users/{user-email}/billing-warning](#delete-apiusersuser-emailbilling-warning) * [GET /api/users/pending-deletion](#get-apiuserspending-deletion) * [PATCH /api/users/{user-email}/geofence](#patch-apiusersuser-emailgeofence) @@ -196,6 +198,17 @@ User status is also set to Pending Deletion. The user cannot exit this state aut Removes the violation freeze on a user account so uploads and downloads may resume. User status is set back to Active. This is the only way to exit the violation frozen state. +#### PUT /api/users/{user-email}/legal-freeze + +Freezes a user account for legal review so no uploads or downloads may occur +User status is also set to Legal hold. The user cannot exit this state automatically. + +#### DELETE /api/users/{user-email}/legal-freeze + +Removes the legal freeze on a user account so uploads and downloads may resume. +User status is set back to Active. This is the only way to exit the legal frozen state. + + #### DELETE /api/users/{user-email}/billing-warning Removes the billing warning status from a user's account. diff --git a/satellite/admin/server.go b/satellite/admin/server.go index 64844345f..04bbb571d 100644 --- a/satellite/admin/server.go +++ b/satellite/admin/server.go @@ -177,6 +177,8 @@ func NewServer( limitUpdateAPI.HandleFunc("/users/{useremail}/billing-warning", server.billingUnWarnUser).Methods("DELETE") limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationFreezeUser).Methods("PUT") limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationUnfreezeUser).Methods("DELETE") + limitUpdateAPI.HandleFunc("/users/{useremail}/legal-freeze", server.legalFreezeUser).Methods("PUT") + limitUpdateAPI.HandleFunc("/users/{useremail}/legal-freeze", server.legalUnfreezeUser).Methods("DELETE") limitUpdateAPI.HandleFunc("/users/pending-deletion", server.usersPendingDeletion).Methods("GET") limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET") limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST") diff --git a/satellite/admin/ui/src/lib/api.ts b/satellite/admin/ui/src/lib/api.ts index 4b35d1c6b..7b6f7bb65 100644 --- a/satellite/admin/ui/src/lib/api.ts +++ b/satellite/admin/ui/src/lib/api.ts @@ -492,6 +492,22 @@ Blank fields will not be updated.`, return this.fetch('DELETE', `users/${email}/violation-freeze`) as Promise; } }, + { + name: 'legal freeze user', + desc: 'freeze a user for legal review, set limits to zero and status to legal hold', + params: [['email', new InputText('email', true)]], + func: async (email: string): Promise => { + return this.fetch('PUT', `users/${email}/legal-freeze`) as Promise; + } + }, + { + name: 'legal unfreeze user', + desc: "remove a user's legal freeze, reinstating their limits and status (Active)", + params: [['email', new InputText('email', true)]], + func: async (email: string): Promise => { + return this.fetch('DELETE', `users/${email}/legal-freeze`) as Promise; + } + }, { name: 'unwarn user', desc: "Remove a user's warning status", diff --git a/satellite/admin/user.go b/satellite/admin/user.go index 4b4e6319a..ff3d55963 100644 --- a/satellite/admin/user.go +++ b/satellite/admin/user.go @@ -720,8 +720,12 @@ func (server *Server) billingUnfreezeUser(w http.ResponseWriter, r *http.Request err = server.freezeAccounts.BillingUnfreezeUser(ctx, u.ID) if err != nil { + status := http.StatusInternalServerError + if errs.Is(err, console.ErrNoFreezeStatus) { + status = http.StatusNotFound + } sendJSONError(w, "failed to billing unfreeze user", - err.Error(), http.StatusInternalServerError) + err.Error(), status) return } } @@ -749,8 +753,12 @@ func (server *Server) billingUnWarnUser(w http.ResponseWriter, r *http.Request) } if err = server.freezeAccounts.BillingUnWarnUser(ctx, u.ID); err != nil { + status := http.StatusInternalServerError + if errs.Is(err, console.ErrNoFreezeStatus) { + status = http.StatusNotFound + } sendJSONError(w, "failed to billing unwarn user", - err.Error(), http.StatusInternalServerError) + err.Error(), status) return } } @@ -821,10 +829,90 @@ func (server *Server) violationUnfreezeUser(w http.ResponseWriter, r *http.Reque err = server.freezeAccounts.ViolationUnfreezeUser(ctx, u.ID) if err != nil { + status := http.StatusInternalServerError + if errs.Is(err, console.ErrNoFreezeStatus) { + status = http.StatusNotFound + } sendJSONError(w, "failed to violation unfreeze user", + err.Error(), status) + return + } +} + +func (server *Server) legalFreezeUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + userEmail, ok := vars["useremail"] + if !ok { + sendJSONError(w, "user-email missing", "", http.StatusBadRequest) + return + } + + u, err := server.db.Console().Users().GetByEmail(ctx, userEmail) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + sendJSONError(w, fmt.Sprintf("user with email %q does not exist", userEmail), + "", http.StatusNotFound) + return + } + sendJSONError(w, "failed to get user details", err.Error(), http.StatusInternalServerError) return } + + err = server.freezeAccounts.LegalFreezeUser(ctx, u.ID) + if err != nil { + sendJSONError(w, "failed to legal freeze user", + err.Error(), http.StatusInternalServerError) + return + } + + invoices, err := server.payments.Invoices().List(ctx, u.ID) + if err != nil { + server.log.Error("failed to get invoices for legal frozen user", zap.Error(err)) + return + } + + for _, invoice := range invoices { + if invoice.Status == payments.InvoiceStatusOpen { + server.analytics.TrackLegalHoldUnpaidInvoice(invoice.ID, u.ID, u.Email) + } + } +} + +func (server *Server) legalUnfreezeUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + userEmail, ok := vars["useremail"] + if !ok { + sendJSONError(w, "user-email missing", "", http.StatusBadRequest) + return + } + + u, err := server.db.Console().Users().GetByEmail(ctx, userEmail) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + sendJSONError(w, fmt.Sprintf("user with email %q does not exist", userEmail), + "", http.StatusNotFound) + return + } + sendJSONError(w, "failed to get user details", + err.Error(), http.StatusInternalServerError) + return + } + + err = server.freezeAccounts.LegalUnfreezeUser(ctx, u.ID) + if err != nil { + status := http.StatusInternalServerError + if errs.Is(err, console.ErrNoFreezeStatus) { + status = http.StatusNotFound + } + sendJSONError(w, "failed to legal unfreeze user", + err.Error(), status) + return + } } func (server *Server) deleteUser(w http.ResponseWriter, r *http.Request) { diff --git a/satellite/admin/user_test.go b/satellite/admin/user_test.go index 083d5d912..853f66dad 100644 --- a/satellite/admin/user_test.go +++ b/satellite/admin/user_test.go @@ -424,8 +424,8 @@ func TestBillingFreezeUnfreezeUser(t *testing.T) { require.Equal(t, projectPreFreeze.StorageLimit, unfrozenProject.StorageLimit) require.Equal(t, projectPreFreeze.BandwidthLimit, unfrozenProject.BandwidthLimit) - body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusInternalServerError, "", planet.Satellites[0].Config.Console.AuthToken) - require.Contains(t, string(body), "not frozen") + body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusNotFound, "", planet.Satellites[0].Config.Console.AuthToken) + require.Contains(t, string(body), console.ErrNoFreezeStatus.Error()) }) } @@ -486,8 +486,70 @@ func TestViolationFreezeUnfreezeUser(t *testing.T) { require.Equal(t, projectPreFreeze.StorageLimit, unfrozenProject.StorageLimit) require.Equal(t, projectPreFreeze.BandwidthLimit, unfrozenProject.BandwidthLimit) - body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusInternalServerError, "", planet.Satellites[0].Config.Console.AuthToken) - require.Contains(t, string(body), "not violation frozen") + body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusNotFound, "", planet.Satellites[0].Config.Console.AuthToken) + require.Contains(t, string(body), console.ErrNoFreezeStatus.Error()) + }) +} + +func TestLegalFreezeUnfreezeUser(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, + StorageNodeCount: 0, + UplinkCount: 1, + Reconfigure: testplanet.Reconfigure{ + Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) { + config.Admin.Address = "127.0.0.1:0" + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + address := planet.Satellites[0].Admin.Admin.Listener.Addr() + userPreFreeze, err := planet.Satellites[0].DB.Console().Users().Get(ctx, planet.Uplinks[0].Projects[0].Owner.ID) + require.NoError(t, err) + require.Equal(t, console.Active, userPreFreeze.Status) + require.NotZero(t, userPreFreeze.ProjectStorageLimit) + require.NotZero(t, userPreFreeze.ProjectBandwidthLimit) + + projectPreFreeze, err := planet.Satellites[0].DB.Console().Projects().Get(ctx, planet.Uplinks[0].Projects[0].ID) + require.NoError(t, err) + require.NotZero(t, projectPreFreeze.BandwidthLimit) + require.NotZero(t, projectPreFreeze.StorageLimit) + + // freeze can be run multiple times. Test that doing so does not affect Unfreeze result. + link := fmt.Sprintf("http://"+address.String()+"/api/users/%s/legal-freeze", userPreFreeze.Email) + body := assertReq(ctx, t, link, http.MethodPut, "", http.StatusOK, "", planet.Satellites[0].Config.Console.AuthToken) + require.Len(t, body, 0) + + body = assertReq(ctx, t, link, http.MethodPut, "", http.StatusOK, "", planet.Satellites[0].Config.Console.AuthToken) + require.Len(t, body, 0) + + userPostFreeze, err := planet.Satellites[0].DB.Console().Users().Get(ctx, userPreFreeze.ID) + require.NoError(t, err) + require.Equal(t, console.LegalHold, userPostFreeze.Status) + require.Zero(t, userPostFreeze.ProjectStorageLimit) + require.Zero(t, userPostFreeze.ProjectBandwidthLimit) + + projectPostFreeze, err := planet.Satellites[0].DB.Console().Projects().Get(ctx, planet.Uplinks[0].Projects[0].ID) + require.NoError(t, err) + require.Zero(t, projectPostFreeze.BandwidthLimit.Int64()) + require.Zero(t, projectPostFreeze.StorageLimit.Int64()) + + link = fmt.Sprintf("http://"+address.String()+"/api/users/%s/legal-freeze", userPreFreeze.Email) + body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusOK, "", planet.Satellites[0].Config.Console.AuthToken) + require.Len(t, body, 0) + + unfrozenUser, err := planet.Satellites[0].DB.Console().Users().Get(ctx, userPreFreeze.ID) + require.NoError(t, err) + require.Equal(t, console.Active, unfrozenUser.Status) + require.Equal(t, userPreFreeze.ProjectStorageLimit, unfrozenUser.ProjectStorageLimit) + require.Equal(t, userPreFreeze.ProjectBandwidthLimit, unfrozenUser.ProjectBandwidthLimit) + + unfrozenProject, err := planet.Satellites[0].DB.Console().Projects().Get(ctx, projectPreFreeze.ID) + require.NoError(t, err) + require.Equal(t, projectPreFreeze.StorageLimit, unfrozenProject.StorageLimit) + require.Equal(t, projectPreFreeze.BandwidthLimit, unfrozenProject.BandwidthLimit) + + body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusNotFound, "", planet.Satellites[0].Config.Console.AuthToken) + require.Contains(t, string(body), console.ErrNoFreezeStatus.Error()) }) } @@ -521,8 +583,8 @@ func TestBillingWarnUnwarnUser(t *testing.T) { require.NoError(t, err) require.Nil(t, freezes.BillingWarning) - body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusInternalServerError, "", planet.Satellites[0].Config.Console.AuthToken) - require.Contains(t, string(body), "user is not warned") + body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusNotFound, "", planet.Satellites[0].Config.Console.AuthToken) + require.Contains(t, string(body), console.ErrNoFreezeStatus.Error()) }) } diff --git a/satellite/console/accountfreezes.go b/satellite/console/accountfreezes.go index ec5b8b96d..146e48129 100644 --- a/satellite/console/accountfreezes.go +++ b/satellite/console/accountfreezes.go @@ -18,6 +18,9 @@ import ( // ErrAccountFreeze is the class for errors that occur during operation of the account freeze service. var ErrAccountFreeze = errs.Class("account freeze service") +// ErrNoFreezeStatus is the error for when a user doesn't have a particular freeze status. +var ErrNoFreezeStatus = errs.New("this freeze event does not exist for this user") + // AccountFreezeEvents exposes methods to manage the account freeze events table in database. // // architecture: Database @@ -264,7 +267,7 @@ func (s *AccountFreezeService) BillingUnfreezeUser(ctx context.Context, userID u event, err := tx.AccountFreezeEvents().Get(ctx, userID, BillingFreeze) if errors.Is(err, sql.ErrNoRows) { - return errs.New("user is not frozen due to nonpayment of invoices") + return ErrNoFreezeStatus } if event.Limits == nil { @@ -358,7 +361,7 @@ func (s *AccountFreezeService) BillingUnWarnUser(ctx context.Context, userID uui _, err = tx.AccountFreezeEvents().Get(ctx, userID, BillingWarning) if errors.Is(err, sql.ErrNoRows) { - return ErrAccountFreeze.New("user is not warned") + return ErrAccountFreeze.Wrap(errs.Combine(err, ErrNoFreezeStatus)) } err = ErrAccountFreeze.Wrap(tx.AccountFreezeEvents().DeleteByUserIDAndEvent(ctx, userID, BillingWarning)) @@ -497,7 +500,7 @@ func (s *AccountFreezeService) ViolationUnfreezeUser(ctx context.Context, userID err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error { event, err := tx.AccountFreezeEvents().Get(ctx, userID, ViolationFreeze) if errors.Is(err, sql.ErrNoRows) { - return errs.New("user is not violation frozen") + return ErrNoFreezeStatus } if event.Limits == nil { @@ -647,7 +650,7 @@ func (s *AccountFreezeService) LegalUnfreezeUser(ctx context.Context, userID uui event, err := tx.AccountFreezeEvents().Get(ctx, userID, LegalFreeze) if errors.Is(err, sql.ErrNoRows) { - return errs.New("user is not legal-frozen") + return ErrNoFreezeStatus } if event.Limits == nil {