From 133fda4befa0c38206b6512c9454ddf7146071ab Mon Sep 17 00:00:00 2001 From: stefanbenten Date: Mon, 6 Jul 2020 23:31:40 +0200 Subject: [PATCH] satellite/admin: add user deletion Change-Id: Ideea978698183e25f5e0d73128c4f93c2caa1577 --- satellite/admin.go | 46 +++++++++- satellite/admin/README.md | 4 + satellite/admin/server.go | 18 ++-- satellite/admin/user.go | 87 +++++++++++++++++++ satellite/admin/user_test.go | 46 ++++++++++ satellite/payments/invoices.go | 2 + .../payments/stripecoinpayments/client.go | 2 + .../payments/stripecoinpayments/invoices.go | 29 +++++++ .../payments/stripecoinpayments/stripemock.go | 5 ++ 9 files changed, 232 insertions(+), 7 deletions(-) diff --git a/satellite/admin.go b/satellite/admin.go index b875757c1..17f2fb3b3 100644 --- a/satellite/admin.go +++ b/satellite/admin.go @@ -22,6 +22,8 @@ import ( "storj.io/storj/private/version/checker" "storj.io/storj/satellite/admin" "storj.io/storj/satellite/metainfo" + "storj.io/storj/satellite/payments" + "storj.io/storj/satellite/payments/stripecoinpayments" ) // Admin is the satellite core process that runs chores @@ -46,6 +48,12 @@ type Admin struct { Service *checker.Service } + Payments struct { + Accounts payments.Accounts + Service *stripecoinpayments.Service + Stripe stripecoinpayments.StripeClient + } + Admin struct { Listener net.Listener Server *admin.Server @@ -104,6 +112,42 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, }) } + { // setup payments + pc := config.Payments + + var stripeClient stripecoinpayments.StripeClient + var err error + switch pc.Provider { + default: + stripeClient = stripecoinpayments.NewStripeMock() + case "stripecoinpayments": + stripeClient = stripecoinpayments.NewStripeClient(log, pc.StripeCoinPayments) + } + + peer.Payments.Service, err = stripecoinpayments.NewService( + peer.Log.Named("payments.stripe:service"), + stripeClient, + pc.StripeCoinPayments, + peer.DB.StripeCoinPayments(), + peer.DB.Console().Projects(), + peer.DB.ProjectAccounting(), + pc.StorageTBPrice, + pc.EgressTBPrice, + pc.ObjectPrice, + pc.BonusRate, + pc.CouponValue, + pc.CouponDuration, + pc.CouponProjectLimit, + pc.MinCoinPayment) + + if err != nil { + return nil, errs.Combine(err, peer.Close()) + } + + peer.Payments.Stripe = stripeClient + peer.Payments.Accounts = peer.Payments.Service.Accounts() + } + { // setup debug var err error peer.Admin.Listener, err = net.Listen("tcp", config.Admin.Address) @@ -114,7 +158,7 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, adminConfig := config.Admin adminConfig.AuthorizationToken = config.Console.AuthToken - peer.Admin.Server = admin.NewServer(log.Named("admin"), peer.Admin.Listener, peer.DB, adminConfig) + peer.Admin.Server = admin.NewServer(log.Named("admin"), peer.Admin.Listener, peer.DB, peer.Payments.Accounts.Invoices(), adminConfig) peer.Servers.Add(lifecycle.Item{ Name: "admin", Run: peer.Admin.Server.Run, diff --git a/satellite/admin/README.md b/satellite/admin/README.md index 6405ce40c..c5586fa4d 100644 --- a/satellite/admin/README.md +++ b/satellite/admin/README.md @@ -80,6 +80,10 @@ A successful response body: } ``` +## DELETE /api/user/{user-email} + +Deletes the user. + ## POST /api/coupon Adds a coupon for specific user. diff --git a/satellite/admin/server.go b/satellite/admin/server.go index ea6c2ff14..dcc11003a 100644 --- a/satellite/admin/server.go +++ b/satellite/admin/server.go @@ -19,6 +19,7 @@ import ( "storj.io/storj/satellite/accounting" "storj.io/storj/satellite/console" "storj.io/storj/satellite/metainfo" + "storj.io/storj/satellite/payments" "storj.io/storj/satellite/payments/stripecoinpayments" ) @@ -49,18 +50,22 @@ type Server struct { server http.Server mux *mux.Router - db DB + db DB + invoices payments.Invoices } // NewServer returns a new debug.Server. -func NewServer(log *zap.Logger, listener net.Listener, db DB, config Config) *Server { +func NewServer(log *zap.Logger, listener net.Listener, db DB, invoices payments.Invoices, config Config) *Server { server := &Server{ - log: log, + log: log, + + listener: listener, + mux: mux.NewRouter(), + + db: db, + invoices: invoices, } - server.db = db - server.listener = listener - server.mux = mux.NewRouter() server.server.Handler = &protectedServer{ allowedAuthorization: config.AuthorizationToken, next: server.mux, @@ -70,6 +75,7 @@ func NewServer(log *zap.Logger, listener net.Listener, db DB, config Config) *Se server.mux.HandleFunc("/api/user", server.addUser).Methods("POST") server.mux.HandleFunc("/api/user/{useremail}", server.updateUser).Methods("PUT") server.mux.HandleFunc("/api/user/{useremail}", server.userInfo).Methods("GET") + server.mux.HandleFunc("/api/user/{useremail}", server.deleteUser).Methods("DELETE") server.mux.HandleFunc("/api/coupon", server.addCoupon).Methods("POST") server.mux.HandleFunc("/api/coupon/{couponid}", server.couponInfo).Methods("GET") server.mux.HandleFunc("/api/coupon/{couponid}", server.deleteCoupon).Methods("DELETE") diff --git a/satellite/admin/user.go b/satellite/admin/user.go index 1f885384e..f562607c5 100644 --- a/satellite/admin/user.go +++ b/satellite/admin/user.go @@ -231,3 +231,90 @@ func (server *Server) updateUser(w http.ResponseWriter, r *http.Request) { return } } + +func (server *Server) deleteUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + userEmail, ok := vars["useremail"] + if !ok { + http.Error(w, "user-email missing", http.StatusBadRequest) + return + } + + user, err := server.db.Console().Users().GetByEmail(ctx, userEmail) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, fmt.Sprintf("user with email %q not found", userEmail), http.StatusNotFound) + return + } + if err != nil { + http.Error(w, fmt.Sprintf("failed to get user %q: %v", userEmail, err), http.StatusInternalServerError) + return + } + + // Ensure user has no own projects any longer + projects, err := server.db.Console().Projects().GetByUserID(ctx, user.ID) + if err != nil { + http.Error(w, fmt.Sprintf("unable to list buckets: %v", err), http.StatusInternalServerError) + return + } + if len(projects) > 0 { + http.Error(w, fmt.Sprintf("some projects still exist: %v", projects), http.StatusConflict) + return + } + + // Delete memberships in foreign projects + members, err := server.db.Console().ProjectMembers().GetByMemberID(ctx, user.ID) + if err != nil { + http.Error(w, fmt.Sprintf("unable to search for user project memberships: %v", err), http.StatusInternalServerError) + return + } + if len(members) > 0 { + for _, project := range members { + err := server.db.Console().ProjectMembers().Delete(ctx, user.ID, project.ProjectID) + if err != nil { + http.Error(w, fmt.Sprintf("unable to delete user project membership: %v", err), http.StatusInternalServerError) + return + } + } + } + + // ensure no unpaid invoices exist. + invoices, err := server.invoices.List(ctx, user.ID) + if err != nil { + http.Error(w, fmt.Sprintf("unable to list user invoices: %v", err), http.StatusInternalServerError) + return + } + if len(invoices) > 0 { + for _, invoice := range invoices { + if invoice.Status != "paid" { + http.Error(w, "user has unpaid/pending invoices", http.StatusConflict) + return + } + } + } + + hasItems, err := server.invoices.CheckPendingItems(ctx, user.ID) + if err != nil { + http.Error(w, fmt.Sprintf("unable to list pending invoice items: %v", err), http.StatusInternalServerError) + return + } + if hasItems { + http.Error(w, "user has pending invoice items", http.StatusConflict) + return + } + + userInfo := &console.User{ + ID: user.ID, + FullName: "", + ShortName: "", + Email: fmt.Sprintf("deactivated+%s@storj.io", user.ID.String()), + Status: console.Deleted, + } + + err = server.db.Console().Users().Update(ctx, userInfo) + if err != nil { + http.Error(w, fmt.Sprintf("unable to delete user: %v", err), http.StatusInternalServerError) + return + } +} diff --git a/satellite/admin/user_test.go b/satellite/admin/user_test.go index 0d9a0134e..4eeb0a28e 100644 --- a/satellite/admin/user_test.go +++ b/satellite/admin/user_test.go @@ -141,3 +141,49 @@ func TestUpdateUser(t *testing.T) { require.Equal(t, user.Status, updatedUser.Status) }) } + +func TestDeleteUser(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, + StorageNodeCount: 0, + UplinkCount: 1, + Reconfigure: testplanet.Reconfigure{ + Satellite: func(log *zap.Logger, index 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() + user, err := planet.Satellites[0].DB.Console().Users().GetByEmail(ctx, planet.Uplinks[0].Projects[0].Owner.Email) + require.NoError(t, err) + + // Deleting the user should fail, as project exists + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("http://"+address.String()+"/api/user/%s", user.Email), nil) + require.NoError(t, err) + req.Header.Set("Authorization", "very-secret-token") + + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusConflict, response.StatusCode) + responseBody, err := ioutil.ReadAll(response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Greater(t, len(responseBody), 0) + + err = planet.Satellites[0].DB.Console().Projects().Delete(ctx, planet.Uplinks[0].Projects[0].ID) + require.NoError(t, err) + + // Deleting the user should pass, as no project exists for given user + req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("http://"+address.String()+"/api/user/%s", user.Email), nil) + require.NoError(t, err) + req.Header.Set("Authorization", "very-secret-token") + + response, err = http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + responseBody, err = ioutil.ReadAll(response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Equal(t, len(responseBody), 0) + }) +} \ No newline at end of file diff --git a/satellite/payments/invoices.go b/satellite/payments/invoices.go index 02b91a9f3..82828ced8 100644 --- a/satellite/payments/invoices.go +++ b/satellite/payments/invoices.go @@ -16,6 +16,8 @@ import ( type Invoices interface { // List returns a list of invoices for a given payment account. List(ctx context.Context, userID uuid.UUID) ([]Invoice, error) + // CheckPendingItems returns if pending invoice items for a given payment account exist. + CheckPendingItems(ctx context.Context, userID uuid.UUID) (existingItems bool, err error) } // Invoice holds all public information about invoice. diff --git a/satellite/payments/stripecoinpayments/client.go b/satellite/payments/stripecoinpayments/client.go index c7c5e87ad..c51fae883 100644 --- a/satellite/payments/stripecoinpayments/client.go +++ b/satellite/payments/stripecoinpayments/client.go @@ -9,6 +9,7 @@ import ( "github.com/stripe/stripe-go/client" "github.com/stripe/stripe-go/customerbalancetransaction" "github.com/stripe/stripe-go/invoice" + "github.com/stripe/stripe-go/invoiceitem" "github.com/stripe/stripe-go/paymentmethod" "go.uber.org/zap" ) @@ -48,6 +49,7 @@ type StripeInvoices interface { // StripeInvoiceItems Stripe InvoiceItems interface. type StripeInvoiceItems interface { New(params *stripe.InvoiceItemParams) (*stripe.InvoiceItem, error) + List(listParams *stripe.InvoiceItemListParams) *invoiceitem.Iter } // StripeCharges Stripe Charges interface. diff --git a/satellite/payments/stripecoinpayments/invoices.go b/satellite/payments/stripecoinpayments/invoices.go index fff274f22..bab6705b6 100644 --- a/satellite/payments/stripecoinpayments/invoices.go +++ b/satellite/payments/stripecoinpayments/invoices.go @@ -62,3 +62,32 @@ func (invoices *invoices) List(ctx context.Context, userID uuid.UUID) (invoicesL return invoicesList, nil } + +// CheckPendingItems returns if pending invoice items for a given payment account exist. +func (invoices *invoices) CheckPendingItems(ctx context.Context, userID uuid.UUID) (existingItems bool, err error) { + defer mon.Task()(&ctx, userID)(&err) + + customerID, err := invoices.service.db.Customers().GetCustomerID(ctx, userID) + if err != nil { + return false, Error.Wrap(err) + } + + params := &stripe.InvoiceItemListParams{ + Customer: &customerID, + Pending: stripe.Bool(true), + } + + itemIterator := invoices.service.stripeClient.InvoiceItems().List(params) + for itemIterator.Next() { + item := itemIterator.InvoiceItem() + if item != nil { + return true, nil + } + } + + if err = itemIterator.Err(); err != nil { + return false, Error.Wrap(err) + } + + return false, nil +} diff --git a/satellite/payments/stripecoinpayments/stripemock.go b/satellite/payments/stripecoinpayments/stripemock.go index 572b90b15..378f7d76b 100644 --- a/satellite/payments/stripecoinpayments/stripemock.go +++ b/satellite/payments/stripecoinpayments/stripemock.go @@ -12,6 +12,7 @@ import ( "github.com/stripe/stripe-go/customerbalancetransaction" "github.com/stripe/stripe-go/form" "github.com/stripe/stripe-go/invoice" + "github.com/stripe/stripe-go/invoiceitem" "github.com/stripe/stripe-go/paymentmethod" "storj.io/common/uuid" @@ -177,6 +178,10 @@ func (m *mockInvoiceItems) New(params *stripe.InvoiceItemParams) (*stripe.Invoic return nil, nil } +func (m *mockInvoiceItems) List(listParams *stripe.InvoiceItemListParams) *invoiceitem.Iter { + return &invoiceitem.Iter{Iter: &stripe.Iter{}} +} + type mockCustomerBalanceTransactions struct { transactions map[string][]*stripe.CustomerBalanceTransaction }