storj/satellite/admin/server.go
Ivan Fraixedes e1c12674c5 satellite/admin: Fix API key delete by name
We couldn't delete API keys by name whose name contained slashes because
Gorilla Mux router interpreted the as path separator and didn't resolve
to the right endpoint.

To fix the issue the name is sent as a query parameter rather than as a
path parameter.

Change-Id: Ica67d6b9f047d7c33a5350457afc822cb8d4c7a1
2023-12-04 20:03:30 +00:00

314 lines
12 KiB
Go

// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
// Package admin implements administrative endpoints for satellite.
package admin
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"storj.io/common/errs2"
"storj.io/storj/satellite/accounting"
backoffice "storj.io/storj/satellite/admin/back-office"
adminui "storj.io/storj/satellite/admin/ui"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/attribution"
"storj.io/storj/satellite/buckets"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleweb"
"storj.io/storj/satellite/console/restkeys"
"storj.io/storj/satellite/oidc"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/stripe"
)
const (
// UnauthorizedNotInGroup - message for when api user is not part of a required access group.
UnauthorizedNotInGroup = "User must be a member of one of these groups to conduct this operation: %s"
// AuthorizationNotEnabled - message for when authorization is disabled.
AuthorizationNotEnabled = "Authorization not enabled."
// BackOfficePathPrefix is the path prefix used for the back office router.
BackOfficePathPrefix = "/back-office"
)
// Config defines configuration for debug server.
type Config struct {
Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""`
StaticDir string `help:"an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""`
AllowedOauthHost string `help:"the oauth host allowed to bypass token authentication."`
Groups Groups
AuthorizationToken string `internal:"true"`
BackOffice backoffice.Config
}
// Groups defines permission groups.
type Groups struct {
LimitUpdate string `help:"the group which is only allowed to update user and project limits and freeze and unfreeze accounts."`
}
// DB is databases needed for the admin server.
type DB interface {
// ProjectAccounting returns database for storing information about project data use
ProjectAccounting() accounting.ProjectAccounting
// Console returns database for satellite console
Console() console.DB
// OIDC returns the database for OIDC and OAuth information.
OIDC() oidc.DB
// StripeCoinPayments returns database for satellite stripe coin payments
StripeCoinPayments() stripe.DB
// Buckets returns database for buckets metainfo.
Buckets() buckets.DB
// Attribution returns database for value attribution.
Attribution() attribution.DB
}
// Server provides endpoints for administrative tasks.
type Server struct {
log *zap.Logger
listener net.Listener
server http.Server
db DB
payments payments.Accounts
buckets *buckets.Service
restKeys *restkeys.Service
analytics *analytics.Service
freezeAccounts *console.AccountFreezeService
nowFn func() time.Time
console consoleweb.Config
config Config
}
// NewServer returns a new administration Server.
func NewServer(
log *zap.Logger,
listener net.Listener,
db DB,
buckets *buckets.Service,
restKeys *restkeys.Service,
freezeAccounts *console.AccountFreezeService,
analyticsService *analytics.Service,
accounts payments.Accounts,
backOfficeService *backoffice.Service,
console consoleweb.Config,
config Config,
) *Server {
server := &Server{
log: log,
listener: listener,
db: db,
payments: accounts,
buckets: buckets,
restKeys: restKeys,
analytics: analyticsService,
freezeAccounts: freezeAccounts,
nowFn: time.Now,
console: console,
config: config,
}
root := mux.NewRouter()
api := root.PathPrefix("/api/").Subrouter()
// When adding new options, also update README.md
// prod owners only
fullAccessAPI := api.NewRoute().Subrouter()
fullAccessAPI.Use(server.withAuth([]string{config.Groups.LimitUpdate}, true))
fullAccessAPI.HandleFunc("/users", server.addUser).Methods("POST")
fullAccessAPI.HandleFunc("/users/{useremail}", server.updateUser).Methods("PUT")
fullAccessAPI.HandleFunc("/users/{useremail}", server.deleteUser).Methods("DELETE")
fullAccessAPI.HandleFunc("/users/{useremail}/mfa", server.disableUserMFA).Methods("DELETE")
fullAccessAPI.HandleFunc("/users/{useremail}/activate-account/disable-bot-restriction", server.disableBotRestriction).Methods("PATCH")
fullAccessAPI.HandleFunc("/users/{useremail}/useragent", server.updateUsersUserAgent).Methods("PATCH")
fullAccessAPI.HandleFunc("/users/{useremail}/geofence", server.createGeofenceForAccount).Methods("PATCH")
fullAccessAPI.HandleFunc("/users/{useremail}/geofence", server.deleteGeofenceForAccount).Methods("DELETE")
fullAccessAPI.HandleFunc("/oauth/clients", server.createOAuthClient).Methods("POST")
fullAccessAPI.HandleFunc("/oauth/clients/{id}", server.updateOAuthClient).Methods("PUT")
fullAccessAPI.HandleFunc("/oauth/clients/{id}", server.deleteOAuthClient).Methods("DELETE")
fullAccessAPI.HandleFunc("/projects", server.addProject).Methods("POST")
fullAccessAPI.HandleFunc("/projects/{project}", server.renameProject).Methods("PUT")
fullAccessAPI.HandleFunc("/projects/{project}", server.deleteProject).Methods("DELETE")
fullAccessAPI.HandleFunc("/projects/{project}", server.getProject).Methods("GET")
fullAccessAPI.HandleFunc("/projects/{project}/apikeys", server.addAPIKey).Methods("POST")
fullAccessAPI.HandleFunc("/projects/{project}/apikeys", server.listAPIKeys).Methods("GET")
fullAccessAPI.HandleFunc("/projects/{project}/apikeys", server.deleteAPIKeyByName).Methods("DELETE").Queries("name", "")
fullAccessAPI.HandleFunc("/projects/{project}/buckets/{bucket}", server.getBucketInfo).Methods("GET")
fullAccessAPI.HandleFunc("/projects/{project}/buckets/{bucket}/geofence", server.createGeofenceForBucket).Methods("POST")
fullAccessAPI.HandleFunc("/projects/{project}/buckets/{bucket}/geofence", server.deleteGeofenceForBucket).Methods("DELETE")
fullAccessAPI.HandleFunc("/projects/{project}/usage", server.checkProjectUsage).Methods("GET")
fullAccessAPI.HandleFunc("/projects/{project}/useragent", server.updateProjectsUserAgent).Methods("PATCH")
fullAccessAPI.HandleFunc("/projects/{project}/geofence", server.createGeofenceForProject).Methods("POST")
fullAccessAPI.HandleFunc("/projects/{project}/geofence", server.deleteGeofenceForProject).Methods("DELETE")
fullAccessAPI.HandleFunc("/apikeys/{apikey}", server.getAPIKey).Methods("GET")
fullAccessAPI.HandleFunc("/apikeys/{apikey}", server.deleteAPIKey).Methods("DELETE")
fullAccessAPI.HandleFunc("/restkeys/{useremail}", server.addRESTKey).Methods("POST")
fullAccessAPI.HandleFunc("/restkeys/{apikey}/revoke", server.revokeRESTKey).Methods("PUT")
// limit update access required
limitUpdateAPI := api.NewRoute().Subrouter()
limitUpdateAPI.Use(server.withAuth([]string{config.Groups.LimitUpdate}, false))
limitUpdateAPI.HandleFunc("/users/{useremail}", server.userInfo).Methods("GET")
limitUpdateAPI.HandleFunc("/users/{useremail}/limits", server.userLimits).Methods("GET")
limitUpdateAPI.HandleFunc("/users/{useremail}/limits", server.updateLimits).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/billing-freeze", server.billingFreezeUser).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/billing-freeze", server.billingUnfreezeUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/users/{useremail}/billing-warning", server.billingUnWarnUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationFreezeUser).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationUnfreezeUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/users/{useremail}/legal-freeze", server.legalFreezeUser).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/legal-freeze", server.legalUnfreezeUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/users/pending-deletion", server.usersPendingDeletion).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST")
// 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,
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.
if config.StaticDir == "" {
root.PathPrefix("/").Handler(http.FileServer(http.FS(adminui.Assets))).Methods("GET")
} else {
root.PathPrefix("/").Handler(http.FileServer(http.Dir(config.StaticDir))).Methods("GET")
}
server.server.Handler = root
return server
}
// Run starts the admin endpoint.
func (server *Server) Run(ctx context.Context) error {
if server.listener == nil {
return nil
}
ctx, cancel := context.WithCancel(ctx)
var group errgroup.Group
group.Go(func() error {
<-ctx.Done()
return Error.Wrap(server.server.Shutdown(context.Background()))
})
group.Go(func() error {
defer cancel()
err := server.server.Serve(server.listener)
if errs2.IsCanceled(err) || errors.Is(err, http.ErrServerClosed) {
err = nil
}
return Error.Wrap(err)
})
return group.Wait()
}
// SetNow allows tests to have the server act as if the current time is whatever they want.
func (server *Server) SetNow(nowFn func() time.Time) {
server.nowFn = nowFn
}
// Close closes server and underlying listener.
func (server *Server) Close() error {
return Error.Wrap(server.server.Close())
}
// SetAllowedOauthHost allows tests to set which address to recognize as belonging to the OAuth proxy.
func (server *Server) SetAllowedOauthHost(host string) {
server.config.AllowedOauthHost = host
}
// withAuth checks if the requester is authorized to perform an operation. If the request did not come from the oauth proxy, verify the auth token.
// Otherwise, check that the user has the required permissions to conduct the operation. `allowedGroups` is a list of groups that are authorized.
// If it is nil, then the api method is not accessible from the oauth proxy.
func (server *Server) withAuth(allowedGroups []string, requireAPIKey bool) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if server.config.AuthorizationToken == "" {
sendJSONError(w, AuthorizationNotEnabled, "", http.StatusForbidden)
return
}
if r.Host != server.config.AllowedOauthHost {
// not behind the proxy; use old authentication method.
if !validateAPIKey(server.config.AuthorizationToken, r.Header.Get("Authorization")) {
sendJSONError(w, "Forbidden", "required a valid authorization token", http.StatusForbidden)
return
}
} else {
var allowed bool
userGroupsString := r.Header.Get("X-Forwarded-Groups")
userGroups := strings.Split(userGroupsString, ",")
AUTHENTICATED:
for _, userGroup := range userGroups {
if userGroup == "" {
continue
}
for _, permGroup := range allowedGroups {
if userGroup == permGroup {
allowed = true
break AUTHENTICATED
}
}
}
if !allowed {
sendJSONError(w, "Forbidden", fmt.Sprintf(UnauthorizedNotInGroup, allowedGroups), http.StatusForbidden)
return
}
// The operation requires to provide a valid authorization token.
if requireAPIKey && !validateAPIKey(server.config.AuthorizationToken, r.Header.Get("Authorization")) {
sendJSONError(
w, "Forbidden",
"you are part of one of the authorized groups, but this operation requires a valid authorization token",
http.StatusForbidden,
)
return
}
}
server.log.Info(
"admin action",
zap.String("host", r.Host),
zap.String("user", r.Header.Get("X-Forwarded-Email")),
zap.String("action", fmt.Sprintf("%s-%s", r.Method, r.RequestURI)),
zap.String("queries", r.URL.Query().Encode()),
)
r.Header.Set("Cache-Control", "must-revalidate")
next.ServeHTTP(w, r)
})
}
}
func validateAPIKey(configured, sent string) bool {
equality := subtle.ConstantTimeCompare([]byte(sent), []byte(configured))
return equality == 1
}