diff --git a/agent-network/README.md b/agent-network/README.md index 1997ea299..4fa68790f 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -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: diff --git a/management/internals/modules/agentnetwork/handlers/me_handler.go b/management/internals/modules/agentnetwork/handlers/me_handler.go new file mode 100644 index 000000000..232e660c2 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/me_handler.go @@ -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, + } +} diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go index 0d8a44ca3..c9f4e2127 100644 --- a/management/internals/modules/agentnetwork/handlers/providers_handler.go +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -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) { diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index 379672989..a06d266d0 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -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 diff --git a/management/internals/modules/agentnetwork/setup.go b/management/internals/modules/agentnetwork/setup.go new file mode 100644 index 000000000..51a0e26af --- /dev/null +++ b/management/internals/modules/agentnetwork/setup.go @@ -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 +} diff --git a/management/internals/modules/agentnetwork/setup_realstore_test.go b/management/internals/modules/agentnetwork/setup_realstore_test.go new file mode 100644 index 000000000..17a1ce095 --- /dev/null +++ b/management/internals/modules/agentnetwork/setup_realstore_test.go @@ -0,0 +1,295 @@ +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", ""))) + + 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) +} diff --git a/management/internals/modules/agentnetwork/types/setup.go b/management/internals/modules/agentnetwork/types/setup.go new file mode 100644 index 000000000..e6724f6e9 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/setup.go @@ -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://."), 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 +} diff --git a/management/server/permissions/agent_network_roles_test.go b/management/server/permissions/agent_network_roles_test.go new file mode 100644 index 000000000..a82198b01 --- /dev/null +++ b/management/server/permissions/agent_network_roles_test.go @@ -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")) +} diff --git a/management/server/permissions/roles/agent_network_admin.go b/management/server/permissions/roles/agent_network_admin.go new file mode 100644 index 000000000..6a0db6ec2 --- /dev/null +++ b/management/server/permissions/roles/agent_network_admin.go @@ -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, + }, + }, +} diff --git a/management/server/permissions/roles/billing_admin.go b/management/server/permissions/roles/billing_admin.go new file mode 100644 index 000000000..22597b587 --- /dev/null +++ b/management/server/permissions/roles/billing_admin.go @@ -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, + }, +} diff --git a/management/server/permissions/roles/role_permissions.go b/management/server/permissions/roles/role_permissions.go index 754e568f5..517f07ff3 100644 --- a/management/server/permissions/roles/role_permissions.go +++ b/management/server/permissions/roles/role_permissions.go @@ -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, } diff --git a/management/server/permissions/roles/usage_viewer.go b/management/server/permissions/roles/usage_viewer.go new file mode 100644 index 000000000..199c05321 --- /dev/null +++ b/management/server/permissions/roles/usage_viewer.go @@ -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, + }, + }, +} diff --git a/management/server/types/user.go b/management/server/types/user.go index dc601e15b..07ec39d0d 100644 --- a/management/server/types/user.go +++ b/management/server/types/user.go @@ -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. diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index bfceadeef..38fa0f1cc 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -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 diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 04e04a24f..1139ce1a2 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -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.