mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-11 04:26:04 -04:00
## Describe your changes The model-allowlist guardrail was merged into one account-wide union and enforced flat on every request, ignoring which policy/group/provider authorised it. With multiple policies — especially a mix of guardrailed and un-guardrailed ones — this caused: - **false-allow**: a model allowlisted for one group/provider leaked to any caller; and - **false-deny**: an un-guardrailed policy (intended unrestricted) was blocked by another policy's allowlist. Enforcement is now policy/group-aware, mirroring `llm_limit_check`: - **Management (`SelectPolicyForRequest`) is authoritative.** It uses the request model (already carried in `CheckLLMPolicyLimitsRequest.model`, previously ignored) to keep only applicable policies whose guardrails permit the model; no allowlist-enabled guardrail = unrestricted. Denies `llm_policy.model_blocked` when policies govern the (provider, groups) but none permits the model. - **Proxy `llm_guardrail` becomes a per-provider fail-closed backstop.** The synthesiser emits an allowlist only for providers every authorising policy restricts; the middleware keys off the resolved provider id and keeps unknown-model fail-closed.
423 lines
17 KiB
Go
423 lines
17 KiB
Go
//go:build e2e
|
|
|
|
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/e2e/harness"
|
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
|
)
|
|
|
|
// pergroupCase describes one provider surface for the per-group allowlist matrix.
|
|
// selectedReq/otherReq are the model identifiers as they travel in the request
|
|
// (URL path for Bedrock/Vertex, body "model" for chat/messages). selectedAllow/
|
|
// otherAllow are the (normalized) forms the guardrail allowlist holds — for
|
|
// Bedrock these differ from the request form so path normalization is exercised.
|
|
type pergroupCase struct {
|
|
name string
|
|
catalogID string
|
|
wire string // "chat", "messages", "vertex", "bedrock"
|
|
models *[]api.AgentNetworkProviderModel
|
|
selectedReq string
|
|
selectedAllow string
|
|
otherReq string
|
|
otherAllow string
|
|
|
|
providerID string // filled during setup
|
|
}
|
|
|
|
// TestGuardrailPerGroupAllowlist_AllProviders proves the per-policy/group model
|
|
// allowlist end to end across every always-on provider surface, including the
|
|
// path-routed ones (Vertex, Bedrock) where the model travels in the URL.
|
|
//
|
|
// For each provider two policies target it: grpMain (the client) is allowed only
|
|
// selectedReq; grpOther (which the client is NOT in) is allowed only otherReq.
|
|
// The client must get selectedReq served (200) and otherReq denied (403,
|
|
// llm_policy.model_blocked) — the cross-group no-leak property. The deny is the
|
|
// authoritative per-policy/group decision from management (the proxy per-provider
|
|
// backstop carries the union of both models), so this also confirms management
|
|
// receives the correct normalized model for path-routed providers.
|
|
func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancel()
|
|
|
|
const (
|
|
vertexProject = "e2e-project"
|
|
vertexRegion = "global"
|
|
)
|
|
|
|
priced := func(ids ...string) *[]api.AgentNetworkProviderModel {
|
|
out := make([]api.AgentNetworkProviderModel, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001})
|
|
}
|
|
return &out
|
|
}
|
|
|
|
cases := []*pergroupCase{
|
|
{
|
|
name: "openai", catalogID: "openai_api", wire: harness.WireChat,
|
|
models: priced("oai-model-a", "oai-model-b"),
|
|
selectedReq: "oai-model-a", selectedAllow: "oai-model-a",
|
|
otherReq: "oai-model-b", otherAllow: "oai-model-b",
|
|
},
|
|
{
|
|
name: "anthropic", catalogID: "anthropic_api", wire: harness.WireMessages,
|
|
models: priced("ant-model-a", "ant-model-b"),
|
|
selectedReq: "ant-model-a", selectedAllow: "ant-model-a",
|
|
otherReq: "ant-model-b", otherAllow: "ant-model-b",
|
|
},
|
|
{
|
|
// Vertex catalog ids travel bare in the rawPredict path.
|
|
name: "vertex", catalogID: "vertex_ai_api", wire: "vertex",
|
|
selectedReq: "claude-sonnet-4-5", selectedAllow: "claude-sonnet-4-5",
|
|
otherReq: "claude-opus-4-6", otherAllow: "claude-opus-4-6",
|
|
},
|
|
{
|
|
// Bedrock request ids are region-prefixed/versioned; the parser
|
|
// normalizes them to the catalog key the allowlist holds.
|
|
name: "bedrock", catalogID: "bedrock_api", wire: "bedrock",
|
|
selectedReq: "us.anthropic.claude-sonnet-4-5-v1:0", selectedAllow: "anthropic.claude-sonnet-4-5",
|
|
otherReq: "us.anthropic.claude-opus-4-8-v1:0", otherAllow: "anthropic.claude-opus-4-8",
|
|
},
|
|
}
|
|
|
|
vllm, err := harness.StartVLLM(ctx, srv)
|
|
require.NoError(t, err, "start mock upstream")
|
|
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
|
|
|
grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-main"})
|
|
require.NoError(t, err, "create main group")
|
|
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) })
|
|
|
|
grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-other"})
|
|
require.NoError(t, err, "create other group")
|
|
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) })
|
|
|
|
ephemeral := false
|
|
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
|
Name: "e2e-pergroup-client",
|
|
Type: "reusable",
|
|
ExpiresIn: 86400,
|
|
UsageLimit: 0,
|
|
AutoGroups: []string{grpMain.Id},
|
|
Ephemeral: &ephemeral,
|
|
})
|
|
require.NoError(t, err, "mint setup key")
|
|
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
|
|
|
staticKey := "static-e2e-token"
|
|
enabled := true
|
|
|
|
for i, c := range cases {
|
|
req := api.AgentNetworkProviderRequest{
|
|
Name: "e2e-pergroup-" + c.name,
|
|
ProviderId: c.catalogID,
|
|
UpstreamUrl: vllm.URL,
|
|
ApiKey: &staticKey,
|
|
Enabled: ptr(true),
|
|
Models: c.models,
|
|
}
|
|
if i == 0 {
|
|
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
|
}
|
|
prov, perr := srv.CreateProvider(ctx, req)
|
|
require.NoError(t, perr, "create provider %s", c.name)
|
|
c.providerID = prov.Id
|
|
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
|
|
|
gSel := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-sel", c.selectedAllow)
|
|
gOth := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-oth", c.otherAllow)
|
|
|
|
polMain, merr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-pergroup-" + c.name + "-main",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{grpMain.Id},
|
|
DestinationProviderIds: []string{prov.Id},
|
|
GuardrailIds: &[]string{gSel.Id},
|
|
})
|
|
require.NoError(t, merr, "create main policy %s", c.name)
|
|
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) })
|
|
|
|
polOther, oerr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-pergroup-" + c.name + "-other",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{grpOther.Id},
|
|
DestinationProviderIds: []string{prov.Id},
|
|
GuardrailIds: &[]string{gOth.Id},
|
|
})
|
|
require.NoError(t, oerr, "create other policy %s", c.name)
|
|
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) })
|
|
}
|
|
|
|
settings, err := srv.GetSettings(ctx)
|
|
require.NoError(t, err, "read settings")
|
|
require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned")
|
|
|
|
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-pergroup-proxy")
|
|
require.NoError(t, err, "mint proxy token")
|
|
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
|
require.NoError(t, err, "start proxy")
|
|
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
|
|
|
cl, err := harness.StartClient(ctx, srv, sk.Key)
|
|
require.NoError(t, err, "start client")
|
|
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
|
|
|
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
|
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
|
require.NoError(t, err, "resolve endpoint to proxy IP")
|
|
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
|
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
|
}
|
|
|
|
send := func(c *pergroupCase, model string) (int, string) {
|
|
var code int
|
|
var body string
|
|
var cerr error
|
|
switch c.wire {
|
|
case "vertex":
|
|
code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "")
|
|
case "bedrock":
|
|
code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "")
|
|
default:
|
|
code, body, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, c.wire, model, "Reply with exactly: pong", "")
|
|
}
|
|
require.NoError(t, cerr, "request must reach the proxy for %s", c.name)
|
|
return code, body
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
// grpMain's own model is served. Retry to absorb tunnel/DNS jitter on
|
|
// the first call over the freshly warmed tunnel.
|
|
var code int
|
|
var body string
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
code, body = send(c, c.selectedReq)
|
|
if code == 200 {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
assert.Equal(t, 200, code,
|
|
"%s: grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background()))
|
|
|
|
// grpOther's model must NOT leak to the grpMain client.
|
|
code, body = send(c, c.otherReq)
|
|
assert.Equal(t, 403, code,
|
|
"%s: another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background()))
|
|
assert.Contains(t, body, "llm_policy.model_blocked",
|
|
"%s: denial must be a model-allowlist decision, not routing; body: %s", c.name, body)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGuardrailMultiGroupUser proves the per-policy/group decision for a caller
|
|
// that belongs to MULTIPLE groups at once. Two scenarios, one shared stack:
|
|
//
|
|
// - union across the user's groups: the client is in gUX and gUY, each with
|
|
// its own policy+guardrail on provider P1 (gUX->union-a, gUY->union-b). The
|
|
// client may use BOTH models (the union of its groups' allowlists) while a
|
|
// third, un-allowlisted model is denied.
|
|
// - an un-guardrailed group lifts the restriction: the client is in gMP and
|
|
// gMQ on provider P2, where gMP restricts to mix-a but gMQ's policy carries
|
|
// NO guardrail. Because one applicable policy is unrestricted, the client may
|
|
// use a model on no allowlist (mix-z) as well as mix-a.
|
|
func TestGuardrailMultiGroupUser(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
|
defer cancel()
|
|
|
|
const (
|
|
unionA = "mg-union-a"
|
|
unionB = "mg-union-b"
|
|
unionC = "mg-union-c" // allowlisted by neither group
|
|
mixA = "mg-mix-a"
|
|
mixZ = "mg-mix-z" // on no allowlist; reachable only via the un-guardrailed policy
|
|
)
|
|
|
|
priced := func(ids ...string) *[]api.AgentNetworkProviderModel {
|
|
out := make([]api.AgentNetworkProviderModel, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001})
|
|
}
|
|
return &out
|
|
}
|
|
|
|
vllm, err := harness.StartVLLM(ctx, srv)
|
|
require.NoError(t, err, "start mock upstream")
|
|
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
|
|
|
mkGroup := func(name string) *api.Group {
|
|
g, gerr := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: name})
|
|
require.NoError(t, gerr, "create group %s", name)
|
|
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), g.Id) })
|
|
return g
|
|
}
|
|
gUX := mkGroup("e2e-mg-union-x")
|
|
gUY := mkGroup("e2e-mg-union-y")
|
|
gMP := mkGroup("e2e-mg-mix-p")
|
|
gMQ := mkGroup("e2e-mg-mix-q")
|
|
|
|
ephemeral := false
|
|
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
|
Name: "e2e-mg-client",
|
|
Type: "reusable",
|
|
ExpiresIn: 86400,
|
|
UsageLimit: 0,
|
|
AutoGroups: []string{gUX.Id, gUY.Id, gMP.Id, gMQ.Id}, // client in all four groups
|
|
Ephemeral: &ephemeral,
|
|
})
|
|
require.NoError(t, err, "mint setup key")
|
|
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
|
|
|
staticKey := "static-e2e-token"
|
|
enabled := true
|
|
|
|
// P1 — union scenario: two restricting policies, one per group.
|
|
p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
|
Name: "e2e-mg-union",
|
|
ProviderId: "openai_api",
|
|
UpstreamUrl: vllm.URL,
|
|
ApiKey: &staticKey,
|
|
Enabled: ptr(true),
|
|
Models: priced(unionA, unionB, unionC),
|
|
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
|
})
|
|
require.NoError(t, err, "create union provider")
|
|
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) })
|
|
|
|
polUX, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-mg-union-x",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{gUX.Id},
|
|
DestinationProviderIds: []string{p1.Id},
|
|
GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-x", unionA).Id},
|
|
})
|
|
require.NoError(t, err, "create union policy X")
|
|
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUX.Id) })
|
|
|
|
polUY, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-mg-union-y",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{gUY.Id},
|
|
DestinationProviderIds: []string{p1.Id},
|
|
GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-y", unionB).Id},
|
|
})
|
|
require.NoError(t, err, "create union policy Y")
|
|
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUY.Id) })
|
|
|
|
// P2 — mixed scenario: one restricting policy + one un-guardrailed policy.
|
|
p2, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
|
Name: "e2e-mg-mix",
|
|
ProviderId: "openai_api",
|
|
UpstreamUrl: vllm.URL,
|
|
ApiKey: &staticKey,
|
|
Enabled: ptr(true),
|
|
Models: priced(mixA, mixZ),
|
|
})
|
|
require.NoError(t, err, "create mix provider")
|
|
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p2.Id) })
|
|
|
|
polMP, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-mg-mix-p",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{gMP.Id},
|
|
DestinationProviderIds: []string{p2.Id},
|
|
GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-mix-p", mixA).Id},
|
|
})
|
|
require.NoError(t, err, "create mix policy P")
|
|
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMP.Id) })
|
|
|
|
polMQ, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-mg-mix-q",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{gMQ.Id},
|
|
DestinationProviderIds: []string{p2.Id}, // NO guardrail -> unrestricted
|
|
})
|
|
require.NoError(t, err, "create mix policy Q")
|
|
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMQ.Id) })
|
|
|
|
settings, err := srv.GetSettings(ctx)
|
|
require.NoError(t, err, "read settings")
|
|
require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned")
|
|
|
|
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-mg-proxy")
|
|
require.NoError(t, err, "mint proxy token")
|
|
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
|
require.NoError(t, err, "start proxy")
|
|
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
|
|
|
cl, err := harness.StartClient(ctx, srv, sk.Key)
|
|
require.NoError(t, err, "start client")
|
|
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
|
|
|
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
|
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
|
require.NoError(t, err, "resolve endpoint to proxy IP")
|
|
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
|
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
|
}
|
|
|
|
send := func(model string) (int, string) {
|
|
code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "")
|
|
require.NoError(t, cerr, "request must reach the proxy")
|
|
return code, body
|
|
}
|
|
sendUntil200 := func(model string) (int, string) {
|
|
var code int
|
|
var body string
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
code, body = send(model)
|
|
if code == 200 {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
return code, body
|
|
}
|
|
|
|
t.Run("union across the user's groups", func(t *testing.T) {
|
|
code, body := sendUntil200(unionA)
|
|
assert.Equal(t, 200, code, "model allowed by group X must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background()))
|
|
|
|
code, body = sendUntil200(unionB)
|
|
assert.Equal(t, 200, code, "model allowed by group Y must also be served (union across the user's groups); body: %s", body)
|
|
|
|
code, body = send(unionC)
|
|
assert.Equal(t, 403, code, "a model on neither group's allowlist must be denied; body: %s", body)
|
|
assert.Contains(t, body, "llm_policy.model_blocked")
|
|
})
|
|
|
|
t.Run("an un-guardrailed group lifts the restriction", func(t *testing.T) {
|
|
code, body := sendUntil200(mixA)
|
|
assert.Equal(t, 200, code, "the restricted group's model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background()))
|
|
|
|
code, body = sendUntil200(mixZ)
|
|
assert.Equal(t, 200, code,
|
|
"a non-allowlisted model must be served because the user is also in a group whose policy has no guardrail; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background()))
|
|
})
|
|
}
|
|
|
|
// mkAllowGuardrail creates a guardrail whose model allowlist is enabled and holds
|
|
// exactly the given model, registering cleanup.
|
|
func mkAllowGuardrail(t *testing.T, ctx context.Context, name, model string) api.AgentNetworkGuardrail {
|
|
t.Helper()
|
|
var gr api.AgentNetworkGuardrailRequest
|
|
gr.Name = name
|
|
gr.Checks.ModelAllowlist.Enabled = true
|
|
gr.Checks.ModelAllowlist.Models = []string{model}
|
|
g, err := srv.CreateGuardrail(ctx, gr)
|
|
require.NoError(t, err, "create guardrail %s", name)
|
|
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) })
|
|
return g
|
|
}
|