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:
Ivan Fraixedes 2023-10-27 18:39:31 +02:00 committed by Storj Robot
parent 720b75ad73
commit 418673f7a2
5 changed files with 459 additions and 6 deletions

View 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)
})
}

View 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")
})
}

View 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

View File

@ -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

View File

@ -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: ""