satellite/admin: add endpoints to violation freeze/unfreeze users

This change adds two new admin endpoints to freeze users for ToS
violation and to remove them from that state,

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

Change-Id: I49c922377c9cdb315ce2777fcd35dcad432b0539
This commit is contained in:
Wilfred Asomani 2023-09-29 14:12:41 +00:00 committed by Wilfred Asomani
parent 594e63f13a
commit c9421d11e7
6 changed files with 222 additions and 30 deletions

View File

@ -219,7 +219,7 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
adminConfig := config.Admin
adminConfig.AuthorizationToken = config.Console.AuthToken
peer.Admin.Server = admin.NewServer(log.Named("admin"), peer.Admin.Listener, peer.DB, peer.Buckets.Service, peer.REST.Keys, peer.FreezeAccounts.Service, peer.Payments.Accounts, config.Console, adminConfig)
peer.Admin.Server = admin.NewServer(log.Named("admin"), peer.Admin.Listener, peer.DB, peer.Buckets.Service, peer.REST.Keys, peer.FreezeAccounts.Service, peer.Analytics.Service, peer.Payments.Accounts, config.Console, adminConfig)
peer.Servers.Add(lifecycle.Item{
Name: "admin",
Run: peer.Admin.Server.Run,

View File

@ -19,8 +19,13 @@ Requires setting `Authorization` header for requests.
* [DELETE /api/users/{user-email}](#delete-apiusersuser-email)
* [PUT /api/users/{user-email}/limits](#put-apiusersuser-emaillimits)
* [DELETE /api/users/{user-email}/mfa](#delete-apiusersuser-emailmfa)
* [PUT /api/users/{user-email}/freeze](#put-apiusersuser-emailfreeze)
* [DELETE /api/users/{user-email}/freeze](#delete-apiusersuser-emailfreeze)
* [PUT /api/users/{user-email}/billing-freeze](#put-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)
* [DELETE /api/users/{user-email}/violation-freeze](#delete-apiusersuser-emailviolation-freeze)
* [DELETE /api/users/{user-email}/billing-warning](#delete-apiusersuser-emailbilling-warning)
* [PATCH /api/users/{user-email}/geofence](#patch-apiusersuser-emailgeofence)
* [DELETE /api/users/{user-email}/geofence](#delete-apiusersuser-emailgeofence)
* [OAuth Client Management](#oauth-client-management)
* [POST /api/oauth/clients](#post-apioauthclients)
* [PUT /api/oauth/clients/{id}](#put-apioauthclientsid)
@ -171,18 +176,28 @@ Updates the limits of the user and user's existing project(s) limits found by it
Disables the user's mfa.
#### PUT /api/users/{user-email}/freeze
#### PUT /api/users/{user-email}/billing-freeze
Freezes a user account so no uploads or downloads may occur.
This is a billing freeze the user can exit automatically by paying their invoice.
#### DELETE /api/users/{user-email}/freeze
#### DELETE /api/users/{user-email}/billing-freeze
Unfreezes a user account so uploads and downloads may resume.
Unfreezes a previously billing frozen user account so uploads and downloads may resume.
#### DELETE /api/users/{user-email}/warning
#### PUT /api/users/{user-email}/violation-freeze
Removes the warning status from a user's account.
Freezes a user account for violation so no uploads or downloads may occur
User status is also set to Pending Deletion. The user cannot exit this state automatically.
#### DELETE /api/users/{user-email}/violation-freeze
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.
#### DELETE /api/users/{user-email}/billing-warning
Removes the billing warning status from a user's account.
#### PATCH /api/users/{user-email}/geofence

View File

@ -22,6 +22,7 @@ import (
"storj.io/storj/satellite/accounting"
backofficeui "storj.io/storj/satellite/admin/back-office/ui"
adminui "storj.io/storj/satellite/admin/ui"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/attribution"
"storj.io/storj/satellite/buckets"
"storj.io/storj/satellite/console"
@ -84,6 +85,7 @@ type Server struct {
payments payments.Accounts
buckets *buckets.Service
restKeys *restkeys.Service
analytics *analytics.Service
freezeAccounts *console.AccountFreezeService
nowFn func() time.Time
@ -100,6 +102,7 @@ func NewServer(
buckets *buckets.Service,
restKeys *restkeys.Service,
freezeAccounts *console.AccountFreezeService,
analyticsService *analytics.Service,
accounts payments.Accounts,
console consoleweb.Config,
config Config,
@ -113,6 +116,7 @@ func NewServer(
payments: accounts,
buckets: buckets,
restKeys: restKeys,
analytics: analyticsService,
freezeAccounts: freezeAccounts,
nowFn: time.Now,
@ -165,9 +169,11 @@ func NewServer(
limitUpdateAPI.HandleFunc("/users/{useremail}", server.userInfo).Methods("GET")
limitUpdateAPI.HandleFunc("/users/{useremail}/limits", server.userLimits).Methods("GET")
limitUpdateAPI.HandleFunc("/users/{useremail}/limits", server.updateLimits).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/freeze", server.freezeUser).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/freeze", server.unfreezeUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/users/{useremail}/warning", server.unWarnUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/users/{useremail}/billing-freeze", server.billingFreezeUser).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/billing-freeze", server.billingUnfreezeUser).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.violationUnfreezeUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST")

View File

@ -450,19 +450,35 @@ Blank fields will not be updated.`,
}
},
{
name: 'freeze user',
name: 'billing freeze user',
desc: "insert user into account_freeze_events and set user's limits to zero",
params: [['email', new InputText('email', true)]],
func: async (email: string): Promise<null> => {
return this.fetch('PUT', `users/${email}/freeze`) as Promise<null>;
return this.fetch('PUT', `users/${email}/billing-freeze`) as Promise<null>;
}
},
{
name: 'unfreeze user',
name: 'billing unfreeze user',
desc: "remove user from account_freeze_events and reset user's limits to what is stored in account_freeze_events",
params: [['email', new InputText('email', true)]],
func: async (email: string): Promise<null> => {
return this.fetch('DELETE', `users/${email}/freeze`) as Promise<null>;
return this.fetch('DELETE', `users/${email}/billing-freeze`) as Promise<null>;
}
},
{
name: 'violation freeze user',
desc: 'freeze a user for ToS violation, set limits to zero and status to pending deletion',
params: [['email', new InputText('email', true)]],
func: async (email: string): Promise<null> => {
return this.fetch('PUT', `users/${email}/violation-freeze`) as Promise<null>;
}
},
{
name: 'violation unfreeze user',
desc: "remove a user's violation 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}/violation-freeze`) as Promise<null>;
}
},
{
@ -470,7 +486,7 @@ Blank fields will not be updated.`,
desc: "Remove a user's warning status",
params: [['email', new InputText('email', true)]],
func: async (email: string): Promise<null> => {
return this.fetch('DELETE', `users/${email}/warning`) as Promise<null>;
return this.fetch('DELETE', `users/${email}/billing-warning`) as Promise<null>;
}
},
{

View File

@ -16,12 +16,14 @@ import (
"github.com/gorilla/mux"
"github.com/zeebo/errs"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"storj.io/common/memory"
"storj.io/common/storj"
"storj.io/common/uuid"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
)
func (server *Server) addUser(w http.ResponseWriter, r *http.Request) {
@ -575,7 +577,7 @@ func (server *Server) disableUserMFA(w http.ResponseWriter, r *http.Request) {
}
}
func (server *Server) freezeUser(w http.ResponseWriter, r *http.Request) {
func (server *Server) billingFreezeUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
@ -599,12 +601,12 @@ func (server *Server) freezeUser(w http.ResponseWriter, r *http.Request) {
err = server.freezeAccounts.BillingFreezeUser(ctx, u.ID)
if err != nil {
sendJSONError(w, "failed to freeze user",
sendJSONError(w, "failed to billing freeze user",
err.Error(), http.StatusInternalServerError)
}
}
func (server *Server) unfreezeUser(w http.ResponseWriter, r *http.Request) {
func (server *Server) billingUnfreezeUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
@ -626,14 +628,21 @@ func (server *Server) unfreezeUser(w http.ResponseWriter, r *http.Request) {
return
}
if err = server.freezeAccounts.BillingUnfreezeUser(ctx, u.ID); err != nil {
sendJSONError(w, "failed to unfreeze user",
err.Error(), http.StatusInternalServerError)
err = server.freezeAccounts.BillingUnfreezeUser(ctx, u.ID)
if err != nil {
if errors.Is(err, console.ErrFreezeUserStatusUpdate) {
sendJSONError(w, "User unfrozen but failed to change user status to active. "+
"Run the command again, but if the error persists, intervene manually.",
err.Error(), http.StatusInternalServerError)
} else {
sendJSONError(w, "failed to violation unfreeze user",
err.Error(), http.StatusInternalServerError)
}
return
}
}
func (server *Server) unWarnUser(w http.ResponseWriter, r *http.Request) {
func (server *Server) billingUnWarnUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
@ -656,12 +665,96 @@ func (server *Server) unWarnUser(w http.ResponseWriter, r *http.Request) {
}
if err = server.freezeAccounts.BillingUnWarnUser(ctx, u.ID); err != nil {
sendJSONError(w, "failed to unwarn user",
sendJSONError(w, "failed to billing unwarn user",
err.Error(), http.StatusInternalServerError)
return
}
}
func (server *Server) violationFreezeUser(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.ViolationFreezeUser(ctx, u.ID)
if err != nil {
if errors.Is(err, console.ErrFreezeUserStatusUpdate) {
sendJSONError(w, "User frozen but failed to change user status to Pending Deletion. "+
"Run the command again, but if the error persists, intervene manually.",
err.Error(), http.StatusInternalServerError)
} else {
sendJSONError(w, "failed to violation 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 violation frozen user", zap.Error(err))
return
}
for _, invoice := range invoices {
if invoice.Status == payments.InvoiceStatusOpen {
server.analytics.TrackViolationFrozenUnpaidInvoice(invoice.ID, u.ID, u.Email)
}
}
}
func (server *Server) violationUnfreezeUser(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.ViolationUnfreezeUser(ctx, u.ID)
if err != nil {
if errors.Is(err, console.ErrFreezeUserStatusUpdate) {
sendJSONError(w, "User unfrozen but failed to change user status to active. "+
"Run the command again, but if the error persists, intervene manually.",
err.Error(), http.StatusInternalServerError)
} else {
sendJSONError(w, "failed to violation unfreeze user",
err.Error(), http.StatusInternalServerError)
}
return
}
}
func (server *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -369,7 +369,7 @@ func TestDisableMFA(t *testing.T) {
})
}
func TestFreezeUnfreezeUser(t *testing.T) {
func TestBillingFreezeUnfreezeUser(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
StorageNodeCount: 0,
@ -392,11 +392,11 @@ func TestFreezeUnfreezeUser(t *testing.T) {
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/freeze", userPreFreeze.Email)
link := fmt.Sprintf("http://"+address.String()+"/api/users/%s/billing-freeze", userPreFreeze.Email)
body := assertReq(ctx, t, link, http.MethodPut, "", http.StatusOK, "", planet.Satellites[0].Config.Console.AuthToken)
require.Len(t, body, 0)
link = fmt.Sprintf("http://"+address.String()+"/api/users/%s/freeze", userPreFreeze.Email)
link = fmt.Sprintf("http://"+address.String()+"/api/users/%s/billing-freeze", userPreFreeze.Email)
body = assertReq(ctx, t, link, http.MethodPut, "", http.StatusOK, "", planet.Satellites[0].Config.Console.AuthToken)
require.Len(t, body, 0)
@ -410,7 +410,7 @@ func TestFreezeUnfreezeUser(t *testing.T) {
require.Zero(t, projectPostFreeze.BandwidthLimit.Int64())
require.Zero(t, projectPostFreeze.StorageLimit.Int64())
link = fmt.Sprintf("http://"+address.String()+"/api/users/%s/freeze", userPreFreeze.Email)
link = fmt.Sprintf("http://"+address.String()+"/api/users/%s/billing-freeze", userPreFreeze.Email)
body = assertReq(ctx, t, link, http.MethodDelete, "", http.StatusOK, "", planet.Satellites[0].Config.Console.AuthToken)
require.Len(t, body, 0)
@ -429,7 +429,69 @@ func TestFreezeUnfreezeUser(t *testing.T) {
})
}
func TestWarnUnwarnUser(t *testing.T) {
func TestViolationFreezeUnfreezeUser(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/violation-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.PendingDeletion, 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/violation-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.StatusInternalServerError, "", planet.Satellites[0].Config.Console.AuthToken)
require.Contains(t, string(body), "not violation frozen")
})
}
func TestBillingWarnUnwarnUser(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
StorageNodeCount: 0,
@ -451,7 +513,7 @@ func TestWarnUnwarnUser(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, freezes.BillingWarning)
link := fmt.Sprintf("http://"+address.String()+"/api/users/%s/warning", user.Email)
link := fmt.Sprintf("http://"+address.String()+"/api/users/%s/billing-warning", user.Email)
body := assertReq(ctx, t, link, http.MethodDelete, "", http.StatusOK, "", planet.Satellites[0].Config.Console.AuthToken)
require.Len(t, body, 0)