Merge remote-tracking branch 'origin/main' into refactor/permissions-manager

This commit is contained in:
pascal
2026-09-11 13:55:02 +02:00
2126 changed files with 284567 additions and 42026 deletions
@@ -0,0 +1,140 @@
package permissions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
"github.com/netbirdio/netbird/management/internals/modules/permissions/roles"
"github.com/netbirdio/netbird/management/server/types"
)
var allOps = []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
// TestAgentNetworkAdminRole pins the delegated-admin contract: full control
// over the whole agent_network area (parent grant cascades to every
// submodule), read-only on the account objects needed to build policies,
// and nothing else in the account.
func TestAgentNetworkAdminRole(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
role, ok := roles.RolesMap[types.UserRoleAgentNetworkAdmin]
require.True(t, ok, "agent_network_admin must exist in RolesMap")
agentNetworkModules := []modules.Module{
modules.AgentNetwork,
modules.AgentNetworkProviders,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkUsage,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
}
for _, m := range agentNetworkModules {
for _, op := range allOps {
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"agent_network_admin must have %s on %s", op, m)
}
}
// Settings read rides along because GET /api/accounts (which the
// dashboard needs to boot) validates it, like network_admin.
for _, m := range []modules.Module{modules.Users, modules.Groups, modules.Peers, modules.Accounts, modules.Settings} {
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
"agent_network_admin must read %s to build policies and load the dashboard", m)
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"agent_network_admin must not have %s on %s", op, m)
}
}
for _, m := range []modules.Module{modules.Networks, modules.Dns, modules.SetupKeys, modules.Routes} {
for _, op := range allOps {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"agent_network_admin must not have %s on %s", op, m)
}
}
}
// TestUsageViewerRole pins the least-privilege cost role: read on the
// aggregated usage overview plus read-only on the resources its filters
// and display columns resolve against (users, groups, peers, the provider
// list) — no policies, no request-level logs (which can contain captured
// prompts), nothing else in the account.
func TestUsageViewerRole(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
role, ok := roles.RolesMap[types.UserRoleUsageViewer]
require.True(t, ok, "usage_viewer must exist in RolesMap")
readOnly := []modules.Module{
modules.AgentNetworkUsage,
modules.AgentNetworkProviders,
modules.Users,
modules.Groups,
modules.Peers,
}
for _, m := range readOnly {
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
"usage_viewer must read %s for the usage view and its filters", m)
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"usage_viewer must not have %s on %s", op, m)
}
}
denied := []modules.Module{
modules.AgentNetwork,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
modules.Networks,
modules.SetupKeys,
}
for _, m := range denied {
for _, op := range allOps {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"usage_viewer must not have %s on %s", op, m)
}
}
}
// TestBillingAdminRoleResolves pins that billing_admin has a proper entry
// in the permission map. Its plan/seat/invoice permissions are enforced
// outside this map; management-side it carries the regular User baseline
// instead of failing role resolution.
func TestBillingAdminRoleResolves(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
role, ok := roles.RolesMap[types.UserRoleBillingAdmin]
require.True(t, ok, "billing_admin must exist in RolesMap")
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleBillingAdmin)
require.NoError(t, err, "billing_admin role must resolve")
require.NotEmpty(t, permissions)
for _, m := range []modules.Module{modules.AgentNetwork, modules.Networks, modules.Users, modules.Peers} {
for _, op := range allOps {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"billing_admin must not have %s on %s", op, m)
}
}
}
// TestNewRolesParse pins the API role strings, which are permanent once
// released.
func TestNewRolesParse(t *testing.T) {
assert.Equal(t, types.UserRoleAgentNetworkAdmin, types.StrRoleToUserRole("agent_network_admin"))
assert.Equal(t, types.UserRoleUsageViewer, types.StrRoleToUserRole("usage_viewer"))
assert.Equal(t, types.UserRoleBillingAdmin, types.StrRoleToUserRole("billing_admin"))
}
@@ -1,6 +1,6 @@
package permissions
//go:generate go run github.com/golang/mock/mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
//go:generate go tool mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
import (
"context"
@@ -27,9 +27,9 @@ type AuthErrorHandler func(w http.ResponseWriter, r *http.Request, userAuth *aut
type Manager interface {
WithPermission(module modules.Module, operation operations.Operation, handlerFunc func(w http.ResponseWriter, r *http.Request, auth *auth.UserAuth), authErrHandler ...AuthErrorHandler) http.HandlerFunc
ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error)
ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error)
ValidateRoleModuleAccess(ctx context.Context, accountID string, role roles.RolePermissions, module modules.Module, operation operations.Operation) bool
ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error
ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error)
GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error)
SetAccountManager(accountManager account.Manager)
@@ -47,6 +47,7 @@ func NewManager(store store.Store) Manager {
// WithPermission wraps an HTTP handler with permission checking logic.
// An optional AuthErrorHandler can be provided to intercept auth errors before the default response is written.
// The wrapped handler receives a request whose context is enriched by the permission validation.
func (m *managerImpl) WithPermission(
module modules.Module,
operation operations.Operation,
@@ -66,27 +67,28 @@ func (m *managerImpl) WithPermission(
return
}
allowed, err := m.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, module, operation)
allowed, ctx, err := m.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, module, operation)
enriched := r.WithContext(ctx)
if err != nil {
if onAuthErr != nil && onAuthErr(w, r, &userAuth, err) {
if onAuthErr != nil && onAuthErr(w, enriched, &userAuth, err) {
return
}
log.WithContext(r.Context()).Errorf("failed to validate permissions for user %s on account %s: %v", userAuth.UserId, userAuth.AccountId, err)
util.WriteError(r.Context(), status.NewPermissionValidationError(err), w)
log.WithContext(ctx).Errorf("failed to validate permissions for user %s on account %s: %v", userAuth.UserId, userAuth.AccountId, err)
util.WriteError(ctx, status.NewPermissionValidationError(err), w)
return
}
if !allowed {
permErr := status.NewPermissionDeniedError()
if onAuthErr != nil && onAuthErr(w, r, &userAuth, permErr) {
if onAuthErr != nil && onAuthErr(w, enriched, &userAuth, permErr) {
return
}
log.WithContext(r.Context()).Tracef("user %s on account %s is not allowed to %s in %s", userAuth.UserId, userAuth.AccountId, operation, module)
util.WriteError(r.Context(), permErr, w)
log.WithContext(ctx).Tracef("user %s on account %s is not allowed to %s in %s", userAuth.UserId, userAuth.AccountId, operation, module)
util.WriteError(ctx, permErr, w)
return
}
handlerFunc(w, r, &userAuth)
handlerFunc(w, enriched, &userAuth)
}
}
@@ -96,40 +98,44 @@ func (m *managerImpl) ValidateUserPermissions(
userID string,
module modules.Module,
operation operations.Operation,
) (bool, error) {
) (bool, context.Context, error) {
if userID == activity.SystemInitiator {
return true, nil
return true, ctx, nil
}
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return false, err
return false, ctx, err
}
if user == nil {
return false, status.NewUserNotFoundError(userID)
return false, ctx, status.NewUserNotFoundError(userID)
}
if user.IsBlocked() && !user.PendingApproval {
return false, status.NewUserBlockedError()
return false, ctx, status.NewUserBlockedError()
}
if user.IsBlocked() && user.PendingApproval {
return false, status.NewUserPendingApprovalError()
return false, ctx, status.NewUserPendingApprovalError()
}
if err := m.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
return false, err
ctxEnriched, err := m.ValidateAccountAccess(ctx, accountID, user, false)
if err != nil {
return false, ctx, err
}
role, ok := roles.RolesMap[user.Role]
if !ok {
return false, status.NewUserRoleNotFoundError(string(user.Role))
return false, ctxEnriched, status.NewUserRoleNotFoundError(string(user.Role))
}
return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), nil
return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
}
// ValidateRoleModuleAccess resolves an operation against the role's explicit
// grant for the module, then the grant for its parent module when the module
// is a dotted submodule, and finally the role's AutoAllowNew default.
func (m *managerImpl) ValidateRoleModuleAccess(
ctx context.Context,
accountID string,
@@ -137,7 +143,7 @@ func (m *managerImpl) ValidateRoleModuleAccess(
module modules.Module,
operation operations.Operation,
) bool {
if permissions, ok := role.Permissions[module]; ok {
if permissions, ok := lookupModulePermissions(role, module); ok {
if allowed, exists := permissions[operation]; exists {
return allowed
}
@@ -148,11 +154,29 @@ func (m *managerImpl) ValidateRoleModuleAccess(
return role.AutoAllowNew[operation]
}
func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error {
if user.AccountID != accountID {
return status.NewUserNotPartOfAccountError()
// lookupModulePermissions returns the role's explicit permission set for the
// module, falling back to the parent module's set for dotted submodules. The
// second return reports whether any explicit set was found.
func lookupModulePermissions(role roles.RolePermissions, module modules.Module) (map[operations.Operation]bool, bool) {
if permissions, ok := role.Permissions[module]; ok {
return permissions, true
}
return nil
if parent, hasParent := module.Parent(); hasParent {
if permissions, ok := role.Permissions[parent]; ok {
return permissions, true
}
}
return nil, false
}
func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
if user.AccountID != accountID {
return ctx, status.NewUserNotPartOfAccountError()
}
ctx = nbcontext.WithRole(ctx, string(user.Role))
return ctx, nil
}
func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error) {
@@ -164,7 +188,7 @@ func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserR
permissions := roles.Permissions{}
for k := range modules.All {
if rolePermissions, ok := roleMap.Permissions[k]; ok {
if rolePermissions, ok := lookupModulePermissions(roleMap, k); ok {
permissions[k] = rolePermissions
continue
}
@@ -1,28 +1,33 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./manager.go
//
// Generated by this command:
//
// mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
//
// Package permissions is a generated GoMock package.
package permissions
import (
"context"
"net/http"
"reflect"
context "context"
http "net/http"
reflect "reflect"
"github.com/golang/mock/gomock"
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
"github.com/netbirdio/netbird/management/internals/modules/permissions/roles"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/auth"
modules "github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
operations "github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
roles "github.com/netbirdio/netbird/management/internals/modules/permissions/roles"
account "github.com/netbirdio/netbird/management/server/account"
types "github.com/netbirdio/netbird/management/server/types"
auth "github.com/netbirdio/netbird/shared/auth"
gomock "go.uber.org/mock/gomock"
)
// MockManager is a mock of Manager interface.
type MockManager struct {
ctrl *gomock.Controller
recorder *MockManagerMockRecorder
isgomock struct{}
}
// MockManagerMockRecorder is the mock recorder for MockManager.
@@ -52,7 +57,7 @@ func (m *MockManager) GetPermissionsByRole(ctx context.Context, role types.UserR
}
// GetPermissionsByRole indicates an expected call of GetPermissionsByRole.
func (mr *MockManagerMockRecorder) GetPermissionsByRole(ctx, role interface{}) *gomock.Call {
func (mr *MockManagerMockRecorder) GetPermissionsByRole(ctx, role any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPermissionsByRole", reflect.TypeOf((*MockManager)(nil).GetPermissionsByRole), ctx, role)
}
@@ -64,21 +69,22 @@ func (m *MockManager) SetAccountManager(accountManager account.Manager) {
}
// SetAccountManager indicates an expected call of SetAccountManager.
func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{}) *gomock.Call {
func (mr *MockManagerMockRecorder) SetAccountManager(accountManager any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAccountManager", reflect.TypeOf((*MockManager)(nil).SetAccountManager), accountManager)
}
// ValidateAccountAccess mocks base method.
func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error {
func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateAccountAccess", ctx, accountID, user, allowOwnerAndAdmin)
ret0, _ := ret[0].(error)
return ret0
ret0, _ := ret[0].(context.Context)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ValidateAccountAccess indicates an expected call of ValidateAccountAccess.
func (mr *MockManagerMockRecorder) ValidateAccountAccess(ctx, accountID, user, allowOwnerAndAdmin interface{}) *gomock.Call {
func (mr *MockManagerMockRecorder) ValidateAccountAccess(ctx, accountID, user, allowOwnerAndAdmin any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateAccountAccess", reflect.TypeOf((*MockManager)(nil).ValidateAccountAccess), ctx, accountID, user, allowOwnerAndAdmin)
}
@@ -92,22 +98,23 @@ func (m *MockManager) ValidateRoleModuleAccess(ctx context.Context, accountID st
}
// ValidateRoleModuleAccess indicates an expected call of ValidateRoleModuleAccess.
func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role, module, operation interface{}) *gomock.Call {
func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role, module, operation any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateRoleModuleAccess", reflect.TypeOf((*MockManager)(nil).ValidateRoleModuleAccess), ctx, accountID, role, module, operation)
}
// ValidateUserPermissions mocks base method.
func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) {
func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateUserPermissions", ctx, accountID, userID, module, operation)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
ret1, _ := ret[1].(context.Context)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// ValidateUserPermissions indicates an expected call of ValidateUserPermissions.
func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userID, module, operation interface{}) *gomock.Call {
func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userID, module, operation any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateUserPermissions", reflect.TypeOf((*MockManager)(nil).ValidateUserPermissions), ctx, accountID, userID, module, operation)
}
@@ -115,7 +122,7 @@ func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userI
// WithPermission mocks base method.
func (m *MockManager) WithPermission(module modules.Module, operation operations.Operation, handlerFunc func(http.ResponseWriter, *http.Request, *auth.UserAuth), authErrHandler ...AuthErrorHandler) http.HandlerFunc {
m.ctrl.T.Helper()
varargs := []interface{}{module, operation, handlerFunc}
varargs := []any{module, operation, handlerFunc}
for _, a := range authErrHandler {
varargs = append(varargs, a)
}
@@ -125,8 +132,8 @@ func (m *MockManager) WithPermission(module modules.Module, operation operations
}
// WithPermission indicates an expected call of WithPermission.
func (mr *MockManagerMockRecorder) WithPermission(module, operation, handlerFunc interface{}, authErrHandler ...interface{}) *gomock.Call {
func (mr *MockManagerMockRecorder) WithPermission(module, operation, handlerFunc any, authErrHandler ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{module, operation, handlerFunc}, authErrHandler...)
varargs := append([]any{module, operation, handlerFunc}, authErrHandler...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithPermission", reflect.TypeOf((*MockManager)(nil).WithPermission), varargs...)
}
@@ -0,0 +1,139 @@
package permissions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
"github.com/netbirdio/netbird/management/internals/modules/permissions/roles"
"github.com/netbirdio/netbird/management/server/types"
)
func TestValidateRoleModuleAccessSubmoduleCascade(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
fullAccess := map[operations.Operation]bool{
operations.Read: true,
operations.Create: true,
operations.Update: true,
operations.Delete: true,
}
readOnly := map[operations.Operation]bool{
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
}
denyAll := map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
}
t.Run("parent grant covers submodules", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{modules.AgentNetwork: fullAccess},
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Create),
"parent full grant should allow create on a submodule")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
"parent full grant should allow read on a submodule")
})
t.Run("submodule grant does not leak to parent or siblings", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{modules.AgentNetworkUsage: readOnly},
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"explicit submodule read should be allowed")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Create),
"read-only submodule grant should not allow create")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetwork, operations.Read),
"submodule grant should not grant the parent module")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
"submodule grant should not grant a sibling submodule")
})
t.Run("explicit submodule entry wins over parent grant", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{
modules.AgentNetwork: fullAccess,
modules.AgentNetworkLogs: denyAll,
},
}
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
"explicit submodule deny should override the parent grant")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"sibling submodules should still resolve through the parent grant")
})
t.Run("auto allow applies when neither submodule nor parent is granted", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: readOnly,
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
"auto-allow read should apply to submodules")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Delete),
"auto-allow should not grant unlisted operations")
})
}
// TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules pins the behavior the
// submodule split must not change: every built-in role resolves the new
// submodules exactly as it resolved the agent_network module before.
func TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
submodules := []modules.Module{
modules.AgentNetworkProviders,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkUsage,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
}
allOperations := []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
for _, role := range []types.UserRole{types.UserRoleOwner, types.UserRoleAdmin, types.UserRoleAuditor, types.UserRoleNetworkAdmin, types.UserRoleUser} {
rolePermissions, ok := roles.RolesMap[role]
require.True(t, ok, "role %s must exist in RolesMap", role)
for _, sub := range submodules {
for _, op := range allOperations {
expected := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, modules.AgentNetwork, op)
actual := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, sub, op)
assert.Equal(t, expected, actual, "role %s: %s on %s should match the agent_network module", role, op, sub)
}
}
}
}
func TestGetPermissionsByRoleIncludesSubmodules(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAuditor)
require.NoError(t, err, "auditor role must resolve")
usage, ok := permissions[modules.AgentNetworkUsage]
require.True(t, ok, "permissions map should contain the usage submodule")
assert.True(t, usage[operations.Read], "auditor should read the usage submodule")
assert.False(t, usage[operations.Update], "auditor should not update the usage submodule")
adminPermissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAdmin)
require.NoError(t, err, "admin role must resolve")
providers, ok := adminPermissions[modules.AgentNetworkProviders]
require.True(t, ok, "permissions map should contain the providers submodule")
assert.True(t, providers[operations.Delete], "admin should delete on the providers submodule")
}
@@ -1,5 +1,7 @@
package modules
import "strings"
type Module string
const (
@@ -19,6 +21,18 @@ const (
Pats Module = "pats"
IdentityProviders Module = "identity_providers"
Services Module = "services"
AgentNetwork Module = "agent_network"
// Agent Network submodules. A role may grant one of these directly
// or grant the AgentNetwork parent, which covers all of them (see
// permissions.Manager cascade resolution).
AgentNetworkProviders Module = "agent_network.providers"
AgentNetworkPolicies Module = "agent_network.policies"
AgentNetworkGuardrails Module = "agent_network.guardrails"
AgentNetworkBudgets Module = "agent_network.budgets"
AgentNetworkUsage Module = "agent_network.usage"
AgentNetworkLogs Module = "agent_network.logs"
AgentNetworkSettings Module = "agent_network.settings"
)
var All = map[Module]struct{}{
@@ -38,4 +52,22 @@ var All = map[Module]struct{}{
Pats: {},
IdentityProviders: {},
Services: {},
AgentNetwork: {},
AgentNetworkProviders: {},
AgentNetworkPolicies: {},
AgentNetworkGuardrails: {},
AgentNetworkBudgets: {},
AgentNetworkUsage: {},
AgentNetworkLogs: {},
AgentNetworkSettings: {},
}
// Parent returns the module owning a dotted submodule name and true, or the
// module itself and false when it has no parent.
func (m Module) Parent() (Module, bool) {
if i := strings.IndexByte(string(m), '.'); i > 0 {
return Module(string(m)[:i]), true
}
return m, false
}
@@ -0,0 +1,62 @@
package roles
import (
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
"github.com/netbirdio/netbird/management/server/types"
)
// AgentNetworkAdmin is the delegated administrator for the Agent Network
// area: full control over providers, policies, guardrails, budgets, usage,
// logs, and its settings, plus read-only visibility into the account
// objects needed to build policies (users, groups, peers) and the account
// settings/meta read the dashboard needs to boot (GET /api/accounts
// validates Settings read, same as network_admin). Nothing else in the
// account is visible.
var AgentNetworkAdmin = RolePermissions{
Role: types.UserRoleAgentNetworkAdmin,
AutoAllowNew: map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
Permissions: Permissions{
modules.AgentNetwork: {
operations.Read: true,
operations.Create: true,
operations.Update: true,
operations.Delete: true,
},
modules.Users: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Groups: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Peers: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Accounts: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Settings: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
},
}
@@ -0,0 +1,20 @@
package roles
import (
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
"github.com/netbirdio/netbird/management/server/types"
)
// BillingAdmin manages plans, seats, and invoices, which are enforced
// outside this permission map (NetBird Cloud). Management-side it carries
// the regular User baseline; the explicit entry keeps role resolution from
// failing with a role-not-found error.
var BillingAdmin = RolePermissions{
Role: types.UserRoleBillingAdmin,
AutoAllowNew: map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
}
@@ -15,9 +15,12 @@ type RolePermissions struct {
type Permissions map[modules.Module]map[operations.Operation]bool
var RolesMap = map[types.UserRole]RolePermissions{
types.UserRoleOwner: Owner,
types.UserRoleAdmin: Admin,
types.UserRoleUser: User,
types.UserRoleAuditor: Auditor,
types.UserRoleNetworkAdmin: NetworkAdmin,
types.UserRoleOwner: Owner,
types.UserRoleAdmin: Admin,
types.UserRoleUser: User,
types.UserRoleAuditor: Auditor,
types.UserRoleNetworkAdmin: NetworkAdmin,
types.UserRoleAgentNetworkAdmin: AgentNetworkAdmin,
types.UserRoleUsageViewer: UsageViewer,
types.UserRoleBillingAdmin: BillingAdmin,
}
@@ -0,0 +1,60 @@
package roles
import (
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
"github.com/netbirdio/netbird/management/server/types"
)
// UsageViewer is the regular User baseline plus read access to the
// aggregated Agent Network usage and cost overview, and read-only access
// to the resources the usage filters and display columns resolve against:
// users and groups (identity filters and name resolution), peers (agent
// principals in the caller column), and the provider list (provider and
// model filter options — the manager redacts connection config such as
// upstream URLs and operator-supplied header values for callers holding
// read without update). It sees no policies and no account-wide
// request-level access logs (which can contain captured prompts); its own
// requests remain readable through the self-scoped endpoints, like any
// caller's.
var UsageViewer = RolePermissions{
Role: types.UserRoleUsageViewer,
AutoAllowNew: map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
Permissions: Permissions{
modules.AgentNetworkUsage: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.AgentNetworkProviders: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Users: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Groups: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Peers: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
},
}