mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-10 08:51:29 +02:00
After #7221, guardrail allowlists built from a path-style provider's declared model ids (Bedrock, Vertex) stopped working: the raw region/version form was compared against the parser's canonical id, so the agent config advertised an empty model list and requests for the allowlisted model were refused. Make every allowlist compare provider-aware, keyed on the destination provider's catalog id: the agent config, the policy gate, and the synthesized proxy allowlists match an entry on both its verbatim and canonical form — Bedrock's strip only under bedrock_api, Vertex's only under vertex_ai_api, verbatim everywhere else, so a plain provider's suffixed entries never widen. The router's claim compare learns the Vertex @version strip. New e2e, realstore, and unit tests reproduce both regressions and pin the fix.
510 lines
21 KiB
Go
510 lines
21 KiB
Go
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"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
|
|
// to reach providerID under the given guardrails. Uncapped keeps the selector's
|
|
// headroom scoring trivial so these tests isolate the model-allowlist gate.
|
|
func guardedPolicy(id, account string, sourceGroups []string, providerID string, guardrailIDs ...string) *types.Policy {
|
|
return &types.Policy{
|
|
ID: id,
|
|
AccountID: account,
|
|
Enabled: true,
|
|
SourceGroups: sourceGroups,
|
|
DestinationProviderIDs: []string{providerID},
|
|
GuardrailIDs: guardrailIDs,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// allowlistGuardrail builds a guardrail whose model allowlist is enabled and
|
|
// carries the given models.
|
|
func allowlistGuardrail(id, account string, models ...string) *types.Guardrail {
|
|
return &types.Guardrail{
|
|
ID: id,
|
|
AccountID: account,
|
|
Checks: types.GuardrailChecks{
|
|
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
|
|
},
|
|
}
|
|
}
|
|
|
|
func expectPolicies(mockStore *store.MockStore, account string, policies ...*types.Policy) {
|
|
mockStore.EXPECT().
|
|
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), account).
|
|
Return(policies, nil)
|
|
}
|
|
|
|
func expectGuardrails(mockStore *store.MockStore, account string, guardrails ...*types.Guardrail) {
|
|
mockStore.EXPECT().
|
|
GetAccountAgentNetworkGuardrails(gomock.Any(), gomock.Any(), account).
|
|
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.
|
|
func TestSelectPolicy_ModelBlockedByAllowlist(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"))
|
|
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: "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, res.Allow, "a model outside the only applicable policy's allowlist must be denied")
|
|
assert.Equal(t, denyCodeModelBlocked, res.DenyCode, "deny code must be model_blocked")
|
|
assert.NotEmpty(t, res.DenyReason, "deny reason must be populated")
|
|
}
|
|
|
|
// TestSelectPolicy_ModelAllowedByAllowlist is the allow counterpart: the model
|
|
// is on the applicable policy's allowlist, so selection proceeds normally.
|
|
func TestSelectPolicy_ModelAllowedByAllowlist(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", "claude-opus-4"))
|
|
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
|
expectConsumptionBatch(mockStore, nil)
|
|
|
|
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
|
AccountID: "acc-1",
|
|
UserID: "user-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "a model on the applicable policy's allowlist must be allowed")
|
|
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
|
}
|
|
|
|
// TestSelectPolicy_CaseInsensitiveModelMatch proves the compare tolerates case
|
|
// and surrounding whitespace, matching the proxy guardrail's normalisation.
|
|
func TestSelectPolicy_CaseInsensitiveModelMatch(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 "))
|
|
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
|
expectConsumptionBatch(mockStore, nil)
|
|
|
|
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
|
AccountID: "acc-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "gpt-4o",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "case/whitespace variants must match the allowlist entry")
|
|
}
|
|
|
|
// TestSelectPolicy_UnguardedPolicyIsUnrestricted is the false-deny fix: when two
|
|
// policies authorise the same (provider, group) and one has no guardrail, that
|
|
// policy makes the request unrestricted — not caught by the other's allowlist.
|
|
func TestSelectPolicy_UnguardedPolicyIsUnrestricted(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mgr, mockStore := newSelectorMgr(t, ctrl)
|
|
|
|
restricted := guardedPolicy("pol-restricted", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
|
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{
|
|
AccountID: "acc-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "an un-guardrailed policy for the same (provider, group) must leave the request unrestricted")
|
|
assert.Equal(t, "pol-open", res.SelectedPolicyID, "the unrestricted policy must be the one that pays")
|
|
}
|
|
|
|
// TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups is the false-allow fix: a
|
|
// model allowlisted only for grp-b must not be usable by a grp-a caller. The
|
|
// selector considers only policies applicable to the caller's groups.
|
|
func TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mgr, mockStore := newSelectorMgr(t, ctrl)
|
|
|
|
polA := guardedPolicy("pol-a", "acc-1", []string{"grp-a"}, "prov-1", "g-a")
|
|
polB := guardedPolicy("pol-b", "acc-1", []string{"grp-b"}, "prov-1", "g-b")
|
|
expectPolicies(mockStore, "acc-1", polA, polB)
|
|
expectGuardrails(mockStore, "acc-1",
|
|
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",
|
|
GroupIDs: []string{"grp-a"},
|
|
ProviderID: "prov-1",
|
|
Model: "claude-opus-4", // only allowed for grp-b
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, res.Allow, "grp-b's allowlisted model must not leak to a grp-a caller")
|
|
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
|
|
}
|
|
|
|
// TestSelectPolicy_UndeterminedModelFailsClosed proves the fail-closed contract
|
|
// mirrors the proxy: with a restricted applicable policy and an empty model
|
|
// (e.g. a path-routed shape the parser couldn't map), the request is denied.
|
|
func TestSelectPolicy_UndeterminedModelFailsClosed(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"))
|
|
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
|
|
|
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
|
AccountID: "acc-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "", // undetermined
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, res.Allow, "an undetermined model must fail closed against a restricted policy")
|
|
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
|
|
}
|
|
|
|
// TestSelectPolicy_DisabledAllowlistDoesNotRestrict proves a guardrail whose
|
|
// model allowlist is disabled imposes no model restriction, even though the
|
|
// policy references it.
|
|
func TestSelectPolicy_DisabledAllowlistDoesNotRestrict(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")
|
|
disabled := &types.Guardrail{
|
|
ID: "g-1",
|
|
AccountID: "acc-1",
|
|
Checks: types.GuardrailChecks{
|
|
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}},
|
|
},
|
|
}
|
|
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{
|
|
AccountID: "acc-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "anything-goes",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "a disabled allowlist must not restrict the model")
|
|
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
|
}
|
|
|
|
// TestSelectPolicy_UnionAcrossPolicyGuardrails proves a policy with multiple
|
|
// allowlist guardrails permits the union of their models (not just the first).
|
|
func TestSelectPolicy_UnionAcrossPolicyGuardrails(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", "g-2")
|
|
expectPolicies(mockStore, "acc-1", policy)
|
|
expectGuardrails(mockStore, "acc-1",
|
|
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{
|
|
AccountID: "acc-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "claude-opus-4", // only in the second guardrail's list
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "a model in any of the policy's allowlist guardrails must be permitted")
|
|
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
|
}
|
|
|
|
// TestSelectPolicy_GuardrailLookupErrorPropagates proves a store failure while
|
|
// resolving the candidate policies' guardrails surfaces as an error, not a
|
|
// silent allow/deny.
|
|
func TestSelectPolicy_GuardrailLookupErrorPropagates(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)
|
|
mockStore.EXPECT().
|
|
GetAccountAgentNetworkGuardrails(gomock.Any(), gomock.Any(), "acc-1").
|
|
Return(nil, errors.New("store unavailable"))
|
|
|
|
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
|
AccountID: "acc-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "gpt-4o",
|
|
})
|
|
require.Error(t, err, "a guardrail-lookup failure must surface as an error")
|
|
assert.Nil(t, res)
|
|
}
|
|
|
|
// TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted proves a
|
|
// policy referencing a guardrail ID absent from the account's set (a stale
|
|
// reference) imposes no model restriction — same as no guardrail.
|
|
func TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mgr, mockStore := newSelectorMgr(t, ctrl)
|
|
|
|
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{
|
|
AccountID: "acc-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "anything-goes",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "an orphaned guardrail reference must not restrict the model")
|
|
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
|
}
|
|
|
|
// TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter proves the model
|
|
// gate narrows candidates before cap scoring: the permitting policy is selected
|
|
// even though the blocked one has a larger, more attractive cap.
|
|
func TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mgr, mockStore := newSelectorMgr(t, ctrl)
|
|
|
|
polBig := guardedPolicy("pol-big", "acc-1", []string{"grp-eng"}, "prov-1", "g-restrict")
|
|
polBig.Limits = types.PolicyLimits{
|
|
TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 1_000_000, WindowSeconds: 3600},
|
|
}
|
|
polSmall := guardedPolicy("pol-small", "acc-1", []string{"grp-eng"}, "prov-1", "g-permit")
|
|
polSmall.Limits = types.PolicyLimits{
|
|
TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 100, WindowSeconds: 3600},
|
|
}
|
|
expectPolicies(mockStore, "acc-1", polBig, polSmall)
|
|
expectGuardrails(mockStore, "acc-1",
|
|
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{
|
|
AccountID: "acc-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
Model: "claude-opus-4", // only pol-small's guardrail permits this
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow)
|
|
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)
|
|
}
|