[management] Add Agent Network access roles and self-service endpoints

Delegating Agent Network today means handing out full account admin, and
regular users cannot see their own usage or how to connect a local tool.

Add two roles on top of the existing agent_network permission
submodules. agent_network_admin owns the whole area (providers,
policies, guardrails, budgets, usage, logs, settings) with read-only
users, groups, peers, and account info needed to build policies, and
nothing else in the account. usage_viewer is the regular User baseline
plus read on the aggregated usage and cost overview: no provider
configuration, no policies, no request-level logs, which can contain
captured prompts. billing_admin gets a proper permission-map entry with
the User baseline so role resolution stops failing with role-not-found;
its plan and invoice permissions stay enforced cloud-side.

Add the self-service endpoints behind the "My Agent Network" view,
available to every authenticated user because both answers are scoped
strictly to the caller. GET /api/agent-network/me/setup returns the
account endpoint plus the providers and models the caller's own groups
authorize, computed with the same rules the proxy enforces: policy
filtering as in policy selection, model allowlist union intersected
with declared models, orphan and disabled providers omitted. Not set up
and no access are deliberately indistinguishable, and the response
carries display metadata only. GET /api/agent-network/me/consumption
returns the caller's own user-dimension counters.

Linear: NET-1399
This commit is contained in:
mlsmaycon
2026-08-15 17:54:52 +00:00
parent 93e97f4bf1
commit a2be755b7d
15 changed files with 1079 additions and 13 deletions
@@ -0,0 +1,130 @@
package permissions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/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)
}
}
for _, m := range []modules.Module{modules.Users, modules.Groups, modules.Peers, modules.Accounts} {
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
"agent_network_admin must read %s to build policies", 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, modules.Settings} {
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 and nothing else — no providers, no policies,
// no request-level logs (which can contain captured prompts), nothing in
// the rest of 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")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"usage_viewer must read the usage overview")
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, op),
"usage_viewer must not have %s on usage", op)
}
denied := []modules.Module{
modules.AgentNetwork,
modules.AgentNetworkProviders,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
modules.Networks,
modules.Users,
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"))
}
@@ -0,0 +1,54 @@
package roles
import (
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/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). 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,
},
},
}
@@ -0,0 +1,20 @@
package roles
import (
"github.com/netbirdio/netbird/management/server/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,30 @@
package roles
import (
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/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. It sees no provider
// configuration, no policies, and no request-level access logs (which can
// contain captured prompts): usage rows carry user and group display names
// in the response itself, so no team-wide read access is needed.
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,
},
},
}
+14 -8
View File
@@ -11,13 +11,15 @@ import (
)
const (
UserRoleOwner UserRole = "owner"
UserRoleAdmin UserRole = "admin"
UserRoleUser UserRole = "user"
UserRoleUnknown UserRole = "unknown"
UserRoleBillingAdmin UserRole = "billing_admin"
UserRoleAuditor UserRole = "auditor"
UserRoleNetworkAdmin UserRole = "network_admin"
UserRoleOwner UserRole = "owner"
UserRoleAdmin UserRole = "admin"
UserRoleUser UserRole = "user"
UserRoleUnknown UserRole = "unknown"
UserRoleBillingAdmin UserRole = "billing_admin"
UserRoleAuditor UserRole = "auditor"
UserRoleNetworkAdmin UserRole = "network_admin"
UserRoleAgentNetworkAdmin UserRole = "agent_network_admin"
UserRoleUsageViewer UserRole = "usage_viewer"
UserStatusActive UserStatus = "active"
UserStatusDisabled UserStatus = "disabled"
@@ -42,6 +44,10 @@ func StrRoleToUserRole(strRole string) UserRole {
return UserRoleAuditor
case "network_admin":
return UserRoleNetworkAdmin
case "agent_network_admin":
return UserRoleAgentNetworkAdmin
case "usage_viewer":
return UserRoleUsageViewer
default:
return UserRoleUnknown
}
@@ -140,7 +146,7 @@ func (u *User) IsRegularUser() bool {
// IsRestrictable checks whether a user is in a restrictable role.
func (u *User) IsRestrictable() bool {
return u.Role == UserRoleUser || u.Role == UserRoleBillingAdmin
return u.Role == UserRoleUser || u.Role == UserRoleBillingAdmin || u.Role == UserRoleUsageViewer
}
// ToUserInfo converts a User object to a UserInfo object.