Merge branch 'main' into embedded-vnc

This commit is contained in:
Viktor Liu
2026-09-03 14:35:48 +02:00
119 changed files with 11877 additions and 6890 deletions
@@ -179,6 +179,10 @@ func policiesForProvider(policies []*types.Policy, providerID string) []*types.P
// 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)
@@ -192,7 +196,7 @@ func effectiveModelsForProvider(provider *types.Provider, policies []*types.Poli
}
policyRestricted = true
for _, model := range g.Checks.ModelAllowlist.Models {
key := normaliseModelID(model)
key := canonicalModelKey(provider.ProviderID, model)
if key == "" {
continue
}
@@ -221,17 +225,26 @@ func effectiveModelsForProvider(provider *types.Provider, policies []*types.Poli
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") while the allowlist holds the
// stripped id the parser matches 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[normaliseModelID(normalizePricingModelID(provider.ProviderID, id))]; ok {
// ("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
@@ -144,6 +144,54 @@ func TestAgentConfig_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically(t *
"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()
@@ -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")
}
@@ -340,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())
@@ -132,13 +132,15 @@ 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
@@ -147,6 +149,19 @@ type managerImpl struct {
reconcileCache map[string]map[string]syntheticMapping
}
// 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
// manager persists provider/policy/guardrail configuration and, on
// every mutation, reconciles the in-memory synthesised reverse-proxy
@@ -157,8 +172,9 @@ func NewManager(
permissionsManager permissions.Manager,
accountManager account.Manager,
proxyController proxy.Controller,
opts ...ManagerOption,
) Manager {
return &managerImpl{
m := &managerImpl{
store: store,
accountManager: accountManager,
permissionsManager: permissionsManager,
@@ -166,6 +182,10 @@ func NewManager(
modelDiscovery: &modeldiscovery.Client{},
reconcileCache: make(map[string]map[string]syntheticMapping),
}
for _, opt := range opts {
opt(m)
}
return m
}
// GetAllProviders returns the account's providers for callers holding the
@@ -290,9 +310,11 @@ func (m *managerImpl) redactProvidersForViewer(ctx context.Context, accountID, u
// 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
@@ -313,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
@@ -335,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)
@@ -370,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.
@@ -123,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)
@@ -145,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() }()
@@ -156,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)
@@ -175,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
}
@@ -193,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
@@ -243,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
}
@@ -254,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)
@@ -265,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
@@ -465,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"
@@ -315,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")
}
@@ -557,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)
}
@@ -26,6 +26,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 {
@@ -47,10 +51,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,
}
}
@@ -211,6 +217,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 {
@@ -907,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
@@ -931,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{}{}
}
}
@@ -952,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
@@ -972,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
@@ -1180,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)
})
}
@@ -96,10 +96,14 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
_, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
AccountID: accountID,
ProviderID: "openai_api",
Name: "openai",
UpstreamURL: "https://api.openai.com",
AccountID: accountID,
ProviderID: "openai_api",
Name: "openai",
// A private address: the save-time credential check leaves it
// unchecked rather than spending a dummy key against the real
// api.openai.com, which the vendor refuses and which would make
// this test depend on the runner having egress.
UpstreamURL: "https://10.255.255.1",
APIKey: "sk-test",
Enabled: true,
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},
@@ -101,10 +101,14 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
drain(proxyCh)
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
AccountID: accountID,
ProviderID: "openai_api",
Name: "openai-test",
UpstreamURL: "https://api.openai.com",
AccountID: accountID,
ProviderID: "openai_api",
Name: "openai-test",
// A private address: the save-time credential check leaves it
// unchecked rather than spending a dummy key against the real
// api.openai.com, which the vendor refuses and which would make
// this test depend on the runner having egress.
UpstreamURL: "https://10.255.255.1",
APIKey: "sk-test-key",
Enabled: true,
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},