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)
* [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.

View File

@ -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")

View File

@ -492,6 +492,22 @@ Blank fields will not be updated.`,
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',
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)
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) {

View File

@ -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())
})
}

View File

@ -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 {