mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-16 12:41:28 +02:00
Compare commits
2 Commits
main
...
agent-netw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a09a648b | ||
|
|
a2be755b7d |
@@ -67,6 +67,30 @@ components:
|
||||
— the management-side control plane: providers, policies, guardrails, limits, routing,
|
||||
and usage/access logs.
|
||||
|
||||
## Access roles
|
||||
|
||||
Agent Network permissions build on the account permission matrix
|
||||
([`management/server/permissions/`](../management/server/permissions)). The
|
||||
`agent_network` area is split into dotted submodules (`agent_network.providers`,
|
||||
`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`); a role may
|
||||
grant a single submodule or the parent, which cascades to all of them.
|
||||
|
||||
Two roles delegate Agent Network access without account-admin rights:
|
||||
|
||||
- **`agent_network_admin`** — full control over the whole `agent_network` area plus
|
||||
read-only users, groups, peers, and account info (needed to build policies).
|
||||
Nothing else in the account.
|
||||
- **`usage_viewer`** — the regular User baseline plus read on
|
||||
`agent_network.usage` (the aggregated usage and cost overview). No provider
|
||||
configuration, no policies, no request-level access logs.
|
||||
|
||||
Every authenticated user, regardless of role, can read the caller-scoped
|
||||
self-service endpoints: `GET /api/agent-network/me/setup` (the endpoint, providers,
|
||||
and models the caller's own policies allow — what a local AI tool needs and nothing
|
||||
more) and `GET /api/agent-network/me/consumption` (the caller's own token and cost
|
||||
counters). Role definitions live in
|
||||
[`management/server/permissions/roles/`](../management/server/permissions/roles).
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation, architecture, and quickstart:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
// addMeEndpoints registers the self-service "My Agent Network" routes.
|
||||
// Both are available to every authenticated user regardless of role: the
|
||||
// responses are scoped strictly to the caller, which is tighter than any
|
||||
// role gate could be.
|
||||
func (h *handler) addMeEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/me/setup", h.getMySetup).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/me/consumption", h.listMyConsumption).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getMySetup(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
setup, err := h.manager.GetSetupForUser(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, setupToAPI(setup))
|
||||
}
|
||||
|
||||
func (h *handler) listMyConsumption(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.manager.ListConsumptionForUser(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]api.AgentNetworkConsumption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, consumptionToAPI(row))
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
func setupToAPI(setup *types.EffectiveSetup) api.AgentNetworkMeSetup {
|
||||
providers := make([]api.AgentNetworkMeProvider, 0, len(setup.Providers))
|
||||
for _, p := range setup.Providers {
|
||||
providers = append(providers, api.AgentNetworkMeProvider{
|
||||
Name: p.Name,
|
||||
CatalogId: p.CatalogID,
|
||||
ApiFlavor: p.APIFlavor,
|
||||
AllModelsAllowed: p.AllModelsAllowed,
|
||||
Models: p.Models,
|
||||
})
|
||||
}
|
||||
return api.AgentNetworkMeSetup{
|
||||
Configured: setup.Configured,
|
||||
Endpoint: setup.Endpoint,
|
||||
Providers: providers,
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) {
|
||||
h.addConsumptionEndpoints(router)
|
||||
h.addAccessLogEndpoints(router)
|
||||
h.addBudgetRuleEndpoints(router)
|
||||
h.addMeEndpoints(router)
|
||||
}
|
||||
|
||||
func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -83,6 +83,12 @@ type Manager interface {
|
||||
RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error
|
||||
RecordUsage(ctx context.Context, in RecordUsageInput) error
|
||||
SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error)
|
||||
|
||||
// GetSetupForUser and ListConsumptionForUser back the self-service
|
||||
// "My Agent Network" endpoints. Both are caller-scoped and skip the
|
||||
// role permission gate; see the implementations.
|
||||
GetSetupForUser(ctx context.Context, accountID, userID string) (*types.EffectiveSetup, error)
|
||||
ListConsumptionForUser(ctx context.Context, accountID, userID string) ([]*types.Consumption, error)
|
||||
}
|
||||
|
||||
// PolicySelectionInput is the per-request selection envelope. The
|
||||
|
||||
261
management/internals/modules/agentnetwork/setup.go
Normal file
261
management/internals/modules/agentnetwork/setup.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// GetSetupForUser returns the Agent Network setup the calling user's
|
||||
// groups authorize. It deliberately performs no role permission check:
|
||||
// the result is scoped to the caller's own groups, which is strictly
|
||||
// tighter than any role gate, so every authenticated user (any role) may
|
||||
// read it. Peers and users carry the same groups, so the answer matches
|
||||
// what the proxy enforces for the caller's machines at request time.
|
||||
func (m *managerImpl) GetSetupForUser(ctx context.Context, accountID, userID string) (*types.EffectiveSetup, error) {
|
||||
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
return m.effectiveSetupForGroups(ctx, accountID, user.AutoGroups)
|
||||
}
|
||||
|
||||
// ListConsumptionForUser returns the caller's own consumption counters:
|
||||
// the user-dimension rows recorded for userID. Caller-scoped by design —
|
||||
// no role permission check, mirroring GetSetupForUser.
|
||||
func (m *managerImpl) ListConsumptionForUser(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) {
|
||||
rows, err := m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
own := make([]*types.Consumption, 0)
|
||||
for _, row := range rows {
|
||||
if row.DimensionKind == types.DimensionUser && row.DimensionID == userID {
|
||||
own = append(own, row)
|
||||
}
|
||||
}
|
||||
return own, nil
|
||||
}
|
||||
|
||||
// effectiveSetupForGroups computes the effective Agent Network setup for
|
||||
// a set of caller groups: the account endpoint plus, per authorized
|
||||
// provider, the effective model set. It mirrors what the proxy enforces
|
||||
// at request time — the policy filter matches filterApplicablePolicies,
|
||||
// the model logic matches policyPermitsModel, and orphan providers
|
||||
// (enabled but referenced by no applicable policy) are omitted just like
|
||||
// the router synthesizer omits them — so the answer never advertises
|
||||
// anything the proxy would refuse.
|
||||
//
|
||||
// Every "nothing available" shape returns Configured=false rather than
|
||||
// an error, and "account not set up" is indistinguishable from "caller
|
||||
// has no access" by design: the response must not leak what exists for
|
||||
// others.
|
||||
func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.EffectiveSetup, error) {
|
||||
notConfigured := &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}
|
||||
|
||||
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case isNotFound(err):
|
||||
return notConfigured, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
if settings.Endpoint() == "" {
|
||||
return notConfigured, nil
|
||||
}
|
||||
|
||||
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account policies: %w", err)
|
||||
}
|
||||
applicable := filterPoliciesByGroups(policies, groupIDs)
|
||||
if len(applicable) == 0 {
|
||||
return notConfigured, nil
|
||||
}
|
||||
|
||||
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account providers: %w", err)
|
||||
}
|
||||
|
||||
var guardrailsByID map[string]*types.Guardrail
|
||||
if anyPolicyHasGuardrails(applicable) {
|
||||
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
authorized := make([]*types.Provider, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
if p == nil || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
if len(policiesForProvider(applicable, p.ID)) == 0 {
|
||||
continue
|
||||
}
|
||||
authorized = append(authorized, p)
|
||||
}
|
||||
if len(authorized) == 0 {
|
||||
return notConfigured, nil
|
||||
}
|
||||
// created_at order, ID tiebreak — same deterministic order the router
|
||||
// synthesizer presents.
|
||||
sort.SliceStable(authorized, func(i, j int) bool {
|
||||
if !authorized[i].CreatedAt.Equal(authorized[j].CreatedAt) {
|
||||
return authorized[i].CreatedAt.Before(authorized[j].CreatedAt)
|
||||
}
|
||||
return authorized[i].ID < authorized[j].ID
|
||||
})
|
||||
|
||||
out := &types.EffectiveSetup{
|
||||
Configured: true,
|
||||
Endpoint: "https://" + settings.Endpoint(),
|
||||
Providers: make([]types.EffectiveProvider, 0, len(authorized)),
|
||||
}
|
||||
for _, p := range authorized {
|
||||
allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
|
||||
flavor := ""
|
||||
if entry, ok := catalog.Lookup(p.ProviderID); ok {
|
||||
flavor = entry.ParserID
|
||||
}
|
||||
out.Providers = append(out.Providers, types.EffectiveProvider{
|
||||
Name: p.Name,
|
||||
CatalogID: p.ProviderID,
|
||||
APIFlavor: flavor,
|
||||
AllModelsAllowed: allAllowed,
|
||||
Models: models,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// filterPoliciesByGroups returns the enabled policies whose SourceGroups
|
||||
// intersect the caller's groups. Same group matching as
|
||||
// filterApplicablePolicies, without the per-provider filter — the setup
|
||||
// answer spans every provider the caller can reach.
|
||||
func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy {
|
||||
groupSet := make(map[string]struct{}, len(groupIDs))
|
||||
for _, g := range groupIDs {
|
||||
if g != "" {
|
||||
groupSet[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if p == nil || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
if !anyGroupMatches(p.SourceGroups, groupSet) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// policiesForProvider returns the subset of policies targeting the
|
||||
// provider, order preserved.
|
||||
func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy {
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if sliceContains(p.DestinationProviderIDs, providerID) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// effectiveModelsForProvider derives the caller's effective model set for
|
||||
// one provider from the applicable policies that target it, mirroring
|
||||
// policyPermitsModel: a policy with no allowlist-enabled guardrail is
|
||||
// unrestricted, and one unrestricted policy makes the whole provider
|
||||
// unrestricted (the proxy would admit any model through it). Otherwise
|
||||
// the union of the policies' allowlists applies, intersected with the
|
||||
// provider's declared models when the operator declared any — the router
|
||||
// only claims declared models, so an allowlisted-but-undeclared model is
|
||||
// unreachable and must not be advertised. With no declared models the
|
||||
// router claims every model, so the allowlist union stands alone.
|
||||
func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) {
|
||||
restricted := true
|
||||
union := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range policies {
|
||||
policyRestricted := false
|
||||
for _, gID := range p.GuardrailIDs {
|
||||
g, ok := guardrailsByID[gID]
|
||||
if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
|
||||
continue
|
||||
}
|
||||
policyRestricted = true
|
||||
for _, model := range g.Checks.ModelAllowlist.Models {
|
||||
key := normaliseModelID(model)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
union = append(union, key)
|
||||
}
|
||||
}
|
||||
if !policyRestricted {
|
||||
restricted = false
|
||||
}
|
||||
}
|
||||
|
||||
declared := declaredModelIDs(provider)
|
||||
if !restricted {
|
||||
return true, declared
|
||||
}
|
||||
if len(provider.Models) == 0 {
|
||||
// No operator declaration: the router claims every model, so the
|
||||
// allowlist union is the effective set as-is.
|
||||
return false, union
|
||||
}
|
||||
out := make([]string, 0, len(declared))
|
||||
for _, id := range declared {
|
||||
if _, ok := seen[normaliseModelID(id)]; ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return false, out
|
||||
}
|
||||
|
||||
// declaredModelIDs returns the models a provider exposes: the operator's
|
||||
// curated list when present, otherwise the catalog entry's models (an
|
||||
// empty operator list means "all catalog models"). Gateway/custom catalog
|
||||
// entries declare no models, so the result may be empty.
|
||||
func declaredModelIDs(provider *types.Provider) []string {
|
||||
if ids := providerModelIDs(provider); len(ids) > 0 {
|
||||
return ids
|
||||
}
|
||||
entry, ok := catalog.Lookup(provider.ProviderID)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, 0, len(entry.Models))
|
||||
for _, m := range entry.Models {
|
||||
if m.ID != "" {
|
||||
out = append(out, m.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetSetupForUser on the mock manager reports "not configured" so tests
|
||||
// that don't care about setup still compile.
|
||||
func (*mockManager) GetSetupForUser(_ context.Context, _, _ string) (*types.EffectiveSetup, error) {
|
||||
return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil
|
||||
}
|
||||
|
||||
// ListConsumptionForUser on the mock manager returns no rows.
|
||||
func (*mockManager) ListConsumptionForUser(_ context.Context, _, _ string) ([]*types.Consumption, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// These tests drive the effective-setup computation through the real
|
||||
// sqlite store, mirroring the policyselect realstore suite: assert on
|
||||
// observable answers (configured / providers / models), not on which
|
||||
// store methods get called. The computation must agree with what the
|
||||
// proxy enforces — policy filtering matches filterApplicablePolicies,
|
||||
// model logic matches policyPermitsModel, and orphan providers are
|
||||
// omitted like the router synthesizer omits them.
|
||||
|
||||
func newSetupTestMgr(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
t.Cleanup(cleanup)
|
||||
return &managerImpl{store: s}, s
|
||||
}
|
||||
|
||||
// newSetupTestGuardrail returns an allowlist-enabled guardrail.
|
||||
func newSetupTestGuardrail(id string, models ...string) *types.Guardrail {
|
||||
return &types.Guardrail{
|
||||
ID: id,
|
||||
AccountID: testAccountID,
|
||||
Name: "allowlist " + id,
|
||||
Checks: types.GuardrailChecks{
|
||||
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_NoSettingsRow(t *testing.T) {
|
||||
mgr, _ := newSetupTestMgr(t)
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(context.Background(), testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, setup.Configured, "account without settings must read as not configured")
|
||||
assert.Empty(t, setup.Endpoint)
|
||||
assert.Empty(t, setup.Providers)
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_NoApplicablePolicy(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-other"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, setup.Configured, "caller outside every policy's source groups must read as not configured")
|
||||
assert.Empty(t, setup.Endpoint, "no-access answer must not leak the endpoint")
|
||||
assert.Empty(t, setup.Providers)
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured)
|
||||
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.Equal(t, "OpenAI", p.Name)
|
||||
assert.Equal(t, "openai_api", p.CatalogID)
|
||||
assert.Equal(t, "openai", p.APIFlavor)
|
||||
assert.True(t, p.AllModelsAllowed, "policy without allowlist guardrail is unrestricted")
|
||||
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy")
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
// Allowlist admits gpt-5.4 (declared, odd casing/spacing) and gpt-4.1
|
||||
// (NOT declared — the router would never route it, so it must not be
|
||||
// advertised).
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1")))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing")
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
|
||||
restricted := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, restricted))
|
||||
open := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
open.ID = "pol-2"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
assert.True(t, setup.Providers[0].AllModelsAllowed,
|
||||
"one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it")
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}, {ID: "o4-mini"}}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-2", "gpt-4o")))
|
||||
p1 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p1))
|
||||
p2 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-2")
|
||||
p2.ID = "pol-2"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies")
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
// Orphan: enabled but referenced by no policy.
|
||||
orphan := newSynthTestProvider()
|
||||
orphan.ID = "prov-orphan"
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, orphan))
|
||||
// Disabled but referenced by an applicable policy.
|
||||
disabled := newSynthTestProvider()
|
||||
disabled.ID = "prov-disabled"
|
||||
disabled.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, setup.Configured, "neither an orphan nor a disabled provider is reachable, so nothing is configured for the caller")
|
||||
assert.Empty(t, setup.Providers)
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_DisabledPolicyIgnored(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
policy.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, setup.Configured)
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
// Gateway-style provider: no declared models — the router claims every
|
||||
// model, so the allowlist union is the effective set on its own.
|
||||
provider := newSynthTestProvider()
|
||||
provider.ProviderID = "litellm_proxy"
|
||||
provider.Name = "LiteLLM"
|
||||
provider.Models = nil
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5")))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models)
|
||||
}
|
||||
|
||||
func TestEffectiveSetup_RealStore_ProvidersInCreatedAtOrder(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
newer := newSynthTestProvider()
|
||||
newer.ID = "prov-newer"
|
||||
newer.Name = "Newer"
|
||||
newer.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, newer))
|
||||
older := newSynthTestProvider()
|
||||
older.ID = "prov-older"
|
||||
older.Name = "Older"
|
||||
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, older))
|
||||
|
||||
policy := newSynthTestPolicy(newer.ID, "grp-eng", "")
|
||||
policy.DestinationProviderIDs = []string{newer.ID, older.ID}
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 2)
|
||||
assert.Equal(t, "Older", setup.Providers[0].Name)
|
||||
assert.Equal(t, "Newer", setup.Providers[1].Name)
|
||||
}
|
||||
|
||||
// TestGetSetupForUser_RealStore pins the self-service entry point: the
|
||||
// user's group memberships (AutoGroups — the same groups the user's peers
|
||||
// carry) scope the answer, and users outside every policy get the
|
||||
// indistinguishable not-configured shape.
|
||||
func TestGetSetupForUser_RealStore(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
// users.account_id is a foreign key into accounts, enforced on
|
||||
// MySQL/Postgres, so the account row must exist before its users.
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-other"},
|
||||
}))
|
||||
|
||||
setupIn, err := mgr.GetSetupForUser(ctx, testAccountID, "user-in")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setupIn.Configured)
|
||||
require.Len(t, setupIn.Providers, 1)
|
||||
|
||||
setupOut, err := mgr.GetSetupForUser(ctx, testAccountID, "user-out")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, setupOut.Configured, "user outside the policy's source groups gets the not-configured answer")
|
||||
}
|
||||
|
||||
// TestListConsumptionForUser_RealStore pins the own-consumption scope: only
|
||||
// the caller's user-dimension rows come back, never another user's rows or
|
||||
// group rows.
|
||||
func TestListConsumptionForUser_RealStore(t *testing.T) {
|
||||
mgr, s := newSetupTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Hour)
|
||||
require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, testAccountID, types.DimensionUser, "user-a", 3600, now, 100, 50, 0.5))
|
||||
require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, testAccountID, types.DimensionUser, "user-b", 3600, now, 999, 999, 9.9))
|
||||
require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, testAccountID, types.DimensionGroup, "grp-eng", 3600, now, 1, 1, 0.1))
|
||||
|
||||
rows, err := mgr.ListConsumptionForUser(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1, "only the caller's own user-dimension rows are visible")
|
||||
assert.Equal(t, "user-a", rows[0].DimensionID)
|
||||
assert.Equal(t, int64(100), rows[0].TokensInput)
|
||||
}
|
||||
40
management/internals/modules/agentnetwork/types/setup.go
Normal file
40
management/internals/modules/agentnetwork/types/setup.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package types
|
||||
|
||||
// EffectiveSetup is the caller-scoped answer to "what may this caller
|
||||
// use on the Agent Network?" — the account's proxy endpoint plus the
|
||||
// providers and models the caller's groups authorize. It intentionally
|
||||
// carries display metadata only: no keys, no upstream URLs, no policy or
|
||||
// guardrail structure, and no hint of providers the caller cannot reach.
|
||||
type EffectiveSetup struct {
|
||||
// Configured is false when the account has no Agent Network set up or
|
||||
// when nothing is authorized for the caller's groups — the two cases
|
||||
// are deliberately indistinguishable so the response leaks nothing
|
||||
// about what exists for others.
|
||||
Configured bool
|
||||
// Endpoint is the account's proxy base URL
|
||||
// ("https://<subdomain>.<cluster>"), reachable over the NetBird tunnel
|
||||
// only. Empty when Configured is false.
|
||||
Endpoint string
|
||||
// Providers lists the providers at least one applicable policy
|
||||
// authorizes for the caller, in the account's created_at order.
|
||||
Providers []EffectiveProvider
|
||||
}
|
||||
|
||||
// EffectiveProvider is one authorized provider in an EffectiveSetup.
|
||||
type EffectiveProvider struct {
|
||||
// Name is the operator-assigned label, e.g. "Bedrock prod".
|
||||
Name string
|
||||
// CatalogID names the catalog entry, e.g. "anthropic_api".
|
||||
CatalogID string
|
||||
// APIFlavor is the request-body shape the provider speaks — the
|
||||
// catalog entry's parser id ("anthropic", "openai"); empty when the
|
||||
// proxy dispatches the provider by URL path instead.
|
||||
APIFlavor string
|
||||
// AllModelsAllowed is true when no model allowlist restricts this
|
||||
// provider for the caller. Models then lists the declared/catalog
|
||||
// models as a courtesy (possibly none for gateway-style providers).
|
||||
AllModelsAllowed bool
|
||||
// Models is the effective model allowlist for the caller, or the
|
||||
// declared/catalog models when AllModelsAllowed is true.
|
||||
Models []string
|
||||
}
|
||||
130
management/server/permissions/agent_network_roles_test.go
Normal file
130
management/server/permissions/agent_network_roles_test.go
Normal file
@@ -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"))
|
||||
}
|
||||
54
management/server/permissions/roles/agent_network_admin.go
Normal file
54
management/server/permissions/roles/agent_network_admin.go
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
20
management/server/permissions/roles/billing_admin.go
Normal file
20
management/server/permissions/roles/billing_admin.go
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
30
management/server/permissions/roles/usage_viewer.go
Normal file
30
management/server/permissions/roles/usage_viewer.go
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -5729,6 +5729,57 @@ components:
|
||||
required:
|
||||
- name
|
||||
- checks
|
||||
AgentNetworkMeSetup:
|
||||
type: object
|
||||
description: The caller-scoped Agent Network connection info backing the "My Agent Network" self-service view. Available to every authenticated user; the answer is computed from the caller's own groups and carries display metadata only.
|
||||
properties:
|
||||
configured:
|
||||
type: boolean
|
||||
description: False when the account has no Agent Network set up or the caller's groups authorize none of it. The two cases are deliberately indistinguishable.
|
||||
endpoint:
|
||||
type: string
|
||||
description: The account's Agent Network base URL, reachable over the NetBird tunnel only. Empty when configured is false.
|
||||
example: https://calm-otter.proxy.example.com
|
||||
providers:
|
||||
type: array
|
||||
description: The providers at least one of the caller's policies authorizes, in creation order.
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentNetworkMeProvider'
|
||||
required:
|
||||
- configured
|
||||
- endpoint
|
||||
- providers
|
||||
AgentNetworkMeProvider:
|
||||
type: object
|
||||
description: One provider the caller may use, reduced to what a local tool needs for configuration.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Operator-assigned provider label.
|
||||
example: Bedrock prod
|
||||
catalog_id:
|
||||
type: string
|
||||
description: Catalog entry id naming the provider type.
|
||||
example: bedrock_api
|
||||
api_flavor:
|
||||
type: string
|
||||
description: Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead.
|
||||
example: anthropic
|
||||
all_models_allowed:
|
||||
type: boolean
|
||||
description: True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy.
|
||||
models:
|
||||
type: array
|
||||
description: The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true).
|
||||
items:
|
||||
type: string
|
||||
example: [ "anthropic.claude-sonnet-4-5" ]
|
||||
required:
|
||||
- name
|
||||
- catalog_id
|
||||
- api_flavor
|
||||
- all_models_allowed
|
||||
- models
|
||||
AgentNetworkConsumption:
|
||||
type: object
|
||||
description: One per-(dimension, window) consumption counter row. The proxy ticks one row per dimension on every served LLM request; the dashboard reads this listing to surface live counter growth.
|
||||
@@ -13723,6 +13774,46 @@ paths:
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/me/setup:
|
||||
get:
|
||||
summary: Retrieve the caller's Agent Network setup
|
||||
description: Returns everything the caller needs to configure a local AI tool and nothing more - the account's Agent Network endpoint plus the providers and models the caller's own policies allow. Available to every authenticated user regardless of role; the response never contains provider credentials, policy or guardrail configuration, or providers the caller cannot reach.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
responses:
|
||||
'200':
|
||||
description: The caller-scoped Agent Network connection info
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentNetworkMeSetup'
|
||||
'401':
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/me/consumption:
|
||||
get:
|
||||
summary: List the caller's own Agent Network consumption
|
||||
description: Returns the caller's own per-window token and cost counters (the user dimension recorded for the calling user), ordered window-newest-first. Available to every authenticated user regardless of role. Empty list when the caller has not consumed anything yet.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
responses:
|
||||
'200':
|
||||
description: A JSON Array of the caller's own consumption counter rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentNetworkConsumption'
|
||||
'401':
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/settings:
|
||||
get:
|
||||
summary: Retrieve Agent Network settings
|
||||
|
||||
@@ -2167,6 +2167,36 @@ type AgentNetworkGuardrailRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AgentNetworkMeProvider One provider the caller may use, reduced to what a local tool needs for configuration.
|
||||
type AgentNetworkMeProvider struct {
|
||||
// AllModelsAllowed True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy.
|
||||
AllModelsAllowed bool `json:"all_models_allowed"`
|
||||
|
||||
// ApiFlavor Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead.
|
||||
ApiFlavor string `json:"api_flavor"`
|
||||
|
||||
// CatalogId Catalog entry id naming the provider type.
|
||||
CatalogId string `json:"catalog_id"`
|
||||
|
||||
// Models The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true).
|
||||
Models []string `json:"models"`
|
||||
|
||||
// Name Operator-assigned provider label.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AgentNetworkMeSetup The caller-scoped Agent Network connection info backing the "My Agent Network" self-service view. Available to every authenticated user; the answer is computed from the caller's own groups and carries display metadata only.
|
||||
type AgentNetworkMeSetup struct {
|
||||
// Configured False when the account has no Agent Network set up or the caller's groups authorize none of it. The two cases are deliberately indistinguishable.
|
||||
Configured bool `json:"configured"`
|
||||
|
||||
// Endpoint The account's Agent Network base URL, reachable over the NetBird tunnel only. Empty when configured is false.
|
||||
Endpoint string `json:"endpoint"`
|
||||
|
||||
// Providers The providers at least one of the caller's policies authorizes, in creation order.
|
||||
Providers []AgentNetworkMeProvider `json:"providers"`
|
||||
}
|
||||
|
||||
// AgentNetworkPolicy defines model for AgentNetworkPolicy.
|
||||
type AgentNetworkPolicy struct {
|
||||
// CreatedAt Timestamp when the policy was created.
|
||||
|
||||
Reference in New Issue
Block a user