satellite/{console/payments/satellitedb}: add validation for deletion of account and project
The same was that our Admin API handles project and account deletions currently, we would like to have the same checks on the user-facing API. This PR adds the same checks to the console service. General more applicable checks have been moved directly into the payments service. In addition it adds the BucketsDB to the console DB, to have easier access and avoiding import cycles with the metainfo package. A small cleanup around our unnecessary monkit imports made it in as well. Change-Id: I8769b01c2271c1687fbd2269a738a41764216e51
This commit is contained in:
parent
50756cb434
commit
1d3b728766
@ -11,7 +11,7 @@ import (
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
|
||||
monkit "github.com/spacemonkeygo/monkit/v3"
|
||||
"github.com/spacemonkeygo/monkit/v3"
|
||||
"github.com/zeebo/errs"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
@ -602,6 +602,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
|
||||
peer.DB.Console(),
|
||||
peer.DB.ProjectAccounting(),
|
||||
peer.Accounting.ProjectUsage,
|
||||
peer.DB.Buckets(),
|
||||
peer.DB.Rewards(),
|
||||
peer.Marketing.PartnersService,
|
||||
peer.Payments.Accounts,
|
||||
|
33
satellite/console/buckets.go
Normal file
33
satellite/console/buckets.go
Normal file
@ -0,0 +1,33 @@
|
||||
// Copyright (C) 2020 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"storj.io/common/macaroon"
|
||||
"storj.io/common/storj"
|
||||
"storj.io/common/uuid"
|
||||
"storj.io/storj/satellite/metainfo/metabase"
|
||||
)
|
||||
|
||||
// Buckets is the interface for the database to interact with buckets.
|
||||
//
|
||||
// architecture: Database
|
||||
type Buckets interface {
|
||||
// Create creates a new bucket.
|
||||
CreateBucket(ctx context.Context, bucket storj.Bucket) (_ storj.Bucket, err error)
|
||||
// Get returns an existing bucket.
|
||||
GetBucket(ctx context.Context, bucketName []byte, projectID uuid.UUID) (bucket storj.Bucket, err error)
|
||||
// GetBucketID returns an existing bucket id.
|
||||
GetBucketID(ctx context.Context, bucket metabase.BucketLocation) (id uuid.UUID, err error)
|
||||
// UpdateBucket updates an existing bucket.
|
||||
UpdateBucket(ctx context.Context, bucket storj.Bucket) (_ storj.Bucket, err error)
|
||||
// Delete deletes a bucket.
|
||||
DeleteBucket(ctx context.Context, bucketName []byte, projectID uuid.UUID) (err error)
|
||||
// List returns all buckets for a project.
|
||||
ListBuckets(ctx context.Context, projectID uuid.UUID, listOpts storj.BucketListOptions, allowedBuckets macaroon.AllowedBuckets) (bucketList storj.BucketList, err error)
|
||||
// CountBuckets returns the number of buckets a project currently has.
|
||||
CountBuckets(ctx context.Context, projectID uuid.UUID) (int, error)
|
||||
}
|
@ -101,6 +101,7 @@ func TestGrapqhlMutation(t *testing.T) {
|
||||
db.Console(),
|
||||
db.ProjectAccounting(),
|
||||
projectUsage,
|
||||
db.Buckets(),
|
||||
db.Rewards(),
|
||||
partnersService,
|
||||
paymentsService.Accounts(),
|
||||
|
@ -85,6 +85,7 @@ func TestGraphqlQuery(t *testing.T) {
|
||||
db.Console(),
|
||||
db.ProjectAccounting(),
|
||||
projectUsage,
|
||||
db.Buckets(),
|
||||
db.Rewards(),
|
||||
partnersService,
|
||||
paymentsService.Accounts(),
|
||||
|
@ -24,7 +24,7 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/graphql-go/graphql"
|
||||
"github.com/graphql-go/graphql/gqlerrors"
|
||||
monkit "github.com/spacemonkeygo/monkit/v3"
|
||||
"github.com/spacemonkeygo/monkit/v3"
|
||||
"github.com/zeebo/errs"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
@ -12,7 +12,8 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
monkit "github.com/spacemonkeygo/monkit/v3"
|
||||
"github.com/spacemonkeygo/monkit/v3"
|
||||
"github.com/stripe/stripe-go"
|
||||
"github.com/zeebo/errs"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@ -66,6 +67,9 @@ var (
|
||||
// ErrProjLimit is error type of project limit.
|
||||
ErrProjLimit = errs.Class("project limit error")
|
||||
|
||||
// ErrUsage is error type of project usage.
|
||||
ErrUsage = errs.Class("project usage error")
|
||||
|
||||
// ErrEmailUsed is error type that occurs on repeating auth attempts with email.
|
||||
ErrEmailUsed = errs.Class("email used")
|
||||
)
|
||||
@ -80,6 +84,7 @@ type Service struct {
|
||||
store DB
|
||||
projectAccounting accounting.ProjectAccounting
|
||||
projectUsage *accounting.Service
|
||||
buckets Buckets
|
||||
rewards rewards.DB
|
||||
partners *rewards.PartnersService
|
||||
accounts payments.Accounts
|
||||
@ -102,7 +107,7 @@ type PaymentsService struct {
|
||||
}
|
||||
|
||||
// NewService returns new instance of Service.
|
||||
func NewService(log *zap.Logger, signer Signer, store DB, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, rewards rewards.DB, partners *rewards.PartnersService, accounts payments.Accounts, config Config, minCoinPayment int64) (*Service, error) {
|
||||
func NewService(log *zap.Logger, signer Signer, store DB, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets Buckets, rewards rewards.DB, partners *rewards.PartnersService, accounts payments.Accounts, config Config, minCoinPayment int64) (*Service, error) {
|
||||
if signer == nil {
|
||||
return nil, errs.New("signer can't be nil")
|
||||
}
|
||||
@ -123,6 +128,7 @@ func NewService(log *zap.Logger, signer Signer, store DB, projectAccounting acco
|
||||
store: store,
|
||||
projectAccounting: projectAccounting,
|
||||
projectUsage: projectUsage,
|
||||
buckets: buckets,
|
||||
rewards: rewards,
|
||||
partners: partners,
|
||||
accounts: accounts,
|
||||
@ -422,6 +428,50 @@ func (paymentService PaymentsService) TokenDeposit(ctx context.Context, amount i
|
||||
return tx, Error.Wrap(err)
|
||||
}
|
||||
|
||||
// checkOutstandingInvoice returns if the payment account has any unpaid/outstanding invoices or/and invoice items.
|
||||
func (paymentService PaymentsService) checkOutstandingInvoice(ctx context.Context) (err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
auth, err := paymentService.service.getAuthAndAuditLog(ctx, "get outstanding invoices")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
invoices, err := paymentService.service.accounts.Invoices().List(ctx, auth.User.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(invoices) > 0 {
|
||||
for _, invoice := range invoices {
|
||||
if invoice.Status != string(stripe.InvoiceStatusPaid) {
|
||||
return ErrUsage.New("user has unpaid/pending invoices")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasItems, err := paymentService.service.accounts.Invoices().CheckPendingItems(ctx, auth.User.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasItems {
|
||||
return ErrUsage.New("user has pending invoice items")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkProjectInvoicingStatus returns if for the given project there are outstanding project records and/or usage
|
||||
// which have not been applied/invoiced yet (meaning sent over to stripe).
|
||||
func (paymentService PaymentsService) checkProjectInvoicingStatus(ctx context.Context, projectID uuid.UUID) (unpaidUsage bool, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
_, err = paymentService.service.getAuthAndAuditLog(ctx, "project charges")
|
||||
if err != nil {
|
||||
return false, Error.Wrap(err)
|
||||
}
|
||||
|
||||
return paymentService.service.accounts.CheckProjectInvoicingStatus(ctx, projectID)
|
||||
}
|
||||
|
||||
// PopulatePromotionalCoupons is used to populate promotional coupons through all active users who already have
|
||||
// a project, payment method and do not have a promotional coupon yet.
|
||||
// And updates project limits to selected size.
|
||||
@ -861,6 +911,11 @@ func (s *Service) DeleteAccount(ctx context.Context, password string) (err error
|
||||
return ErrUnauthorized.New(credentialsErrMsg)
|
||||
}
|
||||
|
||||
err = s.Payments().checkOutstandingInvoice(ctx)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
err = s.store.Users().Delete(ctx, auth.User.ID)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
@ -1024,6 +1079,11 @@ func (s *Service) DeleteProject(ctx context.Context, projectID uuid.UUID) (err e
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
err = s.checkProjectCanBeDeleted(ctx, projectID)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
err = s.store.Projects().Delete(ctx, projectID)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
@ -1454,6 +1514,34 @@ func (s *Service) Authorize(ctx context.Context) (a Authorization, err error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkProjectCanBeDeleted ensures that all data, api-keys and buckets are deleted and usage has been accounted.
|
||||
// no error means the project status is clean.
|
||||
func (s *Service) checkProjectCanBeDeleted(ctx context.Context, project uuid.UUID) (err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
buckets, err := s.buckets.CountBuckets(ctx, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if buckets > 0 {
|
||||
return ErrUsage.New("some buckets still exist")
|
||||
}
|
||||
|
||||
keys, err := s.store.APIKeys().GetPagedByProjectID(ctx, project, APIKeyCursor{Limit: 1, Page: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if keys.TotalCount > 0 {
|
||||
return ErrUsage.New("some api-keys still exist")
|
||||
}
|
||||
|
||||
outstanding, err := s.Payments().checkProjectInvoicingStatus(ctx, project)
|
||||
if outstanding {
|
||||
return ErrUsage.New("there is outstanding usage that is not charged yet")
|
||||
}
|
||||
return ErrUsage.Wrap(err)
|
||||
}
|
||||
|
||||
// checkProjectLimit is used to check if user is able to create a new project.
|
||||
func (s *Service) checkProjectLimit(ctx context.Context, userID uuid.UUID) (err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"storj.io/common/testcontext"
|
||||
"storj.io/common/uuid"
|
||||
"storj.io/storj/private/testplanet"
|
||||
"storj.io/storj/satellite/console"
|
||||
)
|
||||
@ -31,7 +32,7 @@ func TestService(t *testing.T) {
|
||||
require.NotEqual(t, up1Pro1.ID, up2Pro1.ID)
|
||||
require.NotEqual(t, up1Pro1.OwnerID, up2Pro1.OwnerID)
|
||||
|
||||
authctx1, err := sat.AuthenticatedContext(ctx, up1Pro1.OwnerID)
|
||||
authCtx1, err := sat.AuthenticatedContext(ctx, up1Pro1.OwnerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
authCtx2, err := sat.AuthenticatedContext(ctx, up2Pro1.OwnerID)
|
||||
@ -39,44 +40,44 @@ func TestService(t *testing.T) {
|
||||
|
||||
t.Run("TestGetProject", func(t *testing.T) {
|
||||
// Getting own project details should work
|
||||
project, err := service.GetProject(authctx1, up1Pro1.ID)
|
||||
project, err := service.GetProject(authCtx1, up1Pro1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, up1Pro1.ID, project.ID)
|
||||
|
||||
// Getting someone else project details should not work
|
||||
project, err = service.GetProject(authctx1, up2Pro1.ID)
|
||||
project, err = service.GetProject(authCtx1, up2Pro1.ID)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, project)
|
||||
})
|
||||
|
||||
t.Run("TestUpdateProject", func(t *testing.T) {
|
||||
// Updating own project should work
|
||||
updatedPro, err := service.UpdateProject(authctx1, up1Pro1.ID, "newName", "TestUpdate")
|
||||
updatedPro, err := service.UpdateProject(authCtx1, up1Pro1.ID, "newName", "TestUpdate")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, up1Pro1.Name, updatedPro.Name)
|
||||
|
||||
// Updating someone else project details should not work
|
||||
updatedPro, err = service.UpdateProject(authctx1, up2Pro1.ID, "newName", "TestUpdate")
|
||||
updatedPro, err = service.UpdateProject(authCtx1, up2Pro1.ID, "newName", "TestUpdate")
|
||||
require.Error(t, err)
|
||||
require.Nil(t, updatedPro)
|
||||
})
|
||||
|
||||
t.Run("TestAddProjectMembers", func(t *testing.T) {
|
||||
// Adding members to own project should work
|
||||
addedUsers, err := service.AddProjectMembers(authctx1, up1Pro1.ID, []string{up2User.Email})
|
||||
addedUsers, err := service.AddProjectMembers(authCtx1, up1Pro1.ID, []string{up2User.Email})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, addedUsers, 1)
|
||||
require.Contains(t, addedUsers, up2User)
|
||||
|
||||
// Adding members to someone else project should not work
|
||||
addedUsers, err = service.AddProjectMembers(authctx1, up2Pro1.ID, []string{up2User.Email})
|
||||
addedUsers, err = service.AddProjectMembers(authCtx1, up2Pro1.ID, []string{up2User.Email})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, addedUsers)
|
||||
})
|
||||
|
||||
t.Run("TestGetProjectMembers", func(t *testing.T) {
|
||||
// Getting the project members of an own project that one is a part of should work
|
||||
userPage, err := service.GetProjectMembers(authctx1, up1Pro1.ID, console.ProjectMembersCursor{Page: 1, Limit: 10})
|
||||
userPage, err := service.GetProjectMembers(authCtx1, up1Pro1.ID, console.ProjectMembersCursor{Page: 1, Limit: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, userPage.ProjectMembers, 2)
|
||||
|
||||
@ -86,29 +87,48 @@ func TestService(t *testing.T) {
|
||||
require.Len(t, userPage.ProjectMembers, 2)
|
||||
|
||||
// Getting the project members of a foreign project that one is not a part of should not work
|
||||
userPage, err = service.GetProjectMembers(authctx1, up2Pro1.ID, console.ProjectMembersCursor{Page: 1, Limit: 10})
|
||||
userPage, err = service.GetProjectMembers(authCtx1, up2Pro1.ID, console.ProjectMembersCursor{Page: 1, Limit: 10})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, userPage)
|
||||
})
|
||||
|
||||
t.Run("TestDeleteProjectMembers", func(t *testing.T) {
|
||||
// Deleting project members of an own project should work
|
||||
err := service.DeleteProjectMembers(authctx1, up1Pro1.ID, []string{up2User.Email})
|
||||
err := service.DeleteProjectMembers(authCtx1, up1Pro1.ID, []string{up2User.Email})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Deleting Project members of someone else project should not work
|
||||
err = service.DeleteProjectMembers(authctx1, up2Pro1.ID, []string{up2User.Email})
|
||||
err = service.DeleteProjectMembers(authCtx1, up2Pro1.ID, []string{up2User.Email})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("TestDeleteProject", func(t *testing.T) {
|
||||
// Deleting the own project should work
|
||||
err := service.DeleteProject(authctx1, up1Pro1.ID)
|
||||
// Deleting the own project should not work before deleting the API-Key
|
||||
err := service.DeleteProject(authCtx1, up1Pro1.ID)
|
||||
require.Error(t, err)
|
||||
|
||||
keys, err := service.GetAPIKeys(authCtx1, up1Pro1.ID, console.APIKeyCursor{Page: 1, Limit: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys.APIKeys, 1)
|
||||
|
||||
err = service.DeleteAPIKeys(authCtx1, []uuid.UUID{keys.APIKeys[0].ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Deleting the own project should now work
|
||||
err = service.DeleteProject(authCtx1, up1Pro1.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Deleting someone else project should not work
|
||||
err = service.DeleteProject(authctx1, up2Pro1.ID)
|
||||
err = service.DeleteProject(authCtx1, up2Pro1.ID)
|
||||
require.Error(t, err)
|
||||
|
||||
err = planet.Uplinks[1].CreateBucket(ctx, sat, "testbucket")
|
||||
require.NoError(t, err)
|
||||
|
||||
// deleting a project with a bucket should fail
|
||||
err = service.DeleteProject(authCtx2, up2Pro1.ID)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "service error: project usage error: some buckets still exist", err.Error())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
monkit "github.com/spacemonkeygo/monkit/v3"
|
||||
"github.com/spacemonkeygo/monkit/v3"
|
||||
"github.com/zeebo/errs"
|
||||
"go.uber.org/zap"
|
||||
|
||||
|
@ -29,6 +29,10 @@ type Accounts interface {
|
||||
// ProjectCharges returns how much money current user will be charged for each project.
|
||||
ProjectCharges(ctx context.Context, userID uuid.UUID, since, before time.Time) ([]ProjectCharge, error)
|
||||
|
||||
// CheckProjectInvoicingStatus returns true if for the given project there are outstanding project records and/or usage
|
||||
// which have not been applied/invoiced yet (meaning sent over to stripe).
|
||||
CheckProjectInvoicingStatus(ctx context.Context, projectID uuid.UUID) (unpaidUsage bool, err error)
|
||||
|
||||
// Charges returns list of all credit card charges related to account.
|
||||
Charges(ctx context.Context, userID uuid.UUID) ([]Charge, error)
|
||||
|
||||
|
@ -5,6 +5,7 @@ package stripecoinpayments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go"
|
||||
@ -127,6 +128,51 @@ func (accounts *accounts) ProjectCharges(ctx context.Context, userID uuid.UUID,
|
||||
return charges, nil
|
||||
}
|
||||
|
||||
// CheckProjectInvoicingStatus returns true if for the given project there are outstanding project records and/or usage
|
||||
// which have not been applied/invoiced yet (meaning sent over to stripe).
|
||||
func (accounts *accounts) CheckProjectInvoicingStatus(ctx context.Context, projectID uuid.UUID) (unpaidUsage bool, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
// we do not want to delete projects that have usage for the current month.
|
||||
year, month, _ := accounts.service.nowFn().UTC().Date()
|
||||
firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
currentUsage, err := accounts.service.usageDB.GetProjectTotal(ctx, projectID, firstOfMonth, accounts.service.nowFn())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if currentUsage.Storage > 0 || currentUsage.Egress > 0 || currentUsage.ObjectCount > 0 {
|
||||
return true, errors.New("usage for current month exists")
|
||||
}
|
||||
|
||||
// if usage of last month exist, make sure to look for billing records
|
||||
lastMonthUsage, err := accounts.service.usageDB.GetProjectTotal(ctx, projectID, firstOfMonth.AddDate(0, -1, 0), firstOfMonth.AddDate(0, 0, -1))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if lastMonthUsage.Storage > 0 || lastMonthUsage.Egress > 0 || lastMonthUsage.ObjectCount > 0 {
|
||||
//time passed into the check function need to be the UTC midnight dates of the first and last day of the month
|
||||
err = accounts.service.db.ProjectRecords().Check(ctx, projectID, firstOfMonth.AddDate(0, -1, 0), firstOfMonth.Add(-time.Hour*24))
|
||||
switch err {
|
||||
case ErrProjectRecordExists:
|
||||
record, err := accounts.service.db.ProjectRecords().Get(ctx, projectID, firstOfMonth.AddDate(0, -1, 0), firstOfMonth.Add(-time.Hour*24))
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
// state = 0 means unapplied and not invoiced yet.
|
||||
if record.State == 0 {
|
||||
return true, errors.New("unapplied project invoice record exist")
|
||||
}
|
||||
case nil:
|
||||
return true, errors.New("usage for last month exist, but is not billed yet")
|
||||
default:
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Charges returns list of all credit card charges related to account.
|
||||
func (accounts *accounts) Charges(ctx context.Context, userID uuid.UUID) (_ []payments.Charge, err error) {
|
||||
defer mon.Task()(&ctx, userID)(&err)
|
||||
|
Loading…
Reference in New Issue
Block a user