storj/satellite/console/service.go
Jeremy Wharton 1613d37466 satellite/console: add endpoints for listing and revoking api keys
This change adds the following endpoints:
- projects/apikeys/{id}: returns a paged list of API keys for the
	project specified by the given ID
- apikeys/delete/{id}: deletes the API key specified by the given ID

Additionally, the API Go code generator has been given the ability to
process unsigned integer parameters.

Change-Id: I5ff24e012da24a3f06bea1ebb62bae6ff62f951a
2022-09-01 14:24:00 -05:00

2962 lines
87 KiB
Go

// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package console
import (
"context"
"database/sql"
"fmt"
"math"
"net/http"
"net/mail"
"sort"
"strings"
"time"
"github.com/spacemonkeygo/monkit/v3"
"github.com/spf13/pflag"
"github.com/stripe/stripe-go/v72"
"github.com/zeebo/errs"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"storj.io/common/macaroon"
"storj.io/common/memory"
"storj.io/common/storj"
"storj.io/common/uuid"
"storj.io/private/cfgstruct"
"storj.io/storj/private/api"
"storj.io/storj/private/blockchain"
"storj.io/storj/private/post"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console/consoleauth"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/billing"
"storj.io/storj/satellite/payments/monetary"
"storj.io/storj/satellite/rewards"
)
var mon = monkit.Package()
const (
// maxLimit specifies the limit for all paged queries.
maxLimit = 50
// TestPasswordCost is the hashing complexity to use for testing.
TestPasswordCost = bcrypt.MinCost
)
// Error messages.
const (
unauthorizedErrMsg = "You are not authorized to perform this action"
emailUsedErrMsg = "This email is already in use, try another"
emailNotFoundErrMsg = "There are no users with the specified email"
passwordRecoveryTokenIsExpiredErrMsg = "Your password recovery link has expired, please request another one"
credentialsErrMsg = "Your login credentials are incorrect, please try again"
passwordIncorrectErrMsg = "Your password needs at least %d characters long"
projectOwnerDeletionForbiddenErrMsg = "%s is a project owner and can not be deleted"
apiKeyWithNameExistsErrMsg = "An API Key with this name already exists in this project, please use a different name"
apiKeyWithNameDoesntExistErrMsg = "An API Key with this name doesn't exist in this project."
teamMemberDoesNotExistErrMsg = `There is no account on this Satellite for the user(s) you have entered.
Please add team members with active accounts`
activationTokenExpiredErrMsg = "This activation token has expired, please request another one"
usedRegTokenErrMsg = "This registration token has already been used"
projLimitErrMsg = "Sorry, project creation is limited for your account. Please contact support!"
)
var (
// Error describes internal console error.
Error = errs.Class("console service")
// ErrUnauthorized is error class for authorization related errors.
ErrUnauthorized = errs.Class("unauthorized")
// ErrNoMembership is error type of not belonging to a specific project.
ErrNoMembership = errs.Class("no membership")
// ErrTokenExpiration is error type of token reached expiration time.
ErrTokenExpiration = errs.Class("token expiration")
// ErrTokenInvalid is error type of tokens which are invalid.
ErrTokenInvalid = errs.Class("invalid token")
// ErrProjLimit is error type of project limit.
ErrProjLimit = errs.Class("project limit")
// ErrUsage is error type of project usage.
ErrUsage = errs.Class("project usage")
// ErrLoginCredentials occurs when provided invalid login credentials.
ErrLoginCredentials = errs.Class("login credentials")
// ErrLoginPassword occurs when provided invalid login password.
ErrLoginPassword = errs.Class("login password")
// ErrEmailUsed is error type that occurs on repeating auth attempts with email.
ErrEmailUsed = errs.Class("email used")
// ErrEmailNotFound occurs when no users have the specified email.
ErrEmailNotFound = errs.Class("email not found")
// ErrNoAPIKey is error type that occurs when there is no api key found.
ErrNoAPIKey = errs.Class("no api key found")
// ErrAPIKeyRequest is returned when there is an error parsing a request for api keys.
ErrAPIKeyRequest = errs.Class("api key request")
// ErrRegToken describes registration token errors.
ErrRegToken = errs.Class("registration token")
// ErrCaptcha describes captcha validation errors.
ErrCaptcha = errs.Class("captcha validation")
// ErrRecoveryToken describes account recovery token errors.
ErrRecoveryToken = errs.Class("recovery token")
)
// Service is handling accounts related logic.
//
// architecture: Service
type Service struct {
log, auditLogger *zap.Logger
store DB
restKeys RESTKeys
projectAccounting accounting.ProjectAccounting
projectUsage *accounting.Service
buckets Buckets
partners *rewards.PartnersService
accounts payments.Accounts
depositWallets payments.DepositWallets
billing billing.TransactionsDB
registrationCaptchaHandler CaptchaHandler
loginCaptchaHandler CaptchaHandler
analytics *analytics.Service
tokens *consoleauth.Service
mailService *mailservice.Service
satelliteAddress string
config Config
}
func init() {
var c Config
cfgstruct.Bind(pflag.NewFlagSet("", pflag.PanicOnError), &c, cfgstruct.UseTestDefaults())
if c.PasswordCost != TestPasswordCost {
panic("invalid test constant defined in struct tag")
}
cfgstruct.Bind(pflag.NewFlagSet("", pflag.PanicOnError), &c, cfgstruct.UseReleaseDefaults())
if c.PasswordCost != 0 {
panic("invalid release constant defined in struct tag. should be 0 (=automatic)")
}
}
// Config keeps track of core console service configuration parameters.
type Config struct {
PasswordCost int `help:"password hashing cost (0=automatic)" testDefault:"4" default:"0"`
OpenRegistrationEnabled bool `help:"enable open registration" default:"false" testDefault:"true"`
DefaultProjectLimit int `help:"default project limits for users" default:"1" testDefault:"5"`
AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"`
LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"`
FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"`
UsageLimits UsageLimitsConfig
Captcha CaptchaConfig
Session SessionConfig
}
// CaptchaConfig contains configurations for login/registration captcha system.
type CaptchaConfig struct {
Login MultiCaptchaConfig
Registration MultiCaptchaConfig
}
// MultiCaptchaConfig contains configurations for Recaptcha and Hcaptcha systems.
type MultiCaptchaConfig struct {
Recaptcha SingleCaptchaConfig
Hcaptcha SingleCaptchaConfig
}
// SingleCaptchaConfig contains configurations abstract captcha system.
type SingleCaptchaConfig struct {
Enabled bool `help:"whether or not captcha is enabled" default:"false"`
SiteKey string `help:"captcha site key"`
SecretKey string `help:"captcha secret key"`
}
// SessionConfig contains configurations for session management.
type SessionConfig struct {
InactivityTimerEnabled bool `help:"indicates if session can be timed out due inactivity" default:"false"`
InactivityTimerDuration int `help:"inactivity timer delay in seconds" default:"600"`
InactivityTimerViewerEnabled bool `help:"indicates whether remaining session time is shown for debugging" default:"false"`
Duration time.Duration `help:"duration a session is valid for (superseded by inactivity timer delay if inactivity timer is enabled)" default:"168h"`
}
// Payments separates all payment related functionality.
type Payments struct {
service *Service
}
// NewService returns new instance of Service.
func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets Buckets, partners *rewards.PartnersService, accounts payments.Accounts, depositWallets payments.DepositWallets, billing billing.TransactionsDB, analytics *analytics.Service, tokens *consoleauth.Service, mailService *mailservice.Service, satelliteAddress string, config Config) (*Service, error) {
if store == nil {
return nil, errs.New("store can't be nil")
}
if log == nil {
return nil, errs.New("log can't be nil")
}
if config.PasswordCost == 0 {
config.PasswordCost = bcrypt.DefaultCost
}
// We have two separate captcha handlers for login and registration.
// We want to easily swap between captchas independently.
// For example, google recaptcha for login screen and hcaptcha for registration screen.
var registrationCaptchaHandler CaptchaHandler
if config.Captcha.Registration.Recaptcha.Enabled {
registrationCaptchaHandler = NewDefaultCaptcha(Recaptcha, config.Captcha.Registration.Recaptcha.SecretKey)
} else if config.Captcha.Registration.Hcaptcha.Enabled {
registrationCaptchaHandler = NewDefaultCaptcha(Hcaptcha, config.Captcha.Registration.Hcaptcha.SecretKey)
}
var loginCaptchaHandler CaptchaHandler
if config.Captcha.Login.Recaptcha.Enabled {
loginCaptchaHandler = NewDefaultCaptcha(Recaptcha, config.Captcha.Login.Recaptcha.SecretKey)
} else if config.Captcha.Login.Hcaptcha.Enabled {
loginCaptchaHandler = NewDefaultCaptcha(Hcaptcha, config.Captcha.Login.Hcaptcha.SecretKey)
}
return &Service{
log: log,
auditLogger: log.Named("auditlog"),
store: store,
restKeys: restKeys,
projectAccounting: projectAccounting,
projectUsage: projectUsage,
buckets: buckets,
partners: partners,
accounts: accounts,
depositWallets: depositWallets,
billing: billing,
registrationCaptchaHandler: registrationCaptchaHandler,
loginCaptchaHandler: loginCaptchaHandler,
analytics: analytics,
tokens: tokens,
mailService: mailService,
satelliteAddress: satelliteAddress,
config: config,
}, nil
}
func getRequestingIP(ctx context.Context) (source, forwardedFor string) {
if req := GetRequest(ctx); req != nil {
return req.RemoteAddr, req.Header.Get("X-Forwarded-For")
}
return "", ""
}
func (s *Service) auditLog(ctx context.Context, operation string, userID *uuid.UUID, email string, extra ...zap.Field) {
sourceIP, forwardedForIP := getRequestingIP(ctx)
fields := append(
make([]zap.Field, 0, len(extra)+5),
zap.String("operation", operation),
zap.String("source-ip", sourceIP),
zap.String("forwarded-for-ip", forwardedForIP),
)
if userID != nil {
fields = append(fields, zap.String("userID", userID.String()))
}
if email != "" {
fields = append(fields, zap.String("email", email))
}
fields = append(fields, fields...)
s.auditLogger.Info("console activity", fields...)
}
func (s *Service) getUserAndAuditLog(ctx context.Context, operation string, extra ...zap.Field) (*User, error) {
user, err := GetUser(ctx)
if err != nil {
sourceIP, forwardedForIP := getRequestingIP(ctx)
s.auditLogger.Info("console activity unauthorized",
append(append(
make([]zap.Field, 0, len(extra)+4),
zap.String("operation", operation),
zap.Error(err),
zap.String("source-ip", sourceIP),
zap.String("forwarded-for-ip", forwardedForIP),
), extra...)...)
return nil, err
}
s.auditLog(ctx, operation, &user.ID, user.Email, extra...)
return user, nil
}
// Payments separates all payment related functionality.
func (s *Service) Payments() Payments {
return Payments{service: s}
}
// SetupAccount creates payment account for authorized user.
func (payment Payments) SetupAccount(ctx context.Context) (_ payments.CouponType, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "setup payment account")
if err != nil {
return payments.NoCoupon, Error.Wrap(err)
}
return payment.service.accounts.Setup(ctx, user.ID, user.Email, user.SignupPromoCode)
}
// AccountBalance return account balance.
func (payment Payments) AccountBalance(ctx context.Context) (balance payments.Balance, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "get account balance")
if err != nil {
return payments.Balance{}, Error.Wrap(err)
}
return payment.service.accounts.Balance(ctx, user.ID)
}
// AddCreditCard is used to save new credit card and attach it to payment account.
func (payment Payments) AddCreditCard(ctx context.Context, creditCardToken string) (err error) {
defer mon.Task()(&ctx, creditCardToken)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "add credit card")
if err != nil {
return Error.Wrap(err)
}
err = payment.service.accounts.CreditCards().Add(ctx, user.ID, creditCardToken)
if err != nil {
return Error.Wrap(err)
}
payment.service.analytics.TrackCreditCardAdded(user.ID, user.Email)
if !user.PaidTier {
// put this user into the paid tier and convert projects to upgraded limits.
err = payment.service.store.Users().UpdatePaidTier(ctx, user.ID, true,
payment.service.config.UsageLimits.Bandwidth.Paid,
payment.service.config.UsageLimits.Storage.Paid,
payment.service.config.UsageLimits.Segment.Paid,
payment.service.config.UsageLimits.Project.Paid,
)
if err != nil {
return Error.Wrap(err)
}
projects, err := payment.service.store.Projects().GetOwn(ctx, user.ID)
if err != nil {
return Error.Wrap(err)
}
for _, project := range projects {
if project.StorageLimit == nil || *project.StorageLimit < payment.service.config.UsageLimits.Storage.Paid {
project.StorageLimit = new(memory.Size)
*project.StorageLimit = payment.service.config.UsageLimits.Storage.Paid
}
if project.BandwidthLimit == nil || *project.BandwidthLimit < payment.service.config.UsageLimits.Bandwidth.Paid {
project.BandwidthLimit = new(memory.Size)
*project.BandwidthLimit = payment.service.config.UsageLimits.Bandwidth.Paid
}
if project.SegmentLimit == nil || *project.SegmentLimit < payment.service.config.UsageLimits.Segment.Paid {
*project.SegmentLimit = payment.service.config.UsageLimits.Segment.Paid
}
err = payment.service.store.Projects().Update(ctx, &project)
if err != nil {
return Error.Wrap(err)
}
}
}
return nil
}
// MakeCreditCardDefault makes a credit card default payment method.
func (payment Payments) MakeCreditCardDefault(ctx context.Context, cardID string) (err error) {
defer mon.Task()(&ctx, cardID)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "make credit card default")
if err != nil {
return Error.Wrap(err)
}
return payment.service.accounts.CreditCards().MakeDefault(ctx, user.ID, cardID)
}
// ProjectsCharges returns how much money current user will be charged for each project which he owns.
func (payment Payments) ProjectsCharges(ctx context.Context, since, before time.Time) (_ []payments.ProjectCharge, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "project charges")
if err != nil {
return nil, Error.Wrap(err)
}
return payment.service.accounts.ProjectCharges(ctx, user.ID, since, before)
}
// ListCreditCards returns a list of credit cards for a given payment account.
func (payment Payments) ListCreditCards(ctx context.Context) (_ []payments.CreditCard, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "list credit cards")
if err != nil {
return nil, Error.Wrap(err)
}
return payment.service.accounts.CreditCards().List(ctx, user.ID)
}
// RemoveCreditCard is used to detach a credit card from payment account.
func (payment Payments) RemoveCreditCard(ctx context.Context, cardID string) (err error) {
defer mon.Task()(&ctx, cardID)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "remove credit card")
if err != nil {
return Error.Wrap(err)
}
return payment.service.accounts.CreditCards().Remove(ctx, user.ID, cardID)
}
// BillingHistory returns a list of billing history items for payment account.
func (payment Payments) BillingHistory(ctx context.Context) (billingHistory []*BillingHistoryItem, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "get billing history")
if err != nil {
return nil, Error.Wrap(err)
}
invoices, couponUsages, err := payment.service.accounts.Invoices().ListWithDiscounts(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
for _, invoice := range invoices {
billingHistory = append(billingHistory, &BillingHistoryItem{
ID: invoice.ID,
Description: invoice.Description,
Amount: invoice.Amount,
Status: invoice.Status,
Link: invoice.Link,
End: invoice.End,
Start: invoice.Start,
Type: Invoice,
})
}
txsInfos, err := payment.service.accounts.StorjTokens().ListTransactionInfos(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
for _, info := range txsInfos {
billingHistory = append(billingHistory, &BillingHistoryItem{
ID: info.ID.String(),
Description: "STORJ Token Deposit",
Amount: info.AmountCents,
Received: info.ReceivedCents,
Status: info.Status.String(),
Link: info.Link,
Start: info.CreatedAt,
End: info.ExpiresAt,
Type: Transaction,
})
}
charges, err := payment.service.accounts.Charges(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
for _, charge := range charges {
desc := fmt.Sprintf("Payment(%s %s)", charge.CardInfo.Brand, charge.CardInfo.LastFour)
billingHistory = append(billingHistory, &BillingHistoryItem{
ID: charge.ID,
Description: desc,
Amount: charge.Amount,
Start: charge.CreatedAt,
Type: Charge,
})
}
for _, usage := range couponUsages {
desc := "Coupon"
if usage.Coupon.Name != "" {
desc = usage.Coupon.Name
}
if usage.Coupon.PromoCode != "" {
desc += " (" + usage.Coupon.PromoCode + ")"
}
billingHistory = append(billingHistory, &BillingHistoryItem{
Description: desc,
Amount: usage.Amount,
Start: usage.PeriodStart,
End: usage.PeriodEnd,
Type: Coupon,
})
}
bonuses, err := payment.service.accounts.StorjTokens().ListDepositBonuses(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
for _, bonus := range bonuses {
billingHistory = append(billingHistory,
&BillingHistoryItem{
Description: fmt.Sprintf("%d%% Bonus for STORJ Token Deposit", bonus.Percentage),
Amount: bonus.AmountCents,
Status: "Added to balance",
Start: bonus.CreatedAt,
Type: DepositBonus,
},
)
}
sort.SliceStable(billingHistory,
func(i, j int) bool {
return billingHistory[i].Start.After(billingHistory[j].Start)
},
)
return billingHistory, nil
}
// TokenDeposit creates new deposit transaction for adding STORJ tokens to account balance.
func (payment Payments) TokenDeposit(ctx context.Context, amount int64) (_ *payments.Transaction, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "token deposit")
if err != nil {
return nil, Error.Wrap(err)
}
tx, err := payment.service.accounts.StorjTokens().Deposit(ctx, user.ID, amount)
if err != nil {
return nil, Error.Wrap(err)
}
payment.service.analytics.TrackStorjTokenAdded(user.ID, user.Email)
return tx, nil
}
// checkOutstandingInvoice returns if the payment account has any unpaid/outstanding invoices or/and invoice items.
func (payment Payments) checkOutstandingInvoice(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "get outstanding invoices")
if err != nil {
return err
}
invoices, err := payment.service.accounts.Invoices().List(ctx, 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 := payment.service.accounts.Invoices().CheckPendingItems(ctx, user.ID)
if err != nil {
return err
}
if hasItems {
return ErrUsage.New("user has pending invoice items")
}
return nil
}
// checkProjectInvoicingStatus returns error 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 (payment Payments) checkProjectInvoicingStatus(ctx context.Context, projectID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
_, err = payment.service.getUserAndAuditLog(ctx, "project invoicing status")
if err != nil {
return Error.Wrap(err)
}
return payment.service.accounts.CheckProjectInvoicingStatus(ctx, projectID)
}
// checkProjectUsageStatus returns error if for the given project there is some usage for current or previous month.
func (payment Payments) checkProjectUsageStatus(ctx context.Context, projectID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
_, err = payment.service.getUserAndAuditLog(ctx, "project usage status")
if err != nil {
return Error.Wrap(err)
}
return payment.service.accounts.CheckProjectUsageStatus(ctx, projectID)
}
// ApplyCouponCode applies a coupon code to a Stripe customer
// and returns the coupon corresponding to the code.
func (payment Payments) ApplyCouponCode(ctx context.Context, couponCode string) (coupon *payments.Coupon, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "apply coupon code")
if err != nil {
return nil, Error.Wrap(err)
}
coupon, err = payment.service.accounts.Coupons().ApplyCouponCode(ctx, user.ID, couponCode)
if err != nil {
return nil, Error.Wrap(err)
}
return coupon, nil
}
// GetCoupon returns the coupon applied to the user's account.
func (payment Payments) GetCoupon(ctx context.Context) (coupon *payments.Coupon, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "get coupon")
if err != nil {
return nil, Error.Wrap(err)
}
coupon, err = payment.service.accounts.Coupons().GetByUserID(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
return coupon, nil
}
// checkRegistrationSecret returns a RegistrationToken if applicable (nil if not), and an error
// if and only if the registration shouldn't proceed.
func (s *Service) checkRegistrationSecret(ctx context.Context, tokenSecret RegistrationSecret) (*RegistrationToken, error) {
if s.config.OpenRegistrationEnabled && tokenSecret.IsZero() {
// in this case we're going to let the registration happen without a token
return nil, nil
}
// in all other cases, require a registration token
registrationToken, err := s.store.RegistrationTokens().GetBySecret(ctx, tokenSecret)
if err != nil {
return nil, ErrUnauthorized.Wrap(err)
}
// if a registration token is already associated with an user ID, that means the token is already used
// we should terminate the account creation process and return an error
if registrationToken.OwnerID != nil {
return nil, ErrValidation.New(usedRegTokenErrMsg)
}
return registrationToken, nil
}
// CreateUser gets password hash value and creates new inactive User.
func (s *Service) CreateUser(ctx context.Context, user CreateUser, tokenSecret RegistrationSecret) (u *User, err error) {
defer mon.Task()(&ctx)(&err)
var captchaScore *float64
mon.Counter("create_user_attempt").Inc(1) //mon:locked
if s.config.Captcha.Registration.Recaptcha.Enabled || s.config.Captcha.Registration.Hcaptcha.Enabled {
valid, score, err := s.registrationCaptchaHandler.Verify(ctx, user.CaptchaResponse, user.IP)
if err != nil {
mon.Counter("create_user_captcha_error").Inc(1) //mon:locked
s.log.Error("captcha authorization failed", zap.Error(err))
return nil, ErrCaptcha.Wrap(err)
}
if !valid {
mon.Counter("create_user_captcha_unsuccessful").Inc(1) //mon:locked
return nil, ErrCaptcha.New("captcha validation unsuccessful")
}
captchaScore = score
}
if err := user.IsValid(); err != nil {
// NOTE: error is already wrapped with an appropriated class.
return nil, err
}
registrationToken, err := s.checkRegistrationSecret(ctx, tokenSecret)
if err != nil {
return nil, ErrRegToken.Wrap(err)
}
verified, unverified, err := s.store.Users().GetByEmailWithUnverified(ctx, user.Email)
if err != nil {
return nil, Error.Wrap(err)
}
if verified != nil {
mon.Counter("create_user_duplicate_verified").Inc(1) //mon:locked
return nil, ErrEmailUsed.New(emailUsedErrMsg)
} else if len(unverified) != 0 {
mon.Counter("create_user_duplicate_unverified").Inc(1) //mon:locked
return nil, ErrEmailUsed.New(emailUsedErrMsg)
}
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), s.config.PasswordCost)
if err != nil {
return nil, Error.Wrap(err)
}
// store data
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
userID, err := uuid.New()
if err != nil {
return Error.Wrap(err)
}
newUser := &User{
ID: userID,
Email: user.Email,
FullName: user.FullName,
ShortName: user.ShortName,
PasswordHash: hash,
Status: Inactive,
IsProfessional: user.IsProfessional,
Position: user.Position,
CompanyName: user.CompanyName,
EmployeeCount: user.EmployeeCount,
HaveSalesContact: user.HaveSalesContact,
SignupPromoCode: user.SignupPromoCode,
SignupCaptcha: captchaScore,
}
if user.UserAgent != nil {
newUser.UserAgent = user.UserAgent
}
if registrationToken != nil {
newUser.ProjectLimit = registrationToken.ProjectLimit
} else {
newUser.ProjectLimit = s.config.UsageLimits.Project.Free
}
// TODO: move the project limits into the registration token.
newUser.ProjectStorageLimit = s.config.UsageLimits.Storage.Free.Int64()
newUser.ProjectBandwidthLimit = s.config.UsageLimits.Bandwidth.Free.Int64()
newUser.ProjectSegmentLimit = s.config.UsageLimits.Segment.Free
u, err = tx.Users().Insert(ctx,
newUser,
)
if err != nil {
return Error.Wrap(err)
}
if registrationToken != nil {
err = tx.RegistrationTokens().UpdateOwner(ctx, registrationToken.Secret, u.ID)
if err != nil {
return Error.Wrap(err)
}
}
return nil
})
if err != nil {
return nil, Error.Wrap(err)
}
s.auditLog(ctx, "create user", nil, user.Email)
mon.Counter("create_user_success").Inc(1) //mon:locked
return u, nil
}
// TestSwapCaptchaHandler replaces the existing handler for captchas with
// the one specified for use in testing.
func (s *Service) TestSwapCaptchaHandler(h CaptchaHandler) {
s.registrationCaptchaHandler = h
s.loginCaptchaHandler = h
}
// GenerateActivationToken - is a method for generating activation token.
func (s *Service) GenerateActivationToken(ctx context.Context, id uuid.UUID, email string) (token string, err error) {
defer mon.Task()(&ctx)(&err)
return s.tokens.CreateToken(ctx, id, email)
}
// GeneratePasswordRecoveryToken - is a method for generating password recovery token.
func (s *Service) GeneratePasswordRecoveryToken(ctx context.Context, id uuid.UUID) (token string, err error) {
defer mon.Task()(&ctx)(&err)
resetPasswordToken, err := s.store.ResetPasswordTokens().GetByOwnerID(ctx, id)
if err == nil {
err := s.store.ResetPasswordTokens().Delete(ctx, resetPasswordToken.Secret)
if err != nil {
return "", Error.Wrap(err)
}
}
resetPasswordToken, err = s.store.ResetPasswordTokens().Create(ctx, id)
if err != nil {
return "", Error.Wrap(err)
}
s.auditLog(ctx, "generate password recovery token", &id, "")
return resetPasswordToken.Secret.String(), nil
}
// GenerateSessionToken creates a new session and returns the string representation of its token.
func (s *Service) GenerateSessionToken(ctx context.Context, userID uuid.UUID, email, ip, userAgent string) (_ *TokenInfo, err error) {
defer mon.Task()(&ctx)(&err)
sessionID, err := uuid.New()
if err != nil {
return nil, Error.Wrap(err)
}
duration := s.config.Session.Duration
if s.config.Session.InactivityTimerEnabled {
duration = time.Duration(s.config.Session.InactivityTimerDuration) * time.Second
}
expiresAt := time.Now().Add(duration)
_, err = s.store.WebappSessions().Create(ctx, sessionID, userID, ip, userAgent, expiresAt)
if err != nil {
return nil, err
}
token := consoleauth.Token{Payload: sessionID.Bytes()}
signature, err := s.tokens.SignToken(token)
if err != nil {
return nil, err
}
token.Signature = signature
s.auditLog(ctx, "login", &userID, email)
s.analytics.TrackSignedIn(userID, email)
return &TokenInfo{
Token: token,
ExpiresAt: expiresAt,
}, nil
}
// ActivateAccount - is a method for activating user account after registration.
func (s *Service) ActivateAccount(ctx context.Context, activationToken string) (user *User, err error) {
defer mon.Task()(&ctx)(&err)
parsedActivationToken, err := consoleauth.FromBase64URLString(activationToken)
if err != nil {
return nil, ErrTokenInvalid.Wrap(err)
}
valid, err := s.tokens.ValidateToken(parsedActivationToken)
if err != nil {
return nil, Error.Wrap(err)
}
if !valid {
return nil, ErrTokenInvalid.New("incorrect signature")
}
claims, err := consoleauth.FromJSON(parsedActivationToken.Payload)
if err != nil {
return nil, ErrTokenInvalid.New("JSON decoder: %w", err)
}
if time.Now().After(claims.Expiration) {
return nil, ErrTokenExpiration.New(activationTokenExpiredErrMsg)
}
_, err = s.store.Users().GetByEmail(ctx, claims.Email)
if err == nil {
return nil, ErrEmailUsed.New(emailUsedErrMsg)
}
user, err = s.store.Users().Get(ctx, claims.ID)
if err != nil {
return nil, Error.Wrap(err)
}
status := Active
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
Status: &status,
})
if err != nil {
return nil, Error.Wrap(err)
}
s.auditLog(ctx, "activate account", &user.ID, user.Email)
s.analytics.TrackAccountVerified(user.ID, user.Email)
return user, nil
}
// ResetPassword - is a method for resetting user password.
func (s *Service) ResetPassword(ctx context.Context, resetPasswordToken, password string, passcode string, recoveryCode string, t time.Time) (err error) {
defer mon.Task()(&ctx)(&err)
secret, err := ResetPasswordSecretFromBase64(resetPasswordToken)
if err != nil {
return ErrRecoveryToken.Wrap(err)
}
token, err := s.store.ResetPasswordTokens().GetBySecret(ctx, secret)
if err != nil {
return ErrRecoveryToken.Wrap(err)
}
user, err := s.store.Users().Get(ctx, *token.OwnerID)
if err != nil {
return Error.Wrap(err)
}
if user.MFAEnabled {
if recoveryCode != "" {
found := false
for _, code := range user.MFARecoveryCodes {
if code == recoveryCode {
found = true
break
}
}
if !found {
return ErrUnauthorized.Wrap(ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg))
}
} else if passcode != "" {
valid, err := ValidateMFAPasscode(passcode, user.MFASecretKey, t)
if err != nil {
return ErrValidation.Wrap(ErrMFAPasscode.Wrap(err))
}
if !valid {
return ErrValidation.Wrap(ErrMFAPasscode.New(mfaPasscodeInvalidErrMsg))
}
} else {
return ErrMFAMissing.New(mfaRequiredErrMsg)
}
}
if err := ValidatePassword(password); err != nil {
return ErrValidation.Wrap(err)
}
if s.tokens.IsExpired(t, token.CreatedAt) {
return ErrRecoveryToken.Wrap(ErrTokenExpiration.New(passwordRecoveryTokenIsExpiredErrMsg))
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), s.config.PasswordCost)
if err != nil {
return Error.Wrap(err)
}
updateRequest := UpdateUserRequest{
PasswordHash: hash,
}
if user.FailedLoginCount != 0 {
resetFailedLoginCount := 0
resetLoginLockoutExpirationPtr := &time.Time{}
updateRequest.FailedLoginCount = &resetFailedLoginCount
updateRequest.LoginLockoutExpiration = &resetLoginLockoutExpirationPtr
}
err = s.store.Users().Update(ctx, user.ID, updateRequest)
if err != nil {
return Error.Wrap(err)
}
s.auditLog(ctx, "password reset", &user.ID, user.Email)
if err = s.store.ResetPasswordTokens().Delete(ctx, token.Secret); err != nil {
return Error.Wrap(err)
}
_, err = s.store.WebappSessions().DeleteAllByUserID(ctx, user.ID)
if err != nil {
return Error.Wrap(err)
}
return nil
}
// RevokeResetPasswordToken - is a method to revoke reset password token.
func (s *Service) RevokeResetPasswordToken(ctx context.Context, resetPasswordToken string) (err error) {
defer mon.Task()(&ctx)(&err)
secret, err := ResetPasswordSecretFromBase64(resetPasswordToken)
if err != nil {
return Error.Wrap(err)
}
return s.store.ResetPasswordTokens().Delete(ctx, secret)
}
// Token authenticates User by credentials and returns session token.
func (s *Service) Token(ctx context.Context, request AuthUser) (response *TokenInfo, err error) {
defer mon.Task()(&ctx)(&err)
mon.Counter("login_attempt").Inc(1) //mon:locked
if s.config.Captcha.Login.Recaptcha.Enabled || s.config.Captcha.Login.Hcaptcha.Enabled {
valid, _, err := s.loginCaptchaHandler.Verify(ctx, request.CaptchaResponse, request.IP)
if err != nil {
mon.Counter("login_user_captcha_error").Inc(1) //mon:locked
return nil, ErrCaptcha.Wrap(err)
}
if !valid {
mon.Counter("login_user_captcha_unsuccessful").Inc(1) //mon:locked
return nil, ErrCaptcha.New("captcha validation unsuccessful")
}
}
user, unverified, err := s.store.Users().GetByEmailWithUnverified(ctx, request.Email)
if user == nil {
if len(unverified) > 0 {
mon.Counter("login_email_unverified").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed email unverified", nil, request.Email)
} else {
mon.Counter("login_email_invalid").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed invalid email", nil, request.Email)
}
return nil, ErrLoginCredentials.New(credentialsErrMsg)
}
now := time.Now()
if user.LoginLockoutExpiration.After(now) {
mon.Counter("login_locked_out").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed account locked out", &user.ID, request.Email)
return nil, ErrLoginCredentials.New(credentialsErrMsg)
}
handleLockAccount := func() error {
err = s.UpdateUsersFailedLoginState(ctx, user)
if err != nil {
return err
}
mon.Counter("login_failed").Inc(1) //mon:locked
mon.IntVal("login_user_failed_count").Observe(int64(user.FailedLoginCount)) //mon:locked
if user.FailedLoginCount == s.config.LoginAttemptsWithoutPenalty {
mon.Counter("login_lockout_initiated").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed login count reached maximum attempts", &user.ID, request.Email)
}
if user.FailedLoginCount > s.config.LoginAttemptsWithoutPenalty {
mon.Counter("login_lockout_reinitiated").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed locked account", &user.ID, request.Email)
}
return nil
}
err = bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(request.Password))
if err != nil {
err = handleLockAccount()
if err != nil {
return nil, err
}
mon.Counter("login_invalid_password").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed password invalid", &user.ID, user.Email)
return nil, ErrLoginPassword.New(credentialsErrMsg)
}
if user.MFAEnabled {
if request.MFARecoveryCode != "" && request.MFAPasscode != "" {
mon.Counter("login_mfa_conflict").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed mfa conflict", &user.ID, user.Email)
return nil, ErrMFAConflict.New(mfaConflictErrMsg)
}
if request.MFARecoveryCode != "" {
found := false
codeIndex := -1
for i, code := range user.MFARecoveryCodes {
if code == request.MFARecoveryCode {
found = true
codeIndex = i
break
}
}
if !found {
err = handleLockAccount()
if err != nil {
return nil, err
}
mon.Counter("login_mfa_recovery_failure").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed mfa recovery", &user.ID, user.Email)
return nil, ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg)
}
mon.Counter("login_mfa_recovery_success").Inc(1) //mon:locked
user.MFARecoveryCodes = append(user.MFARecoveryCodes[:codeIndex], user.MFARecoveryCodes[codeIndex+1:]...)
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
MFARecoveryCodes: &user.MFARecoveryCodes,
})
if err != nil {
return nil, err
}
} else if request.MFAPasscode != "" {
valid, err := ValidateMFAPasscode(request.MFAPasscode, user.MFASecretKey, now)
if err != nil {
err = handleLockAccount()
if err != nil {
return nil, err
}
return nil, ErrMFAPasscode.Wrap(err)
}
if !valid {
err = handleLockAccount()
if err != nil {
return nil, err
}
mon.Counter("login_mfa_passcode_failure").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed mfa passcode invalid", &user.ID, user.Email)
return nil, ErrMFAPasscode.New(mfaPasscodeInvalidErrMsg)
}
mon.Counter("login_mfa_passcode_success").Inc(1) //mon:locked
} else {
mon.Counter("login_mfa_missing").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed mfa missing", &user.ID, user.Email)
return nil, ErrMFAMissing.New(mfaRequiredErrMsg)
}
}
if user.FailedLoginCount != 0 {
user.FailedLoginCount = 0
loginLockoutExpirationPtr := &time.Time{}
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
FailedLoginCount: &user.FailedLoginCount,
LoginLockoutExpiration: &loginLockoutExpirationPtr,
})
if err != nil {
return nil, err
}
}
response, err = s.GenerateSessionToken(ctx, user.ID, user.Email, request.IP, request.UserAgent)
if err != nil {
return nil, err
}
mon.Counter("login_success").Inc(1) //mon:locked
return response, nil
}
// UpdateUsersFailedLoginState updates User's failed login state.
func (s *Service) UpdateUsersFailedLoginState(ctx context.Context, user *User) (err error) {
defer mon.Task()(&ctx)(&err)
updateRequest := UpdateUserRequest{}
if user.FailedLoginCount >= s.config.LoginAttemptsWithoutPenalty-1 {
lockoutDuration := time.Duration(math.Pow(s.config.FailedLoginPenalty, float64(user.FailedLoginCount-1))) * time.Minute
lockoutExpTime := time.Now().Add(lockoutDuration)
lockoutExpTimePtr := &lockoutExpTime
updateRequest.LoginLockoutExpiration = &lockoutExpTimePtr
address := s.satelliteAddress
if !strings.HasSuffix(address, "/") {
address += "/"
}
s.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: user.Email, Name: user.FullName}},
&LockAccountEmail{
Name: user.FullName,
LockoutDuration: lockoutDuration,
ResetPasswordLink: address + "forgot-password",
},
)
}
user.FailedLoginCount++
updateRequest.FailedLoginCount = &user.FailedLoginCount
return s.store.Users().Update(ctx, user.ID, updateRequest)
}
// GetUser returns User by id.
func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (u *User, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.store.Users().Get(ctx, id)
if err != nil {
return nil, Error.Wrap(err)
}
return user, nil
}
// GenGetUser returns ResponseUser by request context for generated api.
func (s *Service) GenGetUser(ctx context.Context) (*ResponseUser, api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get user")
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
respUser := &ResponseUser{
ID: user.ID,
FullName: user.FullName,
ShortName: user.ShortName,
Email: user.Email,
PartnerID: user.PartnerID,
UserAgent: user.UserAgent,
ProjectLimit: user.ProjectLimit,
IsProfessional: user.IsProfessional,
Position: user.Position,
CompanyName: user.CompanyName,
EmployeeCount: user.EmployeeCount,
HaveSalesContact: user.HaveSalesContact,
PaidTier: user.PaidTier,
MFAEnabled: user.MFAEnabled,
MFARecoveryCodeCount: len(user.MFARecoveryCodes),
}
return respUser, api.HTTPError{}
}
// GetUserID returns the User ID from the session.
func (s *Service) GetUserID(ctx context.Context) (id uuid.UUID, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get user ID")
if err != nil {
return uuid.UUID{}, Error.Wrap(err)
}
return user.ID, nil
}
// GetUserByEmailWithUnverified returns Users by email.
func (s *Service) GetUserByEmailWithUnverified(ctx context.Context, email string) (verified *User, unverified []User, err error) {
defer mon.Task()(&ctx)(&err)
verified, unverified, err = s.store.Users().GetByEmailWithUnverified(ctx, email)
if err != nil {
return verified, unverified, err
}
if verified == nil && len(unverified) == 0 {
err = ErrEmailNotFound.New(emailNotFoundErrMsg)
}
return verified, unverified, err
}
// UpdateAccount updates User.
func (s *Service) UpdateAccount(ctx context.Context, fullName string, shortName string) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "update account")
if err != nil {
return Error.Wrap(err)
}
// validate fullName
err = ValidateFullName(fullName)
if err != nil {
return ErrValidation.Wrap(err)
}
user.FullName = fullName
user.ShortName = shortName
shortNamePtr := &user.ShortName
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
FullName: &user.FullName,
ShortName: &shortNamePtr,
})
if err != nil {
return Error.Wrap(err)
}
return nil
}
// ChangeEmail updates email for a given user.
func (s *Service) ChangeEmail(ctx context.Context, newEmail string) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "change email")
if err != nil {
return Error.Wrap(err)
}
if _, err := mail.ParseAddress(newEmail); err != nil {
return ErrValidation.Wrap(err)
}
verified, unverified, err := s.store.Users().GetByEmailWithUnverified(ctx, newEmail)
if err != nil {
return Error.Wrap(err)
}
if verified != nil || len(unverified) != 0 {
return ErrEmailUsed.New(emailUsedErrMsg)
}
user.Email = newEmail
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
Email: &user.Email,
})
if err != nil {
return Error.Wrap(err)
}
return nil
}
// ChangePassword updates password for a given user.
func (s *Service) ChangePassword(ctx context.Context, pass, newPass string) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "change password")
if err != nil {
return Error.Wrap(err)
}
err = bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(pass))
if err != nil {
return ErrUnauthorized.New(credentialsErrMsg)
}
if err := ValidatePassword(newPass); err != nil {
return ErrValidation.Wrap(err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPass), s.config.PasswordCost)
if err != nil {
return Error.Wrap(err)
}
user.PasswordHash = hash
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
PasswordHash: hash,
})
if err != nil {
return Error.Wrap(err)
}
_, err = s.store.WebappSessions().DeleteAllByUserID(ctx, user.ID)
if err != nil {
return Error.Wrap(err)
}
return nil
}
// DeleteAccount deletes User.
func (s *Service) DeleteAccount(ctx context.Context, password string) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "delete account")
if err != nil {
return Error.Wrap(err)
}
err = bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password))
if err != nil {
return ErrUnauthorized.New(credentialsErrMsg)
}
err = s.Payments().checkOutstandingInvoice(ctx)
if err != nil {
return Error.Wrap(err)
}
err = s.store.Users().Delete(ctx, user.ID)
if err != nil {
return Error.Wrap(err)
}
return nil
}
// GetProject is a method for querying project by id.
func (s *Service) GetProject(ctx context.Context, projectID uuid.UUID) (p *Project, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get project", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
if _, err = s.isProjectMember(ctx, user.ID, projectID); err != nil {
return nil, Error.Wrap(err)
}
p, err = s.store.Projects().Get(ctx, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
return
}
// GetUsersProjects is a method for querying all projects.
func (s *Service) GetUsersProjects(ctx context.Context) (ps []Project, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get users projects")
if err != nil {
return nil, Error.Wrap(err)
}
ps, err = s.store.Projects().GetByUserID(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
return
}
// GenGetUsersProjects is a method for querying all projects for generated api.
func (s *Service) GenGetUsersProjects(ctx context.Context) (ps []Project, httpErr api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get users projects")
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
ps, err = s.store.Projects().GetByUserID(ctx, user.ID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
return
}
// GetUsersOwnedProjectsPage is a method for querying paged projects.
func (s *Service) GetUsersOwnedProjectsPage(ctx context.Context, cursor ProjectsCursor) (_ ProjectsPage, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get user's owned projects page")
if err != nil {
return ProjectsPage{}, Error.Wrap(err)
}
projects, err := s.store.Projects().ListByOwnerID(ctx, user.ID, cursor)
if err != nil {
return ProjectsPage{}, Error.Wrap(err)
}
return projects, nil
}
// CreateProject is a method for creating new project.
func (s *Service) CreateProject(ctx context.Context, projectInfo ProjectInfo) (p *Project, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "create project")
if err != nil {
return nil, Error.Wrap(err)
}
currentProjectCount, err := s.checkProjectLimit(ctx, user.ID)
if err != nil {
s.analytics.TrackProjectLimitError(user.ID, user.Email)
return nil, ErrProjLimit.Wrap(err)
}
newProjectLimits, err := s.getUserProjectLimits(ctx, user.ID)
if err != nil {
return nil, ErrProjLimit.Wrap(err)
}
var projectID uuid.UUID
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
p, err = tx.Projects().Insert(ctx,
&Project{
Description: projectInfo.Description,
Name: projectInfo.Name,
OwnerID: user.ID,
PartnerID: user.PartnerID,
UserAgent: user.UserAgent,
StorageLimit: &newProjectLimits.StorageLimit,
BandwidthLimit: &newProjectLimits.BandwidthLimit,
SegmentLimit: &newProjectLimits.SegmentLimit,
},
)
if err != nil {
return Error.Wrap(err)
}
_, err = tx.ProjectMembers().Insert(ctx, user.ID, p.ID)
if err != nil {
return Error.Wrap(err)
}
projectID = p.ID
return nil
})
if err != nil {
return nil, Error.Wrap(err)
}
s.analytics.TrackProjectCreated(user.ID, user.Email, projectID, currentProjectCount+1)
return p, nil
}
// GenCreateProject is a method for creating new project for generated api.
func (s *Service) GenCreateProject(ctx context.Context, projectInfo ProjectInfo) (p *Project, httpError api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "create project")
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
currentProjectCount, err := s.checkProjectLimit(ctx, user.ID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: ErrProjLimit.Wrap(err),
}
}
newProjectLimits, err := s.getUserProjectLimits(ctx, user.ID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: ErrProjLimit.Wrap(err),
}
}
var projectID uuid.UUID
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
p, err = tx.Projects().Insert(ctx,
&Project{
Description: projectInfo.Description,
Name: projectInfo.Name,
OwnerID: user.ID,
PartnerID: user.PartnerID,
UserAgent: user.UserAgent,
StorageLimit: &newProjectLimits.StorageLimit,
BandwidthLimit: &newProjectLimits.BandwidthLimit,
SegmentLimit: &newProjectLimits.SegmentLimit,
},
)
if err != nil {
return Error.Wrap(err)
}
_, err = tx.ProjectMembers().Insert(ctx, user.ID, p.ID)
if err != nil {
return Error.Wrap(err)
}
projectID = p.ID
return nil
})
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: err,
}
}
s.analytics.TrackProjectCreated(user.ID, user.Email, projectID, currentProjectCount+1)
return p, httpError
}
// DeleteProject is a method for deleting project by id.
func (s *Service) DeleteProject(ctx context.Context, projectID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "delete project", zap.String("projectID", projectID.String()))
if err != nil {
return Error.Wrap(err)
}
_, err = s.isProjectOwner(ctx, user.ID, projectID)
if err != nil {
return Error.Wrap(err)
}
err = s.checkProjectCanBeDeleted(ctx, user, projectID)
if err != nil {
return Error.Wrap(err)
}
err = s.store.Projects().Delete(ctx, projectID)
if err != nil {
return Error.Wrap(err)
}
return nil
}
// GenDeleteProject is a method for deleting project by id for generated API.
func (s *Service) GenDeleteProject(ctx context.Context, projectID uuid.UUID) (httpError api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "delete project", zap.String("projectID", projectID.String()))
if err != nil {
return api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
_, err = s.isProjectOwner(ctx, user.ID, projectID)
if err != nil {
return api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
err = s.checkProjectCanBeDeleted(ctx, user, projectID)
if err != nil {
return api.HTTPError{
Status: http.StatusConflict,
Err: Error.Wrap(err),
}
}
err = s.store.Projects().Delete(ctx, projectID)
if err != nil {
return api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
return httpError
}
// UpdateProject is a method for updating project name and description by id.
func (s *Service) UpdateProject(ctx context.Context, projectID uuid.UUID, updatedProject ProjectInfo) (p *Project, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "update project name and description", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
err = ValidateNameAndDescription(updatedProject.Name, updatedProject.Description)
if err != nil {
return nil, Error.Wrap(err)
}
isMember, err := s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
project := isMember.project
project.Name = updatedProject.Name
project.Description = updatedProject.Description
if user.PaidTier {
if project.BandwidthLimit != nil && *project.BandwidthLimit == 0 {
return nil, Error.New("current bandwidth limit for project is set to 0 (updating disabled)")
}
if project.StorageLimit != nil && *project.StorageLimit == 0 {
return nil, Error.New("current storage limit for project is set to 0 (updating disabled)")
}
if updatedProject.StorageLimit <= 0 || updatedProject.BandwidthLimit <= 0 {
return nil, Error.New("project limits must be greater than 0")
}
if updatedProject.StorageLimit > s.config.UsageLimits.Storage.Paid && updatedProject.StorageLimit > *project.StorageLimit {
return nil, Error.New("specified storage limit exceeds allowed maximum for current tier")
}
if updatedProject.BandwidthLimit > s.config.UsageLimits.Bandwidth.Paid && updatedProject.BandwidthLimit > *project.BandwidthLimit {
return nil, Error.New("specified bandwidth limit exceeds allowed maximum for current tier")
}
storageUsed, err := s.projectUsage.GetProjectStorageTotals(ctx, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
if updatedProject.StorageLimit.Int64() < storageUsed {
return nil, Error.New("cannot set storage limit below current usage")
}
bandwidthUsed, err := s.projectUsage.GetProjectBandwidthTotals(ctx, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
if updatedProject.BandwidthLimit.Int64() < bandwidthUsed {
return nil, Error.New("cannot set bandwidth limit below current usage")
}
project.StorageLimit = new(memory.Size)
*project.StorageLimit = updatedProject.StorageLimit
project.BandwidthLimit = new(memory.Size)
*project.BandwidthLimit = updatedProject.BandwidthLimit
}
err = s.store.Projects().Update(ctx, project)
if err != nil {
return nil, Error.Wrap(err)
}
return project, nil
}
// GenUpdateProject is a method for updating project name and description by id for generated api.
func (s *Service) GenUpdateProject(ctx context.Context, projectID uuid.UUID, projectInfo ProjectInfo) (p *Project, httpError api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "update project name and description", zap.String("projectID", projectID.String()))
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
err = ValidateNameAndDescription(projectInfo.Name, projectInfo.Description)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusBadRequest,
Err: Error.Wrap(err),
}
}
isMember, err := s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
project := isMember.project
project.Name = projectInfo.Name
project.Description = projectInfo.Description
if user.PaidTier {
if project.BandwidthLimit != nil && *project.BandwidthLimit == 0 {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.New("current bandwidth limit for project is set to 0 (updating disabled)"),
}
}
if project.StorageLimit != nil && *project.StorageLimit == 0 {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.New("current storage limit for project is set to 0 (updating disabled)"),
}
}
if projectInfo.StorageLimit <= 0 || projectInfo.BandwidthLimit <= 0 {
return nil, api.HTTPError{
Status: http.StatusBadRequest,
Err: Error.New("project limits must be greater than 0"),
}
}
if projectInfo.StorageLimit > s.config.UsageLimits.Storage.Paid && projectInfo.StorageLimit > *project.StorageLimit {
return nil, api.HTTPError{
Status: http.StatusBadRequest,
Err: Error.New("specified storage limit exceeds allowed maximum for current tier"),
}
}
if projectInfo.BandwidthLimit > s.config.UsageLimits.Bandwidth.Paid && projectInfo.BandwidthLimit > *project.BandwidthLimit {
return nil, api.HTTPError{
Status: http.StatusBadRequest,
Err: Error.New("specified bandwidth limit exceeds allowed maximum for current tier"),
}
}
storageUsed, err := s.projectUsage.GetProjectStorageTotals(ctx, projectID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
if projectInfo.StorageLimit.Int64() < storageUsed {
return nil, api.HTTPError{
Status: http.StatusBadRequest,
Err: Error.New("cannot set storage limit below current usage"),
}
}
bandwidthUsed, err := s.projectUsage.GetProjectBandwidthTotals(ctx, projectID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
if projectInfo.BandwidthLimit.Int64() < bandwidthUsed {
return nil, api.HTTPError{
Status: http.StatusBadRequest,
Err: Error.New("cannot set bandwidth limit below current usage"),
}
}
project.StorageLimit = new(memory.Size)
*project.StorageLimit = projectInfo.StorageLimit
project.BandwidthLimit = new(memory.Size)
*project.BandwidthLimit = projectInfo.BandwidthLimit
}
err = s.store.Projects().Update(ctx, project)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
return project, httpError
}
// AddProjectMembers adds users by email to given project.
func (s *Service) AddProjectMembers(ctx context.Context, projectID uuid.UUID, emails []string) (users []*User, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "add project members", zap.String("projectID", projectID.String()), zap.Strings("emails", emails))
if err != nil {
return nil, Error.Wrap(err)
}
if _, err = s.isProjectMember(ctx, user.ID, projectID); err != nil {
return nil, Error.Wrap(err)
}
var userErr errs.Group
// collect user querying errors
for _, email := range emails {
user, err := s.store.Users().GetByEmail(ctx, email)
if err != nil {
userErr.Add(err)
continue
}
users = append(users, user)
}
if err = userErr.Err(); err != nil {
return nil, ErrValidation.New(teamMemberDoesNotExistErrMsg)
}
// add project members in transaction scope
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
for _, user := range users {
if _, err := tx.ProjectMembers().Insert(ctx, user.ID, projectID); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, Error.Wrap(err)
}
return users, nil
}
// DeleteProjectMembers removes users by email from given project.
func (s *Service) DeleteProjectMembers(ctx context.Context, projectID uuid.UUID, emails []string) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "delete project members", zap.String("projectID", projectID.String()), zap.Strings("emails", emails))
if err != nil {
return Error.Wrap(err)
}
if _, err = s.isProjectMember(ctx, user.ID, projectID); err != nil {
return Error.Wrap(err)
}
var userIDs []uuid.UUID
var userErr errs.Group
// collect user querying errors
for _, email := range emails {
user, err := s.store.Users().GetByEmail(ctx, email)
if err != nil {
userErr.Add(err)
continue
}
isOwner, err := s.isProjectOwner(ctx, user.ID, projectID)
if isOwner {
return ErrValidation.New(projectOwnerDeletionForbiddenErrMsg, user.Email)
}
if err != nil && !ErrUnauthorized.Has(err) {
return Error.Wrap(err)
}
userIDs = append(userIDs, user.ID)
}
if err = userErr.Err(); err != nil {
return ErrValidation.New(teamMemberDoesNotExistErrMsg)
}
// delete project members in transaction scope
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
for _, uID := range userIDs {
err = tx.ProjectMembers().Delete(ctx, uID, projectID)
if err != nil {
return err
}
}
return nil
})
return Error.Wrap(err)
}
// GetProjectMembers returns ProjectMembers for given Project.
func (s *Service) GetProjectMembers(ctx context.Context, projectID uuid.UUID, cursor ProjectMembersCursor) (pmp *ProjectMembersPage, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get project members", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
if cursor.Limit > maxLimit {
cursor.Limit = maxLimit
}
pmp, err = s.store.ProjectMembers().GetPagedByProjectID(ctx, projectID, cursor)
if err != nil {
return nil, Error.Wrap(err)
}
return
}
// CreateAPIKey creates new api key.
func (s *Service) CreateAPIKey(ctx context.Context, projectID uuid.UUID, name string) (_ *APIKeyInfo, _ *macaroon.APIKey, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "create api key", zap.String("projectID", projectID.String()))
if err != nil {
return nil, nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, nil, Error.Wrap(err)
}
_, err = s.store.APIKeys().GetByNameAndProjectID(ctx, name, projectID)
if err == nil {
return nil, nil, ErrValidation.New(apiKeyWithNameExistsErrMsg)
}
secret, err := macaroon.NewSecret()
if err != nil {
return nil, nil, Error.Wrap(err)
}
key, err := macaroon.NewAPIKey(secret)
if err != nil {
return nil, nil, Error.Wrap(err)
}
apikey := APIKeyInfo{
Name: name,
ProjectID: projectID,
Secret: secret,
PartnerID: user.PartnerID,
UserAgent: user.UserAgent,
}
info, err := s.store.APIKeys().Create(ctx, key.Head(), apikey)
if err != nil {
return nil, nil, Error.Wrap(err)
}
return info, key, nil
}
// GenCreateAPIKey creates new api key for generated api.
func (s *Service) GenCreateAPIKey(ctx context.Context, requestInfo CreateAPIKeyRequest) (*CreateAPIKeyResponse, api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "create api key", zap.String("projectID", requestInfo.ProjectID))
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
projectID, err := uuid.FromString(requestInfo.ProjectID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusBadRequest,
Err: Error.Wrap(err),
}
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
_, err = s.store.APIKeys().GetByNameAndProjectID(ctx, requestInfo.Name, projectID)
if err == nil {
return nil, api.HTTPError{
Status: http.StatusConflict,
Err: ErrValidation.New(apiKeyWithNameExistsErrMsg),
}
}
secret, err := macaroon.NewSecret()
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
key, err := macaroon.NewAPIKey(secret)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
apikey := APIKeyInfo{
Name: requestInfo.Name,
ProjectID: projectID,
Secret: secret,
PartnerID: user.PartnerID,
UserAgent: user.UserAgent,
}
info, err := s.store.APIKeys().Create(ctx, key.Head(), apikey)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
return &CreateAPIKeyResponse{
Key: key.Serialize(),
KeyInfo: info,
}, api.HTTPError{}
}
// GenDeleteAPIKey deletes api key for generated api.
func (s *Service) GenDeleteAPIKey(ctx context.Context, keyID uuid.UUID) (httpError api.HTTPError) {
err := s.DeleteAPIKeys(ctx, []uuid.UUID{keyID})
if err != nil {
if errs.Is(err, sql.ErrNoRows) {
return httpError
}
status := http.StatusInternalServerError
if ErrUnauthorized.Has(err) {
status = http.StatusUnauthorized
} else if ErrAPIKeyRequest.Has(err) {
status = http.StatusBadRequest
}
return api.HTTPError{
Status: status,
Err: Error.Wrap(err),
}
}
return httpError
}
// GenGetAPIKeys returns api keys belonging to a project for generated api.
func (s *Service) GenGetAPIKeys(ctx context.Context, projectID uuid.UUID, search string, limit, page uint, order APIKeyOrder, orderDirection OrderDirection) (*APIKeyPage, api.HTTPError) {
akp, err := s.GetAPIKeys(ctx, projectID, APIKeyCursor{
Search: search,
Limit: limit,
Page: page,
Order: order,
OrderDirection: orderDirection,
})
if err != nil {
status := http.StatusInternalServerError
if ErrUnauthorized.Has(err) {
status = http.StatusUnauthorized
} else if ErrAPIKeyRequest.Has(err) {
status = http.StatusBadRequest
}
return nil, api.HTTPError{
Status: status,
Err: Error.Wrap(err),
}
}
return akp, api.HTTPError{}
}
// GetAPIKeyInfoByName retrieves an api key by its name and project id.
func (s *Service) GetAPIKeyInfoByName(ctx context.Context, projectID uuid.UUID, name string) (_ *APIKeyInfo, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get api key info",
zap.String("projectID", projectID.String()),
zap.String("name", name))
if err != nil {
return nil, err
}
key, err := s.store.APIKeys().GetByNameAndProjectID(ctx, name, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, key.ProjectID)
if err != nil {
return nil, Error.Wrap(err)
}
return key, nil
}
// GetAPIKeyInfo retrieves api key by id.
func (s *Service) GetAPIKeyInfo(ctx context.Context, id uuid.UUID) (_ *APIKeyInfo, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get api key info", zap.String("apiKeyID", id.String()))
if err != nil {
return nil, err
}
key, err := s.store.APIKeys().Get(ctx, id)
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, key.ProjectID)
if err != nil {
return nil, Error.Wrap(err)
}
return key, nil
}
// DeleteAPIKeys deletes api key by id.
func (s *Service) DeleteAPIKeys(ctx context.Context, ids []uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
idStrings := make([]string, 0, len(ids))
for _, id := range ids {
idStrings = append(idStrings, id.String())
}
user, err := s.getUserAndAuditLog(ctx, "delete api keys", zap.Strings("apiKeyIDs", idStrings))
if err != nil {
return Error.Wrap(err)
}
var keysErr errs.Group
for _, keyID := range ids {
key, err := s.store.APIKeys().Get(ctx, keyID)
if err != nil {
keysErr.Add(err)
continue
}
_, err = s.isProjectMember(ctx, user.ID, key.ProjectID)
if err != nil {
keysErr.Add(ErrUnauthorized.Wrap(err))
continue
}
}
if err = keysErr.Err(); err != nil {
return Error.Wrap(err)
}
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
for _, keyToDeleteID := range ids {
err = tx.APIKeys().Delete(ctx, keyToDeleteID)
if err != nil {
return err
}
}
return nil
})
return Error.Wrap(err)
}
// DeleteAPIKeyByNameAndProjectID deletes api key by name and project ID.
func (s *Service) DeleteAPIKeyByNameAndProjectID(ctx context.Context, name string, projectID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "delete api key by name and project ID", zap.String("apiKeyName", name), zap.String("projectID", projectID.String()))
if err != nil {
return Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return Error.Wrap(err)
}
key, err := s.store.APIKeys().GetByNameAndProjectID(ctx, name, projectID)
if err != nil {
return ErrNoAPIKey.New(apiKeyWithNameDoesntExistErrMsg)
}
err = s.store.APIKeys().Delete(ctx, key.ID)
if err != nil {
return Error.Wrap(err)
}
return nil
}
// GetAPIKeys returns paged api key list for given Project.
func (s *Service) GetAPIKeys(ctx context.Context, projectID uuid.UUID, cursor APIKeyCursor) (page *APIKeyPage, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get api keys", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, ErrUnauthorized.Wrap(err)
}
if cursor.Limit > maxLimit {
cursor.Limit = maxLimit
}
page, err = s.store.APIKeys().GetPagedByProjectID(ctx, projectID, cursor)
if err != nil {
return nil, Error.Wrap(err)
}
return
}
// CreateRESTKey creates a satellite rest key.
func (s *Service) CreateRESTKey(ctx context.Context, expiration time.Duration) (apiKey string, expiresAt time.Time, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "create rest key")
if err != nil {
return "", time.Time{}, Error.Wrap(err)
}
apiKey, expiresAt, err = s.restKeys.Create(ctx, user.ID, expiration)
if err != nil {
return "", time.Time{}, Error.Wrap(err)
}
return apiKey, expiresAt, nil
}
// RevokeRESTKey revokes a satellite REST key.
func (s *Service) RevokeRESTKey(ctx context.Context, apiKey string) (err error) {
defer mon.Task()(&ctx)(&err)
_, err = s.getUserAndAuditLog(ctx, "revoke rest key")
if err != nil {
return Error.Wrap(err)
}
err = s.restKeys.Revoke(ctx, apiKey)
if err != nil {
return Error.Wrap(err)
}
return nil
}
// GetProjectUsage retrieves project usage for a given period.
func (s *Service) GetProjectUsage(ctx context.Context, projectID uuid.UUID, since, before time.Time) (_ *accounting.ProjectUsage, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get project usage", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
projectUsage, err := s.projectAccounting.GetProjectTotal(ctx, projectID, since, before)
if err != nil {
return nil, Error.Wrap(err)
}
return projectUsage, nil
}
// GetBucketTotals retrieves paged bucket total usages since project creation.
func (s *Service) GetBucketTotals(ctx context.Context, projectID uuid.UUID, cursor accounting.BucketUsageCursor, before time.Time) (_ *accounting.BucketUsagePage, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get bucket totals", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
usage, err := s.projectAccounting.GetBucketTotals(ctx, projectID, cursor, before)
if err != nil {
return nil, Error.Wrap(err)
}
return usage, nil
}
// GetAllBucketNames retrieves all bucket names of a specific project.
func (s *Service) GetAllBucketNames(ctx context.Context, projectID uuid.UUID) (_ []string, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get all bucket names", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
listOptions := storj.BucketListOptions{
Direction: storj.Forward,
}
allowedBuckets := macaroon.AllowedBuckets{
All: true,
}
bucketsList, err := s.buckets.ListBuckets(ctx, projectID, listOptions, allowedBuckets)
if err != nil {
return nil, Error.Wrap(err)
}
var list []string
for _, bucket := range bucketsList.Items {
list = append(list, bucket.Name)
}
return list, nil
}
// GetBucketUsageRollups retrieves summed usage rollups for every bucket of particular project for a given period.
func (s *Service) GetBucketUsageRollups(ctx context.Context, projectID uuid.UUID, since, before time.Time) (_ []accounting.BucketUsageRollup, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get bucket usage rollups", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
result, err := s.projectAccounting.GetBucketUsageRollups(ctx, projectID, since, before)
if err != nil {
return nil, Error.Wrap(err)
}
return result, nil
}
// GenGetBucketUsageRollups retrieves summed usage rollups for every bucket of particular project for a given period for generated api.
func (s *Service) GenGetBucketUsageRollups(ctx context.Context, projectID uuid.UUID, since, before time.Time) (rollups []accounting.BucketUsageRollup, httpError api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get bucket usage rollups", zap.String("projectID", projectID.String()))
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
rollups, err = s.projectAccounting.GetBucketUsageRollups(ctx, projectID, since, before)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
return
}
// GenGetSingleBucketUsageRollup retrieves usage rollup for single bucket of particular project for a given period for generated api.
func (s *Service) GenGetSingleBucketUsageRollup(ctx context.Context, projectID uuid.UUID, bucket string, since, before time.Time) (rollup *accounting.BucketUsageRollup, httpError api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get single bucket usage rollup", zap.String("projectID", projectID.String()))
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusUnauthorized,
Err: Error.Wrap(err),
}
}
rollup, err = s.projectAccounting.GetSingleBucketUsageRollup(ctx, projectID, bucket, since, before)
if err != nil {
return nil, api.HTTPError{
Status: http.StatusInternalServerError,
Err: Error.Wrap(err),
}
}
return
}
// GetDailyProjectUsage returns daily usage by project ID.
func (s *Service) GetDailyProjectUsage(ctx context.Context, projectID uuid.UUID, from, to time.Time) (_ *accounting.ProjectDailyUsage, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get daily usage by project ID")
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
usage, err := s.projectAccounting.GetProjectDailyUsageByDateRange(ctx, projectID, from, to, s.config.AsOfSystemTimeDuration)
if err != nil {
return nil, Error.Wrap(err)
}
return usage, nil
}
// GetProjectUsageLimits returns project limits and current usage.
//
// Among others,it can return one of the following errors returned by
// storj.io/storj/satellite/accounting.Service, wrapped Error.
func (s *Service) GetProjectUsageLimits(ctx context.Context, projectID uuid.UUID) (_ *ProjectUsageLimits, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get project usage limits", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
prUsageLimits, err := s.getProjectUsageLimits(ctx, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
prObjectsSegments, err := s.projectAccounting.GetProjectObjectsSegments(ctx, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
return &ProjectUsageLimits{
StorageLimit: prUsageLimits.StorageLimit,
BandwidthLimit: prUsageLimits.BandwidthLimit,
StorageUsed: prUsageLimits.StorageUsed,
BandwidthUsed: prUsageLimits.BandwidthUsed,
ObjectCount: prObjectsSegments.ObjectCount,
SegmentCount: prObjectsSegments.SegmentCount,
}, nil
}
// GetTotalUsageLimits returns total limits and current usage for all the projects.
func (s *Service) GetTotalUsageLimits(ctx context.Context) (_ *ProjectUsageLimits, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get total usage and limits for all the projects")
if err != nil {
return nil, Error.Wrap(err)
}
projects, err := s.store.Projects().GetOwn(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
var totalStorageLimit int64
var totalBandwidthLimit int64
var totalStorageUsed int64
var totalBandwidthUsed int64
for _, pr := range projects {
prUsageLimits, err := s.getProjectUsageLimits(ctx, pr.ID)
if err != nil {
return nil, Error.Wrap(err)
}
totalStorageLimit += prUsageLimits.StorageLimit
totalBandwidthLimit += prUsageLimits.BandwidthLimit
totalStorageUsed += prUsageLimits.StorageUsed
totalBandwidthUsed += prUsageLimits.BandwidthUsed
}
return &ProjectUsageLimits{
StorageLimit: totalStorageLimit,
BandwidthLimit: totalBandwidthLimit,
StorageUsed: totalStorageUsed,
BandwidthUsed: totalBandwidthUsed,
}, nil
}
func (s *Service) getProjectUsageLimits(ctx context.Context, projectID uuid.UUID) (_ *ProjectUsageLimits, err error) {
defer mon.Task()(&ctx)(&err)
storageLimit, err := s.projectUsage.GetProjectStorageLimit(ctx, projectID)
if err != nil {
return nil, err
}
bandwidthLimit, err := s.projectUsage.GetProjectBandwidthLimit(ctx, projectID)
if err != nil {
return nil, err
}
storageUsed, err := s.projectUsage.GetProjectStorageTotals(ctx, projectID)
if err != nil {
return nil, err
}
bandwidthUsed, err := s.projectUsage.GetProjectBandwidthTotals(ctx, projectID)
if err != nil {
return nil, err
}
return &ProjectUsageLimits{
StorageLimit: storageLimit.Int64(),
BandwidthLimit: bandwidthLimit.Int64(),
StorageUsed: storageUsed,
BandwidthUsed: bandwidthUsed,
}, nil
}
// TokenAuth returns an authenticated context by session token.
func (s *Service) TokenAuth(ctx context.Context, token consoleauth.Token, authTime time.Time) (_ context.Context, err error) {
defer mon.Task()(&ctx)(&err)
valid, err := s.tokens.ValidateToken(token)
if err != nil {
return nil, Error.Wrap(err)
}
if !valid {
return nil, Error.New("incorrect signature")
}
sessionID, err := uuid.FromBytes(token.Payload)
if err != nil {
return nil, Error.Wrap(err)
}
session, err := s.store.WebappSessions().GetBySessionID(ctx, sessionID)
if err != nil {
return nil, Error.Wrap(err)
}
ctx, err = s.authorize(ctx, session.UserID, session.ExpiresAt, authTime)
if err != nil {
err := errs.Combine(err, s.store.WebappSessions().DeleteBySessionID(ctx, sessionID))
if err != nil {
return nil, Error.Wrap(err)
}
return nil, err
}
return ctx, nil
}
// KeyAuth returns an authenticated context by api key.
func (s *Service) KeyAuth(ctx context.Context, apikey string, authTime time.Time) (_ context.Context, err error) {
defer mon.Task()(&ctx)(&err)
ctx = consoleauth.WithAPIKey(ctx, []byte(apikey))
userID, exp, err := s.restKeys.GetUserAndExpirationFromKey(ctx, apikey)
if err != nil {
return nil, err
}
ctx, err = s.authorize(ctx, userID, exp, authTime)
if err != nil {
return nil, err
}
return ctx, 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, user *User, projectID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
buckets, err := s.buckets.CountBuckets(ctx, projectID)
if err != nil {
return err
}
if buckets > 0 {
return ErrUsage.New("some buckets still exist")
}
keys, err := s.store.APIKeys().GetPagedByProjectID(ctx, projectID, APIKeyCursor{Limit: 1, Page: 1})
if err != nil {
return err
}
if keys.TotalCount > 0 {
return ErrUsage.New("some api keys still exist")
}
if user.PaidTier {
err = s.Payments().checkProjectUsageStatus(ctx, projectID)
if err != nil {
return ErrUsage.Wrap(err)
}
}
err = s.Payments().checkProjectInvoicingStatus(ctx, projectID)
if err != nil {
return ErrUsage.Wrap(err)
}
return nil
}
// checkProjectLimit is used to check if user is able to create a new project.
func (s *Service) checkProjectLimit(ctx context.Context, userID uuid.UUID) (currentProjects int, err error) {
defer mon.Task()(&ctx)(&err)
limit, err := s.store.Users().GetProjectLimit(ctx, userID)
if err != nil {
return 0, Error.Wrap(err)
}
projects, err := s.GetUsersProjects(ctx)
if err != nil {
return 0, Error.Wrap(err)
}
if len(projects) >= limit {
return 0, ErrProjLimit.New(projLimitErrMsg)
}
return len(projects), nil
}
// getUserProjectLimits is a method to get the users storage and bandwidth limits for new projects.
func (s *Service) getUserProjectLimits(ctx context.Context, userID uuid.UUID) (_ *UserProjectLimits, err error) {
defer mon.Task()(&ctx)(&err)
result, err := s.store.Users().GetUserProjectLimits(ctx, userID)
if err != nil {
return nil, Error.Wrap(err)
}
return &UserProjectLimits{
StorageLimit: result.ProjectStorageLimit,
BandwidthLimit: result.ProjectBandwidthLimit,
SegmentLimit: result.ProjectSegmentLimit,
}, nil
}
// CreateRegToken creates new registration token. Needed for testing.
func (s *Service) CreateRegToken(ctx context.Context, projLimit int) (_ *RegistrationToken, err error) {
defer mon.Task()(&ctx)(&err)
result, err := s.store.RegistrationTokens().Create(ctx, projLimit)
if err != nil {
return nil, Error.Wrap(err)
}
return result, nil
}
// authorize returns an authorized context by user ID.
func (s *Service) authorize(ctx context.Context, userID uuid.UUID, expiration time.Time, authTime time.Time) (_ context.Context, err error) {
defer mon.Task()(&ctx)(&err)
if !expiration.IsZero() && expiration.Before(authTime) {
return nil, ErrTokenExpiration.New("authorization failed. expiration reached.")
}
user, err := s.store.Users().Get(ctx, userID)
if err != nil {
return nil, Error.New("authorization failed. no user with id: %s", userID.String())
}
if user.Status != Active {
return nil, Error.New("authorization failed. no active user with id: %s", userID.String())
}
return WithUser(ctx, user), nil
}
// isProjectMember is return type of isProjectMember service method.
type isProjectMember struct {
project *Project
membership *ProjectMember
}
// isProjectOwner checks if the user is an owner of a project.
func (s *Service) isProjectOwner(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (isOwner bool, err error) {
defer mon.Task()(&ctx)(&err)
project, err := s.store.Projects().Get(ctx, projectID)
if err != nil {
return false, err
}
if project.OwnerID != userID {
return false, ErrUnauthorized.New(unauthorizedErrMsg)
}
return true, nil
}
// isProjectMember checks if the user is a member of given project.
func (s *Service) isProjectMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (_ isProjectMember, err error) {
defer mon.Task()(&ctx)(&err)
project, err := s.store.Projects().Get(ctx, projectID)
if err != nil {
return isProjectMember{}, Error.Wrap(err)
}
memberships, err := s.store.ProjectMembers().GetByMemberID(ctx, userID)
if err != nil {
return isProjectMember{}, Error.Wrap(err)
}
membership, ok := findMembershipByProjectID(memberships, projectID)
if ok {
return isProjectMember{
project: project,
membership: &membership,
}, nil
}
return isProjectMember{}, ErrNoMembership.New(unauthorizedErrMsg)
}
// WalletInfo contains all the information about a destination wallet assigned to a user.
type WalletInfo struct {
Address blockchain.Address `json:"address"`
Balance string `json:"balance"`
}
// PaymentInfo includes token payment information required by GUI.
type PaymentInfo struct {
ID string
Type string
Wallet string
Amount monetary.Amount
Received monetary.Amount
Status string
Link string
Timestamp time.Time
}
// WalletPayments represents the list of ERC-20 token payments.
type WalletPayments struct {
Payments []PaymentInfo `json:"payments"`
}
// EtherscanURL creates etherscan transaction URI.
func EtherscanURL(tx string) string {
return "https://etherscan.io/tx/" + tx
}
// ErrWalletNotClaimed shows that no address is claimed by the user.
var ErrWalletNotClaimed = errs.Class("wallet is not claimed")
// ClaimWallet requests a new wallet for the users to be used for payments. If wallet is already claimed,
// it will return with the info without error.
func (payment Payments) ClaimWallet(ctx context.Context) (_ WalletInfo, err error) {
defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "claim wallet")
if err != nil {
return WalletInfo{}, Error.Wrap(err)
}
address, err := payment.service.depositWallets.Claim(ctx, user.ID)
if err != nil {
return WalletInfo{}, Error.Wrap(err)
}
balance, err := payment.service.billing.GetBalance(ctx, user.ID)
if err != nil {
return WalletInfo{}, Error.Wrap(err)
}
return WalletInfo{
Address: address,
Balance: balance.AsDecimal().String(),
}, nil
}
// GetWallet returns with the assigned wallet, or with ErrWalletNotClaimed if not yet claimed.
func (payment Payments) GetWallet(ctx context.Context) (_ WalletInfo, err error) {
defer mon.Task()(&ctx)(&err)
user, err := GetUser(ctx)
if err != nil {
return WalletInfo{}, Error.Wrap(err)
}
address, err := payment.service.depositWallets.Get(ctx, user.ID)
if err != nil {
return WalletInfo{}, Error.Wrap(err)
}
balance, err := payment.service.billing.GetBalance(ctx, user.ID)
if err != nil {
return WalletInfo{}, Error.Wrap(err)
}
return WalletInfo{
Address: address,
Balance: balance.AsDecimal().String(),
}, nil
}
// WalletPayments returns with all the native blockchain payments for a user's wallet.
func (payment Payments) WalletPayments(ctx context.Context) (_ WalletPayments, err error) {
defer mon.Task()(&ctx)(&err)
user, err := GetUser(ctx)
if err != nil {
return WalletPayments{}, Error.Wrap(err)
}
address, err := payment.service.depositWallets.Get(ctx, user.ID)
if err != nil {
return WalletPayments{}, Error.Wrap(err)
}
walletPayments, err := payment.service.depositWallets.Payments(ctx, address, 3000, 0)
if err != nil {
return WalletPayments{}, Error.Wrap(err)
}
txInfos, err := payment.service.accounts.StorjTokens().ListTransactionInfos(ctx, user.ID)
if err != nil {
return WalletPayments{}, Error.Wrap(err)
}
var paymentInfos []PaymentInfo
for _, walletPayment := range walletPayments {
paymentInfos = append(paymentInfos, PaymentInfo{
ID: fmt.Sprintf("%s#%d", walletPayment.Transaction.Hex(), walletPayment.LogIndex),
Type: "storjscan",
Wallet: walletPayment.To.Hex(),
Amount: walletPayment.USDValue,
Status: string(walletPayment.Status),
Link: EtherscanURL(walletPayment.Transaction.Hex()),
Timestamp: walletPayment.Timestamp,
})
}
for _, txInfo := range txInfos {
paymentInfos = append(paymentInfos, PaymentInfo{
ID: txInfo.ID.String(),
Type: "coinpayments",
Wallet: txInfo.Address,
Amount: monetary.AmountFromBaseUnits(txInfo.AmountCents, monetary.USDollars),
Received: monetary.AmountFromBaseUnits(txInfo.ReceivedCents, monetary.USDollars),
Status: txInfo.Status.String(),
Link: txInfo.Link,
Timestamp: txInfo.CreatedAt.UTC(),
})
}
return WalletPayments{
Payments: paymentInfos,
}, nil
}
func findMembershipByProjectID(memberships []ProjectMember, projectID uuid.UUID) (ProjectMember, bool) {
for _, membership := range memberships {
if membership.ProjectID == projectID {
return membership, true
}
}
return ProjectMember{}, false
}
// DeleteSession removes the session from the database.
func (s *Service) DeleteSession(ctx context.Context, sessionID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
return Error.Wrap(s.store.WebappSessions().DeleteBySessionID(ctx, sessionID))
}
// RefreshSession resets the expiration time of the session.
func (s *Service) RefreshSession(ctx context.Context, sessionID uuid.UUID) (expiresAt time.Time, err error) {
defer mon.Task()(&ctx)(&err)
_, err = s.getUserAndAuditLog(ctx, "refresh session")
if err != nil {
return time.Time{}, Error.Wrap(err)
}
expiresAt = time.Now().Add(time.Duration(s.config.Session.InactivityTimerDuration) * time.Second)
err = s.store.WebappSessions().UpdateExpiration(ctx, sessionID, expiresAt)
if err != nil {
return time.Time{}, err
}
return expiresAt, nil
}