Files
netbird/e2e/harness/agentnetwork.go
Maycon Santos 277d8e4c53 [proxy] enforce model allowlist for URL-routed providers (Bedrock/Vertex) (#6764)
## Describe your changes

The Agent Network policy Guardrail "Model Allowlist" was not enforced
for providers whose model travels in the URL/path rather than the JSON
body — most visibly AWS Bedrock (reported in netbirdio/netbird#6751),
and the same class applies to Google Vertex.

Root cause: the `llm_guardrail` allowlist check **failed open**.
`evaluateAllowlist` returned allow whenever the request model was absent
from the metadata bag (`middleware.go`, `if !modelPresent { return nil
}`). The model is stamped upstream by `llm_request_parser`; for
body-routed providers (OpenAI/Anthropic) it comes from the JSON body,
but for path-routed providers the model is recovered only when the
request matches a recognized path shape (Bedrock
`/model/{id}/{invoke|converse|...}`, Vertex
`/v1/projects/.../publishers/.../models/...`). Any shape the parser did
not recognize reached the guardrail with no model and was allowed
regardless of the allowlist.

Fix (provider-agnostic): **fail closed**. When an allowlist is
configured and the model cannot be determined (absent or empty), the
request is denied `403` with a distinct `llm_policy.model_unknown`
reason. This closes the bypass for Bedrock, Vertex, and any future
URL-routed provider in one place. When no allowlist is configured,
behavior is unchanged.

The model allowlist is enforced solely in the proxy `llm_guardrail`;
management's `CheckLLMPolicyLimits` handles only token/budget caps, so
no management change is required.

## Issue ticket number and link

<https://github.com/netbirdio/netbird/discussions/6751>

## Stack

- \#6726 <!-- branch-stack -->
  - \#6764 :point\_left:

### Checklist

- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation

Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Bug fix that restores the documented allowlist behavior; no user-facing
surface changes.

### Docs PR URL (required if "docs added" is checked)

Paste the PR link from <https://github.com/netbirdio/docs> here:

<https://github.com/netbirdio/docs/pull/>\_\_

## Tests

- `llm_guardrail`: absent/empty model under a configured allowlist now
denies (`model_unknown`); empty allowlist still allows a missing model
(fail-closed only applies when a list is set); existing
allow/deny/case-insensitive cases retained.
- `llm_request_parser`: new parser→guardrail integration test drives
real **Bedrock** (`/model/{id}/invoke`) and **Vertex**
(`/v1/projects/.../models/...`) URL shapes and asserts allowed→200,
disallowed→403 (`model_blocked`), and an unrecognized Bedrock action→403
(`model_unknown`, the #6751 regression guard).

Note: a full through-tunnel e2e for the allowlist is intentionally
deferred — the agent-network e2e (`WaitProxyPeer`) is currently red on
`main`/`0.74.x` for an unrelated lazy-connection reason; it will be
added once that harness gate is fixed.
2026-07-14 19:03:01 +02:00

142 lines
5.7 KiB
Go

//go:build e2e
package harness
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// The shared REST client doesn't (yet) expose typed agent-network methods, so
// these helpers drive the /api/agent-network/* endpoints through the client's
// NewRequest primitive — reusing its auth, error handling (rest.APIError on
// non-2xx), and transport — while still speaking the generated api types.
// anRequest issues an agent-network API call and decodes the JSON response into
// T. A non-2xx response surfaces as a *rest.APIError from the client, which
// tests inspect for negative-path status assertions.
func anRequest[T any](ctx context.Context, c *Combined, method, path string, body any) (T, error) {
var out T
var reader io.Reader
if body != nil {
bs, err := json.Marshal(body)
if err != nil {
return out, fmt.Errorf("marshal %s %s: %w", method, path, err)
}
reader = bytes.NewReader(bs)
}
resp, err := c.api.NewRequest(ctx, method, path, reader, nil)
if err != nil {
return out, err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return out, fmt.Errorf("decode %s %s response: %w", method, path, err)
}
return out, nil
}
// anDelete issues a DELETE and discards the (empty-object) body.
func anDelete(ctx context.Context, c *Combined, path string) error {
resp, err := c.api.NewRequest(ctx, http.MethodDelete, path, nil, nil)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// CreateProvider creates an agent-network provider.
func (c *Combined) CreateProvider(ctx context.Context, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) {
return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPost, "/api/agent-network/providers", req)
}
// GetProvider fetches a provider by id.
func (c *Combined) GetProvider(ctx context.Context, id string) (api.AgentNetworkProvider, error) {
return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers/"+id, nil)
}
// ListProviders returns all providers for the account.
func (c *Combined) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) {
return anRequest[[]api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers", nil)
}
// DeleteProvider removes a provider by id.
func (c *Combined) DeleteProvider(ctx context.Context, id string) error {
return anDelete(ctx, c, "/api/agent-network/providers/"+id)
}
// SetProviderEnabled toggles a provider's enabled flag, preserving its other
// fields (the API key is omitted, which keeps the stored one). Used to run one
// provider at a time so model→provider routing is unambiguous.
func (c *Combined) SetProviderEnabled(ctx context.Context, id string, enabled bool) error {
p, err := c.GetProvider(ctx, id)
if err != nil {
return err
}
_, err = anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, api.AgentNetworkProviderRequest{
Name: p.Name,
ProviderId: p.ProviderId,
UpstreamUrl: p.UpstreamUrl,
Enabled: &enabled,
Models: &p.Models,
})
return err
}
// CreatePolicy creates an agent-network policy.
func (c *Combined) CreatePolicy(ctx context.Context, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) {
return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPost, "/api/agent-network/policies", req)
}
// UpdatePolicy replaces a policy by id.
func (c *Combined) UpdatePolicy(ctx context.Context, id string, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) {
return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPut, "/api/agent-network/policies/"+id, req)
}
// DeletePolicy removes a policy by id.
func (c *Combined) DeletePolicy(ctx context.Context, id string) error {
return anDelete(ctx, c, "/api/agent-network/policies/"+id)
}
// CreateGuardrail creates an agent-network guardrail (e.g. a model allowlist)
// that can then be attached to a policy via its GuardrailIds.
func (c *Combined) CreateGuardrail(ctx context.Context, req api.AgentNetworkGuardrailRequest) (api.AgentNetworkGuardrail, error) {
return anRequest[api.AgentNetworkGuardrail](ctx, c, http.MethodPost, "/api/agent-network/guardrails", req)
}
// DeleteGuardrail removes a guardrail by id.
func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error {
return anDelete(ctx, c, "/api/agent-network/guardrails/"+id)
}
// GetSettings returns the account's agent-network settings row. It exists only
// after the first provider create bootstraps it.
func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) {
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil)
}
// UpdateSettings applies the mutable collection toggles.
func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) {
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req)
}
// ListConsumption returns the account's consumption rows (possibly empty).
func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) {
return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil)
}
// ListAccessLogs returns the account's agent-network access-log page (the
// flattened per-request rows the proxy ships and management ingests).
func (c *Combined) ListAccessLogs(ctx context.Context) (api.AgentNetworkAccessLogsResponse, error) {
return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, "/api/agent-network/access-logs", nil)
}