storj/satellite/admin/project.go
Stefan Benten 06944f062d satellite/{admin,payments,satellitedb}: add checks for deletion of free tier accounts
This change adds some more checks to the deletion process for projects and
users, since we ran into a race condition during invoicing, where projects
have been deleted before the invoicing was finished, leading to missing
references.
This PR changes the logic to block user deletion if we are in exactly that period,
while also allowing the deletion of projects/users on free tier during the month.

Change-Id: Ic0735205e6633762fb7e3c2fa13e744cdfa5ec32
2022-02-08 10:11:31 +00:00

588 lines
15 KiB
Go

// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package admin
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
"storj.io/common/macaroon"
"storj.io/common/memory"
"storj.io/common/storj"
"storj.io/common/uuid"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments/stripecoinpayments"
)
func (server *Server) checkProjectUsage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
projectUUIDString, ok := vars["project"]
if !ok {
sendJSONError(w, "project-uuid missing",
"", http.StatusBadRequest)
return
}
projectUUID, err := uuid.FromString(projectUUIDString)
if err != nil {
sendJSONError(w, "invalid project-uuid",
err.Error(), http.StatusBadRequest)
return
}
if !server.checkUsage(ctx, w, projectUUID) {
sendJSONData(w, http.StatusOK, []byte(`{"result":"no project usage exist"}`))
}
}
func (server *Server) getProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
projectUUIDString, ok := vars["project"]
if !ok {
sendJSONError(w, "project-uuid missing",
"", http.StatusBadRequest)
return
}
projectUUID, err := uuid.FromString(projectUUIDString)
if err != nil {
sendJSONError(w, "invalid project-uuid",
err.Error(), http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
sendJSONError(w, "invalid form",
err.Error(), http.StatusBadRequest)
return
}
project, err := server.db.Console().Projects().Get(ctx, projectUUID)
if err != nil {
sendJSONError(w, "unable to fetch project details",
err.Error(), http.StatusInternalServerError)
return
}
data, err := json.Marshal(project)
if err != nil {
sendJSONError(w, "json encoding failed",
err.Error(), http.StatusInternalServerError)
return
}
sendJSONData(w, http.StatusOK, data)
}
func (server *Server) getProjectLimit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
projectUUIDString, ok := vars["project"]
if !ok {
sendJSONError(w, "project-uuid missing",
"", http.StatusBadRequest)
return
}
projectUUID, err := uuid.FromString(projectUUIDString)
if err != nil {
sendJSONError(w, "invalid project-uuid",
err.Error(), http.StatusBadRequest)
return
}
project, err := server.db.Console().Projects().Get(ctx, projectUUID)
if errors.Is(err, sql.ErrNoRows) {
sendJSONError(w, "project with specified uuid does not exist",
"", http.StatusNotFound)
return
}
if err != nil {
sendJSONError(w, "failed to get project",
err.Error(), http.StatusInternalServerError)
return
}
var output struct {
Usage struct {
Amount memory.Size `json:"amount"`
Bytes int64 `json:"bytes"`
} `json:"usage"`
Bandwidth struct {
Amount memory.Size `json:"amount"`
Bytes int64 `json:"bytes"`
} `json:"bandwidth"`
Rate struct {
RPS int `json:"rps"`
} `json:"rate"`
Buckets int `json:"maxBuckets"`
Segments int64 `json:"maxSegments"`
}
if project.StorageLimit != nil {
output.Usage.Amount = *project.StorageLimit
output.Usage.Bytes = project.StorageLimit.Int64()
}
if project.BandwidthLimit != nil {
output.Bandwidth.Amount = *project.BandwidthLimit
output.Bandwidth.Bytes = project.BandwidthLimit.Int64()
}
if project.MaxBuckets != nil {
output.Buckets = *project.MaxBuckets
}
if project.RateLimit != nil {
output.Rate.RPS = *project.RateLimit
}
if project.SegmentLimit != nil {
output.Segments = *project.SegmentLimit
}
data, err := json.Marshal(output)
if err != nil {
sendJSONError(w, "json encoding failed",
err.Error(), http.StatusInternalServerError)
return
}
sendJSONData(w, http.StatusOK, data)
}
func (server *Server) putProjectLimit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
projectUUIDString, ok := vars["project"]
if !ok {
sendJSONError(w, "project-uuid missing",
"", http.StatusBadRequest)
return
}
projectUUID, err := uuid.FromString(projectUUIDString)
if err != nil {
sendJSONError(w, "invalid project-uuid",
err.Error(), http.StatusBadRequest)
return
}
var arguments struct {
Usage *memory.Size `schema:"usage"`
Bandwidth *memory.Size `schema:"bandwidth"`
Rate *int `schema:"rate"`
Burst *int `schema:"burst"`
Buckets *int `schema:"buckets"`
Segments *int64 `schema:"segments"`
}
if err := r.ParseForm(); err != nil {
sendJSONError(w, "invalid form",
err.Error(), http.StatusBadRequest)
return
}
decoder := schema.NewDecoder()
err = decoder.Decode(&arguments, r.Form)
if err != nil {
sendJSONError(w, "invalid arguments",
err.Error(), http.StatusBadRequest)
return
}
// check if the project exists.
_, err = server.db.Console().Projects().Get(ctx, projectUUID)
if errors.Is(err, sql.ErrNoRows) {
sendJSONError(w, "project with specified uuid does not exist",
"", http.StatusNotFound)
return
}
if err != nil {
sendJSONError(w, "failed to get project",
err.Error(), http.StatusInternalServerError)
return
}
if arguments.Usage != nil {
if *arguments.Usage < 0 {
sendJSONError(w, "negative usage",
fmt.Sprintf("%v", arguments.Usage), http.StatusBadRequest)
return
}
err = server.db.ProjectAccounting().UpdateProjectUsageLimit(ctx, projectUUID, *arguments.Usage)
if err != nil {
sendJSONError(w, "failed to update usage",
err.Error(), http.StatusInternalServerError)
return
}
}
if arguments.Bandwidth != nil {
if *arguments.Bandwidth < 0 {
sendJSONError(w, "negative bandwidth",
fmt.Sprintf("%v", arguments.Usage), http.StatusBadRequest)
return
}
err = server.db.ProjectAccounting().UpdateProjectBandwidthLimit(ctx, projectUUID, *arguments.Bandwidth)
if err != nil {
sendJSONError(w, "failed to update bandwidth",
err.Error(), http.StatusInternalServerError)
return
}
}
if arguments.Rate != nil {
if *arguments.Rate < 0 {
sendJSONError(w, "negative rate",
fmt.Sprintf("%v", arguments.Rate), http.StatusBadRequest)
return
}
err = server.db.Console().Projects().UpdateRateLimit(ctx, projectUUID, *arguments.Rate)
if err != nil {
sendJSONError(w, "failed to update rate",
err.Error(), http.StatusInternalServerError)
return
}
}
if arguments.Burst != nil {
if *arguments.Burst < 0 {
sendJSONError(w, "negative burst rate",
fmt.Sprintf("%v", arguments.Burst), http.StatusBadRequest)
return
}
err = server.db.Console().Projects().UpdateBurstLimit(ctx, projectUUID, *arguments.Burst)
if err != nil {
sendJSONError(w, "failed to update burst",
err.Error(), http.StatusInternalServerError)
return
}
}
if arguments.Buckets != nil {
if *arguments.Buckets < 0 {
sendJSONError(w, "negative bucket count",
fmt.Sprintf("t: %v", arguments.Buckets), http.StatusBadRequest)
return
}
err = server.db.Console().Projects().UpdateBucketLimit(ctx, projectUUID, *arguments.Buckets)
if err != nil {
sendJSONError(w, "failed to update bucket limit",
err.Error(), http.StatusInternalServerError)
return
}
}
if arguments.Segments != nil {
if *arguments.Segments < 0 {
sendJSONError(w, "negative segments count",
fmt.Sprintf("t: %v", arguments.Buckets), http.StatusBadRequest)
return
}
err = server.db.ProjectAccounting().UpdateProjectSegmentLimit(ctx, projectUUID, *arguments.Segments)
if err != nil {
sendJSONError(w, "failed to update segments limit",
err.Error(), http.StatusInternalServerError)
return
}
}
}
func (server *Server) addProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
sendJSONError(w, "failed to read body",
err.Error(), http.StatusInternalServerError)
return
}
var input struct {
OwnerID uuid.UUID `json:"ownerId"`
ProjectName string `json:"projectName"`
}
var output struct {
ProjectID uuid.UUID `json:"projectId"`
}
err = json.Unmarshal(body, &input)
if err != nil {
sendJSONError(w, "failed to unmarshal request",
err.Error(), http.StatusBadRequest)
return
}
if input.OwnerID.IsZero() {
sendJSONError(w, "OwnerID is not set",
"", http.StatusBadRequest)
return
}
if input.ProjectName == "" {
sendJSONError(w, "ProjectName is not set",
"", http.StatusBadRequest)
return
}
project, err := server.db.Console().Projects().Insert(ctx, &console.Project{
Name: input.ProjectName,
OwnerID: input.OwnerID,
})
if err != nil {
sendJSONError(w, "failed to insert project",
err.Error(), http.StatusInternalServerError)
return
}
_, err = server.db.Console().ProjectMembers().Insert(ctx, project.OwnerID, project.ID)
if err != nil {
sendJSONError(w, "failed to insert project member",
err.Error(), http.StatusInternalServerError)
return
}
output.ProjectID = project.ID
data, err := json.Marshal(output)
if err != nil {
sendJSONError(w, "json encoding failed",
err.Error(), http.StatusInternalServerError)
return
}
sendJSONData(w, http.StatusOK, data)
}
func (server *Server) renameProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
projectUUIDString, ok := vars["project"]
if !ok {
sendJSONError(w, "project-uuid missing",
"", http.StatusBadRequest)
return
}
projectUUID, err := uuid.FromString(projectUUIDString)
if err != nil {
sendJSONError(w, "invalid project-uuid",
err.Error(), http.StatusBadRequest)
return
}
project, err := server.db.Console().Projects().Get(ctx, projectUUID)
if errors.Is(err, sql.ErrNoRows) {
sendJSONError(w, "project with specified uuid does not exist",
"", http.StatusNotFound)
return
}
if err != nil {
sendJSONError(w, "error getting project",
err.Error(), http.StatusInternalServerError)
return
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
sendJSONError(w, "ailed to read body",
err.Error(), http.StatusInternalServerError)
return
}
var input struct {
ProjectName string `json:"projectName"`
Description string `json:"description"`
}
err = json.Unmarshal(body, &input)
if err != nil {
sendJSONError(w, "failed to unmarshal request",
err.Error(), http.StatusBadRequest)
return
}
if input.ProjectName == "" {
sendJSONError(w, "ProjectName is not set",
"", http.StatusBadRequest)
return
}
project.Name = input.ProjectName
if input.Description != "" {
project.Description = input.Description
}
err = server.db.Console().Projects().Update(ctx, project)
if err != nil {
sendJSONError(w, "error renaming project",
err.Error(), http.StatusInternalServerError)
return
}
}
func (server *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
projectUUIDString, ok := vars["project"]
if !ok {
sendJSONError(w, "project-uuid missing",
"", http.StatusBadRequest)
return
}
projectUUID, err := uuid.FromString(projectUUIDString)
if err != nil {
sendJSONError(w, "invalid project-uuid",
err.Error(), http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
sendJSONError(w, "invalid form",
err.Error(), http.StatusBadRequest)
return
}
options := storj.BucketListOptions{Limit: 1, Direction: storj.Forward}
buckets, err := server.buckets.ListBuckets(ctx, projectUUID, options, macaroon.AllowedBuckets{All: true})
if err != nil {
sendJSONError(w, "unable to list buckets",
err.Error(), http.StatusInternalServerError)
return
}
if len(buckets.Items) > 0 {
sendJSONError(w, "buckets still exist",
fmt.Sprintf("%v", bucketNames(buckets.Items)), http.StatusConflict)
return
}
keys, err := server.db.Console().APIKeys().GetPagedByProjectID(ctx, projectUUID, console.APIKeyCursor{Limit: 1, Page: 1})
if err != nil {
sendJSONError(w, "unable to list api-keys",
err.Error(), http.StatusInternalServerError)
return
}
if keys.TotalCount > 0 {
sendJSONError(w, "api-keys still exist",
fmt.Sprintf("count %d", keys.TotalCount), http.StatusConflict)
return
}
// if usage exist, return error to client and exit
if server.checkUsage(ctx, w, projectUUID) {
return
}
err = server.db.Console().Projects().Delete(ctx, projectUUID)
if err != nil {
sendJSONError(w, "unable to delete project",
err.Error(), http.StatusInternalServerError)
return
}
}
func (server *Server) checkInvoicing(ctx context.Context, w http.ResponseWriter, projectID uuid.UUID) (openInvoices bool) {
year, month, _ := server.nowFn().UTC().Date()
firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
// Check if an invoice project record exists already
err := server.db.StripeCoinPayments().ProjectRecords().Check(ctx, projectID, firstOfMonth.AddDate(0, -1, 0), firstOfMonth)
if errors.Is(err, stripecoinpayments.ErrProjectRecordExists) {
record, err := server.db.StripeCoinPayments().ProjectRecords().Get(ctx, projectID, firstOfMonth.AddDate(0, -1, 0), firstOfMonth)
if err != nil {
sendJSONError(w, "unable to get project records", err.Error(), http.StatusInternalServerError)
return true
}
// state = 0 means unapplied and not invoiced yet.
if record.State == 0 {
sendJSONError(w, "unapplied project invoice record exist", "", http.StatusConflict)
return true
}
// Record has been applied, so project can be deleted.
return false
}
if err != nil {
sendJSONError(w, "unable to get project records", err.Error(), http.StatusInternalServerError)
return true
}
return false
}
func (server *Server) checkUsage(ctx context.Context, w http.ResponseWriter, projectID uuid.UUID) (hasUsage bool) {
year, month, _ := server.nowFn().UTC().Date()
firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
prj, err := server.db.Console().Projects().Get(ctx, projectID)
if err != nil {
sendJSONError(w, "unable to get project details",
err.Error(), http.StatusInternalServerError)
return
}
// If user is paid tier, check the usage limit, otherwise it is ok to delete it.
paid, err := server.db.Console().Users().GetUserPaidTier(ctx, prj.OwnerID)
if err != nil {
sendJSONError(w, "unable to project owner tier",
err.Error(), http.StatusInternalServerError)
return
}
if paid {
// check current month usage and do not allow deletion if usage exists
currentUsage, err := server.db.ProjectAccounting().GetProjectTotal(ctx, projectID, firstOfMonth, server.nowFn())
if err != nil {
sendJSONError(w, "unable to list project usage", err.Error(), http.StatusInternalServerError)
return true
}
if currentUsage.Storage > 0 || currentUsage.Egress > 0 || currentUsage.SegmentCount > 0 {
sendJSONError(w, "usage for current month exists", "", http.StatusConflict)
return true
}
// check usage for last month
lastMonthUsage, err := server.db.ProjectAccounting().GetProjectTotal(ctx, projectID, firstOfMonth.AddDate(0, -1, 0), firstOfMonth.AddDate(0, 0, -1))
if err != nil {
sendJSONError(w, "error getting project totals",
"", http.StatusInternalServerError)
return true
}
// project had usage that is not billed yet
if lastMonthUsage.Storage > 0 || lastMonthUsage.Egress > 0 || lastMonthUsage.SegmentCount > 0 {
sendJSONError(w, "usage for last month exist, but is not billed yet", "", http.StatusConflict)
return true
}
}
// If we have open invoice items, do not delete the project yet and wait for invoice completion.
return server.checkInvoicing(ctx, w, projectID)
}
func bucketNames(buckets []storj.Bucket) []string {
var xs []string
for _, b := range buckets {
xs = append(xs, b.Name)
}
return xs
}