Merge branch 'main' into agent-network-validate-proxy-cluster

This commit is contained in:
Maycon Santos
2026-09-12 10:45:11 +02:00
committed by GitHub
506 changed files with 31909 additions and 11042 deletions
@@ -0,0 +1,300 @@
package agentnetwork
import (
"context"
"fmt"
"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"
)
// GetAgentConfigForUser 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. The group source matches enforcement: the proxy authorizes
// each Agent Network request against the calling user's groups as well —
// session validation resolves them from the same user record's
// auto-groups — so this answer and the proxy's verdict are computed from
// the same memberships.
func (m *managerImpl) GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error) {
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return m.agentConfigForGroups(ctx, accountID, user.AutoGroups)
}
// agentConfigForGroups 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.
//
// Configured tracks the account, not the caller: once the account has an
// endpoint every member gets it, with Providers empty for those no policy
// covers yet. The dashboard shows each user the same connection config
// regardless of role, and an empty provider list tells them to ask for
// access. Only the account having no Agent Network at all reads as not
// configured. Providers stays caller-scoped either way — the endpoint on
// its own authorizes nothing, and the proxy still refuses every request
// no policy permits.
func (m *managerImpl) agentConfigForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.AgentConfig, error) {
notConfigured := &types.AgentConfig{Providers: []types.AgentConfigProvider{}}
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
}
authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, groupIDs)
if err != nil {
return nil, err
}
out := &types.AgentConfig{
Configured: true,
Endpoint: "https://" + settings.Endpoint(),
Providers: make([]types.AgentConfigProvider, 0, len(authorized)),
}
if len(authorized) == 0 {
return out, nil
}
var guardrailsByID map[string]*types.Guardrail
if anyPolicyHasGuardrails(applicable) {
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
if err != nil {
return nil, err
}
}
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.AgentConfigProvider{
Name: p.Name,
CatalogID: p.ProviderID,
APIFlavor: flavor,
AllModelsAllowed: allAllowed,
Models: models,
})
}
return out, nil
}
// authorizedProvidersForGroups returns the enabled providers referenced
// by at least one enabled policy whose source groups intersect groupIDs —
// the providers the caller's own policies authorize — in created_at order
// with ID tiebreak, the same deterministic order the router synthesizer
// presents. The applicable policies come back alongside so callers that
// need per-provider policy context (the setup's model computation) don't
// re-filter. Both the self-service setup answer and the caller-scoped
// provider list are built from this selection, so what the dashboard
// offers and what the proxy enforces never diverge.
func (m *managerImpl) authorizedProvidersForGroups(ctx context.Context, accountID string, groupIDs []string) ([]*types.Provider, []*types.Policy, error) {
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, nil, fmt.Errorf("list account policies: %w", err)
}
applicable := filterPoliciesByGroups(policies, groupIDs)
if len(applicable) == 0 {
return nil, nil, nil
}
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, nil, fmt.Errorf("list account providers: %w", err)
}
// filterEnabledProviders carries the enabled filter and the
// created_at/ID order shared with the router synthesizer.
enabled := filterEnabledProviders(providers)
authorized := make([]*types.Provider, 0, len(enabled))
for _, p := range enabled {
if len(policiesForProvider(applicable, p.ID)) == 0 {
continue
}
authorized = append(authorized, p)
}
return authorized, applicable, 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.
// Allowlist entries and declared ids both compare through the canonical
// id the proxy's parser emits, so an allowlist may hold either form: the
// raw declared id the dashboard's picker copies from the provider, or
// the stripped id the parser matches at request time.
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 := canonicalModelKey(provider.ProviderID, 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 {
// Compare through the canonical id the proxy's parser emits — a
// Bedrock declaration may carry the region/version form
// ("eu.anthropic.claude-...-v1:0") that the parser strips at
// request time, and the raw forms would never intersect. The
// declared id itself is what gets advertised, matching the
// router's route claim.
if _, ok := seen[canonicalModelKey(provider.ProviderID, id)]; ok {
out = append(out, id)
}
}
return false, out
}
// canonicalModelKey builds the compare key for a model id: lowercased,
// trimmed, and canonicalized through the provider-aware normalization the
// proxy's parser applies. Lowercase/trim comes FIRST — the path-style
// strippers anchor on a lowercase id's tail, so a trailing space or a
// case-variant geography/version would otherwise survive into the key.
func canonicalModelKey(catalogProviderID, id string) string {
return normaliseModelID(normalizePricingModelID(catalogProviderID, normaliseModelID(id)))
}
// providerModelsByID maps effective model ids (as effectiveModelsForProvider
// returns them) back onto the operator's declared entries, keeping the
// declared casing and prices. With no operator declaration the ids are the
// allowlist union and have no declared entry to map to, so bare entries are
// synthesized — the router claims every model in that case, so those ids are
// reachable and belong in the answer.
func providerModelsByID(provider *types.Provider, ids []string) []types.ProviderModel {
if len(provider.Models) == 0 {
out := make([]types.ProviderModel, 0, len(ids))
for _, id := range ids {
out = append(out, types.ProviderModel{ID: id})
}
return out
}
keep := make(map[string]struct{}, len(ids))
for _, id := range ids {
keep[normaliseModelID(id)] = struct{}{}
}
out := make([]types.ProviderModel, 0, len(ids))
for _, m := range provider.Models {
if _, ok := keep[normaliseModelID(m.ID)]; ok {
out = append(out, m)
}
}
return 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
}
// GetAgentConfigForUser on the mock manager reports "not configured" so tests
// that don't care about setup still compile.
func (*mockManager) GetAgentConfigForUser(_ context.Context, _, _ string) (*types.AgentConfig, error) {
return &types.AgentConfig{Providers: []types.AgentConfigProvider{}}, nil
}
@@ -0,0 +1,406 @@
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/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/server/permissions"
"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 newAgentConfigTestMgr(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 TestAgentConfig_RealStore_NoSettingsRow(t *testing.T) {
mgr, _ := newAgentConfigTestMgr(t)
setup, err := mgr.agentConfigForGroups(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 TestAgentConfig_RealStore_NoApplicablePolicy(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(ctx, testAccountID, []string{"grp-other"})
require.NoError(t, err)
assert.True(t, setup.Configured, "the account is set up, so every member reads as configured")
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint, "every member gets the same connection config")
assert.Empty(t, setup.Providers, "a caller no policy covers is authorized for nothing")
}
func TestAgentConfig_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(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 TestAgentConfig_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(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 TestAgentConfig_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically(t *testing.T) {
mgr, s := newAgentConfigTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
// A Bedrock operator typically declares the region/version form the
// vendor lists, while the allowlist holds the canonical id the proxy's
// parser emits at request time. The intersection must compare through
// the same normalization the parser applies, and the declared (raw)
// id is what gets advertised — it is what the router claims.
provider := newSynthTestProvider()
provider.ProviderID = "bedrock_api"
provider.Name = "Bedrock"
provider.Models = []types.ProviderModel{
{ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
{ID: "eu.amazon.nova-pro-v1:0"},
}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "anthropic.claude-sonnet-4-5")))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
setup, err := mgr.agentConfigForGroups(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{"eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, p.Models,
"the allowlisted canonical id must admit the declared region/version form, and only it")
}
func TestAgentConfig_RealStore_AllowlistHoldsRawDeclaredIDs(t *testing.T) {
// The dashboard's allowlist picker copies the provider's declared ids
// verbatim, so for path-style providers the allowlist carries the
// region/version form rather than the canonical id the parser emits.
// Both forms must admit the declared model.
cases := []struct {
name string
catalogID string
declared string
allowlist string
}{
{"bedrock", "bedrock_api", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", ""},
{"vertex", "vertex_ai_api", "claude-sonnet-4-5@20250929", ""},
// The geography/version strippers anchor on a lowercase tail, so a
// case-variant entry must be lowercased before canonicalization or
// the prefix and suffix survive into the compare key.
{"bedrock-case-variant", "bedrock_api", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
" EU.Anthropic.Claude-Sonnet-4-5-20250929-V1:0 "},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mgr, s := newAgentConfigTestMgr(t)
ctx := context.Background()
allowlisted := tc.allowlist
if allowlisted == "" {
allowlisted = tc.declared
}
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
provider.ProviderID = tc.catalogID
provider.Name = tc.name
provider.Models = []types.ProviderModel{{ID: tc.declared}}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", allowlisted)))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
setup, err := mgr.agentConfigForGroups(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{tc.declared}, p.Models,
"an allowlist holding the raw declared id must admit that declared model")
})
}
}
func TestAgentConfig_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(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 TestAgentConfig_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(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 TestAgentConfig_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.True(t, setup.Configured)
assert.Empty(t, setup.Providers, "neither an orphan nor a disabled provider is reachable for the caller")
}
func TestAgentConfig_RealStore_DisabledPolicyIgnored(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.True(t, setup.Configured)
assert.Empty(t, setup.Providers, "a disabled policy authorizes nothing")
}
func TestAgentConfig_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(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 TestAgentConfig_RealStore_ProvidersInCreatedAtOrder(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.agentConfigForGroups(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)
}
// TestGetAgentConfigForUser_RealStore pins the self-service entry point: the
// user's group memberships (AutoGroups — the same groups the user's peers
// carry) scope the providers, while the account's endpoint reaches every
// member — a user outside every policy gets the config with nothing
// authorized in it.
func TestGetAgentConfigForUser_RealStore(t *testing.T) {
mgr, s := newAgentConfigTestMgr(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.GetAgentConfigForUser(ctx, testAccountID, "user-in")
require.NoError(t, err)
assert.True(t, setupIn.Configured)
require.Len(t, setupIn.Providers, 1)
setupOut, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-out")
require.NoError(t, err)
assert.True(t, setupOut.Configured, "the account is set up, so the user reads as configured")
assert.Equal(t, "https://"+testEndpoint, setupOut.Endpoint)
assert.Empty(t, setupOut.Providers, "user outside the policy's source groups is authorized for nothing")
}
// TestGetUsageOverview_RealStore_SelfScoped pins the self-scope fallback:
// a caller without the account-wide usage grant gets the same aggregation
// the admin overview serves, but only ever their own rows — a user_id
// filter for someone else must be overridden, not honored, and never
// denied. A caller holding the grant keeps the account-wide view.
func TestGetUsageOverview_RealStore_SelfScoped(t *testing.T) {
mgr, s := newAgentConfigTestMgr(t)
mgr.permissionsManager = permissions.NewManager(s)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser,
}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
}))
own1 := newIngestTestEntry()
own1.ID, own1.UserId = "log-own-1", "user-a"
own2 := newIngestTestEntry()
own2.ID, own2.UserId = "log-own-2", "user-a"
other := newIngestTestEntry()
other.ID, other.UserId = "log-other", "user-b"
for _, e := range []*accesslogs.AccessLogEntry{own1, own2, other} {
require.NoError(t, IngestAccessLog(ctx, s, e))
}
otherID := "user-b"
filter := types.AgentNetworkAccessLogFilter{UserID: &otherID}
buckets, err := mgr.GetUsageOverview(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity(""))
require.NoError(t, err)
require.Len(t, buckets, 1, "same-day rows aggregate into one daily bucket")
assert.Equal(t, int64(200), buckets[0].InputTokens, "only the caller's two rows count — the foreign user_id filter is overridden")
assert.Equal(t, int64(100), buckets[0].OutputTokens)
adminBuckets, err := mgr.GetUsageOverview(ctx, testAccountID, "admin", types.AgentNetworkAccessLogFilter{}, types.ParseUsageGranularity(""))
require.NoError(t, err)
require.Len(t, adminBuckets, 1)
assert.Equal(t, int64(300), adminBuckets[0].InputTokens, "the account-wide grant keeps the unscoped view")
}
@@ -81,6 +81,10 @@ type Provider struct {
// surface — the proxy middleware then falls back to URL sniffing
// or skips request-side enrichment.
ParserID string
// RouterVendors declares every parser surface a gateway route can serve.
// Leave empty for single-surface providers, where ParserID remains the
// router discriminator for backward compatibility.
RouterVendors []string
// PricingSurfaces names the cost-meter pricing surfaces this
// provider's Models are priced under ("openai", "anthropic",
// "bedrock" — the llm.Parser surface the request parser stamps as
@@ -116,8 +120,7 @@ type Provider struct {
// Discovery, when non-nil, describes how to ask this vendor which
// models the operator's own credential can actually reach, so the
// provider form can offer a live list instead of only the hand-curated
// Models above. Nil for entries with no listing endpoint (gateways
// vary too much) — those keep free-text entry.
// Models above. Nil entries keep free-text entry.
Discovery *Discovery
}
@@ -154,10 +157,13 @@ const (
// one from the caller is also what keeps this from being an open proxy: the
// only hosts management will dial are the ones written here.
type Discovery struct {
Host string
Path string
Query string
Shape ListingShape
Host string
Path string
Query string
Shape ListingShape
// ExactModelsOnly omits wildcard patterns from listings when NetBird's
// provider model rows cannot represent the vendor's matching semantics.
ExactModelsOnly bool
// Headers are static headers the vendor requires beyond the credential
// (Anthropic versions its API through one and rejects a request without
// it). The auth header itself comes from AuthHeaderName/Template.
@@ -635,6 +641,34 @@ var providers = []Provider{
},
Models: []Model{},
},
{
ID: "agentgateway",
Kind: KindGateway,
Name: "agentgateway",
Description: "Bring your own agentgateway with trusted NetBird identity stamped on every request",
DefaultHost: "",
AuthHeaderName: "Authorization",
AuthHeaderTemplate: "Bearer ${API_KEY}",
DefaultContentType: "application/json",
BrandColor: "#8023C3",
// Agentgateway accepts both OpenAI and Anthropic request shapes.
// Leave ParserID empty so the proxy detects the shape from the URL.
ParserID: "",
RouterVendors: []string{"openai", "anthropic"},
PricingSurfaces: []string{"openai", "anthropic"},
Discovery: &Discovery{
Path: "/v1/models",
Shape: ShapeOpenAIData,
ExactModelsOnly: true,
},
IdentityInjection: &IdentityInjection{
HeaderPair: &HeaderPairInjection{
EndUserIDHeader: "x-netbird-user-id",
TagsHeader: "x-netbird-groups",
},
},
Models: []Model{},
},
{
ID: "portkey",
Kind: KindGateway,
@@ -5,6 +5,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// TestClaudeLineupSelectable pins the models Claude Code resolves to by
@@ -34,3 +36,51 @@ func TestClaudeLineupSelectable(t *testing.T) {
}
}
}
func TestAgentgatewayCatalogEntry(t *testing.T) {
entry, ok := Lookup("agentgateway")
require.True(t, ok, "agentgateway must be available in the provider catalog")
assert.Equal(t, KindGateway, entry.Kind, "agentgateway must be grouped with AI gateways")
assert.Empty(t, entry.DefaultHost, "operators must provide their agentgateway proxy URL")
assert.Equal(t, "Authorization", entry.AuthHeaderName)
assert.Equal(t, "Bearer ${API_KEY}", entry.AuthHeaderTemplate)
assert.Equal(t, "application/json", entry.DefaultContentType)
assert.Empty(t, entry.ParserID, "URL detection must select the OpenAI or Anthropic parser")
assert.Equal(t, []string{"openai", "anthropic"}, entry.RouterVendors,
"agentgateway must accept both parser surfaces")
assert.Equal(t, []string{"openai", "anthropic"}, entry.PricingSurfaces,
"agentgateway models can use either pricing surface")
assert.Empty(t, entry.Models, "an empty model list makes agentgateway a catch-all route")
require.NotNil(t, entry.Discovery)
assert.Empty(t, entry.Discovery.Host, "discovery must use the configured proxy URL")
assert.Equal(t, "/v1/models", entry.Discovery.Path)
assert.Equal(t, ShapeOpenAIData, entry.Discovery.Shape)
assert.True(t, entry.Discovery.ExactModelsOnly,
"wildcard model semantics are not supported by NetBird")
require.NotNil(t, entry.IdentityInjection)
require.NotNil(t, entry.IdentityInjection.HeaderPair)
assert.Nil(t, entry.IdentityInjection.JSONMetadata)
assert.False(t, entry.IdentityInjection.HeaderPair.Customizable,
"NetBird identity header names are part of the integration contract")
assert.Equal(t, "x-netbird-user-id", entry.IdentityInjection.HeaderPair.EndUserIDHeader)
assert.Equal(t, "x-netbird-groups", entry.IdentityInjection.HeaderPair.TagsHeader)
assert.False(t, entry.IdentityInjection.HeaderPair.EndUserIDInBody)
assert.False(t, entry.IdentityInjection.HeaderPair.TagsInBody)
}
func TestAgentgatewayCatalogAPIResponse(t *testing.T) {
entry, ok := Lookup("agentgateway")
require.True(t, ok)
resp := entry.ToAPIResponse()
assert.Equal(t, "agentgateway", resp.Id)
assert.Equal(t, api.AgentNetworkCatalogProviderKindGateway, resp.Kind)
assert.Empty(t, resp.Models)
require.NotNil(t, resp.IdentityInjection)
require.NotNil(t, resp.IdentityInjection.HeaderPair)
assert.False(t, resp.IdentityInjection.HeaderPair.Customizable)
assert.Equal(t, "x-netbird-user-id", resp.IdentityInjection.HeaderPair.EndUserIdHeader)
assert.Equal(t, "x-netbird-groups", resp.IdentityInjection.HeaderPair.TagsHeader)
}
@@ -0,0 +1,135 @@
package agentnetwork
import (
"context"
"errors"
"net/http"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// ModelLister is the vendor-facing half of the credential check.
// modeldiscovery.Client is the only production implementation; it is an
// interface because the check runs on a write path, so without a seam every
// test that saves a provider would reach a vendor to do it.
type ModelLister interface {
Fetch(ctx context.Context, req modeldiscovery.Request) ([]modeldiscovery.Model, error)
}
// checkProviderCredential refuses a record whose upstream or credential the
// vendor will not accept.
//
// It reuses the discovery Fetch rather than a lighter status probe so it
// exercises the path the model picker takes: a URL answering 200 with a login
// page fails here instead of producing an empty picker later.
func (m *managerImpl) checkProviderCredential(ctx context.Context, provider *types.Provider) error {
// A record that asks the proxy to skip certificate verification is one this
// check cannot speak for. Discovery verifies certificates, so a self-hosted
// endpoint behind a self-signed one would be refused for a reason the
// operator already told us to ignore — a lockout of exactly the setup the
// flag exists for. Sending the credential over a connection management
// declines to verify is the other way out, and a worse one.
if provider.SkipTLSVerification {
log.WithContext(ctx).Debugf("agent network provider %s not credential-checked: tls verification is disabled for it", provider.ProviderID)
return nil
}
_, err := m.modelDiscovery.Fetch(ctx, modeldiscovery.Request{
CatalogID: provider.ProviderID,
UpstreamURL: provider.UpstreamURL,
APIKey: provider.APIKey,
})
if err == nil {
return nil
}
message, blocking := credentialCheckFailure(err)
if !blocking {
log.WithContext(ctx).Debugf("agent network provider %s not credential-checked: %v", provider.ProviderID, err)
return nil
}
// WriteError logs only what we return, and that carries no status code,
// so the vendor's number is recorded here or nowhere.
log.WithContext(ctx).Infof("agent network provider %s failed its credential check: %v", provider.ProviderID, err)
return status.Errorf(status.InvalidArgument, "%s", message)
}
// discoveryFailure renders a failed model listing for the operator who pressed
// the button. Every outcome here is something they did or configured — a key
// the vendor refused, an upstream that does not answer — so it owes them the
// same sentence a refused save gives, not the generic 500 an unclassified
// error turns into.
//
// ErrNoDiscovery and ErrInvalidRequest pass through untouched: the handler
// already maps them, and "this provider has no listing endpoint" is a fact
// about the catalog rather than a failure to report as one.
func discoveryFailure(ctx context.Context, catalogID string, err error) error {
if errors.Is(err, modeldiscovery.ErrNoDiscovery) || errors.Is(err, modeldiscovery.ErrInvalidRequest) {
return err
}
message, _ := credentialCheckFailure(err)
if message == "" {
return err
}
// The operator's message carries no status code, so the vendor's number is
// recorded here or nowhere.
log.WithContext(ctx).Infof("agent network model discovery for %s failed: %v", catalogID, err)
return status.Errorf(status.InvalidArgument, "%s", message)
}
// credentialCheckFailure renders a discovery failure as the sentence the
// provider form shows, and reports whether it should block the write.
//
// The strings survive WriteError lowercasing them, and never echo the
// operator's URL: paths are case-sensitive, so an echoed URL comes back
// altered and describes something they did not type.
func credentialCheckFailure(err error) (message string, blocking bool) {
// Not checkable. The record may be perfectly good and we have no way to
// ask, so reporting a failure would be a guess.
switch {
case errors.Is(err, modeldiscovery.ErrNoDiscovery),
errors.Is(err, modeldiscovery.ErrNoDiscoveryHost),
errors.Is(err, modeldiscovery.ErrPrivateHost):
return "", false
}
var vendor *modeldiscovery.VendorStatusError
if errors.As(err, &vendor) {
switch vendor.Status {
case http.StatusUnauthorized, http.StatusForbidden:
return "the provider rejected the credential", true
case http.StatusNotFound, http.StatusMethodNotAllowed:
return "the upstream url did not answer a model listing", true
default:
// 5xx and 429 included: an outage still leaves the record
// unverified, which is what this refuses to save.
return "the provider returned an error", true
}
}
var unreachable *modeldiscovery.UnreachableError
if errors.As(err, &unreachable) {
if reason := unreachable.Reason(); reason != "" {
return "the upstream url could not be reached: " + reason, true
}
return "the upstream url could not be reached", true
}
if errors.Is(err, modeldiscovery.ErrUnparseableListing) {
return "the upstream url answered, but not with a model listing", true
}
// Ours rather than the vendor's — a request this code built badly, or a
// catalog entry that does not match its parser. Still unverified, so it
// still blocks.
return "the provider could not be checked", true
}
@@ -0,0 +1,605 @@
package agentnetwork
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"os"
"syscall"
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/status"
)
// stubLister stands in for the vendor on the write path. It records what it
// was asked so a test can assert not only that the check ran, but that it ran
// against the right upstream and the right credential — and, for an edit that
// touches neither, that it did not run at all.
type stubLister struct {
err error
requests []modeldiscovery.Request
}
func (s *stubLister) Fetch(_ context.Context, req modeldiscovery.Request) ([]modeldiscovery.Model, error) {
s.requests = append(s.requests, req)
if s.err != nil {
return nil, s.err
}
return []modeldiscovery.Model{{ID: "a-model", PricingKnown: true}}, nil
}
func (s *stubLister) calls() int { return len(s.requests) }
func (s *stubLister) only(t *testing.T) modeldiscovery.Request {
t.Helper()
require.Len(t, s.requests, 1, "the vendor must be asked exactly once")
return s.requests[0]
}
// TestCredentialCheckFailure_SeparatesTheUrlFromTheCredential is the contract
// the provider form is written against: an operator gets told which of the two
// fields they have to look at, and the message says so without a status code
// and without echoing the URL back at them.
func TestCredentialCheckFailure_SeparatesTheUrlFromTheCredential(t *testing.T) {
cases := []struct {
name string
err error
want string
}{
{
name: "401 is the credential",
err: &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401},
want: "the provider rejected the credential",
},
{
name: "403 is the credential",
err: &modeldiscovery.VendorStatusError{Provider: "Bedrock", Status: 403},
want: "the provider rejected the credential",
},
{
// The host authenticated us fine and then said it has no such
// endpoint, which is the URL being wrong rather than the key.
name: "404 is the url",
err: &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 404},
want: "the upstream url did not answer a model listing",
},
{
name: "405 is the url",
err: &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 405},
want: "the upstream url did not answer a model listing",
},
{
name: "500 is the vendor",
err: &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 500},
want: "the provider returned an error",
},
{
name: "503 is the vendor",
err: &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 503},
want: "the provider returned an error",
},
{
name: "429 is the vendor",
err: &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 429},
want: "the provider returned an error",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, blocking := credentialCheckFailure(tc.err)
require.True(t, blocking, "a vendor refusal must block the write")
require.Equal(t, tc.want, got)
})
}
}
// TestCredentialCheckFailure_NamesTheTransportFault covers the failures that
// never reached the vendor. The distinction inside them is worth keeping: a
// refused connection is a wrong port and an unknown host is a wrong hostname,
// and an operator staring at a URL they believe in needs to be told which.
func TestCredentialCheckFailure_NamesTheTransportFault(t *testing.T) {
cases := []struct {
name string
err error
want string
}{
{
name: "unknown host",
err: &net.DNSError{Err: "no such host", Name: "api.example.com", IsNotFound: true},
want: "the upstream url could not be reached: no such host",
},
{
name: "dns failure that is not a missing name",
err: &net.DNSError{Err: "server misbehaving", Name: "api.example.com"},
want: "the upstream url could not be reached: dns lookup failed",
},
{
name: "connection refused",
err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
want: "the upstream url could not be reached: connection refused",
},
{
name: "host unreachable",
err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.EHOSTUNREACH},
want: "the upstream url could not be reached: host unreachable",
},
{
name: "timeout",
err: fmt.Errorf("dial: %w", os.ErrDeadlineExceeded),
want: "the upstream url could not be reached: connection timed out",
},
{
name: "context deadline",
err: fmt.Errorf("dial: %w", context.DeadlineExceeded),
want: "the upstream url could not be reached: connection timed out",
},
{
name: "untrusted certificate",
err: &tls.CertificateVerificationError{},
want: "the upstream url could not be reached: tls certificate not trusted",
},
{
name: "plaintext service on an https url",
err: tls.RecordHeaderError{Msg: "first record does not look like a TLS handshake"},
want: "the upstream url could not be reached: not a tls endpoint",
},
{
// Nothing we recognise. Better to say only that it could not be
// reached than to paste a Go error into the provider form.
name: "cause we do not recognise",
err: errors.New("something went sideways"),
want: "the upstream url could not be reached",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
wrapped := &modeldiscovery.UnreachableError{Provider: "OpenAI", Err: tc.err}
got, blocking := credentialCheckFailure(wrapped)
require.True(t, blocking, "an unreachable upstream must block the write")
require.Equal(t, tc.want, got)
})
}
}
// TestCredentialCheckFailure_AnAnsweringUrlThatIsNotTheApi covers the case a
// status probe would wave through: the host is up, the credential was accepted
// or not required, and the body is a login page. Reusing the discovery parser
// for the check is what catches it.
func TestCredentialCheckFailure_AnAnsweringUrlThatIsNotTheApi(t *testing.T) {
err := fmt.Errorf("%w: decode model listing: unexpected token", modeldiscovery.ErrUnparseableListing)
got, blocking := credentialCheckFailure(err)
require.True(t, blocking)
require.Equal(t, "the upstream url answered, but not with a model listing", got)
}
// TestCredentialCheckFailure_WhatCannotBeCheckedIsNotAFailure pins the
// difference between "this record is wrong" and "we have no way to ask". A
// gateway with no listing endpoint, a Bedrock record pointed at a proxy, and a
// self-hosted endpoint the proxy reaches through the tunnel are all legitimate
// providers. Blocking them would make the feature a lockout.
func TestCredentialCheckFailure_WhatCannotBeCheckedIsNotAFailure(t *testing.T) {
cases := map[string]error{
"no listing endpoint": modeldiscovery.ErrNoDiscovery,
"no derivable host": fmt.Errorf("%w: %w: bedrock", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrNoDiscoveryHost),
"private upstream": fmt.Errorf("%w: %w: 10.0.0.5", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrPrivateHost),
}
for name, err := range cases {
t.Run(name, func(t *testing.T) {
message, blocking := credentialCheckFailure(err)
require.False(t, blocking, "a provider we cannot check must still save")
require.Empty(t, message)
})
}
}
// TestCredentialCheckFailure_AnUnrecognisedFailureStillBlocks covers a fault of
// ours rather than the vendor's — a malformed request this code built, or a
// catalog entry whose parser does not match its endpoint. The record went
// unverified either way, and silently saving what we could not check is the
// thing this feature exists to prevent.
func TestCredentialCheckFailure_AnUnrecognisedFailureStillBlocks(t *testing.T) {
message, blocking := credentialCheckFailure(errors.New("no parser for listing shape \"\""))
require.True(t, blocking)
require.Equal(t, "the provider could not be checked", message)
}
// newCheckedProvider returns a record shaped the way the handler guarantees
// one: a known catalog id, a public upstream and a key.
func newCheckedProvider(accountID string) *types.Provider {
provider := types.NewProvider(accountID)
provider.ProviderID = "openai_api"
provider.Name = "openai"
provider.UpstreamURL = "https://api.openai.com"
provider.APIKey = "sk-good"
provider.Enabled = true
return provider
}
// TestCreateProvider_RefusesARecordTheVendorRejects is the whole point of the
// feature: a key with a character missing used to save cleanly and surface
// minutes later as a failed request with nothing pointing back at the record.
func TestCreateProvider_RefusesARecordTheVendorRejects(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401}
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.Error(t, err)
require.Contains(t, err.Error(), "the provider rejected the credential")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
require.Equal(t, status.InvalidArgument, sErr.Type(), "the refusal must reach the caller as a 422")
stored, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
require.Empty(t, stored, "a record that failed its check must not be written")
}
// TestCreateProvider_ChecksTheCredentialItWasGiven pins what the vendor is
// asked with, since a check run against the wrong upstream or a stale key
// would pass while proving nothing.
func TestCreateProvider_ChecksTheCredentialItWasGiven(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.NoError(t, err)
asked := f.vendor.only(t)
require.Equal(t, "openai_api", asked.CatalogID)
require.Equal(t, "https://api.openai.com", asked.UpstreamURL)
require.Equal(t, "sk-good", asked.APIKey)
}
// TestUpdateProvider_AUrlOnlyChangeIsCheckedAgainstTheStoredKey covers the
// case that shaped where the check sits. The key never returns to the browser,
// so an operator editing only the URL has none to offer — the stored one is
// the only credential there is, and the new URL still has to be proven with
// it.
func TestUpdateProvider_AUrlOnlyChangeIsCheckedAgainstTheStoredKey(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.NoError(t, err)
f.vendor.requests = nil
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
edit := newCheckedProvider("account1")
edit.ID = created.ID
edit.UpstreamURL = "https://gateway.example.com"
edit.APIKey = "" // the form sends no key when it was not retyped
_, err = f.manager.UpdateProvider(ctx, "user1", edit)
require.NoError(t, err)
asked := f.vendor.only(t)
require.Equal(t, "https://gateway.example.com", asked.UpstreamURL, "the new url must be what gets tested")
require.Equal(t, "sk-good", asked.APIKey, "and the stored key must be what tests it")
}
// TestUpdateProvider_AFailedRotationLeavesTheWorkingKeyInPlace is the
// half-applied state the check must never produce: refusing the new key while
// having already replaced the old one would take the provider down.
func TestUpdateProvider_AFailedRotationLeavesTheWorkingKeyInPlace(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.NoError(t, err)
f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 403}
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
rotation := newCheckedProvider("account1")
rotation.ID = created.ID
rotation.APIKey = "sk-typo"
_, err = f.manager.UpdateProvider(ctx, "user1", rotation)
require.Error(t, err)
require.Contains(t, err.Error(), "the provider rejected the credential")
stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, "account1", created.ID)
require.NoError(t, err)
require.Equal(t, "sk-good", stored.APIKey, "the rejected key must not have replaced the working one")
}
// TestUpdateProvider_AnEditTouchingNeitherFieldAsksNoVendor keeps renames,
// model rows and price edits off the vendor's doorstep. They have nothing new
// to prove, and making them wait on a vendor — or fail because one is having a
// bad day — would be a tax on edits that carry no risk.
func TestUpdateProvider_AnEditTouchingNeitherFieldAsksNoVendor(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.NoError(t, err)
f.vendor.requests = nil
// Any call at all now would fail the update, which is what makes the
// assertion below load-bearing rather than decorative.
f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 500}
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
rename := newCheckedProvider("account1")
rename.ID = created.ID
rename.Name = "openai-renamed"
rename.APIKey = ""
_, err = f.manager.UpdateProvider(ctx, "user1", rename)
require.NoError(t, err, "an edit that changes neither url nor key must not be checked")
require.Zero(t, f.vendor.calls(), "and must not reach the vendor at all")
}
// TestCreateProvider_AProviderWeCannotCheckStillSaves covers the eleven
// catalog entries with no listing endpoint, a Bedrock record behind a proxy,
// and a self-hosted endpoint on a private network. None of those are evidence
// the record is wrong, and refusing them would make this a lockout.
func TestCreateProvider_AProviderWeCannotCheckStillSaves(t *testing.T) {
cases := map[string]error{
"gateway with no listing endpoint": modeldiscovery.ErrNoDiscovery,
"bedrock behind a proxy": fmt.Errorf("%w: %w", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrNoDiscoveryHost),
"self-hosted on a private network": fmt.Errorf("%w: %w", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrPrivateHost),
}
for name, vendorErr := range cases {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.vendor.err = vendorErr
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.NoError(t, err)
require.NotNil(t, created)
stored, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
require.Len(t, stored, 1, "a provider we cannot check must still be written")
})
}
}
// TestDiscoveryFailure_TellsTheOperatorWhatWentWrong covers the button, not the
// save. Pressing "Load models from provider" against a bad key used to answer
// "internal server error", which names neither the thing that failed nor
// anything the operator could act on — every outcome here is their key or their
// URL.
func TestDiscoveryFailure_TellsTheOperatorWhatWentWrong(t *testing.T) {
cases := map[string]struct {
err error
want string
}{
"refused credential": {
err: &modeldiscovery.VendorStatusError{Provider: "Bedrock", Status: 403},
want: "the provider rejected the credential",
},
"upstream that is not the api": {
err: &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 404},
want: "the upstream url did not answer a model listing",
},
"upstream that does not resolve": {
err: &modeldiscovery.UnreachableError{
Provider: "OpenAI",
Err: &net.DNSError{Err: "no such host", Name: "api.example.com", IsNotFound: true},
},
want: "the upstream url could not be reached: no such host",
},
"vendor having a bad day": {
err: &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 503},
want: "the provider returned an error",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
err := discoveryFailure(context.Background(), "openai_api", tc.err)
require.EqualError(t, err, tc.want)
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
require.Equal(t, status.InvalidArgument, sErr.Type(),
"a failure the operator caused must not read as a server fault")
})
}
}
// TestDiscoveryFailure_LeavesTheCatalogFactsAlone keeps the two outcomes the
// handler already maps. A provider with no listing endpoint is a fact about the
// catalog entry, and the caller falls back to the catalog's own models rather
// than showing an error at all — rewriting it as a refusal would turn a normal
// path into one.
func TestDiscoveryFailure_LeavesTheCatalogFactsAlone(t *testing.T) {
for name, err := range map[string]error{
"no listing endpoint": modeldiscovery.ErrNoDiscovery,
"bad request": fmt.Errorf("%w: unknown catalog provider", modeldiscovery.ErrInvalidRequest),
} {
t.Run(name, func(t *testing.T) {
require.Equal(t, err, discoveryFailure(context.Background(), "openai_api", err),
"the handler's own mapping must still see the original error")
})
}
}
// TestDiscoverProviderModels_SurfacesTheVendorRefusal drives the manager rather
// than the classifier, so a future refactor that stops translating on this path
// fails here rather than silently going back to 500s.
func TestDiscoverProviderModels_SurfacesTheVendorRefusal(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401}
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
CatalogID: "openai_api",
UpstreamURL: "https://api.openai.com",
APIKey: "sk-wrong",
}, "")
require.EqualError(t, err, "the provider rejected the credential")
}
// TestDiscoverProviderModels_ListsAgainstTheUrlOnTheForm covers the edit the
// operator cannot otherwise make: the upstream has been retyped and the
// credential has not, because the API never returned it to be retyped. Naming
// the record supplies the key; the request supplies the URL under test.
func TestDiscoverProviderModels_ListsAgainstTheUrlOnTheForm(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
// Twice: the create, and the listing, which is gated on Create too.
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.NoError(t, err)
f.vendor.requests = nil
_, err = f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
CatalogID: "openai_api",
UpstreamURL: "https://gateway.example.com",
}, created.ID)
require.NoError(t, err)
asked := f.vendor.only(t)
require.Equal(t, "https://gateway.example.com", asked.UpstreamURL, "the typed url must be the one listed against")
require.Equal(t, "sk-good", asked.APIKey, "and the stored key must be what lists it")
}
// TestDiscoverProviderModels_FallsBackToTheStoredUrl keeps the plain refresh
// working: a request naming only the record still reaches the saved upstream.
func TestDiscoverProviderModels_FallsBackToTheStoredUrl(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.NoError(t, err)
stored := f.vendor.only(t).UpstreamURL
f.vendor.requests = nil
_, err = f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
CatalogID: "openai_api",
}, created.ID)
require.NoError(t, err)
require.Equal(t, stored, f.vendor.only(t).UpstreamURL)
}
// TestUpdateProvider_MovingARecordToAnotherVendorIsChecked covers the edit that
// changes neither field the vendor judges and still invalidates both. The
// catalog entry decides which vendor is asked and under which auth header, so
// the unchanged credential is now being offered somewhere it has never been
// accepted.
func TestUpdateProvider_MovingARecordToAnotherVendorIsChecked(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
require.NoError(t, err)
f.vendor.requests = nil
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
edit := newCheckedProvider("account1")
edit.ID = created.ID
edit.ProviderID = "anthropic_api"
edit.APIKey = ""
_, err = f.manager.UpdateProvider(ctx, "user1", edit)
require.NoError(t, err)
require.Equal(t, "anthropic_api", f.vendor.only(t).CatalogID,
"the new vendor is the one that has to accept the key")
}
// TestCreateProvider_ASkipTlsRecordIsNotCheckedAgainstItsCertificate covers the
// lockout the check would otherwise be: the flag exists for a self-hosted
// endpoint behind a certificate nothing public can verify, and discovery
// verifies certificates. Refusing the save would reject the record for the one
// reason the operator already declared they accept.
func TestCreateProvider_ASkipTlsRecordIsNotCheckedAgainstItsCertificate(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.vendor.err = &modeldiscovery.UnreachableError{
Provider: "OpenAI",
Err: &tls.CertificateVerificationError{},
}
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
provider := newCheckedProvider("account1")
provider.SkipTLSVerification = true
created, err := f.manager.CreateProvider(ctx, "user1", provider)
require.NoError(t, err, "a record we were told not to verify must still save")
require.NotEmpty(t, created.ID)
require.Zero(t, f.vendor.calls(), "and the vendor must not be asked at all")
}
// TestCreateProvider_TheStoredKeyIsTheOneThatWasChecked pins the two halves to
// one value. The vendor call trims the credential before building its auth
// header; the synthesiser substitutes the stored one verbatim. A key pasted
// with surrounding whitespace would otherwise pass its check and then fail
// every request the provider serves.
func TestCreateProvider_TheStoredKeyIsTheOneThatWasChecked(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
provider := newCheckedProvider("account1")
provider.APIKey = " sk-good\n"
created, err := f.manager.CreateProvider(ctx, "user1", provider)
require.NoError(t, err)
require.Equal(t, "sk-good", f.vendor.only(t).APIKey, "the vendor is asked about the trimmed key")
stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, "account1", created.ID)
require.NoError(t, err)
require.Equal(t, "sk-good", stored.APIKey, "and that is the one the proxy will send")
}
// TestUpdateProvider_TurningTlsVerificationBackOnChecksTheRecord covers the
// hole the skip-TLS exemption opens on its own. Such a record is stored without
// ever being checked, so the moment verification is switched back on is the
// first moment it can be checked at all — and none of the three fields the
// re-check usually watches has to move for that to happen.
func TestUpdateProvider_TurningTlsVerificationBackOnChecksTheRecord(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
unchecked := newCheckedProvider("account1")
unchecked.SkipTLSVerification = true
created, err := f.manager.CreateProvider(ctx, "user1", unchecked)
require.NoError(t, err)
require.Zero(t, f.vendor.calls(), "the create was exempt")
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
edit := newCheckedProvider("account1")
edit.ID = created.ID
edit.APIKey = ""
edit.SkipTLSVerification = false
_, err = f.manager.UpdateProvider(ctx, "user1", edit)
require.NoError(t, err)
require.Equal(t, 1, f.vendor.calls(), "switching verification on must check what was never checked")
}
@@ -0,0 +1,56 @@
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"
)
// addAgentConfigEndpoints registers the self-service agent-config route.
// It is available to every authenticated user regardless of role: the
// providers in the response are scoped strictly to the caller, which is
// tighter than any role gate could be. The caller's own usage and requests are served by
// the regular usage/logs endpoints, which self-scope for callers without
// the account-wide grants.
func (h *handler) addAgentConfigEndpoints(router *mux.Router) {
router.HandleFunc("/agent-network/agent-config", h.getAgentConfig).Methods("GET", "OPTIONS")
}
func (h *handler) getAgentConfig(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.GetAgentConfigForUser(r.Context(), userAuth.AccountId, userAuth.UserId)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, agentConfigToAPI(setup))
}
func agentConfigToAPI(setup *types.AgentConfig) api.AgentNetworkAgentConfig {
providers := make([]api.AgentNetworkAgentConfigProvider, 0, len(setup.Providers))
for _, p := range setup.Providers {
providers = append(providers, api.AgentNetworkAgentConfigProvider{
Name: p.Name,
CatalogId: p.CatalogID,
ApiFlavor: p.APIFlavor,
AllModelsAllowed: p.AllModelsAllowed,
Models: p.Models,
})
}
return api.AgentNetworkAgentConfig{
Configured: setup.Configured,
Endpoint: setup.Endpoint,
Providers: providers,
}
}
@@ -46,6 +46,7 @@ func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) {
h.addConsumptionEndpoints(router)
h.addAccessLogEndpoints(router)
h.addBudgetRuleEndpoints(router)
h.addAgentConfigEndpoints(router)
}
func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
@@ -339,6 +340,14 @@ func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error {
if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") {
return status.Errorf(status.InvalidArgument, "api_key is required")
}
// An update omits api_key to keep the stored credential. A key that is
// present but blank is not that: Provider.FromAPIRequest drops it exactly
// as if it were absent, so a rotation the operator believes they performed
// would answer 200 having changed nothing. Refuse it here, where the
// request still carries the difference between absent and blank.
if req.ApiKey != nil && strings.TrimSpace(*req.ApiKey) == "" {
return status.Errorf(status.InvalidArgument, "api_key must be omitted to keep the stored credential rather than sent blank")
}
if req.Models != nil {
for i, m := range *req.Models {
if err := validateModel(i, m); err != nil {
@@ -54,6 +54,39 @@ func TestValidate_ModelRates(t *testing.T) {
}
}
// TestValidate_ABlankApiKeyIsNotTheSameAsAnOmittedOne covers the one shape the
// manager's own guard cannot see. Provider.FromAPIRequest assigns the key only
// when it trims to something, so a request carrying " " arrives at
// UpdateProvider indistinguishable from one that omitted it — the stored
// credential is kept and the write answers 200, telling an operator who thinks
// they just rotated a key that it worked.
//
// The request still knows the difference, so the refusal belongs here.
func TestValidate_ABlankApiKeyIsNotTheSameAsAnOmittedOne(t *testing.T) {
req := func(key *string) *api.AgentNetworkProviderRequest {
return &api.AgentNetworkProviderRequest{
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
ApiKey: key,
}
}
blank := " "
err := validate(req(&blank), false)
require.Error(t, err, "a blank api_key on update must not be read as 'keep what is stored'")
assert.Contains(t, err.Error(), "api_key")
require.NoError(t, validate(req(nil), false), "an omitted api_key is how an update keeps the stored credential")
// Create already refuses this, and keeps its own message: a caller who sent
// no usable key is told the field is required rather than being told how to
// preserve a credential that does not exist yet.
err = validate(req(&blank), true)
require.Error(t, err)
assert.Contains(t, err.Error(), "api_key is required")
}
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
// with the other PUT endpoints: the request replaces the provider's mutable
// state, so optional fields absent from the JSON land as their zero values.
@@ -64,10 +97,13 @@ func TestValidate_ModelRates(t *testing.T) {
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
// A private upstream: the save-time credential check leaves it unchecked
// rather than spending "sk-test" against the real api.openai.com, which
// the vendor refuses.
create := `{
"provider_id": "openai_api",
"name": "openai",
"upstream_url": "https://api.openai.com",
"upstream_url": "https://10.255.255.1",
"api_key": "sk-test",
"enabled": true,
"metadata_disabled": true,
@@ -84,7 +120,7 @@ func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
// Minimal update: only the required fields, no api_key. Everything
// optional must land as its zero value.
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}`
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://10.255.255.1", "enabled": true}`
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
@@ -3,9 +3,10 @@ package labelgen
import (
"fmt"
"math/rand"
"sort"
"sync"
"github.com/netbirdio/netbird/management/server/util"
)
// pickAttempts caps the random retries before falling back to the
@@ -40,16 +41,15 @@ func uniqueWords() []string {
// PickUnique selects a label not already in `taken`. It tries up to
// pickAttempts random picks; on exhaustion it scans the deduplicated
// wordlist for any remaining free entry, and if none is left appends
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
// is responsible for seeding rng (math/rand).
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
// `-<fallbackSuffix>` to a random word and returns.
func PickUnique(taken map[string]struct{}, fallbackSuffix string) string {
pool := uniqueWords()
if len(pool) == 0 {
return fallbackSuffix
}
for i := 0; i < pickAttempts; i++ {
w := pool[rng.Intn(len(pool))]
w := pool[util.RandIntn(len(pool))]
if _, ok := taken[w]; !ok {
return w
}
@@ -61,7 +61,7 @@ func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string
}
}
w := pool[rng.Intn(len(pool))]
w := pool[util.RandIntn(len(pool))]
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
}
@@ -74,10 +74,10 @@ func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string
// a noun spans len(adjectives) * 857 instead. Uniqueness is enforced by a
// database constraint and retried by the caller, rather than guessed from a
// pre-read set that a concurrent allocation can invalidate.
func PickTuple(rng *rand.Rand) string {
func PickTuple() string {
nouns := uniqueWords()
if len(nouns) == 0 || len(adjectives) == 0 {
return ""
}
return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))]
return adjectives[util.RandIntn(len(adjectives))] + "-" + nouns[util.RandIntn(len(nouns))]
}
@@ -1,7 +1,7 @@
package labelgen
import (
"math/rand"
"slices"
"strings"
"testing"
@@ -9,19 +9,12 @@ import (
"github.com/stretchr/testify/require"
)
// TestPickUnique_DeterministicWithSeededRng locks the property the
// caller relies on: same seed + same taken set → same pick. Without
// that, the bootstrap flow can't reproduce a label across retries.
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
taken := map[string]struct{}{}
// TestPickUnique_ReturnsWordFromPool confirms a pick against an empty
// taken set is always drawn verbatim from the wordlist.
func TestPickUnique_ReturnsWordFromPool(t *testing.T) {
got := PickUnique(map[string]struct{}{}, "abcd")
rngA := rand.New(rand.NewSource(42))
rngB := rand.New(rand.NewSource(42))
a := PickUnique(rngA, taken, "abcd")
b := PickUnique(rngB, taken, "abcd")
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
assert.True(t, slices.Contains(uniqueWords(), got), "Pick %q must be drawn from the wordlist", got)
}
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
@@ -46,8 +39,7 @@ func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
taken[w] = struct{}{}
}
rng := rand.New(rand.NewSource(7))
got := PickUnique(rng, taken, "abcd")
got := PickUnique(taken, "abcd")
_, isFree := free[got]
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
@@ -65,8 +57,7 @@ func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
taken[w] = struct{}{}
}
rng := rand.New(rand.NewSource(99))
got := PickUnique(rng, taken, "abcd")
got := PickUnique(taken, "abcd")
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)
@@ -114,9 +105,8 @@ func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
inAdjectives[a] = struct{}{}
}
rng := rand.New(rand.NewSource(7))
for i := 0; i < 200; i++ {
got := PickTuple(rng)
got := PickTuple()
parts := strings.Split(got, "-")
require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
@@ -158,22 +148,13 @@ func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
}
// TestPickTuple_DeterministicWithSeededRng documents that generation is a pure
// function of the rng, which is what makes allocation retries reproducible in tests.
func TestPickTuple_DeterministicWithSeededRng(t *testing.T) {
a := PickTuple(rand.New(rand.NewSource(42)))
b := PickTuple(rand.New(rand.NewSource(42)))
assert.Equal(t, a, b, "Same seed must yield the same tuple")
}
// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
// yield overwhelmingly distinct values.
func TestPickTuple_SpansALargeNamespace(t *testing.T) {
rng := rand.New(rand.NewSource(11))
seen := make(map[string]struct{}, 2000)
for i := 0; i < 2000; i++ {
seen[PickTuple(rng)] = struct{}{}
seen[PickTuple()] = struct{}{}
}
assert.Greater(t, len(seen), 1900,
"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"math/rand"
"slices"
"strings"
"sync"
@@ -85,6 +84,13 @@ 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)
// GetAgentConfigForUser backs the self-service agent-config endpoint.
// Caller-scoped, so it skips the role permission gate; see
// the implementation. The caller's own usage and requests come
// through GetUsageOverview / ListAccessLogs, which self-scope when
// the account-wide grant is missing.
GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error)
}
// PolicySelectionInput is the per-request selection envelope. The
@@ -126,24 +132,34 @@ type managerImpl struct {
proxyController proxy.Controller
// modelDiscovery queries vendors for the models a credential can reach.
// A field rather than a package call so tests can drive it without
// reaching the network.
// An interface rather than the concrete client because it is now on a
// write path: the credential check runs inside CreateProvider and
// UpdateProvider, so every test that saves a provider would otherwise
// reach a vendor over the network to do it.
//
// One instance serves every request for the process's lifetime, so its
// fields must stay read-only after construction: lazy initialisation
// inside Fetch or httpClient would race across request goroutines.
modelDiscovery *modeldiscovery.Client
modelDiscovery ModelLister
// reconcileCache holds the last set of synthesised proxy mappings
// per account, each paired with the proxy that served it, so a change
// of serving proxy can be diffed without re-deriving it.
reconcileMu sync.Mutex
reconcileCache map[string]map[string]syntheticMapping
}
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
// state; concurrent provider creates would otherwise race.
labelRngMu sync.Mutex
labelRng *rand.Rand
// ManagerOption replaces a manager dependency at construction. Production
// passes none; each option exists for something a test cannot let run for
// real.
type ManagerOption func(*managerImpl)
// WithModelLister replaces the vendor call behind the provider credential
// check. A test that saves a provider needs this — the check runs inside
// CreateProvider and UpdateProvider, so the write path reaches a vendor
// without it.
func WithModelLister(lister ModelLister) ManagerOption {
return func(m *managerImpl) { m.modelDiscovery = lister }
}
// NewManager constructs the persistent Agent Network manager. The
@@ -156,37 +172,149 @@ func NewManager(
permissionsManager permissions.Manager,
accountManager account.Manager,
proxyController proxy.Controller,
opts ...ManagerOption,
) Manager {
return &managerImpl{
m := &managerImpl{
store: store,
accountManager: accountManager,
permissionsManager: permissionsManager,
proxyController: proxyController,
modelDiscovery: &modeldiscovery.Client{},
reconcileCache: make(map[string]map[string]syntheticMapping),
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
for _, opt := range opts {
opt(m)
}
return m
}
// GetAllProviders returns the account's providers for callers holding the
// providers read grant (connection config redacted unless they can also
// update). A caller without the grant self-scopes instead of being denied
// — mirroring the usage and log endpoints: they get the providers their
// own policies authorize, redacted to the display surface, which is what
// feeds the dashboard's provider filter for plain users.
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !ok {
return m.callerScopedProviders(ctx, accountID, userID)
}
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
return m.redactProvidersForViewer(ctx, accountID, userID, providers)
}
// GetProvider self-scopes like GetAllProviders: a caller without the read
// grant may fetch a provider their own policies authorize (redacted), and
// gets the same not-found answer for any other id — an out-of-scope
// provider must be indistinguishable from a nonexistent one.
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !ok {
scoped, err := m.callerScopedProviders(ctx, accountID, userID)
if err != nil {
return nil, err
}
for _, p := range scoped {
if p.ID == providerID {
return p, nil
}
}
return nil, status.NewAgentNetworkProviderNotFoundError(providerID)
}
provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
if err != nil {
return nil, err
}
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
redacted, err := m.redactProvidersForViewer(ctx, accountID, userID, []*types.Provider{provider})
if err != nil {
return nil, err
}
return redacted[0], nil
}
// callerScopedProviders returns the providers the caller's own policies
// authorize — the same selection the self-service setup answer and the
// proxy's routing derive from — each reduced to the display surface. No
// role permission is needed: the answer is scoped strictly to the caller,
// and a caller outside every policy gets an empty list, indistinguishable
// from an account with nothing configured.
func (m *managerImpl) callerScopedProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, user.AutoGroups)
if err != nil {
return nil, err
}
var guardrailsByID map[string]*types.Guardrail
if anyPolicyHasGuardrails(applicable) {
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
if err != nil {
return nil, err
}
}
out := make([]*types.Provider, 0, len(authorized))
for _, p := range authorized {
r := p.RedactedForViewer()
// The model list follows the same effective computation the setup
// answer and the proxy use: allowlist-restricted callers see only
// the models their guardrails permit, and an unrestricted policy
// on a provider without an operator declaration surfaces the
// catalog models, matching the setup response — so the dashboard's
// model filter never offers a model the caller's own requests
// could not use, and never comes up empty when the setup page
// lists models. Grant holders keep the full declared lists —
// their usage view spans everyone's requests.
_, effective := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
r.Models = providerModelsByID(p, effective)
out = append(out, r)
}
return out, nil
}
// redactProvidersForViewer strips the connection configuration from
// providers handed to a caller who holds only the read grant on
// agent_network.providers. Update is the managing signal: a role that can
// edit a provider sees its config in the edit form anyway, while a
// read-only role (usage_viewer) only needs the display surface the usage
// filters resolve against — upstream URLs and operator-supplied header
// values are not part of that. Validation errors fail closed.
func (m *managerImpl) redactProvidersForViewer(ctx context.Context, accountID, userID string, providers []*types.Provider) ([]*types.Provider, error) {
canManage, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if canManage {
return providers, nil
}
out := make([]*types.Provider, 0, len(providers))
for _, p := range providers {
if p == nil {
out = append(out, nil)
continue
}
out = append(out, p.RedactedForViewer())
}
return out, nil
}
// DiscoverProviderModels asks the vendor which models a credential can reach.
//
// recordID, when set, names an existing provider whose stored credential and
// upstream are used instead of the ones in req — so the dashboard can refresh
// the list without ever holding the key.
// recordID, when set, names an existing provider whose stored credential is
// used instead of the one in req — so the dashboard can refresh the list
// without ever holding the key. An upstream in req overrides the stored one,
// which is what lets a form list against a URL the operator has typed but not
// saved yet, using the credential they cannot retype.
//
// Gated on Create rather than Read: this spends the operator's credential
// against a third party, which is not something a read-only role should be
@@ -207,11 +335,29 @@ func (m *managerImpl) DiscoverProviderModels(ctx context.Context, accountID, use
// name a different one would run a provider's credential against
// whichever vendor endpoint they picked.
req.CatalogID = record.ProviderID
req.UpstreamURL = record.UpstreamURL
req.APIKey = record.APIKey
// The upstream is the one field the caller may override, so that a URL
// typed into the form can be listed against before it is saved.
//
// It sends the stored credential to a host the caller named, which is
// a capability they already have: the same permission set updates the
// record's upstream, and that write runs this same check against
// whatever it is pointed at. What it would not otherwise be is silent,
// since the write leaves an activity event behind — so the override is
// recorded here.
if strings.TrimSpace(req.UpstreamURL) == "" {
req.UpstreamURL = record.UpstreamURL
} else if req.UpstreamURL != record.UpstreamURL {
log.WithContext(ctx).Infof("agent network provider %s listed against caller-supplied upstream %s by user %s",
recordID, req.UpstreamURL, userID)
}
}
return m.modelDiscovery.Fetch(ctx, req)
models, err := m.modelDiscovery.Fetch(ctx, req)
if err != nil {
return nil, discoveryFailure(ctx, req.CatalogID, err)
}
return models, nil
}
// CreateProvider persists a new provider for the account. Providers have no
@@ -229,6 +375,18 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
if strings.TrimSpace(provider.APIKey) == "" {
return nil, status.Errorf(status.InvalidArgument, "api_key is required when creating an agent network provider")
}
// Stored as it will be sent. The vendor call below trims the key before
// building the auth header while the synthesiser substitutes the stored
// value verbatim, so a key pasted with surrounding whitespace would pass
// its check and then fail every request the provider serves.
provider.APIKey = strings.TrimSpace(provider.APIKey)
// Before anything is persisted: a record whose upstream or credential does
// not work is rejected here rather than discovered later as a failed
// request with nothing pointing back at it.
if err := m.checkProviderCredential(ctx, provider); err != nil {
return nil, err
}
if provider.ID == "" {
fresh := types.NewProvider(provider.AccountID)
@@ -264,11 +422,47 @@ func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provide
// Preserve the API key if the caller didn't rotate it. A
// whitespace-only value is treated as "not rotated" rather than a
// real key, but it must not silently overwrite a valid stored key.
if provider.APIKey == "" {
provider.APIKey = existing.APIKey
} else if strings.TrimSpace(provider.APIKey) == "" {
switch trimmed := strings.TrimSpace(provider.APIKey); {
case provider.APIKey == "":
// Trimmed on the way through: a record stored before keys were
// normalised carries whitespace the proxy still sends, and an edit
// that preserves the key is the occasion to repair it. Doing so makes
// the comparison below see a change, which is correct — that key has
// never been tested in the form it is about to be sent in.
provider.APIKey = strings.TrimSpace(existing.APIKey)
case trimmed == "":
return nil, status.Errorf(status.InvalidArgument, "api_key must be non-blank when rotating an agent network provider")
default:
// See CreateProvider: the key is stored in the form the proxy will
// send, so the check below tests what the provider will actually use.
provider.APIKey = trimmed
}
// Only the fields the vendor would judge are worth a round-trip. This same
// call carries renames, model rows and price edits, and none of those
// should wait on a vendor — or be refused because one is having a bad day.
//
// The catalog entry counts as one of them: it decides which vendor is
// asked, under which auth header, so moving a record from one to another
// sends an unchanged credential somewhere it has never been accepted.
//
// The comparison runs after the merge above, so an update that changes only
// the URL reads as unchanged on the key and is checked against the stored
// one, which is the only credential the operator has to offer here.
//
// Turning TLS verification back on is the fourth: the record was stored
// unchecked precisely because that flag was set, so this is the first
// moment it can be checked at all, and nothing else about it need change
// for that to be true.
if provider.UpstreamURL != existing.UpstreamURL ||
provider.APIKey != existing.APIKey ||
provider.ProviderID != existing.ProviderID ||
(existing.SkipTLSVerification && !provider.SkipTLSVerification) {
if err := m.checkProviderCredential(ctx, provider); err != nil {
return nil, err
}
}
// Always preserve the session keypair across updates so existing
// session cookies stay valid. The keys are server-managed and
// never surfaced through the API.
@@ -990,9 +1184,7 @@ func (m *managerImpl) bootstrapLabeled(ctx context.Context, settings *types.Sett
}
for attempt := 1; attempt <= maxDomainAllocationAttempts; attempt++ {
m.labelRngMu.Lock()
label := labelgen.PickTuple(m.labelRng)
m.labelRngMu.Unlock()
label := labelgen.PickTuple()
if label == "" {
// Only reachable if either word pool were emptied. An empty label
// would produce a broken endpoint like ".example.com", so fail
@@ -1062,8 +1254,11 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
// ListAccessLogs returns a paginated, server-side-filtered page of
// agent-network access logs plus the total count matching the filter.
// Callers without the account-wide logs grant get a self-scoped page —
// only their own requests — instead of a denial.
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter)
if err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
@@ -1071,18 +1266,23 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
// ListAccessLogSessions returns a paginated, server-side-filtered page of
// agent-network access logs grouped by session, plus the total number of
// sessions matching the filter.
// sessions matching the filter. Self-scoped like ListAccessLogs for
// callers without the account-wide logs grant.
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter)
if err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
}
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
// at the requested granularity, oldest-first.
// at the requested granularity, oldest-first. Callers without the
// account-wide usage grant get their own rows aggregated instead of a
// denial, so the dashboard serves "my usage" from the same endpoint.
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkUsage, filter)
if err != nil {
return nil, err
}
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
@@ -1092,6 +1292,25 @@ func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID st
return types.AggregateUsageByGranularity(rows, granularity), nil
}
// scopeFilterToCaller applies the account-wide read gate for module and,
// when the caller lacks the grant, pins the filter to the caller instead
// of denying: their own user id replaces any requested one and group
// filters are dropped. A caller may always see their own rows — strictly
// tighter than any role gate — which is what lets every authenticated
// user read their usage and requests through the regular endpoints.
// Validation errors (not denials) still fail closed.
func (m *managerImpl) scopeFilterToCaller(ctx context.Context, accountID, userID string, module modules.Module, filter types.AgentNetworkAccessLogFilter) (types.AgentNetworkAccessLogFilter, error) {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, operations.Read)
if err != nil {
return filter, status.NewPermissionValidationError(err)
}
if !ok {
filter.UserID = &userID
filter.GroupIDs = nil
}
return filter, nil
}
// StartAccessLogCleanup launches a background sweep that periodically deletes
// each account's agent-network access-log rows older than that account's
// AccessLogRetentionDays. Usage records are never swept. A non-positive
@@ -52,9 +52,8 @@ const (
)
// ErrNoDiscovery is returned for a catalog entry that declares no listing
// endpoint. Gateways vary too much to have one, and the caller should fall
// back to the catalog list plus free-text entry rather than treating this as
// a failure.
// endpoint. The caller should fall back to the catalog list plus free-text
// entry rather than treating this as a failure.
var ErrNoDiscovery = errors.New("provider has no model-discovery endpoint")
// ErrInvalidRequest marks a discovery failure caused by the caller's own input
@@ -124,14 +123,28 @@ func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) {
return nil, ErrNoDiscovery
}
endpoint, err := c.discoveryURL(entry, req)
// One deadline over the whole operation. Both host lookups and the request
// itself run under it, so a vendor cannot be slow twice, and a caller that
// gives up is not left waiting on a resolver.
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
// An entry with a listing host of its own answers from somewhere other
// than the upstream on the record — Bedrock lists from the control plane
// and infers on the runtime host. Reaching the listing therefore proves
// nothing about the host requests will actually go to, so that one is
// checked separately or not at all.
if entry.Discovery.Host != "" {
if err := c.checkUpstreamHost(ctx, entry, req.UpstreamURL); err != nil {
return nil, err
}
}
endpoint, err := c.discoveryURL(ctx, entry, req)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build discovery request: %w", err)
@@ -146,7 +159,7 @@ func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) {
resp, err := c.httpClient().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("reach %s: %w", entry.Name, err)
return nil, &UnreachableError{Provider: entry.Name, Err: err}
}
defer func() { _ = resp.Body.Close() }()
@@ -157,7 +170,7 @@ func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) {
if resp.StatusCode != http.StatusOK {
// Surface the vendor's own status. An operator whose key lacks a scope
// needs to see 403 rather than a generic failure.
return nil, fmt.Errorf("%s returned %d for its model listing", entry.Name, resp.StatusCode)
return nil, &VendorStatusError{Provider: entry.Name, Status: resp.StatusCode}
}
ids, err := parseListing(entry.Discovery.Shape, body)
@@ -176,12 +189,16 @@ func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) {
// management holds credentials for every provider, and an upstream pointed at
// an internal address would turn this endpoint into a probe of the management
// server's own network.
func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, error) {
func (c *Client) discoveryURL(ctx context.Context, entry catalog.Provider, req Request) (string, error) {
host := entry.Discovery.Host
if host == "" {
parsed, err := url.Parse(strings.TrimSpace(req.UpstreamURL))
if err != nil || parsed.Host == "" {
return "", fmt.Errorf("%w: provider upstream %q is not a usable URL", ErrInvalidRequest, req.UpstreamURL)
// The URL is left out of the message on purpose: it reaches the
// operator through an endpoint that does not lowercase it, but the
// rest of this feature's copy never echoes what they typed, and one
// path that does is the one that ends up quoted in a bug report.
return "", fmt.Errorf("%w: the provider upstream is not a usable URL", ErrInvalidRequest)
}
host = parsed.Host
}
@@ -194,19 +211,47 @@ func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, erro
region = RegionFromUpstream(entry, req.UpstreamURL)
}
if region == "" {
return "", fmt.Errorf("%w: %s discovery needs a region, and none could be read from the provider upstream",
ErrInvalidRequest, entry.Name)
return "", fmt.Errorf("%w: %w: %s discovery needs a region, and none could be read from the provider upstream",
ErrInvalidRequest, ErrNoDiscoveryHost, entry.Name)
}
host = strings.ReplaceAll(host, catalog.RegionPlaceholder, region)
}
target := &url.URL{Scheme: "https", Host: host, Path: entry.Discovery.Path, RawQuery: entry.Discovery.Query}
if err := c.checkPublicHost(target.Hostname()); err != nil {
if err := c.classifyHost(ctx, entry, target.Hostname()); err != nil {
return "", err
}
return target.String(), nil
}
// checkUpstreamHost verifies the host the operator configured, for entries
// whose listing lives elsewhere and so cannot vouch for it.
//
// A name that does not resolve is the record being wrong. One that resolves
// privately is not: an upstream behind a proxy is a supported configuration,
// and ErrPrivateHost carries that difference on to the caller, which treats it
// as unverifiable rather than as a failure.
func (c *Client) checkUpstreamHost(ctx context.Context, entry catalog.Provider, upstreamURL string) error {
parsed, err := url.Parse(strings.TrimSpace(upstreamURL))
if err != nil || parsed.Hostname() == "" {
return fmt.Errorf("%w: the provider upstream is not a usable URL", ErrInvalidRequest)
}
return c.classifyHost(ctx, entry, parsed.Hostname())
}
// classifyHost renders a failed host check as the two outcomes the caller
// distinguishes. A host that refuses to resolve is the commonest way for an
// upstream to be wrong and has to arrive as unreachable rather than as an
// unclassified fault. ErrPrivateHost means something else entirely — not a bad
// host, one we decline to dial.
func (c *Client) classifyHost(ctx context.Context, entry catalog.Provider, host string) error {
err := c.checkPublicHost(ctx, host)
if err == nil || errors.Is(err, ErrPrivateHost) {
return err
}
return &UnreachableError{Provider: entry.Name, Err: err}
}
// RegionFromUpstream recovers the region an operator embedded in the provider
// upstream, by matching it against the catalog's own host template. Bedrock's
// template is "bedrock-runtime.<region>.amazonaws.com" and Vertex's is
@@ -244,7 +289,7 @@ func RegionFromUpstream(entry catalog.Provider, upstreamURL string) string {
// checkPublicHost refuses hosts that resolve to an address the management
// server should never be asked to reach on an operator's behalf.
func (c *Client) checkPublicHost(host string) error {
func (c *Client) checkPublicHost(ctx context.Context, host string) error {
if c.AllowPrivateHosts {
return nil
}
@@ -255,9 +300,6 @@ func (c *Client) checkPublicHost(host string) error {
if resolver == nil {
resolver = net.DefaultResolver
}
ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
defer cancel()
addrs, err := resolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("resolve discovery host %q: %w", host, err)
@@ -266,7 +308,7 @@ func (c *Client) checkPublicHost(host string) error {
// loopback address is still a way to reach loopback.
for _, addr := range addrs {
if !isPublic(addr) {
return fmt.Errorf("%w: discovery host %q resolves to a non-public address", ErrInvalidRequest, host)
return fmt.Errorf("%w: %w: discovery host %q resolves to a non-public address", ErrInvalidRequest, ErrPrivateHost, host)
}
}
return nil
@@ -356,6 +398,9 @@ func decorate(entry catalog.Provider, ids []listedModel) []Model {
if listed.id == "" {
continue
}
if entry.Discovery.ExactModelsOnly && strings.Contains(listed.id, "*") {
continue
}
if _, dup := seen[listed.id]; dup {
continue
}
@@ -463,6 +508,13 @@ func guardDialAddress(address string) error {
return fmt.Errorf("discovery dial address %q is not an IP", host)
}
if !isPublic(addr) {
// Deliberately not ErrPrivateHost, which means "this upstream is on a
// private network, so we cannot check it" and lets a save through
// unchecked. checkPublicHost has already cleared the target by the
// time anything is dialled, so an address refused here is not the
// operator's upstream: it is a rebinding attempt, or an HTTP proxy in
// the path. Neither may quietly skip the check — one is hostile, and
// the other would silently disable this on every provider.
return fmt.Errorf("discovery refused to dial non-public address %s", addr)
}
return nil
@@ -2,7 +2,9 @@ package modeldiscovery
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
@@ -59,6 +61,13 @@ const openAIListing = `{"object":"list","data":[
{"id":"gpt-4o","object":"model","created":1715367049,"owned_by":"system"}
]}`
const agentgatewayListing = `{"object":"list","data":[
{"id":"gpt-4o-mini","object":"model","created":1785166485,"owned_by":"openai"},
{"id":"claude-haiku-4-5","object":"model","created":1785166485,"owned_by":"anthropic"},
{"id":"openai/*","object":"model","created":1785166485,"owned_by":"openai"},
{"id":"*-latest","object":"model","created":1785166485,"owned_by":"openai"}
]}`
const anthropicListing = `{"data":[
{"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5"},
{"type":"model","id":"claude-sonnet-4-6","display_name":"Claude Sonnet 4.6"}
@@ -97,6 +106,26 @@ func TestFetchOpenAIListing(t *testing.T) {
}
}
func TestFetchAgentgatewayListing(t *testing.T) {
cl, tr := newStubClient(http.StatusOK, agentgatewayListing)
models, err := cl.Fetch(context.Background(), Request{
CatalogID: "agentgateway",
UpstreamURL: "https://gateway.example.com",
APIKey: "virtual-key",
})
require.NoError(t, err)
assert.Equal(t, "https://gateway.example.com/v1/models", tr.got.URL.String())
assert.Equal(t, "Bearer virtual-key", tr.got.Header.Get("Authorization"),
"agentgateway model discovery must use the configured virtual key")
assert.Equal(t, []string{"gpt-4o-mini", "claude-haiku-4-5"}, ids(models),
"model patterns must not be offered as exact NetBird authorization rows")
for _, m := range models {
assert.True(t, m.PricingKnown, "known upstream model must use NetBird catalog pricing: %s", m.ID)
}
}
func TestFetchAnthropicSendsTheVersionHeader(t *testing.T) {
cl, tr := newStubClient(http.StatusOK, anthropicListing)
@@ -288,7 +317,7 @@ func TestHostGuardRejectsNonPublicAddresses(t *testing.T) {
func TestHostGuardResolvesAndRejectsLocalhost(t *testing.T) {
cl := &Client{}
err := cl.checkPublicHost("localhost")
err := cl.checkPublicHost(context.Background(), "localhost")
require.Error(t, err, "a name resolving to loopback must be refused, not just a literal address")
assert.Contains(t, err.Error(), "non-public")
}
@@ -530,3 +559,120 @@ func TestBedrockProfilesFromAnyGeographyArrivePriced(t *testing.T) {
// only form that works at invoke time.
assert.Equal(t, "jp.anthropic.claude-sonnet-5-20260514-v1:0", models[0].ID)
}
// TestFetch_AHostThatWillNotResolveIsUnreachable closes a gap the live suite
// found. The SSRF guard resolves the host before any request is built, so a
// name that does not resolve fails there rather than at the transport — and
// that error used to reach the caller unclassified. A wrong hostname is the
// commonest way for an upstream to be wrong, so it has to arrive as
// "unreachable" and not as an unrecognised fault.
func TestFetch_AHostThatWillNotResolveIsUnreachable(t *testing.T) {
// A resolver whose dial always fails, so the lookup errors without the
// test depending on real DNS.
refusing := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, errors.New("resolver unavailable")
},
}
client := &Client{Resolver: refusing}
_, err := client.Fetch(context.Background(), Request{
CatalogID: "openai_api",
UpstreamURL: "https://not-a-real-vendor-host.example.invalid",
APIKey: "sk-test",
})
require.Error(t, err)
var unreachable *UnreachableError
require.ErrorAs(t, err, &unreachable, "a host that will not resolve must classify as unreachable")
require.NotErrorIs(t, err, ErrPrivateHost, "it is not a host we declined to dial")
}
// TestFetch_AProxyInThePathDoesNotSilentlyDisableTheCheck pins a fail-open the
// dial-time guard can produce. checkPublicHost clears the target before
// anything is dialled, so a private address refused at the socket is never the
// operator's upstream — it is a rebinding attempt, or an HTTP proxy the
// management server egresses through. Reporting either as ErrPrivateHost would
// read as "this provider cannot be checked" and let every save through
// unchecked, which is how a proxied deployment would install this feature and
// have it quietly do nothing.
func TestFetch_AProxyInThePathDoesNotSilentlyDisableTheCheck(t *testing.T) {
// A transport that refuses at the socket exactly as the guard does, with a
// loopback address standing in for the proxy the dial went to.
// AllowPrivateHosts short-circuits the resolve-stage check only; the
// injected transport below is still what the request goes through. Without
// it this test resolves api.openai.com for real, and on a runner with no
// egress that lookup fails as an UnreachableError too — so it would pass
// while never reaching the socket guard it is named for.
client := &Client{AllowPrivateHosts: true, HTTPClient: &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, guardDialAddress("127.0.0.1:38599")
}),
CheckRedirect: refuseRedirect,
}}
_, err := client.Fetch(context.Background(), Request{
CatalogID: "openai_api",
UpstreamURL: "https://api.openai.com",
APIKey: "sk-test",
})
require.Error(t, err)
require.NotErrorIs(t, err, ErrPrivateHost,
"a refusal at the socket must not read as an upstream we cannot check")
var unreachable *UnreachableError
require.ErrorAs(t, err, &unreachable, "it is the vendor we failed to reach")
}
// roundTripFunc adapts a function to http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// TestFetch_TheUpstreamIsCheckedWhenTheListingCannotVouchForIt covers the hole
// a separate listing host leaves. Bedrock lists from the control plane, so a
// record whose runtime upstream does not exist reaches a perfectly good
// listing and saves — the requests it then serves go nowhere.
//
// Both halves matter. A runtime host that cannot be resolved is the record
// being wrong, and blocks. A proxied one resolves and only leaves the region
// underivable, which stays the unverifiable outcome it already was.
func TestFetch_TheUpstreamIsCheckedWhenTheListingCannotVouchForIt(t *testing.T) {
refusing := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, errors.New("resolver unavailable")
},
}
client := &Client{Resolver: refusing}
_, err := client.Fetch(context.Background(), Request{
CatalogID: "bedrock_api",
// Matches no catalog template, so nothing here reaches the control
// plane the listing comes from: without its own check this upstream
// was never contacted at all.
UpstreamURL: "https://bedrock.typo.example.invalid",
APIKey: "aws-bearer",
})
require.Error(t, err)
var unreachable *UnreachableError
require.ErrorAs(t, err, &unreachable, "a runtime host that will not resolve must block the save")
}
// TestFetch_AListingHostOfItsOwnDoesNotReachThroughTheUpstream keeps the check
// above from reading the operator's upstream as the place to list from.
func TestFetch_AListingHostOfItsOwnDoesNotReachThroughTheUpstream(t *testing.T) {
cl, tr := newStubClient(http.StatusOK, bedrockListing)
_, err := cl.Fetch(context.Background(), Request{
CatalogID: "bedrock_api",
UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com",
APIKey: "aws-bearer",
})
require.NoError(t, err)
assert.Equal(t, "bedrock.eu-central-1.amazonaws.com", tr.got.URL.Host,
"checking the runtime host must not turn it into the listing host")
}
@@ -0,0 +1,114 @@
package modeldiscovery
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"os"
"syscall"
)
// Fetch serves two callers with different needs: the model picker, which only
// needs to know it failed, and the provider credential check, which has to
// tell an operator whether the URL or the key is at fault. Each failure
// carries a type so the second does not have to branch on a message.
// VendorStatusError reports a listing answered with something other than 200.
// Only the vendor's own code separates a refused credential (401, 403) from a
// URL that does not serve this API (404, 405) from an unwell vendor (5xx).
type VendorStatusError struct {
Provider string
Status int
}
func (e *VendorStatusError) Error() string {
return fmt.Sprintf("%s returned %d for its model listing", e.Provider, e.Status)
}
// UnreachableError reports that the request never reached the vendor: the
// name did not resolve, the connection was refused, TLS failed, or it timed
// out. Nothing was authenticated, so only the URL is implicated.
type UnreachableError struct {
Provider string
Err error
}
func (e *UnreachableError) Error() string {
return fmt.Sprintf("reach %s: %v", e.Provider, e.Err)
}
func (e *UnreachableError) Unwrap() error { return e.Err }
// Reason names the transport failure in words an operator can act on: a wrong
// port and a wrong hostname fail differently and are worth telling apart.
// Empty means unrecognised, and the caller should say only that the host could
// not be reached rather than paste a Go error into the UI.
func (e *UnreachableError) Reason() string {
err := e.Err
var dns *net.DNSError
if errors.As(err, &dns) {
if dns.IsNotFound {
return "no such host"
}
// Named apart from the dial timeout below. A resolver that never
// answered and an upstream that never answered send an operator to
// different places, and the generic "connection timed out" would
// describe a connection that was never attempted.
if dns.IsTimeout {
return "dns lookup timed out"
}
return "dns lookup failed"
}
// Timeouts are checked before the syscall cases: a dial that times out is
// reported as a net.OpError wrapping a timeout, and the operator needs to
// hear "timed out" rather than the syscall underneath it.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {
return "connection timed out"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "connection timed out"
}
if errors.Is(err, syscall.ECONNREFUSED) {
return "connection refused"
}
if errors.Is(err, syscall.EHOSTUNREACH) || errors.Is(err, syscall.ENETUNREACH) {
return "host unreachable"
}
var certErr *tls.CertificateVerificationError
if errors.As(err, &certErr) {
return "tls certificate not trusted"
}
var recordErr tls.RecordHeaderError
if errors.As(err, &recordErr) {
return "not a tls endpoint"
}
return ""
}
// ErrUnparseableListing marks a 200 whose body is not a listing in the shape
// the catalog declared. Distinct from a status refusal: the host answered and
// authenticated fine, it is just not the API — a login page, say.
var ErrUnparseableListing = errors.New("response is not a model listing")
// ErrNoDiscoveryHost marks a provider whose listing host cannot be derived
// from the record: Bedrock's control-plane host comes from the region in the
// upstream, so a proxied endpoint leaves nowhere to send it, and inventing one
// would spend the credential somewhere never configured.
//
// Wraps ErrInvalidRequest so the discovery endpoint still answers 400, while a
// credential check can read it as "cannot be checked" rather than "broken".
var ErrNoDiscoveryHost = errors.New("provider has no derivable discovery host")
// ErrPrivateHost marks an upstream resolving somewhere management will not
// dial. A self-hosted endpoint on a private network is a legitimate provider
// the proxy reaches through the tunnel, so this means the check cannot run,
// not that the record is wrong.
var ErrPrivateHost = errors.New("discovery host is not publicly routable")
@@ -43,7 +43,7 @@ func parseOpenAIData(body []byte) ([]listedModel, error) {
} `json:"data"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return nil, fmt.Errorf("decode model listing: %w", err)
return nil, fmt.Errorf("%w: decode model listing: %w", ErrUnparseableListing, err)
}
out := make([]listedModel, 0, len(doc.Data))
for _, entry := range doc.Data {
@@ -71,7 +71,7 @@ func parseBedrockInferenceProfiles(body []byte) ([]listedModel, error) {
} `json:"inferenceProfileSummaries"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return nil, fmt.Errorf("decode inference-profile listing: %w", err)
return nil, fmt.Errorf("%w: decode inference-profile listing: %w", ErrUnparseableListing, err)
}
out := make([]listedModel, 0, len(doc.Summaries))
for _, entry := range doc.Summaries {
@@ -98,7 +98,7 @@ func parseVertexPublisherModels(body []byte) ([]listedModel, error) {
} `json:"publisherModels"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return nil, fmt.Errorf("decode publisher-model listing: %w", err)
return nil, fmt.Errorf("%w: decode publisher-model listing: %w", ErrUnparseableListing, err)
}
out := make([]listedModel, 0, len(doc.Models))
for _, entry := range doc.Models {
@@ -164,23 +164,12 @@ func (m *managerImpl) SelectPolicyForRequest(ctx context.Context, in PolicySelec
}
candidates := filterApplicablePolicies(policies, in)
// Model-allowlist gate scoped to the matched policies: keep candidates whose
// guardrails permit the model (none enabled = unrestricted), deny when
// policies apply but none permits it. Skip the load when none has a guardrail.
if len(candidates) > 0 && anyPolicyHasGuardrails(candidates) {
guardrailsByID, gErr := m.loadGuardrailsByID(ctx, in.AccountID)
if gErr != nil {
return nil, gErr
}
permitted := filterModelPermittedPolicies(candidates, guardrailsByID, in.Model)
if len(permitted) == 0 {
return &PolicySelectionResult{
Allow: false,
DenyCode: denyCodeModelBlocked,
DenyReason: modelBlockedReason(in.Model),
}, nil
}
candidates = permitted
candidates, denied, err := m.applyModelGate(ctx, in, candidates)
if err != nil {
return nil, err
}
if denied != nil {
return denied, nil
}
// Prefetch every consumption counter the ceiling + candidate policies will
@@ -285,6 +274,59 @@ func anyPolicyHasGuardrails(policies []*types.Policy) bool {
return false
}
// applyModelGate is the model-allowlist gate scoped to the matched policies:
// it keeps the candidates whose guardrails permit the model (none enabled =
// unrestricted) and returns a deny result when policies apply but none
// permits it. The guardrail load is skipped when no candidate references a
// guardrail, and the provider's catalog id — which picks the model-id
// normalizer — is resolved only when a candidate actually restricts models:
// with no enabled allowlist every candidate is unrestricted, and a
// provider-store failure must not fail a request the gate would have waved
// through.
func (m *managerImpl) applyModelGate(ctx context.Context, in PolicySelectionInput, candidates []*types.Policy) ([]*types.Policy, *PolicySelectionResult, error) {
if len(candidates) == 0 || !anyPolicyHasGuardrails(candidates) {
return candidates, nil, nil
}
guardrailsByID, err := m.loadGuardrailsByID(ctx, in.AccountID)
if err != nil {
return nil, nil, err
}
if !anyEnabledModelAllowlist(candidates, guardrailsByID) {
return candidates, nil, nil
}
catalogID, err := m.providerCatalogID(ctx, in.AccountID, in.ProviderID)
if err != nil {
return nil, nil, err
}
permitted := filterModelPermittedPolicies(candidates, guardrailsByID, in.Model, catalogID)
if len(permitted) == 0 {
return nil, &PolicySelectionResult{
Allow: false,
DenyCode: denyCodeModelBlocked,
DenyReason: modelBlockedReason(in.Model),
}, nil
}
return permitted, nil, nil
}
// anyEnabledModelAllowlist reports whether any policy references a guardrail
// whose model allowlist is enabled — the only case the model gate restricts
// anything. Disabled allowlists, stale guardrail references, and guardrails
// carrying only other checks all leave every candidate unrestricted.
func anyEnabledModelAllowlist(policies []*types.Policy, byID map[string]*types.Guardrail) bool {
for _, p := range policies {
if p == nil {
continue
}
for _, gID := range p.GuardrailIDs {
if g, ok := byID[gID]; ok && g != nil && g.Checks.ModelAllowlist.Enabled {
return true
}
}
}
return false
}
// loadGuardrailsByID loads the account's guardrails indexed by ID. Used by the
// model-allowlist gate to resolve each candidate policy's attached guardrails.
func (m *managerImpl) loadGuardrailsByID(ctx context.Context, accountID string) (map[string]*types.Guardrail, error) {
@@ -301,12 +343,33 @@ func (m *managerImpl) loadGuardrailsByID(ctx context.Context, accountID string)
return byID, nil
}
// providerCatalogID resolves a provider record id to its catalog provider
// id, the key the model-id normalizers are picked by. A missing provider
// resolves to the empty catalog id — the compare then runs verbatim-only,
// which can never widen an allowlist — while a store failure propagates
// rather than degrading a security decision.
func (m *managerImpl) providerCatalogID(ctx context.Context, accountID, providerID string) (string, error) {
if providerID == "" {
return "", nil
}
provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
switch {
case err == nil:
return provider.ProviderID, nil
case isNotFound(err):
return "", nil
default:
return "", fmt.Errorf("get provider: %w", err)
}
}
// filterModelPermittedPolicies returns the subset of policies whose guardrails
// permit the model. Order is preserved so downstream scoring is unaffected.
func filterModelPermittedPolicies(policies []*types.Policy, byID map[string]*types.Guardrail, model string) []*types.Policy {
// permit the model on the provider with the given catalog id. Order is
// preserved so downstream scoring is unaffected.
func filterModelPermittedPolicies(policies []*types.Policy, byID map[string]*types.Guardrail, model, catalogProviderID string) []*types.Policy {
out := make([]*types.Policy, 0, len(policies))
for _, p := range policies {
if policyPermitsModel(p, byID, model) {
if policyPermitsModel(p, byID, model, catalogProviderID) {
out = append(out, p)
}
}
@@ -316,8 +379,13 @@ func filterModelPermittedPolicies(policies []*types.Policy, byID map[string]*typ
// policyPermitsModel reports whether a policy permits the model. No
// allowlist-enabled guardrail = unrestricted (permits any, incl. empty);
// otherwise the model must be in the union of its allowlists, so an
// empty/undetermined model fails closed.
func policyPermitsModel(p *types.Policy, byID map[string]*types.Guardrail, model string) bool {
// empty/undetermined model fails closed. An entry matches on its own
// normalised form or, for a path-style provider, its canonical form: the
// parser emits the canonical id for path-routed requests, while an
// allowlist may hold the raw declared id the dashboard's picker copies
// from the provider. The catalog id picks the normalizer, so a plain
// provider's entries always compare verbatim.
func policyPermitsModel(p *types.Policy, byID map[string]*types.Guardrail, model, catalogProviderID string) bool {
if p == nil {
return false
}
@@ -333,7 +401,7 @@ func policyPermitsModel(p *types.Policy, byID map[string]*types.Guardrail, model
continue
}
for _, allowed := range g.Checks.ModelAllowlist.Models {
if normaliseModelID(allowed) == wanted {
if normaliseModelID(allowed) == wanted || canonicalModelKey(catalogProviderID, allowed) == wanted {
return true
}
}
@@ -6,12 +6,13 @@ import (
"testing"
"time"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/status"
)
// guardedPolicy builds an enabled, uncapped policy that authorises sourceGroups
@@ -53,6 +54,17 @@ func expectGuardrails(mockStore *store.MockStore, account string, guardrails ...
Return(guardrails, nil)
}
// expectProviderCatalog resolves the destination provider to the given
// catalog provider id, which picks the model-id normalizer the allowlist
// gate compares through. AnyTimes: the lookup runs only when the guardrail
// gate is reached.
func expectProviderCatalog(mockStore *store.MockStore, account, providerID, catalog string) {
mockStore.EXPECT().
GetAgentNetworkProviderByID(gomock.Any(), gomock.Any(), account, providerID).
Return(&types.Provider{ID: providerID, AccountID: account, ProviderID: catalog}, nil).
AnyTimes()
}
// TestSelectPolicy_ModelBlockedByAllowlist proves the authoritative allowlist
// decision: a policy authorises the (provider, group) but restricts the model,
// and the requested model isn't on the list, so the request is denied.
@@ -63,6 +75,7 @@ func TestSelectPolicy_ModelBlockedByAllowlist(t *testing.T) {
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o"))
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
AccountID: "acc-1",
@@ -86,6 +99,7 @@ func TestSelectPolicy_ModelAllowedByAllowlist(t *testing.T) {
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o", "claude-opus-4"))
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
expectConsumptionBatch(mockStore, nil)
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
@@ -109,6 +123,7 @@ func TestSelectPolicy_CaseInsensitiveModelMatch(t *testing.T) {
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", " GPT-4o "))
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
expectConsumptionBatch(mockStore, nil)
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
@@ -132,6 +147,7 @@ func TestSelectPolicy_UnguardedPolicyIsUnrestricted(t *testing.T) {
open := guardedPolicy("pol-open", "acc-1", []string{"grp-eng"}, "prov-1") // no guardrail
expectPolicies(mockStore, "acc-1", restricted, open)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o"))
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
expectConsumptionBatch(mockStore, nil)
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
@@ -159,6 +175,7 @@ func TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups(t *testing.T) {
allowlistGuardrail("g-a", "acc-1", "gpt-4o"),
allowlistGuardrail("g-b", "acc-1", "claude-opus-4"),
)
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
AccountID: "acc-1",
@@ -181,6 +198,7 @@ func TestSelectPolicy_UndeterminedModelFailsClosed(t *testing.T) {
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o"))
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
AccountID: "acc-1",
@@ -210,6 +228,8 @@ func TestSelectPolicy_DisabledAllowlistDoesNotRestrict(t *testing.T) {
}
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", disabled)
// Deliberately no provider expectation: with no enabled allowlist the
// gate must skip the catalog-id lookup entirely.
expectConsumptionBatch(mockStore, nil)
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
@@ -235,6 +255,7 @@ func TestSelectPolicy_UnionAcrossPolicyGuardrails(t *testing.T) {
allowlistGuardrail("g-1", "acc-1", "gpt-4o"),
allowlistGuardrail("g-2", "acc-1", "claude-opus-4"),
)
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
expectConsumptionBatch(mockStore, nil)
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
@@ -281,6 +302,8 @@ func TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted(t *testing.
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-missing")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1")
// Deliberately no provider expectation: an orphaned guardrail reference
// restricts nothing, so the gate must skip the catalog-id lookup.
expectConsumptionBatch(mockStore, nil)
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
@@ -314,6 +337,7 @@ func TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter(t *testing.T) {
allowlistGuardrail("g-restrict", "acc-1", "gpt-4o"),
allowlistGuardrail("g-permit", "acc-1", "claude-opus-4"),
)
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
expectConsumptionBatch(mockStore, nil)
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
@@ -327,3 +351,159 @@ func TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter(t *testing.T) {
assert.Equal(t, "pol-small", res.SelectedPolicyID,
"the model filter must exclude pol-big before cap scoring")
}
// TestSelectPolicy_RawDeclaredAllowlistPermitsCanonicalModel proves an
// allowlist holding the raw vendor-issued id — the form the dashboard's
// picker copies from a provider's declared models — permits the request:
// the parser emits the path-style canonical id, so the entry must match
// through the same canonicalization.
func TestSelectPolicy_RawDeclaredAllowlistPermitsCanonicalModel(t *testing.T) {
cases := []struct {
name string
catalog string
entry string
request string
}{
{"bedrock raw region/version form", "bedrock_api", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-sonnet-4-5"},
{"vertex raw @version form", "vertex_ai_api", "claude-sonnet-4-5@20250929", "claude-sonnet-4-5"},
{"vertex raw dated @version form", "vertex_ai_api", "gpt-4o@2024-08-06", "gpt-4o"},
{"bedrock raw form with case and whitespace", "bedrock_api", " EU.Anthropic.Claude-Sonnet-4-5-20250929-V1:0 ", "anthropic.claude-sonnet-4-5"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mgr, mockStore := newSelectorMgr(t, ctrl)
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", tc.entry))
expectProviderCatalog(mockStore, "acc-1", "prov-1", tc.catalog)
expectConsumptionBatch(mockStore, nil)
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
AccountID: "acc-1",
UserID: "user-1",
GroupIDs: []string{"grp-eng"},
ProviderID: "prov-1",
Model: tc.request,
})
require.NoError(t, err)
assert.True(t, res.Allow, "the raw declared allowlist entry must permit its canonical model")
assert.Equal(t, "pol-A", res.SelectedPolicyID)
})
}
// A model outside the allowlist stays denied under the same entry shape.
t.Run("unrelated canonical model stays denied", func(t *testing.T) {
ctrl := gomock.NewController(t)
mgr, mockStore := newSelectorMgr(t, ctrl)
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"))
expectProviderCatalog(mockStore, "acc-1", "prov-1", "bedrock_api")
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
AccountID: "acc-1",
UserID: "user-1",
GroupIDs: []string{"grp-eng"},
ProviderID: "prov-1",
Model: "anthropic.claude-opus-4-8",
})
require.NoError(t, err)
assert.False(t, res.Allow, "a model the allowlist never names must stay denied")
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
})
}
// TestSelectPolicy_PlainProviderEntriesStayVerbatim proves the canonical-form
// compare never relaxes an allowlist on a body-routed provider: its catalog
// id selects no normalizer, so a suffix that would be stripped under Bedrock
// ("-v2") or Vertex ("@...") stays part of the entry and must NOT also admit
// the stripped id — on this provider that is a different model.
func TestSelectPolicy_PlainProviderEntriesStayVerbatim(t *testing.T) {
cases := []struct {
name string
entry string
request string
}{
{"a -vN suffix is not a Bedrock version tag here", "claude-3-5-sonnet-v2", "claude-3-5-sonnet"},
{"an @word suffix is not a Vertex version tag here", "custom-model@team", "custom-model"},
{"an @digits suffix is not a Vertex version tag here", "custom-model@2024", "custom-model"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mgr, mockStore := newSelectorMgr(t, ctrl)
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", tc.entry))
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
AccountID: "acc-1",
UserID: "user-1",
GroupIDs: []string{"grp-eng"},
ProviderID: "prov-1",
Model: tc.request,
})
require.NoError(t, err)
assert.False(t, res.Allow, "a plain provider's allowlist entry must not widen to its stripped form")
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
})
}
}
// TestSelectPolicy_MissingProviderRecordComparesVerbatim proves a provider the
// store no longer holds degrades to the verbatim-only compare — the raw entry
// still matches itself, and nothing widens — rather than erroring or guessing
// a normalizer.
func TestSelectPolicy_MissingProviderRecordComparesVerbatim(t *testing.T) {
ctrl := gomock.NewController(t)
mgr, mockStore := newSelectorMgr(t, ctrl)
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"))
mockStore.EXPECT().
GetAgentNetworkProviderByID(gomock.Any(), gomock.Any(), "acc-1", "prov-1").
Return(nil, status.Errorf(status.NotFound, "provider not found")).
AnyTimes()
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
AccountID: "acc-1",
UserID: "user-1",
GroupIDs: []string{"grp-eng"},
ProviderID: "prov-1",
Model: "anthropic.claude-sonnet-4-5",
})
require.NoError(t, err)
assert.False(t, res.Allow, "without the provider record the compare runs verbatim and must not widen")
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
}
// TestSelectPolicy_ProviderLookupErrorPropagates proves a store failure while
// resolving the provider's catalog id surfaces as an error — the model gate is
// a security decision and must not silently degrade.
func TestSelectPolicy_ProviderLookupErrorPropagates(t *testing.T) {
ctrl := gomock.NewController(t)
mgr, mockStore := newSelectorMgr(t, ctrl)
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
expectPolicies(mockStore, "acc-1", policy)
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o"))
mockStore.EXPECT().
GetAgentNetworkProviderByID(gomock.Any(), gomock.Any(), "acc-1", "prov-1").
Return(nil, errors.New("store unavailable"))
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
AccountID: "acc-1",
UserID: "user-1",
GroupIDs: []string{"grp-eng"},
ProviderID: "prov-1",
Model: "gpt-4o",
})
require.Error(t, err, "a provider-lookup failure must surface as an error")
assert.Nil(t, res)
}
@@ -0,0 +1,289 @@
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/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// These tests pin the provider read surface per grant: a caller holding
// providers read together with update (managers) gets the full record,
// while read-only viewers (usage_viewer) get the display surface only —
// connection configuration is redacted before it reaches the wire layer.
func TestGetAllProviders_RedactsConnectionConfigForReadOnlyViewer(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
saved := newSynthTestProvider()
saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"}
saved.IdentityHeaderUserID = "X-User"
saved.IdentityHeaderGroups = "X-Groups"
saved.SkipTLSVerification = true
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true)
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false)
providers, err := f.manager.GetAllProviders(ctx, testAccountID, "viewer")
require.NoError(t, err)
require.Len(t, providers, 1)
p := providers[0]
assert.Equal(t, saved.ID, p.ID, "identity survives redaction")
assert.Equal(t, saved.Name, p.Name)
assert.Equal(t, saved.ProviderID, p.ProviderID)
assert.Equal(t, saved.Models, p.Models, "the model list backs the usage filters and stays")
assert.True(t, p.Enabled)
assert.Empty(t, p.UpstreamURL, "upstream URL is connection config")
assert.Empty(t, p.ExtraValues, "operator-typed header values are connection config")
assert.Empty(t, p.IdentityHeaderUserID)
assert.Empty(t, p.IdentityHeaderGroups)
assert.False(t, p.SkipTLSVerification)
assert.Empty(t, p.APIKey)
assert.Empty(t, p.SessionPrivateKey)
stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, testAccountID, saved.ID)
require.NoError(t, err)
assert.NotEmpty(t, stored.UpstreamURL, "redaction must not write back to the store")
}
func TestGetProvider_FullConfigForManagingCaller(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
saved := newSynthTestProvider()
saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"}
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Read, true)
f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Update, true)
p, err := f.manager.GetProvider(ctx, testAccountID, "admin", saved.ID)
require.NoError(t, err)
assert.Equal(t, saved.UpstreamURL, p.UpstreamURL, "a caller who can edit the provider sees its config")
assert.Equal(t, saved.ExtraValues, p.ExtraValues)
}
func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
saved := newSynthTestProvider()
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true)
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false)
p, err := f.manager.GetProvider(ctx, testAccountID, "viewer", saved.ID)
require.NoError(t, err)
assert.Equal(t, saved.ID, p.ID)
assert.Empty(t, p.UpstreamURL)
}
// The self-scope tests drive the real permissions manager over the real
// store, so role resolution is the production one: a plain user holds no
// providers grant and must fall back to the caller-scoped list — the same
// selection the self-service setup answer derives from — while an admin
// keeps the account-wide view with full config.
// newSelfScopeStore seeds the account and its users only, so each test
// declares exactly the providers and policies it asserts on — the store
// rejects re-saving a policy id on MySQL, so tests never overwrite each
// other's rows.
func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) {
t.Helper()
mgr, s := newAgentConfigTestMgr(t)
mgr.permissionsManager = permissions.NewManager(s)
ctx := context.Background()
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "user-a", 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,
}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
}))
return mgr, s
}
func newSelfScopeProvidersFixture(t *testing.T) (*managerImpl, store.Store) {
t.Helper()
mgr, s := newSelfScopeStore(t)
ctx := context.Background()
granted := newSynthTestProvider()
granted.ID = "prov-granted"
granted.Name = "Granted"
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
other := newSynthTestProvider()
other.ID = "prov-other"
other.Name = "Other"
other.CreatedAt = granted.CreatedAt.Add(time.Hour)
require.NoError(t, s.SaveAgentNetworkProvider(ctx, other))
disabled := newSynthTestProvider()
disabled.ID = "prov-disabled"
disabled.Name = "Disabled"
disabled.Enabled = false
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
// user-a's group authorizes the granted and the disabled provider; the
// disabled one must still not surface (the proxy never routes it).
policy := newSynthTestPolicy(granted.ID, "grp-eng", "")
policy.DestinationProviderIDs = []string{granted.ID, disabled.ID}
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
return mgr, s
}
func TestGetAllProviders_SelfScopedForPlainUser(t *testing.T) {
ctx := context.Background()
mgr, _ := newSelfScopeProvidersFixture(t)
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
require.NoError(t, err, "a caller without the read grant self-scopes instead of being denied")
require.Len(t, scoped, 1)
assert.Equal(t, "prov-granted", scoped[0].ID)
assert.Empty(t, scoped[0].UpstreamURL, "the caller-scoped list is the redacted display surface")
assert.NotEmpty(t, scoped[0].Models, "model list backs the dashboard filters")
empty, err := mgr.GetAllProviders(ctx, testAccountID, "user-out")
require.NoError(t, err)
assert.Empty(t, empty, "a caller outside every policy gets an empty list, not an error")
all, err := mgr.GetAllProviders(ctx, testAccountID, "admin")
require.NoError(t, err)
assert.Len(t, all, 3, "grant holders keep the account-wide list, disabled providers included")
for _, p := range all {
if p.ID == "prov-granted" {
assert.NotEmpty(t, p.UpstreamURL, "a managing caller sees the connection config")
}
}
}
func TestGetProvider_SelfScopedForPlainUser(t *testing.T) {
ctx := context.Background()
mgr, _ := newSelfScopeProvidersFixture(t)
p, err := mgr.GetProvider(ctx, testAccountID, "user-a", "prov-granted")
require.NoError(t, err)
assert.Equal(t, "prov-granted", p.ID)
assert.Empty(t, p.UpstreamURL)
assertNotFound := func(id string) {
t.Helper()
_, err := mgr.GetProvider(ctx, testAccountID, "user-a", id)
require.Error(t, err)
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.NotFound, sErr.Type(),
"out-of-scope and nonexistent providers must be indistinguishable")
}
assertNotFound("prov-other")
assertNotFound("prov-disabled")
assertNotFound("prov-does-not-exist")
}
func TestGetAllProviders_SelfScopedModelsFollowGuardrails(t *testing.T) {
ctx := context.Background()
mgr, s := newSelfScopeStore(t)
// A provider declaring two models, restricted by an allowlist admitting
// one declared model plus one the operator never declared (unreachable —
// the router only claims declared models, so it must not surface).
granted := newSynthTestProvider()
granted.ID = "prov-models"
granted.Name = "Granted"
granted.Models = []types.ProviderModel{
{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02},
{ID: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01},
}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-models", "gpt-5.4", "gpt-undeclared")))
policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-models")
policy.ID = "pol-guard-models"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
require.NoError(t, err)
require.Len(t, scoped, 1)
require.Len(t, scoped[0].Models, 1,
"the self-scoped model list is the effective set: allowlist ∩ declared")
assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID)
assert.Equal(t, 0.004, scoped[0].Models[0].InputPer1k, "declared entry survives, prices included")
all, err := mgr.GetAllProviders(ctx, testAccountID, "admin")
require.NoError(t, err)
for _, p := range all {
if p.ID == granted.ID {
assert.Len(t, p.Models, 2,
"grant holders keep the full declared list — their usage view spans everyone's requests")
}
}
}
func TestGetAllProviders_SelfScopedAllowlistWithoutDeclaredModels(t *testing.T) {
ctx := context.Background()
mgr, s := newSelfScopeStore(t)
// No operator declaration: the router claims every model, so the
// allowlist union is the effective set and comes back as bare entries.
granted := newSynthTestProvider()
granted.ID = "prov-bare"
granted.Name = "Granted"
granted.Models = nil
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-bare", "gpt-5.4")))
policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-bare")
policy.ID = "pol-guard-bare"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
require.NoError(t, err)
require.Len(t, scoped, 1)
require.Len(t, scoped[0].Models, 1)
assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID)
}
func TestGetAllProviders_SelfScopedUnrestrictedFallsBackToCatalogModels(t *testing.T) {
ctx := context.Background()
mgr, s := newSelfScopeStore(t)
// Unrestricted policy on a provider without an operator declaration:
// the setup answer advertises the catalog models, and the scoped
// provider list must match so the model filter is never emptier than
// the setup page.
granted := newSynthTestProvider()
granted.ID = "prov-catalog"
granted.Name = "Granted"
granted.Models = nil
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
policy := newSynthTestPolicy(granted.ID, "grp-eng", "")
policy.ID = "pol-catalog"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
require.NoError(t, err)
require.Len(t, scoped, 1)
require.NotEmpty(t, scoped[0].Models, "catalog models back the filter when the operator declared none")
ids := make([]string, 0, len(scoped[0].Models))
for _, m := range scoped[0].Models {
ids = append(ids, m.ID)
}
assert.Equal(t, declaredModelIDs(granted), ids, "the scoped list mirrors the setup answer's declared/catalog set")
}
@@ -28,6 +28,10 @@ type bootstrapFixture struct {
manager Manager
store store.Store
perms *permissions.MockManager
// vendor stands in for the provider credential check's vendor call, which
// runs on every provider write. Without it these tests would reach a real
// vendor to save a record.
vendor *stubLister
}
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
@@ -49,10 +53,12 @@ func newBootstrapFixture(t *testing.T) *bootstrapFixture {
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
vendor := &stubLister{}
return &bootstrapFixture{
manager: NewManager(st, perms, accounts, nil),
manager: NewManager(st, perms, accounts, nil, WithModelLister(vendor)),
store: st,
perms: perms,
vendor: vendor,
}
}
@@ -251,6 +257,7 @@ func TestCreateProviderHasNoSettingsSideEffects(t *testing.T) {
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
provider := types.NewProvider("account1")
provider.ProviderID = "openai_api"
provider.Name = "openai"
provider.UpstreamURL = "https://api.openai.com"
provider.APIKey = "sk-test"
@@ -211,18 +211,19 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
}
groupIndex := indexProviderGroups(enabledPolicies)
catalogByProvider := catalogIDsByProvider(enabledProviders)
// The proxy guardrail is a per-provider fail-closed backstop; the
// authoritative per-policy/group decision is management's
// SelectPolicyForRequest. A provider lands in that map only when every
// authorising policy restricts models.
providerAllowlists := buildProviderAllowlists(enabledPolicies, guardrailsByID)
providerAllowlists := buildProviderAllowlists(enabledPolicies, guardrailsByID, catalogByProvider)
// Discovery gets the finer view: per policy rather than flattened per
// provider, so a listing can be bounded to what the calling groups may
// actually use instead of the union across everyone who reaches the
// provider.
modelPolicies := buildModelPolicies(enabledPolicies, guardrailsByID)
modelPolicies := buildModelPolicies(enabledPolicies, guardrailsByID, catalogByProvider)
routerCfgJSON, err := buildRouterConfigJSON(enabledProviders, groupIndex, modelPolicies)
if err != nil {
@@ -352,6 +353,7 @@ type routerConfig struct {
type routerProviderRoute struct {
ID string `json:"id"`
Vendor string `json:"vendor,omitempty"`
Vendors []string `json:"vendors,omitempty"`
Models []string `json:"models"`
UpstreamScheme string `json:"upstream_scheme"`
UpstreamHost string `json:"upstream_host"`
@@ -461,6 +463,7 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
cfg.Providers = append(cfg.Providers, routerProviderRoute{
ID: p.ID,
Vendor: providerVendor(p),
Vendors: providerVendors(p),
Models: providerModelIDs(p),
UpstreamScheme: scheme,
UpstreamHost: host,
@@ -525,6 +528,17 @@ func providerVendor(p *types.Provider) string {
return entry.ParserID
}
// providerVendors returns the parser surfaces a multi-surface gateway route
// accepts. Single-surface providers keep using the singular vendor field so
// existing proxy versions and configurations retain their wire shape.
func providerVendors(p *types.Provider) []string {
entry, ok := catalog.Lookup(p.ProviderID)
if !ok || len(entry.RouterVendors) == 0 {
return nil
}
return append([]string(nil), entry.RouterVendors...)
}
// providerModelIDs returns the model identifiers exposed by the
// provider, deduplicated and in the operator's declared order. Empty
// slice when no models are configured — the router treats that as
@@ -894,7 +908,9 @@ func marshalGuardrailConfig(providerAllowlists map[string][]string, capture Merg
// buildProviderAllowlists returns the proxy's per-provider backstop: a provider
// is included only when every authorising policy restricts models (their union);
// if any leaves it unrestricted it is omitted, so management decides per group.
func buildProviderAllowlists(policies []*types.Policy, byID map[string]*types.Guardrail) map[string][]string {
// Entries carry their provider-specific canonical form alongside the verbatim
// one, resolved through catalogByProvider.
func buildProviderAllowlists(policies []*types.Policy, byID map[string]*types.Guardrail, catalogByProvider map[string]string) map[string][]string {
type providerAcc struct {
models map[string]struct{}
anyUnrestricted bool
@@ -918,7 +934,7 @@ func buildProviderAllowlists(policies []*types.Policy, byID map[string]*types.Gu
acc.anyUnrestricted = true
continue
}
for _, m := range models {
for _, m := range expandModelsForProvider(models, catalogByProvider[providerID]) {
acc.models[m] = struct{}{}
}
}
@@ -939,8 +955,10 @@ func buildProviderAllowlists(policies []*types.Policy, byID map[string]*types.Gu
}
// policyModelAllowlist reports whether a policy restricts models (has an
// allowlist-enabled guardrail) and the union of allowed models. Models are
// verbatim; the proxy factory lowercases/trims them at decode time.
// allowlist-enabled guardrail) and the union of allowed models, verbatim.
// Consumers expand the entries per destination provider with
// expandModelsForProvider — the canonical form is provider-specific — and
// the proxy factory lowercases/trims them at decode time.
func policyModelAllowlist(p *types.Policy, byID map[string]*types.Guardrail) (bool, []string) {
restricted := false
var models []string
@@ -959,6 +977,45 @@ func policyModelAllowlist(p *types.Policy, byID map[string]*types.Guardrail) (bo
return restricted, models
}
// expandModelsForProvider returns the allowlist entries for one destination
// provider: each entry verbatim plus, when it differs, its canonical form
// under that provider's catalog id — the id the proxy's parser emits at
// request time — deduplicated. The proxy-side compares (guardrail backstop,
// per-group router rules) then admit an allowlist however the operator
// wrote it, raw declared id or canonical, while a plain provider's entries
// stay verbatim and can never widen.
func expandModelsForProvider(models []string, catalogProviderID string) []string {
out := make([]string, 0, len(models))
seen := make(map[string]struct{}, len(models))
add := func(m string) {
if m == "" {
return
}
if _, dup := seen[m]; dup {
return
}
seen[m] = struct{}{}
out = append(out, m)
}
for _, m := range models {
add(m)
add(canonicalModelKey(catalogProviderID, m))
}
return out
}
// catalogIDsByProvider indexes providers' catalog ids by provider record id,
// the lookup the per-provider allowlist expansion keys the normalizer on.
func catalogIDsByProvider(providers []*types.Provider) map[string]string {
out := make(map[string]string, len(providers))
for _, p := range providers {
if p != nil {
out[p.ID] = p.ProviderID
}
}
return out
}
// buildAccountService composes the per-account gateway Service. The
// target carries the noop placeholder URL — the router middleware
// rewrites every request to the matched provider's upstream before the
@@ -1167,23 +1224,25 @@ type routerModelPolicy struct {
// models — a picker full of entries the next request refuses. Keeping the
// source groups alongside the models lets the router answer it at request time,
// where it knows the caller's groups.
func buildModelPolicies(policies []*types.Policy, byID map[string]*types.Guardrail) map[string][]routerModelPolicy {
func buildModelPolicies(policies []*types.Policy, byID map[string]*types.Guardrail, catalogByProvider map[string]string) map[string][]routerModelPolicy {
out := make(map[string][]routerModelPolicy)
for _, p := range policies {
if p == nil || len(p.SourceGroups) == 0 {
continue
}
restricted, models := policyModelAllowlist(p, byID)
rule := routerModelPolicy{GroupIDs: append([]string(nil), p.SourceGroups...)}
if restricted {
// Never nil when restricted: an allowlist permitting nothing must
// stay distinguishable from no allowlist at all.
rule.Models = append([]string{}, models...)
}
for _, providerID := range p.DestinationProviderIDs {
if providerID == "" {
continue
}
rule := routerModelPolicy{GroupIDs: append([]string(nil), p.SourceGroups...)}
if restricted {
// Never nil when restricted: an allowlist permitting nothing
// must stay distinguishable from no allowlist at all. The
// expansion is per provider — the canonical form of an entry
// depends on the destination's catalog id.
rule.Models = append([]string{}, expandModelsForProvider(models, catalogByProvider[providerID])...)
}
out[providerID] = append(out[providerID], rule)
}
}
@@ -33,7 +33,7 @@ func TestBuildProviderAllowlists(t *testing.T) {
policyForProviders("p1", []string{"g-4o"}, "prov-x"),
policyForProviders("p2", []string{"g-opus"}, "prov-x"),
}
got := buildProviderAllowlists(policies, byID)
got := buildProviderAllowlists(policies, byID, nil)
assert.Equal(t, map[string][]string{"prov-x": {"claude-opus-4", "gpt-4o"}}, got,
"a provider every policy restricts carries the sorted union of their models")
})
@@ -43,7 +43,7 @@ func TestBuildProviderAllowlists(t *testing.T) {
policyForProviders("p1", []string{"g-4o"}, "prov-x"),
policyForProviders("p2", nil, "prov-x"), // no guardrail
}
got := buildProviderAllowlists(policies, byID)
got := buildProviderAllowlists(policies, byID, nil)
assert.NotContains(t, got, "prov-x",
"a provider reachable by an un-guardrailed policy must be omitted so the proxy treats it as unrestricted")
})
@@ -52,7 +52,7 @@ func TestBuildProviderAllowlists(t *testing.T) {
policies := []*types.Policy{
policyForProviders("p1", []string{"g-disabled"}, "prov-x"),
}
got := buildProviderAllowlists(policies, byID)
got := buildProviderAllowlists(policies, byID, nil)
assert.NotContains(t, got, "prov-x",
"a policy whose only guardrail has a disabled allowlist is unrestricted")
})
@@ -62,7 +62,7 @@ func TestBuildProviderAllowlists(t *testing.T) {
policyForProviders("p1", []string{"g-4o"}, "prov-x"),
policyForProviders("p2", []string{"g-opus"}, "prov-y"),
}
got := buildProviderAllowlists(policies, byID)
got := buildProviderAllowlists(policies, byID, nil)
assert.Equal(t, []string{"gpt-4o"}, got["prov-x"], "prov-x keeps only its own model")
assert.Equal(t, []string{"claude-opus-4"}, got["prov-y"], "prov-y keeps only its own model")
})
@@ -71,7 +71,7 @@ func TestBuildProviderAllowlists(t *testing.T) {
policies := []*types.Policy{
policyForProviders("p1", []string{"g-4o"}, "prov-x", "prov-y"),
}
got := buildProviderAllowlists(policies, byID)
got := buildProviderAllowlists(policies, byID, nil)
assert.Equal(t, []string{"gpt-4o"}, got["prov-x"])
assert.Equal(t, []string{"gpt-4o"}, got["prov-y"])
})
@@ -80,7 +80,7 @@ func TestBuildProviderAllowlists(t *testing.T) {
policies := []*types.Policy{
policyForProviders("p1", []string{"g-4o", "g-opus"}, "prov-x"),
}
got := buildProviderAllowlists(policies, byID)
got := buildProviderAllowlists(policies, byID, nil)
assert.ElementsMatch(t, []string{"claude-opus-4", "gpt-4o"}, got["prov-x"],
"a policy's own multiple allowlist guardrails union together")
})
@@ -89,7 +89,7 @@ func TestBuildProviderAllowlists(t *testing.T) {
empty := map[string]*types.Guardrail{"g-empty": allowlistGuardrail("g-empty", "acc-1")}
got := buildProviderAllowlists([]*types.Policy{
policyForProviders("p1", []string{"g-empty"}, "prov-x"),
}, empty)
}, empty, nil)
assert.Equal(t, map[string][]string{"prov-x": {}}, got,
"an enabled-but-empty allowlist is restricted with an empty set, not unrestricted")
})
@@ -124,7 +124,7 @@ func TestBuildModelPolicies(t *testing.T) {
policyForGroups("p1", []string{"grp-eng"}, []string{"g-4o"}, "prov-x"),
policyForGroups("p2", []string{"grp-sales"}, []string{"g-opus"}, "prov-x"),
}
got := buildModelPolicies(policies, byID)
got := buildModelPolicies(policies, byID, nil)
assert.Equal(t, []routerModelPolicy{
{GroupIDs: []string{"grp-eng"}, Models: []string{"gpt-4o"}},
{GroupIDs: []string{"grp-sales"}, Models: []string{"claude-opus-4"}},
@@ -137,14 +137,14 @@ func TestBuildModelPolicies(t *testing.T) {
policyForGroups("p1", []string{"grp-eng"}, []string{"g-4o"}, "prov-x"),
policyForGroups("p2", []string{"grp-admin"}, nil, "prov-x"),
}
got := buildModelPolicies(policies, byID)
got := buildModelPolicies(policies, byID, nil)
assert.Nil(t, got["prov-x"][1].Models,
"no allowlist must reach the router as nil, which lifts the restriction for its groups")
})
t.Run("a disabled allowlist is not a restriction", func(t *testing.T) {
policies := []*types.Policy{policyForGroups("p1", []string{"grp-eng"}, []string{"g-disabled"}, "prov-x")}
got := buildModelPolicies(policies, byID)
got := buildModelPolicies(policies, byID, nil)
assert.Nil(t, got["prov-x"][0].Models,
"a guardrail with the allowlist check off restricts nothing")
})
@@ -154,7 +154,7 @@ func TestBuildModelPolicies(t *testing.T) {
"g-empty": {ID: "g-empty", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true}}},
}
policies := []*types.Policy{policyForGroups("p1", []string{"grp-eng"}, []string{"g-empty"}, "prov-x")}
got := buildModelPolicies(policies, byIDEmpty)
got := buildModelPolicies(policies, byIDEmpty, nil)
require.NotNil(t, got["prov-x"][0].Models,
"an empty allowlist must not arrive as nil — that would read as unrestricted")
assert.Empty(t, got["prov-x"][0].Models)
@@ -162,7 +162,71 @@ func TestBuildModelPolicies(t *testing.T) {
t.Run("a policy binding no groups is skipped", func(t *testing.T) {
policies := []*types.Policy{policyForGroups("p1", nil, []string{"g-4o"}, "prov-x")}
assert.Empty(t, buildModelPolicies(policies, byID),
assert.Empty(t, buildModelPolicies(policies, byID, nil),
"a policy with no source groups authorises nobody, so it bounds nobody's listing")
})
}
// TestSynthesizedAllowlists_ExpandDeclaredIDsPerProvider proves the
// synthesized allowlists carry the canonical form alongside a raw declared
// entry — under the destination provider's own catalog id, never another's —
// so the proxy-side compares (guardrail backstop, per-group router rules)
// admit the allowlist however the operator wrote it, while a plain provider's
// "-vN"- or "@"-suffixed entries stay verbatim and cannot widen.
func TestSynthesizedAllowlists_ExpandDeclaredIDsPerProvider(t *testing.T) {
byID := map[string]*types.Guardrail{
"g-raw": allowlistGuardrail("g-raw", "acc-1",
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
"claude-sonnet-4-5@20250929",
"gpt-4o"),
}
catalogByProvider := map[string]string{
"prov-bedrock": "bedrock_api",
"prov-vertex": "vertex_ai_api",
"prov-plain": "openai_api",
}
policies := []*types.Policy{
policyForGroups("p1", []string{"grp-eng"}, []string{"g-raw"},
"prov-bedrock", "prov-vertex", "prov-plain"),
}
t.Run("guardrail backstop expands under each provider's own normalizer", func(t *testing.T) {
got := buildProviderAllowlists(policies, byID, catalogByProvider)
assert.ElementsMatch(t, []string{
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-sonnet-4-5",
"claude-sonnet-4-5@20250929",
"gpt-4o",
}, got["prov-bedrock"],
"the Bedrock destination strips geography/version, but must not apply Vertex's @-strip")
assert.ElementsMatch(t, []string{
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
"claude-sonnet-4-5@20250929",
"claude-sonnet-4-5",
"gpt-4o",
}, got["prov-vertex"],
"the Vertex destination strips @version, but must not apply Bedrock's suffix strip")
assert.ElementsMatch(t, []string{
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
"claude-sonnet-4-5@20250929",
"gpt-4o",
}, got["prov-plain"],
"a body-routed provider keeps every entry verbatim — no alternate can widen it")
})
t.Run("router model rules expand the same way", func(t *testing.T) {
got := buildModelPolicies(policies, byID, catalogByProvider)
require.Len(t, got["prov-bedrock"], 1)
assert.Contains(t, got["prov-bedrock"][0].Models, "anthropic.claude-sonnet-4-5")
assert.NotContains(t, got["prov-bedrock"][0].Models, "claude-sonnet-4-5")
require.Len(t, got["prov-vertex"], 1)
assert.Contains(t, got["prov-vertex"][0].Models, "claude-sonnet-4-5")
assert.NotContains(t, got["prov-vertex"][0].Models, "anthropic.claude-sonnet-4-5")
require.Len(t, got["prov-plain"], 1)
assert.ElementsMatch(t, []string{
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
"claude-sonnet-4-5@20250929",
"gpt-4o",
}, got["prov-plain"][0].Models)
})
}
@@ -6,9 +6,9 @@ import (
"testing"
"time"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
@@ -497,6 +497,55 @@ func TestSynthesizeServices_IdentityInject_LiteLLM(t *testing.T) {
assert.Equal(t, "x-litellm-tags", entry.HeaderPair.TagsHeader)
}
func TestBuildIdentityInjectConfigJSON_Agentgateway(t *testing.T) {
provider := &types.Provider{
ID: "prov-agentgateway",
ProviderID: "agentgateway",
}
raw, err := buildIdentityInjectConfigJSON(
[]*types.Provider{provider},
map[string][]string{provider.ID: []string{"grp-eng"}},
)
require.NoError(t, err)
var cfg identityInjectConfig
require.NoError(t, json.Unmarshal(raw, &cfg))
require.Len(t, cfg.Providers, 1)
rule := cfg.Providers[0]
assert.Equal(t, provider.ID, rule.ProviderID)
require.NotNil(t, rule.HeaderPair)
assert.Nil(t, rule.JSONMetadata)
assert.Equal(t, "x-netbird-user-id", rule.HeaderPair.EndUserIDHeader)
assert.Equal(t, "x-netbird-groups", rule.HeaderPair.TagsHeader)
assert.False(t, rule.HeaderPair.EndUserIDInBody)
assert.False(t, rule.HeaderPair.TagsInBody)
}
func TestBuildRouterConfigJSON_AgentgatewayVendors(t *testing.T) {
provider := &types.Provider{
ID: "prov-agentgateway",
ProviderID: "agentgateway",
UpstreamURL: "https://gateway.example.com",
APIKey: "virtual-key",
}
raw, err := buildRouterConfigJSON(
[]*types.Provider{provider},
map[string][]string{provider.ID: {"grp-eng"}},
nil,
)
require.NoError(t, err)
var cfg routerConfig
require.NoError(t, json.Unmarshal(raw, &cfg))
require.Len(t, cfg.Providers, 1)
assert.Empty(t, cfg.Providers[0].Vendor,
"the singular vendor remains empty for a multi-surface gateway")
assert.Equal(t, []string{"openai", "anthropic"}, cfg.Providers[0].Vendors)
}
// TestSynthesizeServices_IdentityInject_Bifrost_OperatorOverrides
// covers the customizable HeaderPair contract. The Bifrost catalog
// entry sets HeaderPair.Customizable=true with x-bf-dim-* defaults
@@ -0,0 +1,42 @@
package types
// AgentConfig 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 AgentConfig struct {
// Configured is false only when the account has no Agent Network set
// up. A caller no policy covers yet still reads as configured, with an
// empty Providers list: every member gets the same connection config,
// and the empty list is what tells them to ask for access.
Configured bool
// Endpoint is the account's proxy base URL
// ("https://<subdomain>.<cluster>"), reachable over the NetBird tunnel
// only. Empty when Configured is false. Handing it to a member the
// policies do not cover authorizes nothing on its own — the proxy
// still refuses every request no policy permits.
Endpoint string
// Providers lists the providers at least one applicable policy
// authorizes for the caller, in the account's created_at order.
Providers []AgentConfigProvider
}
// AgentConfigProvider is one authorized provider in an AgentConfig.
type AgentConfigProvider 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
}
@@ -175,6 +175,26 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
// ToAPIResponse renders the provider as the API representation. The API
// key is intentionally never surfaced.
// RedactedForViewer returns a copy with the connection configuration
// blanked: upstream URL, operator-typed extra header values, identity
// header names, the TLS-verification override, and (defence in depth —
// they never reach the wire anyway) the sealed credentials. Read-only
// viewers such as usage_viewer only need the display surface — id,
// catalog id, name, enabled state, and the model list the usage filters
// resolve against — so their responses carry nothing about how the
// operator connects to the vendor.
func (p *Provider) RedactedForViewer() *Provider {
c := *p
c.UpstreamURL = ""
c.APIKey = ""
c.ExtraValues = nil
c.IdentityHeaderUserID = ""
c.IdentityHeaderGroups = ""
c.SkipTLSVerification = false
c.SessionPrivateKey = ""
return &c
}
func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
models := make([]api.AgentNetworkProviderModel, 0, len(p.Models))
for _, m := range p.Models {