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
This commit is contained in:
Wilfred Asomani 2023-11-10 17:29:48 +00:00
parent 2b4f347c33
commit 33fb21c8e0
6 changed files with 196 additions and 12 deletions

View File

@ -23,6 +23,8 @@ Requires setting `Authorization` header for requests.
* [DELETE /api/users/{user-email}/billing-freeze](#delete-apiusersuser-emailbilling-freeze) * [DELETE /api/users/{user-email}/billing-freeze](#delete-apiusersuser-emailbilling-freeze)
* [PUT /api/users/{user-email}/violation-freeze](#put-apiusersuser-emailviolation-freeze) * [PUT /api/users/{user-email}/violation-freeze](#put-apiusersuser-emailviolation-freeze)
* [DELETE /api/users/{user-email}/violation-freeze](#delete-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) * [DELETE /api/users/{user-email}/billing-warning](#delete-apiusersuser-emailbilling-warning)
* [GET /api/users/pending-deletion](#get-apiuserspending-deletion) * [GET /api/users/pending-deletion](#get-apiuserspending-deletion)
* [PATCH /api/users/{user-email}/geofence](#patch-apiusersuser-emailgeofence) * [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. 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. 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 #### DELETE /api/users/{user-email}/billing-warning
Removes the billing warning status from a user's account. Removes the billing warning status from a user's account.

View File

@ -177,6 +177,8 @@ func NewServer(
limitUpdateAPI.HandleFunc("/users/{useremail}/billing-warning", server.billingUnWarnUser).Methods("DELETE") 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.violationFreezeUser).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationUnfreezeUser).Methods("DELETE") 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("/users/pending-deletion", server.usersPendingDeletion).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET") limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST") limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST")

View File

@ -492,6 +492,22 @@ Blank fields will not be updated.`,
return this.fetch('DELETE', `users/${email}/violation-freeze`) as Promise<null>; return this.fetch('DELETE', `users/${email}/violation-freeze`) as Promise<null>;
} }
}, },
{
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<null> => {
return this.fetch('PUT', `users/${email}/legal-freeze`) as Promise<null>;
}
},
{
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<null> => {
return this.fetch('DELETE', `users/${email}/legal-freeze`) as Promise<null>;
}
},
{ {
name: 'unwarn user', name: 'unwarn user',
desc: "Remove a user's warning status", desc: "Remove a user's warning status",

View File

@ -720,8 +720,12 @@ func (server *Server) billingUnfreezeUser(w http.ResponseWriter, r *http.Request
err = server.freezeAccounts.BillingUnfreezeUser(ctx, u.ID) err = server.freezeAccounts.BillingUnfreezeUser(ctx, u.ID)
if err != nil { if err != nil {
status := http.StatusInternalServerError
if errs.Is(err, console.ErrNoFreezeStatus) {
status = http.StatusNotFound
}
sendJSONError(w, "failed to billing unfreeze user", sendJSONError(w, "failed to billing unfreeze user",
err.Error(), http.StatusInternalServerError) err.Error(), status)
return 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 { 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", sendJSONError(w, "failed to billing unwarn user",
err.Error(), http.StatusInternalServerError) err.Error(), status)
return return
} }
} }
@ -821,10 +829,90 @@ func (server *Server) violationUnfreezeUser(w http.ResponseWriter, r *http.Reque
err = server.freezeAccounts.ViolationUnfreezeUser(ctx, u.ID) err = server.freezeAccounts.ViolationUnfreezeUser(ctx, u.ID)
if err != nil { if err != nil {
status := http.StatusInternalServerError
if errs.Is(err, console.ErrNoFreezeStatus) {
status = http.StatusNotFound
}
sendJSONError(w, "failed to violation unfreeze user", 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) err.Error(), http.StatusInternalServerError)
return 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) { func (server *Server) deleteUser(w http.ResponseWriter, r *http.Request) {

View File

@ -424,8 +424,8 @@ func TestBillingFreezeUnfreezeUser(t *testing.T) {
require.Equal(t, projectPreFreeze.StorageLimit, unfrozenProject.StorageLimit) require.Equal(t, projectPreFreeze.StorageLimit, unfrozenProject.StorageLimit)
require.Equal(t, projectPreFreeze.BandwidthLimit, unfrozenProject.BandwidthLimit) require.Equal(t, projectPreFreeze.BandwidthLimit, unfrozenProject.BandwidthLimit)
body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusInternalServerError, "", planet.Satellites[0].Config.Console.AuthToken) body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusNotFound, "", planet.Satellites[0].Config.Console.AuthToken)
require.Contains(t, string(body), "not frozen") 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.StorageLimit, unfrozenProject.StorageLimit)
require.Equal(t, projectPreFreeze.BandwidthLimit, unfrozenProject.BandwidthLimit) require.Equal(t, projectPreFreeze.BandwidthLimit, unfrozenProject.BandwidthLimit)
body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusInternalServerError, "", planet.Satellites[0].Config.Console.AuthToken) body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusNotFound, "", planet.Satellites[0].Config.Console.AuthToken)
require.Contains(t, string(body), "not violation frozen") 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.NoError(t, err)
require.Nil(t, freezes.BillingWarning) require.Nil(t, freezes.BillingWarning)
body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusInternalServerError, "", planet.Satellites[0].Config.Console.AuthToken) body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusNotFound, "", planet.Satellites[0].Config.Console.AuthToken)
require.Contains(t, string(body), "user is not warned") require.Contains(t, string(body), console.ErrNoFreezeStatus.Error())
}) })
} }

View File

@ -18,6 +18,9 @@ import (
// ErrAccountFreeze is the class for errors that occur during operation of the account freeze service. // ErrAccountFreeze is the class for errors that occur during operation of the account freeze service.
var ErrAccountFreeze = errs.Class("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. // AccountFreezeEvents exposes methods to manage the account freeze events table in database.
// //
// architecture: Database // architecture: Database
@ -264,7 +267,7 @@ func (s *AccountFreezeService) BillingUnfreezeUser(ctx context.Context, userID u
event, err := tx.AccountFreezeEvents().Get(ctx, userID, BillingFreeze) event, err := tx.AccountFreezeEvents().Get(ctx, userID, BillingFreeze)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return errs.New("user is not frozen due to nonpayment of invoices") return ErrNoFreezeStatus
} }
if event.Limits == nil { if event.Limits == nil {
@ -358,7 +361,7 @@ func (s *AccountFreezeService) BillingUnWarnUser(ctx context.Context, userID uui
_, err = tx.AccountFreezeEvents().Get(ctx, userID, BillingWarning) _, err = tx.AccountFreezeEvents().Get(ctx, userID, BillingWarning)
if errors.Is(err, sql.ErrNoRows) { 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)) 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 { err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
event, err := tx.AccountFreezeEvents().Get(ctx, userID, ViolationFreeze) event, err := tx.AccountFreezeEvents().Get(ctx, userID, ViolationFreeze)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return errs.New("user is not violation frozen") return ErrNoFreezeStatus
} }
if event.Limits == nil { 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) event, err := tx.AccountFreezeEvents().Get(ctx, userID, LegalFreeze)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return errs.New("user is not legal-frozen") return ErrNoFreezeStatus
} }
if event.Limits == nil { if event.Limits == nil {