diff --git a/cmd/satellite/admin.go b/cmd/satellite/admin.go
index 072c74067..56e092372 100644
--- a/cmd/satellite/admin.go
+++ b/cmd/satellite/admin.go
@@ -11,6 +11,8 @@ import (
"storj.io/private/process"
"storj.io/private/version"
"storj.io/storj/satellite"
+ "storj.io/storj/satellite/accounting"
+ "storj.io/storj/satellite/accounting/live"
"storj.io/storj/satellite/metabase"
"storj.io/storj/satellite/satellitedb"
)
@@ -47,7 +49,21 @@ func cmdAdminRun(cmd *cobra.Command, args []string) (err error) {
err = errs.Combine(err, metabaseDB.Close())
}()
- peer, err := satellite.NewAdmin(log, identity, db, metabaseDB, version.Build, &runCfg.Config, process.AtomicLevel(cmd))
+ accountingCache, err := live.OpenCache(ctx, log.Named("live-accounting"), runCfg.LiveAccounting)
+ if err != nil {
+ if !accounting.ErrSystemOrNetError.Has(err) || accountingCache == nil {
+ return errs.New("Error instantiating live accounting cache: %w", err)
+ }
+
+ log.Warn("Unable to connect to live accounting cache. Verify connection",
+ zap.Error(err),
+ )
+ }
+ defer func() {
+ err = errs.Combine(err, accountingCache.Close())
+ }()
+
+ peer, err := satellite.NewAdmin(log, identity, db, metabaseDB, accountingCache, version.Build, &runCfg.Config, process.AtomicLevel(cmd))
if err != nil {
return err
}
diff --git a/private/testplanet/satellite.go b/private/testplanet/satellite.go
index 041c69a82..d31a50ccc 100644
--- a/private/testplanet/satellite.go
+++ b/private/testplanet/satellite.go
@@ -695,7 +695,13 @@ func (planet *Planet) newAdmin(ctx context.Context, index int, identity *identit
prefix := "satellite-admin" + strconv.Itoa(index)
log := planet.log.Named(prefix)
- return satellite.NewAdmin(log, identity, db, metabaseDB, versionInfo, &config, nil)
+ liveAccounting, err := live.OpenCache(ctx, log.Named("live-accounting"), config.LiveAccounting)
+ if err != nil {
+ return nil, errs.Wrap(err)
+ }
+ planet.databases = append(planet.databases, liveAccounting)
+
+ return satellite.NewAdmin(log, identity, db, metabaseDB, liveAccounting, versionInfo, &config, nil)
}
func (planet *Planet) newRepairer(ctx context.Context, index int, identity *identity.FullIdentity, db satellite.DB, metabaseDB *metabase.DB, config satellite.Config, versionInfo version.Info) (_ *satellite.Repairer, err error) {
diff --git a/satellite/admin.go b/satellite/admin.go
index 443f1d7a2..4157d65d4 100644
--- a/satellite/admin.go
+++ b/satellite/admin.go
@@ -20,7 +20,9 @@ import (
"storj.io/private/version"
"storj.io/storj/private/lifecycle"
"storj.io/storj/private/version/checker"
+ "storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/admin"
+ backoffice "storj.io/storj/satellite/admin/back-office"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/buckets"
"storj.io/storj/satellite/console"
@@ -66,6 +68,7 @@ type Admin struct {
Admin struct {
Listener net.Listener
Server *admin.Server
+ Service *backoffice.Service
}
Buckets struct {
@@ -79,11 +82,23 @@ type Admin struct {
FreezeAccounts struct {
Service *console.AccountFreezeService
}
+
+ LiveAccounting struct {
+ Cache accounting.Cache
+ }
+
+ ProjectLimits struct {
+ Cache *accounting.ProjectLimitCache
+ }
+
+ Accounting struct {
+ Service *accounting.Service
+ }
}
// NewAdmin creates a new satellite admin peer.
func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *metabase.DB,
- versionInfo version.Info, config *Config, atomicLogLevel *zap.AtomicLevel) (*Admin, error) {
+ liveAccounting accounting.Cache, versionInfo version.Info, config *Config, atomicLogLevel *zap.AtomicLevel) (*Admin, error) {
peer := &Admin{
Log: log,
Identity: full,
@@ -208,7 +223,31 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
peer.Payments.Accounts = peer.Payments.Service.Accounts()
}
- { // setup admin endpoint
+ { // setup live accounting
+ peer.LiveAccounting.Cache = liveAccounting
+ }
+
+ { // setup project limits
+ peer.ProjectLimits.Cache = accounting.NewProjectLimitCache(peer.DB.ProjectAccounting(),
+ config.Console.Config.UsageLimits.Storage.Free,
+ config.Console.Config.UsageLimits.Bandwidth.Free,
+ config.Console.Config.UsageLimits.Segment.Free,
+ config.ProjectLimit,
+ )
+ }
+
+ { // setup accounting project usage
+ peer.Accounting.Service = accounting.NewService(
+ peer.DB.ProjectAccounting(),
+ peer.LiveAccounting.Cache,
+ peer.ProjectLimits.Cache,
+ *metabaseDB,
+ config.LiveAccounting.BandwidthCacheTTL,
+ config.LiveAccounting.AsOfSystemInterval,
+ )
+ }
+
+ { // setup admin
var err error
peer.Admin.Listener, err = net.Listen("tcp", config.Admin.Address)
if err != nil {
@@ -220,6 +259,14 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
return nil, err
}
+ peer.Admin.Service = backoffice.NewService(
+ log.Named("back-office:service"),
+ peer.DB.Console(),
+ peer.DB.ProjectAccounting(),
+ peer.Accounting.Service,
+ placement,
+ )
+
adminConfig := config.Admin
adminConfig.AuthorizationToken = config.Console.AuthToken
@@ -232,7 +279,7 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
peer.FreezeAccounts.Service,
peer.Analytics.Service,
peer.Payments.Accounts,
- placement,
+ peer.Admin.Service,
config.Console,
adminConfig,
)
diff --git a/satellite/admin/back-office/api-docs.gen.md b/satellite/admin/back-office/api-docs.gen.md
index 58577cf4b..2c8566ceb 100644
--- a/satellite/admin/back-office/api-docs.gen.md
+++ b/satellite/admin/back-office/api-docs.gen.md
@@ -6,6 +6,8 @@
* PlacementManagement
* [Get placements](#placementmanagement-get-placements)
+* UserManagement
+ * [Get user](#usermanagement-get-user)
@@ -26,3 +28,45 @@ Gets placement rule IDs and their locations
```
+
+
+Gets user by email address
+
+`GET /back-office/api/v1/users/{email}`
+
+**Path Params:**
+
+| name | type | elaboration |
+|---|---|---|
+| `email` | `string` | |
+
+**Response body:**
+
+```typescript
+{
+ id: string // UUID formatted as `00000000-0000-0000-0000-000000000000`
+ fullName: string
+ email: string
+ paidTier: boolean
+ createdAt: string // Date timestamp formatted as `2006-01-02T15:00:00Z`
+ status: string
+ userAgent: string
+ defaultPlacement: number
+ projectUsageLimits: [
+ {
+ id: string // UUID formatted as `00000000-0000-0000-0000-000000000000`
+ name: string
+ storageLimit: number
+ storageUsed: number
+ bandwidthLimit: number
+ bandwidthUsed: number
+ segmentLimit: number
+ segmentUsed: number
+ }
+
+ ]
+
+}
+
+```
+
diff --git a/satellite/admin/back-office/gen/main.go b/satellite/admin/back-office/gen/main.go
index ce3e0d25a..dbeb41bfa 100644
--- a/satellite/admin/back-office/gen/main.go
+++ b/satellite/admin/back-office/gen/main.go
@@ -34,6 +34,19 @@ func main() {
Response: []backoffice.PlacementInfo{},
})
+ group = api.Group("UserManagement", "users")
+
+ group.Get("/{email}", &apigen.Endpoint{
+ Name: "Get user",
+ Description: "Gets user by email address",
+ GoName: "GetUserByEmail",
+ TypeScriptName: "getUserByEmail",
+ PathParams: []apigen.Param{
+ apigen.NewParam("email", ""),
+ },
+ Response: backoffice.User{},
+ })
+
modroot := findModuleRootDir()
api.MustWriteGo(filepath.Join(modroot, "satellite", "admin", "back-office", "handlers.gen.go"))
api.MustWriteTS(filepath.Join(modroot, "satellite", "admin", "back-office", "ui", "src", "api", "client.gen.ts"))
diff --git a/satellite/admin/back-office/handlers.gen.go b/satellite/admin/back-office/handlers.gen.go
index 53affda77..ec286af73 100644
--- a/satellite/admin/back-office/handlers.gen.go
+++ b/satellite/admin/back-office/handlers.gen.go
@@ -17,11 +17,16 @@ import (
)
var ErrPlacementsAPI = errs.Class("admin placements api")
+var ErrUsersAPI = errs.Class("admin users api")
type PlacementManagementService interface {
GetPlacements(ctx context.Context) ([]PlacementInfo, api.HTTPError)
}
+type UserManagementService interface {
+ GetUserByEmail(ctx context.Context, email string) (*User, api.HTTPError)
+}
+
// PlacementManagementHandler is an api handler that implements all PlacementManagement API endpoints functionality.
type PlacementManagementHandler struct {
log *zap.Logger
@@ -29,6 +34,13 @@ type PlacementManagementHandler struct {
service PlacementManagementService
}
+// UserManagementHandler is an api handler that implements all UserManagement API endpoints functionality.
+type UserManagementHandler struct {
+ log *zap.Logger
+ mon *monkit.Scope
+ service UserManagementService
+}
+
func NewPlacementManagement(log *zap.Logger, mon *monkit.Scope, service PlacementManagementService, router *mux.Router) *PlacementManagementHandler {
handler := &PlacementManagementHandler{
log: log,
@@ -42,6 +54,19 @@ func NewPlacementManagement(log *zap.Logger, mon *monkit.Scope, service Placemen
return handler
}
+func NewUserManagement(log *zap.Logger, mon *monkit.Scope, service UserManagementService, router *mux.Router) *UserManagementHandler {
+ handler := &UserManagementHandler{
+ log: log,
+ mon: mon,
+ service: service,
+ }
+
+ usersRouter := router.PathPrefix("/back-office/api/v1/users").Subrouter()
+ usersRouter.HandleFunc("/{email}", handler.handleGetUserByEmail).Methods("GET")
+
+ return handler
+}
+
func (h *PlacementManagementHandler) handleGetPlacements(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
@@ -60,3 +85,28 @@ func (h *PlacementManagementHandler) handleGetPlacements(w http.ResponseWriter,
h.log.Debug("failed to write json GetPlacements response", zap.Error(ErrPlacementsAPI.Wrap(err)))
}
}
+
+func (h *UserManagementHandler) handleGetUserByEmail(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ var err error
+ defer h.mon.Task()(&ctx)(&err)
+
+ w.Header().Set("Content-Type", "application/json")
+
+ email, ok := mux.Vars(r)["email"]
+ if !ok {
+ api.ServeError(h.log, w, http.StatusBadRequest, errs.New("missing email route param"))
+ return
+ }
+
+ retVal, httpErr := h.service.GetUserByEmail(ctx, email)
+ if httpErr.Err != nil {
+ api.ServeError(h.log, w, httpErr.Status, httpErr.Err)
+ return
+ }
+
+ err = json.NewEncoder(w).Encode(retVal)
+ if err != nil {
+ h.log.Debug("failed to write json GetUserByEmail response", zap.Error(ErrUsersAPI.Wrap(err)))
+ }
+}
diff --git a/satellite/admin/back-office/placements.go b/satellite/admin/back-office/placements.go
index a846734da..324886ec4 100644
--- a/satellite/admin/back-office/placements.go
+++ b/satellite/admin/back-office/placements.go
@@ -18,7 +18,7 @@ type PlacementInfo struct {
}
// GetPlacements returns IDs and locations of placement rules.
-func (s *Server) GetPlacements(ctx context.Context) ([]PlacementInfo, api.HTTPError) {
+func (s *Service) GetPlacements(ctx context.Context) ([]PlacementInfo, api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
diff --git a/satellite/admin/back-office/server.go b/satellite/admin/back-office/server.go
index 7512eb72d..b088f4ef9 100644
--- a/satellite/admin/back-office/server.go
+++ b/satellite/admin/back-office/server.go
@@ -17,7 +17,6 @@ import (
"storj.io/common/errs2"
ui "storj.io/storj/satellite/admin/back-office/ui"
- "storj.io/storj/satellite/overlay"
)
// PathPrefix is the path that will be prefixed to the router passed to the NewServer constructor.
@@ -44,9 +43,8 @@ type Config struct {
// Server serves the API endpoints and the web application to allow preforming satellite
// administration tasks.
type Server struct {
- log *zap.Logger
- listener net.Listener
- placement *overlay.PlacementDefinitions
+ log *zap.Logger
+ listener net.Listener
config Config
@@ -60,15 +58,14 @@ type Server struct {
func NewServer(
log *zap.Logger,
listener net.Listener,
- placement *overlay.PlacementDefinitions,
+ service *Service,
root *mux.Router,
config Config,
) *Server {
server := &Server{
- log: log,
- listener: listener,
- placement: placement,
- config: config,
+ log: log,
+ listener: listener,
+ config: config,
}
if root == nil {
@@ -77,7 +74,8 @@ func NewServer(
// API endpoints.
// API generator already add the PathPrefix.
- NewPlacementManagement(log, mon, server, root)
+ NewPlacementManagement(log, mon, service, root)
+ NewUserManagement(log, mon, service, root)
root = root.PathPrefix(PathPrefix).Subrouter()
// Static assets for the web interface.
diff --git a/satellite/admin/back-office/service.go b/satellite/admin/back-office/service.go
new file mode 100644
index 000000000..f8061d602
--- /dev/null
+++ b/satellite/admin/back-office/service.go
@@ -0,0 +1,38 @@
+// Copyright (C) 2023 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+package admin
+
+import (
+ "go.uber.org/zap"
+
+ "storj.io/storj/satellite/accounting"
+ "storj.io/storj/satellite/console"
+ "storj.io/storj/satellite/overlay"
+)
+
+// Service provides functionality for administrating satellites.
+type Service struct {
+ log *zap.Logger
+ consoleDB console.DB
+ accountingDB accounting.ProjectAccounting
+ accounting *accounting.Service
+ placement *overlay.PlacementDefinitions
+}
+
+// NewService creates a new satellite administration service.
+func NewService(
+ log *zap.Logger,
+ consoleDB console.DB,
+ accountingDB accounting.ProjectAccounting,
+ accounting *accounting.Service,
+ placement *overlay.PlacementDefinitions,
+) *Service {
+ return &Service{
+ log: log,
+ consoleDB: consoleDB,
+ accountingDB: accountingDB,
+ accounting: accounting,
+ placement: placement,
+ }
+}
diff --git a/satellite/admin/back-office/ui/src/api/client.gen.ts b/satellite/admin/back-office/ui/src/api/client.gen.ts
index 825f15e57..9bff6bbcf 100644
--- a/satellite/admin/back-office/ui/src/api/client.gen.ts
+++ b/satellite/admin/back-office/ui/src/api/client.gen.ts
@@ -2,12 +2,36 @@
// DO NOT EDIT.
import { HttpClient } from '@/utils/httpClient';
+import { Time, UUID } from '@/types/common';
export class PlacementInfo {
id: number;
location: string;
}
+export class ProjectUsageLimits {
+ id: UUID;
+ name: string;
+ storageLimit: number;
+ storageUsed: number | null;
+ bandwidthLimit: number;
+ bandwidthUsed: number;
+ segmentLimit: number;
+ segmentUsed: number | null;
+}
+
+export class User {
+ id: UUID;
+ fullName: string;
+ email: string;
+ paidTier: boolean;
+ createdAt: Time;
+ status: string;
+ userAgent: string;
+ defaultPlacement: number;
+ projectUsageLimits: ProjectUsageLimits[] | null;
+}
+
class APIError extends Error {
constructor(
public readonly msg: string,
@@ -31,3 +55,18 @@ export class PlacementManagementHttpApiV1 {
throw new APIError(err.error, response.status);
}
}
+
+export class UserManagementHttpApiV1 {
+ private readonly http: HttpClient = new HttpClient();
+ private readonly ROOT_PATH: string = '/back-office/api/v1/users';
+
+ public async getUserByEmail(email: string): Promise {
+ const fullPath = `${this.ROOT_PATH}/${email}`;
+ const response = await this.http.get(fullPath);
+ if (response.ok) {
+ return response.json().then((body) => body as User);
+ }
+ const err = await response.json();
+ throw new APIError(err.error, response.status);
+ }
+}
diff --git a/satellite/admin/back-office/ui/src/types/common.ts b/satellite/admin/back-office/ui/src/types/common.ts
new file mode 100644
index 000000000..8bc24d0cd
--- /dev/null
+++ b/satellite/admin/back-office/ui/src/types/common.ts
@@ -0,0 +1,7 @@
+// Copyright (C) 2023 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+// TODO: fully implement these types and their methods according to their Go counterparts
+export type UUID = string;
+export type MemorySize = string;
+export type Time = string;
diff --git a/satellite/admin/back-office/users.go b/satellite/admin/back-office/users.go
new file mode 100644
index 000000000..a0060cac6
--- /dev/null
+++ b/satellite/admin/back-office/users.go
@@ -0,0 +1,159 @@
+// Copyright (C) 2023 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+package admin
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "net/http"
+ "time"
+
+ "go.uber.org/zap"
+
+ "storj.io/common/storj"
+ "storj.io/common/uuid"
+ "storj.io/storj/private/api"
+ "storj.io/storj/satellite/accounting"
+)
+
+// User holds information about a user account.
+type User struct {
+ ID uuid.UUID `json:"id"`
+ FullName string `json:"fullName"`
+ Email string `json:"email"`
+ PaidTier bool `json:"paidTier"`
+ CreatedAt time.Time `json:"createdAt"`
+ Status string `json:"status"`
+ UserAgent string `json:"userAgent"`
+ DefaultPlacement storj.PlacementConstraint `json:"defaultPlacement"`
+ ProjectUsageLimits []ProjectUsageLimits `json:"projectUsageLimits"`
+}
+
+// ProjectUsageLimits holds project usage limits and current usage.
+// StorageUsed, BandwidthUsed, and SegmentUsed are nil if there was
+// an error connecting to the Redis live accounting cache.
+type ProjectUsageLimits struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ StorageLimit int64 `json:"storageLimit"`
+ StorageUsed *int64 `json:"storageUsed"`
+ BandwidthLimit int64 `json:"bandwidthLimit"`
+ BandwidthUsed int64 `json:"bandwidthUsed"`
+ SegmentLimit int64 `json:"segmentLimit"`
+ SegmentUsed *int64 `json:"segmentUsed"`
+}
+
+// GetUserByEmail returns a verified user by its email address.
+func (s *Service) GetUserByEmail(ctx context.Context, email string) (*User, api.HTTPError) {
+ var err error
+ defer mon.Task()(&ctx)(&err)
+
+ user, err := s.consoleDB.Users().GetByEmail(ctx, email)
+ if err != nil {
+ status := http.StatusInternalServerError
+ if errors.Is(err, sql.ErrNoRows) {
+ status = http.StatusNotFound
+ }
+ return nil, api.HTTPError{
+ Status: status,
+ Err: Error.Wrap(err),
+ }
+ }
+
+ projects, err := s.consoleDB.Projects().GetOwn(ctx, user.ID)
+ if err != nil {
+ return nil, api.HTTPError{
+ Status: http.StatusInternalServerError,
+ Err: Error.Wrap(err),
+ }
+ }
+
+ // We return status 409 in the rare case that a project is deleted
+ // before its limits can be obtained.
+ makeDBErr := func(err error) api.HTTPError {
+ status := http.StatusInternalServerError
+ if errors.Is(err, sql.ErrNoRows) {
+ status = http.StatusConflict
+ }
+ return api.HTTPError{
+ Status: status,
+ Err: Error.Wrap(err),
+ }
+ }
+
+ var cacheErrs []error
+ usageLimits := make([]ProjectUsageLimits, 0, len(projects))
+ for _, project := range projects {
+ usage := ProjectUsageLimits{
+ ID: project.ID,
+ Name: project.Name,
+ }
+
+ storageLimit, err := s.accounting.GetProjectStorageLimit(ctx, project.ID)
+ if err != nil {
+ return nil, makeDBErr(err)
+ }
+ usage.StorageLimit = storageLimit.Int64()
+
+ bandwidthLimit, err := s.accounting.GetProjectBandwidthLimit(ctx, project.ID)
+ if err != nil {
+ return nil, makeDBErr(err)
+ }
+ usage.BandwidthLimit = bandwidthLimit.Int64()
+
+ segmentLimit, err := s.accounting.GetProjectSegmentLimit(ctx, project.ID)
+ if err != nil {
+ return nil, makeDBErr(err)
+ }
+ usage.SegmentLimit = segmentLimit.Int64()
+
+ storageUsed, err := s.accounting.GetProjectStorageTotals(ctx, project.ID)
+ if err == nil {
+ usage.StorageUsed = &storageUsed
+ } else if accounting.ErrSystemOrNetError.Has(err) {
+ cacheErrs = append(cacheErrs, err)
+ } else {
+ return nil, api.HTTPError{
+ Status: http.StatusInternalServerError,
+ Err: Error.Wrap(err),
+ }
+ }
+
+ usage.BandwidthUsed, err = s.accounting.GetProjectBandwidthTotals(ctx, project.ID)
+ if err != nil {
+ return nil, makeDBErr(err)
+ }
+
+ segmentUsed, err := s.accounting.GetProjectSegmentTotals(ctx, project.ID)
+ if err == nil {
+ usage.SegmentUsed = &segmentUsed
+ } else if accounting.ErrSystemOrNetError.Has(err) {
+ cacheErrs = append(cacheErrs, err)
+ } else {
+ return nil, api.HTTPError{
+ Status: http.StatusInternalServerError,
+ Err: Error.Wrap(err),
+ }
+ }
+
+ usageLimits = append(usageLimits, usage)
+ }
+
+ if len(cacheErrs) != 0 {
+ s.log.Warn("Error getting project usage data from live accounting cache", zap.Errors("errors", cacheErrs))
+ }
+
+ return &User{
+ ID: user.ID,
+ FullName: user.FullName,
+ Email: user.Email,
+ PaidTier: user.PaidTier,
+ CreatedAt: user.CreatedAt,
+ Status: user.Status.String(),
+ UserAgent: string(user.UserAgent),
+ DefaultPlacement: user.DefaultPlacement,
+ ProjectUsageLimits: usageLimits,
+ }, api.HTTPError{}
+}
diff --git a/satellite/admin/back-office/users_test.go b/satellite/admin/back-office/users_test.go
new file mode 100644
index 000000000..3babd9de3
--- /dev/null
+++ b/satellite/admin/back-office/users_test.go
@@ -0,0 +1,168 @@
+// Copyright (C) 2023 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+package admin_test
+
+import (
+ "fmt"
+ "net/http"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "storj.io/common/memory"
+ "storj.io/common/pb"
+ "storj.io/common/testcontext"
+ "storj.io/common/testrand"
+ "storj.io/storj/private/testplanet"
+ "storj.io/storj/satellite"
+ "storj.io/storj/satellite/buckets"
+ "storj.io/storj/satellite/console"
+ "storj.io/storj/satellite/metabase"
+ "storj.io/storj/satellite/metabase/metabasetest"
+)
+
+func TestGetUser(t *testing.T) {
+ testplanet.Run(t, testplanet.Config{
+ SatelliteCount: 1,
+ Reconfigure: testplanet.Reconfigure{
+ Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
+ config.LiveAccounting.AsOfSystemInterval = 0
+ },
+ },
+ }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
+ sat := planet.Satellites[0]
+ service := sat.Admin.Admin.Service
+ consoleDB := sat.DB.Console()
+
+ consoleUser := &console.User{
+ ID: testrand.UUID(),
+ FullName: "Test User",
+ Email: "test@storj.io",
+ PasswordHash: testrand.Bytes(8),
+ Status: console.Inactive,
+ UserAgent: []byte("agent"),
+ DefaultPlacement: 5,
+ }
+
+ _, apiErr := service.GetUserByEmail(ctx, consoleUser.Email)
+ require.Equal(t, http.StatusNotFound, apiErr.Status)
+ require.Error(t, apiErr.Err)
+
+ _, err := consoleDB.Users().Insert(ctx, consoleUser)
+ require.NoError(t, err)
+
+ consoleUser.PaidTier = true
+ require.NoError(t, sat.DB.Console().Users().Update(ctx, consoleUser.ID, console.UpdateUserRequest{PaidTier: &consoleUser.PaidTier}))
+
+ _, apiErr = service.GetUserByEmail(ctx, consoleUser.Email)
+ require.Equal(t, http.StatusNotFound, apiErr.Status)
+ require.Error(t, apiErr.Err)
+
+ consoleUser.Status = console.Active
+ require.NoError(t, consoleDB.Users().Update(ctx, consoleUser.ID, console.UpdateUserRequest{Status: &consoleUser.Status}))
+
+ user, apiErr := service.GetUserByEmail(ctx, consoleUser.Email)
+ require.NoError(t, apiErr.Err)
+ require.NotNil(t, user)
+ require.Equal(t, consoleUser.ID, user.ID)
+ require.Equal(t, consoleUser.FullName, user.FullName)
+ require.Equal(t, consoleUser.Email, user.Email)
+ require.Equal(t, consoleUser.PaidTier, user.PaidTier)
+ require.Equal(t, consoleUser.Status.String(), user.Status)
+ require.Equal(t, string(consoleUser.UserAgent), user.UserAgent)
+ require.Equal(t, consoleUser.DefaultPlacement, user.DefaultPlacement)
+ require.Empty(t, user.ProjectUsageLimits)
+
+ type expectedTotal struct {
+ storage int64
+ segments int64
+ bandwidth int64
+ objects int64
+ }
+
+ var projects []*console.Project
+ var expectedTotals []expectedTotal
+
+ for projNum := 1; projNum <= 2; projNum++ {
+ storageLimit := memory.GB * memory.Size(projNum)
+ bandwidthLimit := memory.GB * memory.Size(projNum*2)
+ segmentLimit := int64(1000 * projNum)
+
+ proj := &console.Project{
+ ID: testrand.UUID(),
+ Name: fmt.Sprintf("Project %d", projNum),
+ OwnerID: consoleUser.ID,
+ StorageLimit: &storageLimit,
+ BandwidthLimit: &bandwidthLimit,
+ SegmentLimit: &segmentLimit,
+ }
+ projects = append(projects, proj)
+
+ _, err := consoleDB.Projects().Insert(ctx, proj)
+ require.NoError(t, err)
+
+ _, err = consoleDB.ProjectMembers().Insert(ctx, user.ID, proj.ID)
+ require.NoError(t, err)
+
+ bucket, err := sat.DB.Buckets().CreateBucket(ctx, buckets.Bucket{
+ ID: testrand.UUID(),
+ Name: testrand.BucketName(),
+ ProjectID: proj.ID,
+ })
+ require.NoError(t, err)
+
+ total := expectedTotal{}
+
+ for objNum := 0; objNum < projNum*10; objNum++ {
+ obj := metabasetest.CreateObject(ctx, t, sat.Metabase.DB, metabase.ObjectStream{
+ ProjectID: proj.ID,
+ BucketName: bucket.Name,
+ ObjectKey: metabasetest.RandObjectKey(),
+ Version: 12345,
+ StreamID: testrand.UUID(),
+ }, byte(16*projNum))
+
+ total.storage += obj.TotalEncryptedSize
+ total.segments += int64(obj.SegmentCount)
+ total.objects++
+ }
+
+ testBandwidth := int64(2000 * projNum)
+ err = sat.DB.Orders().UpdateBucketBandwidthAllocation(ctx, proj.ID, []byte(bucket.Name), pb.PieceAction_GET, testBandwidth, time.Now())
+ require.NoError(t, err)
+ total.bandwidth += testBandwidth
+
+ expectedTotals = append(expectedTotals, total)
+ }
+
+ sat.Accounting.Tally.Loop.TriggerWait()
+
+ user, apiErr = service.GetUserByEmail(ctx, consoleUser.Email)
+ require.NoError(t, apiErr.Err)
+ require.NotNil(t, user)
+ require.Len(t, user.ProjectUsageLimits, len(projects))
+
+ sort.Slice(user.ProjectUsageLimits, func(i, j int) bool {
+ return user.ProjectUsageLimits[i].Name < user.ProjectUsageLimits[j].Name
+ })
+
+ for i, info := range user.ProjectUsageLimits {
+ proj := projects[i]
+ name := proj.Name
+ require.Equal(t, proj.ID, info.ID, name)
+ require.Equal(t, name, info.Name, name)
+ require.EqualValues(t, *proj.StorageLimit, info.StorageLimit, name)
+ require.EqualValues(t, *proj.BandwidthLimit, info.BandwidthLimit, name)
+ require.Equal(t, *proj.SegmentLimit, info.SegmentLimit, name)
+
+ total := expectedTotals[i]
+ require.Equal(t, total.storage, *info.StorageUsed, name)
+ require.Equal(t, total.bandwidth, info.BandwidthUsed, name)
+ require.Equal(t, total.segments, *info.SegmentUsed, name)
+ }
+ })
+}
diff --git a/satellite/admin/server.go b/satellite/admin/server.go
index 04bbb571d..227cce003 100644
--- a/satellite/admin/server.go
+++ b/satellite/admin/server.go
@@ -29,7 +29,6 @@ import (
"storj.io/storj/satellite/console/consoleweb"
"storj.io/storj/satellite/console/restkeys"
"storj.io/storj/satellite/oidc"
- "storj.io/storj/satellite/overlay"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/stripe"
)
@@ -106,7 +105,7 @@ func NewServer(
freezeAccounts *console.AccountFreezeService,
analyticsService *analytics.Service,
accounts payments.Accounts,
- placement *overlay.PlacementDefinitions,
+ backOfficeService *backoffice.Service,
console consoleweb.Config,
config Config,
) *Server {
@@ -185,7 +184,13 @@ func NewServer(
// NewServer adds the backoffice.PahtPrefix for the static assets, but not for the API because the
// generator already add the PathPrefix to router when the API handlers are hooked.
- _ = backoffice.NewServer(log.Named("back-office"), nil, placement, root, config.BackOffice)
+ _ = backoffice.NewServer(
+ log.Named("back-office"),
+ nil,
+ backOfficeService,
+ root,
+ config.BackOffice,
+ )
// This handler must be the last one because it uses the root as prefix,
// otherwise will try to serve all the handlers set after this one.
diff --git a/satellite/console/users.go b/satellite/console/users.go
index a13416a17..6e54265e3 100644
--- a/satellite/console/users.go
+++ b/satellite/console/users.go
@@ -174,6 +174,24 @@ const (
LegalHold UserStatus = 4
)
+// String returns a string representation of the user status.
+func (s UserStatus) String() string {
+ switch s {
+ case Inactive:
+ return "Inactive"
+ case Active:
+ return "Active"
+ case Deleted:
+ return "Deleted"
+ case PendingDeletion:
+ return "Pending Deletion"
+ case LegalHold:
+ return "Legal Hold"
+ default:
+ return ""
+ }
+}
+
// User is a database object that describes User entity.
type User struct {
ID uuid.UUID `json:"id"`