satellite/admin/back-office: Implement authorization
Implement the authorization that will hook into each endpoint handler through a wrapping handler for defining the permissions that each endpoint requires. Change-Id: I9c8f12b58f48e849e7ea35f372dddce5c9cfc5b5
This commit is contained in:
parent
720b75ad73
commit
418673f7a2
169
satellite/admin/back-office/authorization.go
Normal file
169
satellite/admin/back-office/authorization.go
Normal file
@ -0,0 +1,169 @@
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/zeebo/errs"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"storj.io/storj/private/api"
|
||||
)
|
||||
|
||||
// These constants are the list of permissions that the service uses for authorizing users to
|
||||
// perform operations.
|
||||
const (
|
||||
PermAccountView Permission = 1 << iota
|
||||
PermAccountChangeEmail
|
||||
PermAccountDisableMFA
|
||||
PermAccountChangeLimits
|
||||
PermAccountSetDataPlacement
|
||||
PermAccountRemoveDataPlacement
|
||||
PermAccountSetUserAgent
|
||||
PermAccountSuspendTemporary
|
||||
PermAccountReActivateTemporary
|
||||
PermAccountSuspendPermanently
|
||||
PermAccountReActivatePermanently
|
||||
PermAccountDeleteNoData
|
||||
PermAccountDeleteWithData
|
||||
PermProjectView
|
||||
PermProjectSetLimits
|
||||
PermProjectSetDataPlacement
|
||||
PermProjectRemoveDataPlacement
|
||||
PermProjectSetUserAgent
|
||||
PermProjectSendInvitation
|
||||
PermBucketView
|
||||
PermBucketSetDataPlacement
|
||||
PermBucketRemoveDataPlacement
|
||||
PermBucketSetUserAgent
|
||||
)
|
||||
|
||||
// These constants are the list of roles that users can have and the service uses to match
|
||||
// permissions to perform operations.
|
||||
const (
|
||||
RoleAdmin = Authorization(
|
||||
PermAccountView | PermAccountChangeEmail | PermAccountDisableMFA | PermAccountChangeLimits |
|
||||
PermAccountSetDataPlacement | PermAccountRemoveDataPlacement | PermAccountSetUserAgent |
|
||||
PermAccountSuspendTemporary | PermAccountReActivateTemporary | PermAccountSuspendPermanently |
|
||||
PermAccountReActivatePermanently | PermAccountDeleteNoData | PermAccountDeleteWithData |
|
||||
PermProjectView | PermProjectSetLimits | PermProjectSetDataPlacement |
|
||||
PermProjectRemoveDataPlacement | PermProjectSetUserAgent | PermProjectSendInvitation |
|
||||
PermBucketView | PermBucketSetDataPlacement | PermBucketRemoveDataPlacement |
|
||||
PermBucketSetUserAgent,
|
||||
)
|
||||
RoleViewer = Authorization(PermAccountView | PermProjectView | PermBucketView)
|
||||
RoleCustomerSupport = Authorization(
|
||||
PermAccountView | PermAccountChangeEmail | PermAccountDisableMFA | PermAccountChangeLimits |
|
||||
PermAccountSetDataPlacement | PermAccountRemoveDataPlacement | PermAccountSetUserAgent |
|
||||
PermAccountSuspendTemporary | PermAccountReActivateTemporary | PermAccountDeleteNoData |
|
||||
PermProjectView | PermProjectSetLimits | PermProjectSetDataPlacement |
|
||||
PermProjectRemoveDataPlacement | PermProjectSetUserAgent | PermProjectSendInvitation |
|
||||
PermBucketView | PermBucketSetDataPlacement | PermBucketRemoveDataPlacement |
|
||||
PermBucketSetUserAgent,
|
||||
)
|
||||
RoleFinanceManager = Authorization(
|
||||
PermAccountView | PermAccountSuspendTemporary | PermAccountReActivateTemporary |
|
||||
PermAccountSuspendPermanently | PermAccountReActivatePermanently | PermAccountDeleteNoData |
|
||||
PermAccountDeleteWithData | PermProjectView | PermBucketView,
|
||||
)
|
||||
)
|
||||
|
||||
// ErrAuthorizer is the error class that wraps all the errors returned by the authorization.
|
||||
var ErrAuthorizer = errs.Class("authorizer")
|
||||
|
||||
// Permission represents a permissions to perform an operation.
|
||||
type Permission uint64
|
||||
|
||||
// Authorization specifies the permissions that user role has and validates if it has certain
|
||||
// permissions.
|
||||
type Authorization uint64
|
||||
|
||||
// Has returns true if auth has all the passed permissions.
|
||||
func (auth Authorization) Has(perms ...Permission) bool {
|
||||
for _, p := range perms {
|
||||
if uint64(auth)&uint64(p) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Authorizer checks if a group has certain permissions.
|
||||
type Authorizer struct {
|
||||
log *zap.Logger
|
||||
groupsRoles map[string]Authorization
|
||||
}
|
||||
|
||||
// NewAuthorizer creates an Authorizer with the list of groups that are assigned to each different
|
||||
// role. log is the parent logger where it will attach a prefix to identify messages coming from it.
|
||||
//
|
||||
// In the case that a group is assigned to more than one role, it will get the less permissive role.
|
||||
func NewAuthorizer(
|
||||
log *zap.Logger,
|
||||
adminGroups, viewerGroups, customerSupportGroups, financeManagerGroups []string,
|
||||
) *Authorizer {
|
||||
groupsRoles := make(map[string]Authorization)
|
||||
|
||||
// NOTE the order of iterating over all the groups matters because in the case that a group is in
|
||||
// more than one designed role, the group will get the role with less permissions that allow to
|
||||
// perform devastating operations.
|
||||
|
||||
for _, g := range adminGroups {
|
||||
groupsRoles[g] = RoleAdmin
|
||||
}
|
||||
|
||||
for _, g := range financeManagerGroups {
|
||||
groupsRoles[g] = RoleFinanceManager
|
||||
}
|
||||
|
||||
for _, g := range customerSupportGroups {
|
||||
groupsRoles[g] = RoleCustomerSupport
|
||||
}
|
||||
|
||||
for _, g := range viewerGroups {
|
||||
groupsRoles[g] = RoleViewer
|
||||
}
|
||||
|
||||
return &Authorizer{
|
||||
log: log.Named("authorizer"),
|
||||
groupsRoles: groupsRoles,
|
||||
}
|
||||
}
|
||||
|
||||
// HasPermissions check if group has all perms.
|
||||
func (auth *Authorizer) HasPermissions(group string, perms ...Permission) bool {
|
||||
groupAuth, ok := auth.groupsRoles[group]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return groupAuth.Has(perms...)
|
||||
}
|
||||
|
||||
// Middleware returns an HTTP handler which verifies if the request is performed by a user with a
|
||||
// role that allows all the passed permissions.
|
||||
func (auth *Authorizer) Middleware(next http.Handler, perms ...Permission) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
groupsh := r.Header.Get("X-Forwarded-Groups")
|
||||
if groupsh == "" {
|
||||
err := Error.Wrap(ErrAuthorizer.New("You do not belong to any group"))
|
||||
api.ServeError(auth.log, w, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
|
||||
groups := strings.Split(groupsh, ",")
|
||||
for _, g := range groups {
|
||||
if auth.HasPermissions(g, perms...) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := Error.Wrap(ErrAuthorizer.New("Not enough permissions (your groups: %s)", groupsh))
|
||||
api.ServeError(auth.log, w, http.StatusUnauthorized, err)
|
||||
})
|
||||
}
|
263
satellite/admin/back-office/authorization_test.go
Normal file
263
satellite/admin/back-office/authorization_test.go
Normal file
@ -0,0 +1,263 @@
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"storj.io/common/testcontext"
|
||||
admin "storj.io/storj/satellite/admin/back-office"
|
||||
)
|
||||
|
||||
func TestAuthorization(t *testing.T) {
|
||||
permissions := "10001001010111011011"
|
||||
a, err := strconv.ParseUint(permissions, 2, 64)
|
||||
require.NoError(t, err)
|
||||
auth := admin.Authorization(a)
|
||||
|
||||
var (
|
||||
permsOn []admin.Permission
|
||||
permsOff []admin.Permission
|
||||
)
|
||||
|
||||
for i, b := range permissions {
|
||||
perm := admin.Permission(1 << (len(permissions) - 1 - i))
|
||||
|
||||
if b == '1' {
|
||||
permsOn = append(permsOn, perm)
|
||||
require.True(t, auth.Has(perm), "has permission")
|
||||
} else {
|
||||
permsOff = append(permsOff, perm)
|
||||
require.False(t, auth.Has(perm), "doesn't have permission")
|
||||
}
|
||||
}
|
||||
|
||||
rand.Shuffle(len(permsOn), func(i, j int) {
|
||||
permsOn[i], permsOn[j] = permsOn[j], permsOn[i]
|
||||
})
|
||||
|
||||
rand.Shuffle(len(permsOff), func(i, j int) {
|
||||
permsOff[i], permsOff[j] = permsOff[j], permsOff[i]
|
||||
})
|
||||
|
||||
require.True(t, auth.Has(permsOn...), "full list of permissions that has")
|
||||
require.False(t, auth.Has(permsOff...), "full list of permissions that doesn't have")
|
||||
|
||||
permOnIdx := rand.Intn(len(permsOn))
|
||||
permOffIdx := rand.Intn(len(permsOff))
|
||||
|
||||
require.False(t, auth.Has(permsOn[permOnIdx], permsOff[permOffIdx]))
|
||||
}
|
||||
|
||||
func TestAuthorizer(t *testing.T) {
|
||||
ctx := testcontext.New(t)
|
||||
log := zaptest.NewLogger(t)
|
||||
gropusAdmin := []string{"root", "super"}
|
||||
groupsViewer := []string{"everyone", "everyone-else"}
|
||||
groupsSupport := []string{"customers-success", "customers-troubleshooter"}
|
||||
groupsFinance := []string{"accountant"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
group string
|
||||
permissions []admin.Permission
|
||||
hasAccess bool
|
||||
}{
|
||||
{
|
||||
name: "root deletes account with data",
|
||||
group: "root",
|
||||
permissions: []admin.Permission{admin.PermAccountDeleteWithData},
|
||||
hasAccess: true,
|
||||
},
|
||||
{
|
||||
name: "super re-activates account permanently",
|
||||
group: "super",
|
||||
permissions: []admin.Permission{admin.PermAccountDeleteWithData},
|
||||
hasAccess: true,
|
||||
},
|
||||
{
|
||||
name: "everyone view account data",
|
||||
group: "everyone",
|
||||
permissions: []admin.Permission{admin.PermAccountView},
|
||||
hasAccess: true,
|
||||
},
|
||||
{
|
||||
name: "everyone removes project data placement",
|
||||
group: "everyone",
|
||||
permissions: []admin.Permission{admin.PermProjectRemoveDataPlacement},
|
||||
hasAccess: false,
|
||||
},
|
||||
{
|
||||
name: "everyone-else views bucket data",
|
||||
group: "everyone-else",
|
||||
permissions: []admin.Permission{admin.PermBucketView},
|
||||
hasAccess: true,
|
||||
},
|
||||
{
|
||||
name: "everyone-else sets project user agent",
|
||||
group: "everyone-else",
|
||||
permissions: []admin.Permission{admin.PermProjectSetUserAgent},
|
||||
hasAccess: false,
|
||||
},
|
||||
{
|
||||
name: "customers-success suspends account temporary",
|
||||
group: "customers-success",
|
||||
permissions: []admin.Permission{admin.PermAccountSuspendTemporary},
|
||||
hasAccess: true,
|
||||
},
|
||||
{
|
||||
name: "customers-success suspends account permanently",
|
||||
group: "customers-success",
|
||||
permissions: []admin.Permission{admin.PermAccountSuspendPermanently},
|
||||
hasAccess: false,
|
||||
},
|
||||
{
|
||||
name: "customers-troubleshooter suspends account temporary and sets project limits",
|
||||
group: "customers-troubleshooter",
|
||||
permissions: []admin.Permission{admin.PermAccountSuspendTemporary, admin.PermProjectSetLimits},
|
||||
hasAccess: true,
|
||||
},
|
||||
{
|
||||
name: "customers-troubleshooter suspends account temporary and deletes account with data",
|
||||
group: "customers-troubleshooter",
|
||||
permissions: []admin.Permission{admin.PermAccountSuspendTemporary, admin.PermAccountDeleteWithData},
|
||||
hasAccess: false,
|
||||
},
|
||||
{
|
||||
name: "accountant suspends account permanently",
|
||||
group: "accountant",
|
||||
permissions: []admin.Permission{admin.PermAccountSuspendPermanently},
|
||||
hasAccess: true,
|
||||
},
|
||||
{
|
||||
name: "accountant sets bucket user agent",
|
||||
group: "accountant",
|
||||
permissions: []admin.Permission{admin.PermBucketSetUserAgent},
|
||||
hasAccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
auth := admin.NewAuthorizer(log, gropusAdmin, groupsViewer, groupsSupport, groupsFinance)
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Run("HasPermissions", func(t *testing.T) {
|
||||
require.Equal(t, c.hasAccess, auth.HasPermissions(c.group, c.permissions...))
|
||||
})
|
||||
|
||||
t.Run("Middleware", func(t *testing.T) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.test", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-Forwarded-Groups", c.group)
|
||||
w := httptest.NewRecorder()
|
||||
wbuff := &bytes.Buffer{}
|
||||
w.Body = wbuff
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := auth.Middleware(next, c.permissions...)
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if c.hasAccess {
|
||||
assert.True(t, nextCalled, "Next handler is called")
|
||||
assert.Equal(t, http.StatusOK, w.Code, "HTTP Status Code")
|
||||
} else {
|
||||
assert.False(t, nextCalled, "Next handler is called")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "HTTP Status Code")
|
||||
assert.Contains(t, wbuff.String(), fmt.Sprintf(`Not enough permissions (your groups: %s)`, c.group))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Middleware request with multiple groups one has the permissions", func(t *testing.T) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.test", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-Forwarded-Groups", "everyone-else,super")
|
||||
w := httptest.NewRecorder()
|
||||
wbuff := &bytes.Buffer{}
|
||||
w.Body = wbuff
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := auth.Middleware(next, admin.PermAccountDeleteWithData)
|
||||
handler.ServeHTTP(w, req)
|
||||
assert.True(t, nextCalled, "Next handler is called")
|
||||
assert.Equal(t, http.StatusOK, w.Code, "HTTP Status Code")
|
||||
})
|
||||
|
||||
t.Run("Middleware request with multiple groups none has all the permissions", func(t *testing.T) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.test", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-Forwarded-Groups", "customers-troubleshooter,everyone-else")
|
||||
w := httptest.NewRecorder()
|
||||
wbuff := &bytes.Buffer{}
|
||||
w.Body = wbuff
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := auth.Middleware(next, admin.PermAccountDeleteWithData)
|
||||
handler.ServeHTTP(w, req)
|
||||
assert.False(t, nextCalled, "Next handler is called")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "HTTP Status Code")
|
||||
assert.Contains(t, wbuff.String(), `Not enough permissions (your groups: customers-troubleshooter,everyone-else)`)
|
||||
})
|
||||
|
||||
t.Run("Middleware request with a unauthorized group", func(t *testing.T) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.test", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-Forwarded-Groups", "engineering")
|
||||
w := httptest.NewRecorder()
|
||||
wbuff := &bytes.Buffer{}
|
||||
w.Body = wbuff
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := auth.Middleware(next, admin.PermAccountDeleteWithData)
|
||||
handler.ServeHTTP(w, req)
|
||||
assert.False(t, nextCalled, "Next handler is called")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "HTTP Status Code")
|
||||
assert.Contains(t, wbuff.String(), `Not enough permissions (your groups: engineering)`)
|
||||
})
|
||||
|
||||
t.Run("Middleware request with no groups", func(t *testing.T) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.test", nil)
|
||||
require.NoError(t, err)
|
||||
w := httptest.NewRecorder()
|
||||
wbuff := &bytes.Buffer{}
|
||||
w.Body = wbuff
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := auth.Middleware(next, admin.PermAccountDeleteWithData)
|
||||
handler.ServeHTTP(w, req)
|
||||
assert.False(t, nextCalled, "Next handler is called")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "HTTP Status Code")
|
||||
assert.Contains(t, wbuff.String(), "You do not belong to any group")
|
||||
})
|
||||
}
|
10
satellite/admin/back-office/doc.go
Normal file
10
satellite/admin/back-office/doc.go
Normal file
@ -0,0 +1,10 @@
|
||||
// Package admin implements a server which serves a REST API and a web application to allow
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
|
||||
// See LICENSE for copying information.
|
||||
// performing satellite administration tasks.
|
||||
//
|
||||
// NOTE this is work in progress and will eventually replace the current satellite administration
|
||||
// server implemented in the parent package, hence this package name is the same than its parent
|
||||
// because it will simplify the replace once it's ready.
|
||||
package admin
|
@ -1,12 +1,6 @@
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
// Package admin implements a server which serves a REST API and a web application to allow
|
||||
// performing satellite administration tasks.
|
||||
//
|
||||
// NOTE this is work in progress and will eventually replace the current satellite administration
|
||||
// server implemented in the parent package, hence this package name is the same than its parent
|
||||
// because it will simplify the replace once it's ready.
|
||||
package admin
|
||||
|
||||
import (
|
||||
@ -34,6 +28,11 @@ var Error = errs.Class("satellite-admin")
|
||||
// Config defines configuration for the satellite administration server.
|
||||
type Config struct {
|
||||
StaticDir string `help:"an alternate directory path which contains the static assets for the satellite administration web app. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""`
|
||||
|
||||
UserGroupsRoleAdmin []string `help:"the list of groups whose users has the administration role" releaseDefault:"" devDefault:""`
|
||||
UserGroupsRoleViewer []string `help:"the list of groups whose users has the viewer role" releaseDefault:"" devDefault:""`
|
||||
UserGroupsRoleCustomerSupport []string `help:"the list of groups whose users has the customer support role" releaseDefault:"" devDefault:""`
|
||||
UserGroupsRoleFinanceManager []string `help:"the list of groups whose users has the finance manager role" releaseDefault:"" devDefault:""`
|
||||
}
|
||||
|
||||
// Server serves the API endpoints and the web application to allow preforming satellite
|
||||
|
12
scripts/testdata/satellite-config.yaml.lock
vendored
12
scripts/testdata/satellite-config.yaml.lock
vendored
@ -19,6 +19,18 @@
|
||||
# an alternate directory path which contains the static assets for the satellite administration web app. When empty, it uses the embedded assets
|
||||
# admin.back-office.static-dir: ""
|
||||
|
||||
# the list of groups whose users has the administration role
|
||||
# admin.back-office.user-groups-role-admin: []
|
||||
|
||||
# the list of groups whose users has the customer support role
|
||||
# admin.back-office.user-groups-role-customer-support: []
|
||||
|
||||
# the list of groups whose users has the finance manager role
|
||||
# admin.back-office.user-groups-role-finance-manager: []
|
||||
|
||||
# the list of groups whose users has the viewer role
|
||||
# admin.back-office.user-groups-role-viewer: []
|
||||
|
||||
# the group which is only allowed to update user and project limits and freeze and unfreeze accounts.
|
||||
# admin.groups.limit-update: ""
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user