satellite/console: return edge URL overrides in project info responses

API responses containing project information now contain the edge
service URL overrides configured for that project. The overrides are
based on the project's default placement.

References #6188

Change-Id: Ifc3dc74e75c0f5daf0419ac3be184415c65b202e
This commit is contained in:
Jeremy Wharton 2023-09-11 22:16:35 -05:00
parent 89d682f49f
commit c8f4f5210d
8 changed files with 294 additions and 82 deletions

View File

@ -36,10 +36,6 @@ func TestAdminProjectGeofenceAPI(t *testing.T) {
project, err := sat.DB.Console().Projects().Get(ctx, uplink.Projects[0].ID)
require.NoError(t, err)
// update project set default placement to EEA
project.DefaultPlacement = storj.EEA
require.NoError(t, sat.DB.Console().Projects().Update(ctx, project))
testCases := []struct {
name string
project uuid.UUID

113
satellite/console/config.go Normal file
View File

@ -0,0 +1,113 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
package console
import (
"encoding/json"
"time"
"github.com/spf13/pflag"
"storj.io/common/storj"
)
// 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"`
ProjectInvitationExpiration time.Duration `help:"duration that project member invitations are valid for" default:"168h"`
UserBalanceForUpgrade int64 `help:"amount of base units of US micro dollars needed to upgrade user's tier status" default:"10000000"`
PlacementEdgeURLOverrides PlacementEdgeURLOverrides `help:"placement-specific edge service URL overrides in the format {\"placementID\": {\"authService\": \"...\", \"publicLinksharing\": \"...\", \"internalLinksharing\": \"...\"}, \"placementID2\": ...}"`
UsageLimits UsageLimitsConfig
Captcha CaptchaConfig
Session SessionConfig
}
// CaptchaConfig contains configurations for login/registration captcha system.
type CaptchaConfig struct {
Login MultiCaptchaConfig `json:"login"`
Registration MultiCaptchaConfig `json:"registration"`
}
// MultiCaptchaConfig contains configurations for Recaptcha and Hcaptcha systems.
type MultiCaptchaConfig struct {
Recaptcha SingleCaptchaConfig `json:"recaptcha"`
Hcaptcha SingleCaptchaConfig `json:"hcaptcha"`
}
// SingleCaptchaConfig contains configurations abstract captcha system.
type SingleCaptchaConfig struct {
Enabled bool `help:"whether or not captcha is enabled" default:"false" json:"enabled"`
SiteKey string `help:"captcha site key" json:"siteKey"`
SecretKey string `help:"captcha secret key" json:"-"`
}
// SessionConfig contains configurations for session management.
type SessionConfig struct {
InactivityTimerEnabled bool `help:"indicates if session can be timed out due inactivity" default:"true"`
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"`
}
// EdgeURLOverrides contains edge service URL overrides.
type EdgeURLOverrides struct {
AuthService string `json:"authService,omitempty"`
PublicLinksharing string `json:"publicLinksharing,omitempty"`
InternalLinksharing string `json:"internalLinksharing,omitempty"`
}
// PlacementEdgeURLOverrides represents a mapping between placement IDs and edge service URL overrides.
type PlacementEdgeURLOverrides struct {
overrideMap map[storj.PlacementConstraint]EdgeURLOverrides
}
// Ensure that PlacementEdgeOverrides implements pflag.Value.
var _ pflag.Value = (*PlacementEdgeURLOverrides)(nil)
// Type implements pflag.Value.
func (PlacementEdgeURLOverrides) Type() string { return "console.PlacementEdgeURLOverrides" }
// String implements pflag.Value.
func (ov *PlacementEdgeURLOverrides) String() string {
if ov == nil || len(ov.overrideMap) == 0 {
return ""
}
overrides, err := json.Marshal(ov.overrideMap)
if err != nil {
return ""
}
return string(overrides)
}
// Set implements pflag.Value.
func (ov *PlacementEdgeURLOverrides) Set(s string) error {
if s == "" {
return nil
}
overrides := make(map[storj.PlacementConstraint]EdgeURLOverrides)
err := json.Unmarshal([]byte(s), &overrides)
if err != nil {
return err
}
ov.overrideMap = overrides
return nil
}
// Get returns the edge service URL overrides for the given placement ID.
func (ov *PlacementEdgeURLOverrides) Get(placement storj.PlacementConstraint) (overrides EdgeURLOverrides, ok bool) {
if ov == nil {
return EdgeURLOverrides{}, false
}
overrides, ok = ov.overrideMap[placement]
return overrides, ok
}

View File

@ -83,7 +83,7 @@ func (p *Projects) GetUserProjects(w http.ResponseWriter, r *http.Request) {
response := make([]console.ProjectInfo, 0)
for _, project := range projects {
response = append(response, project.GetMinimal())
response = append(response, p.service.GetMinimalProject(&project))
}
err = json.NewEncoder(w).Encode(response)
@ -156,7 +156,7 @@ func (p *Projects) GetPagedProjects(w http.ResponseWriter, r *http.Request) {
}
for _, project := range projectsPage.Projects {
pageToSend.Projects = append(pageToSend.Projects, project.GetMinimal())
pageToSend.Projects = append(pageToSend.Projects, p.service.GetMinimalProject(&project))
}
err = json.NewEncoder(w).Encode(pageToSend)
@ -287,7 +287,7 @@ func (p *Projects) CreateProject(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(project.GetMinimal())
err = json.NewEncoder(w).Encode(p.service.GetMinimalProject(project))
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}

View File

@ -14,12 +14,15 @@ import (
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"storj.io/common/memory"
"storj.io/common/storj"
"storj.io/common/testcontext"
"storj.io/common/testrand"
"storj.io/common/uuid"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleweb/consoleapi"
)
@ -373,3 +376,95 @@ func TestDeleteProjectMembers(t *testing.T) {
require.Contains(t, string(body), "error")
})
}
func TestEdgeURLOverrides(t *testing.T) {
var (
noOverridePlacementID storj.PlacementConstraint
partialOverridePlacementID storj.PlacementConstraint = 1
fullOverridePlacementID storj.PlacementConstraint = 2
authServiceURL = "auth.storj.io"
publicLinksharingURL = "public-link.storj.io"
internalLinksharingURL = "link.storj.io"
)
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, UplinkCount: 1,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
err := config.Console.PlacementEdgeURLOverrides.Set(
fmt.Sprintf(
`{
"%d": {"authService": "%s"},
"%d": {
"authService": "%s",
"publicLinksharing": "%s",
"internalLinksharing": "%s"
}
}`,
partialOverridePlacementID, authServiceURL,
fullOverridePlacementID, authServiceURL, publicLinksharingURL, internalLinksharingURL,
),
)
require.NoError(t, err)
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
project, err := sat.DB.Console().Projects().Get(ctx, planet.Uplinks[0].Projects[0].ID)
require.NoError(t, err)
user, err := sat.API.Console.Service.GetUser(ctx, project.OwnerID)
require.NoError(t, err)
for _, tt := range []struct {
name string
placement *storj.PlacementConstraint
expectedEdgeURLs *console.EdgeURLOverrides
}{
{"nil placement", nil, nil},
{"placement with no overrides", &noOverridePlacementID, nil},
{
"placement with partial override",
&partialOverridePlacementID,
&console.EdgeURLOverrides{AuthService: authServiceURL},
}, {
"placement with full override",
&fullOverridePlacementID,
&console.EdgeURLOverrides{
AuthService: authServiceURL,
PublicLinksharing: publicLinksharingURL,
InternalLinksharing: internalLinksharingURL,
},
},
} {
t.Run(tt.name, func(t *testing.T) {
result, err := sat.DB.Testing().RawDB().ExecContext(ctx,
"UPDATE projects SET default_placement = $1 WHERE id = $2",
tt.placement, project.ID,
)
require.NoError(t, err)
count, err := result.RowsAffected()
require.NoError(t, err)
require.EqualValues(t, 1, count)
body, status, err := doRequestWithAuth(ctx, t, sat, user, http.MethodGet, "projects", nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, status)
var infos []console.ProjectInfo
require.NoError(t, json.Unmarshal(body, &infos))
require.NotEmpty(t, infos)
if tt.expectedEdgeURLs == nil {
require.Nil(t, infos[0].EdgeURLOverrides)
return
}
require.NotNil(t, infos[0].EdgeURLOverrides)
require.Equal(t, *tt.expectedEdgeURLs, *infos[0].EdgeURLOverrides)
})
}
})
}

View File

@ -125,12 +125,13 @@ type UpsertProjectInfo struct {
// ProjectInfo holds data sent via user facing http endpoints.
type ProjectInfo struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
OwnerID uuid.UUID `json:"ownerId"`
Description string `json:"description"`
MemberCount int `json:"memberCount"`
CreatedAt time.Time `json:"createdAt"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
OwnerID uuid.UUID `json:"ownerId"`
Description string `json:"description"`
MemberCount int `json:"memberCount"`
CreatedAt time.Time `json:"createdAt"`
EdgeURLOverrides *EdgeURLOverrides `json:"edgeURLOverrides,omitempty"`
}
// ProjectsCursor holds info for project
@ -194,15 +195,3 @@ func ValidateNameAndDescription(name string, description string) error {
return nil
}
// GetMinimal returns a ProjectInfo copy of a project.
func (p Project) GetMinimal() ProjectInfo {
return ProjectInfo{
ID: p.PublicID,
Name: p.Name,
OwnerID: p.OwnerID,
Description: p.Description,
MemberCount: p.MemberCount,
CreatedAt: p.CreatedAt,
}
}

View File

@ -187,48 +187,6 @@ func init() {
}
}
// 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"`
ProjectInvitationExpiration time.Duration `help:"duration that project member invitations are valid for" default:"168h"`
UserBalanceForUpgrade int64 `help:"amount of base units of US micro dollars needed to upgrade user's tier status" default:"10000000"`
UsageLimits UsageLimitsConfig
Captcha CaptchaConfig
Session SessionConfig
}
// CaptchaConfig contains configurations for login/registration captcha system.
type CaptchaConfig struct {
Login MultiCaptchaConfig `json:"login"`
Registration MultiCaptchaConfig `json:"registration"`
}
// MultiCaptchaConfig contains configurations for Recaptcha and Hcaptcha systems.
type MultiCaptchaConfig struct {
Recaptcha SingleCaptchaConfig `json:"recaptcha"`
Hcaptcha SingleCaptchaConfig `json:"hcaptcha"`
}
// SingleCaptchaConfig contains configurations abstract captcha system.
type SingleCaptchaConfig struct {
Enabled bool `help:"whether or not captcha is enabled" default:"false" json:"enabled"`
SiteKey string `help:"captcha site key" json:"siteKey"`
SecretKey string `help:"captcha secret key" json:"-"`
}
// SessionConfig contains configurations for session management.
type SessionConfig struct {
InactivityTimerEnabled bool `help:"indicates if session can be timed out due inactivity" default:"true"`
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
@ -1614,6 +1572,24 @@ func (s *Service) GetUsersProjects(ctx context.Context) (ps []Project, err error
return
}
// GetMinimalProject returns a ProjectInfo copy of a project.
func (s *Service) GetMinimalProject(project *Project) ProjectInfo {
info := ProjectInfo{
ID: project.PublicID,
Name: project.Name,
OwnerID: project.OwnerID,
Description: project.Description,
MemberCount: project.MemberCount,
CreatedAt: project.CreatedAt,
}
if edgeURLs, ok := s.config.PlacementEdgeURLOverrides.Get(project.DefaultPlacement); ok {
info.EdgeURLOverrides = &edgeURLs
}
return info
}
// 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

View File

@ -68,13 +68,22 @@ func (projects *projects) GetByUserID(ctx context.Context, userID uuid.UUID) (_
defer mon.Task()(&ctx)(&err)
rows, err := projects.sdb.Query(ctx, projects.sdb.Rebind(`
SELECT projects.id, projects.public_id, projects.name, projects.description, projects.owner_id, projects.rate_limit, projects.max_buckets, projects.created_at,
SELECT
projects.id,
projects.public_id,
projects.name,
projects.description,
projects.owner_id,
projects.rate_limit,
projects.max_buckets,
projects.created_at,
COALESCE(projects.default_placement, 0),
(SELECT COUNT(*) FROM project_members WHERE project_id = projects.id) AS member_count
FROM projects
JOIN project_members ON projects.id = project_members.project_id
WHERE project_members.member_id = ?
ORDER BY name ASC
`), userID)
FROM projects
JOIN project_members ON projects.id = project_members.project_id
WHERE project_members.member_id = ?
ORDER BY name ASC
`), userID)
if err != nil {
return nil, err
}
@ -84,7 +93,18 @@ func (projects *projects) GetByUserID(ctx context.Context, userID uuid.UUID) (_
var rateLimit, maxBuckets sql.NullInt32
projectsToSend := make([]console.Project, 0)
for rows.Next() {
err = rows.Scan(&nextProject.ID, &nextProject.PublicID, &nextProject.Name, &nextProject.Description, &nextProject.OwnerID, &rateLimit, &maxBuckets, &nextProject.CreatedAt, &nextProject.MemberCount)
err = rows.Scan(
&nextProject.ID,
&nextProject.PublicID,
&nextProject.Name,
&nextProject.Description,
&nextProject.OwnerID,
&rateLimit,
&maxBuckets,
&nextProject.CreatedAt,
&nextProject.DefaultPlacement,
&nextProject.MemberCount,
)
if err != nil {
return nil, err
}
@ -366,14 +386,23 @@ func (projects *projects) ListByOwnerID(ctx context.Context, ownerID uuid.UUID,
}
rows, err := projects.sdb.Query(ctx, projects.sdb.Rebind(`
SELECT id, public_id, name, description, owner_id, rate_limit, max_buckets, created_at,
SELECT
id,
public_id,
name,
description,
owner_id,
rate_limit,
max_buckets,
created_at,
COALESCE(default_placement, 0),
(SELECT COUNT(*) FROM project_members WHERE project_id = projects.id) AS member_count
FROM projects
WHERE owner_id = ?
ORDER BY name ASC
OFFSET ? ROWS
LIMIT ?
`), ownerID, page.Offset, page.Limit+1) // add 1 to limit to see if there is another page
FROM projects
WHERE owner_id = ?
ORDER BY name ASC
OFFSET ? ROWS
LIMIT ?
`), ownerID, page.Offset, page.Limit+1) // add 1 to limit to see if there is another page
if err != nil {
return console.ProjectsPage{}, err
}
@ -391,7 +420,18 @@ func (projects *projects) ListByOwnerID(ctx context.Context, ownerID uuid.UUID,
}
var rateLimit, maxBuckets sql.NullInt32
nextProject := &console.Project{}
err = rows.Scan(&nextProject.ID, &nextProject.PublicID, &nextProject.Name, &nextProject.Description, &nextProject.OwnerID, &rateLimit, &maxBuckets, &nextProject.CreatedAt, &nextProject.MemberCount)
err = rows.Scan(
&nextProject.ID,
&nextProject.PublicID,
&nextProject.Name,
&nextProject.Description,
&nextProject.OwnerID,
&rateLimit,
&maxBuckets,
&nextProject.CreatedAt,
&nextProject.DefaultPlacement,
&nextProject.MemberCount,
)
if err != nil {
return console.ProjectsPage{}, err
}

View File

@ -337,6 +337,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# indicates if the overview onboarding step should render with pathways
# console.pathway-overview-enabled: true
# placement-specific edge service URL overrides in the format {"placementID": {"authService": "...", "publicLinksharing": "...", "internalLinksharing": "..."}, "placementID2": ...}
# console.placement-edge-url-overrides: ""
# whether to allow purchasing pricing packages
# console.pricing-packages-enabled: false