mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-03 20:19:07 +02:00
* [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly.
215 lines
8.8 KiB
Go
215 lines
8.8 KiB
Go
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
)
|
|
|
|
// This file is the no-mock regression guard for policy limit enforcement.
|
|
// policyselect_test.go pins the same behavior through a gomock store with
|
|
// explicit call-sequence expectations — brittle precisely where the upcoming
|
|
// account-budget work (GC-2) refactors the cap-eval primitive and adds an
|
|
// account-level gate. These tests drive the REAL sqlite store + REAL
|
|
// consumption accounting and assert observable behavior (allow / deny /
|
|
// selection / attribution), not which store methods get called. They must keep
|
|
// passing unchanged after GC-2 lands, which is what proves "current behavior is
|
|
// not changed."
|
|
|
|
const realSelectAccount = "acc-realselect-1"
|
|
|
|
// newRealSelectorMgr builds a managerImpl backed by a real sqlite test store.
|
|
func newRealSelectorMgr(t *testing.T) (*managerImpl, store.Store) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
|
require.NoError(t, err, "real sqlite test store must come up")
|
|
t.Cleanup(cleanup)
|
|
return &managerImpl{store: s}, s
|
|
}
|
|
|
|
// TestSelectPolicy_RealStore_NoApplicablePolicies pins the pass-through:
|
|
// nothing targets the (provider, groups) combination, so the selector allows
|
|
// without attribution or consumption tracking.
|
|
func TestSelectPolicy_RealStore_NoApplicablePolicies(t *testing.T) {
|
|
mgr, _ := newRealSelectorMgr(t)
|
|
|
|
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
|
AccountID: realSelectAccount,
|
|
UserID: "user-1",
|
|
GroupIDs: []string{"grp-x"},
|
|
ProviderID: "prov-1",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "no applicable policy must pass through as allow")
|
|
assert.Empty(t, res.SelectedPolicyID, "no selection when nothing applies")
|
|
}
|
|
|
|
// TestSelectPolicy_RealStore_AllowAndLowestGroupAttribution pins the v1
|
|
// attribution rule (lowest intersecting group by string sort) through the
|
|
// real store, with a fresh (zero) consumption row.
|
|
func TestSelectPolicy_RealStore_AllowAndLowestGroupAttribution(t *testing.T) {
|
|
mgr, s := newRealSelectorMgr(t)
|
|
ctx := context.Background()
|
|
|
|
p := capPolicy("pol-A", realSelectAccount, []string{"grp-zz", "grp-aa", "grp-mm"}, "prov-1", 10_000, 86_400)
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p))
|
|
|
|
res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{
|
|
AccountID: realSelectAccount,
|
|
UserID: "user-1",
|
|
GroupIDs: []string{"grp-zz", "grp-aa", "grp-mm"},
|
|
ProviderID: "prov-1",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "fresh state under cap must allow")
|
|
assert.Equal(t, "pol-A", res.SelectedPolicyID, "only applicable policy must be selected")
|
|
assert.Equal(t, "grp-aa", res.AttributionGroupID, "lowest-by-sort intersecting group must win")
|
|
assert.Equal(t, int64(86_400), res.WindowSeconds, "selected policy's window must be returned")
|
|
}
|
|
|
|
// TestSelectPolicy_RealStore_LargerPoolWins_FallsThroughWhenExhausted pins the
|
|
// core selection behavior end to end. The two policies bind DISTINCT groups so
|
|
// they read separate counters — the only shape where fall-through actually
|
|
// yields headroom (policies on the same group share one counter, as
|
|
// policyselect_test.go notes). Larger pool wins fresh; after real consumption
|
|
// drains the larger group, selection falls through to the smaller; once both
|
|
// counters are exhausted the request is denied.
|
|
func TestSelectPolicy_RealStore_LargerPoolWins_FallsThroughWhenExhausted(t *testing.T) {
|
|
mgr, s := newRealSelectorMgr(t)
|
|
ctx := context.Background()
|
|
|
|
tight := capPolicy("pol-tight", realSelectAccount, []string{"grp-tight"}, "prov-1", 100, 86_400)
|
|
tight.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
wide := capPolicy("pol-wide", realSelectAccount, []string{"grp-wide"}, "prov-1", 10_000, 86_400)
|
|
wide.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, tight))
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, wide))
|
|
|
|
// Caller is in both groups, so both policies apply with independent counters.
|
|
in := PolicySelectionInput{
|
|
AccountID: realSelectAccount,
|
|
UserID: "user-1",
|
|
GroupIDs: []string{"grp-tight", "grp-wide"},
|
|
ProviderID: "prov-1",
|
|
}
|
|
|
|
// Fresh: larger pool wins.
|
|
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "pol-wide", res.SelectedPolicyID, "larger pool drains first")
|
|
|
|
// Drain only the wide group's counter to its cap.
|
|
require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-wide", 86_400, 10_000, 0, 0))
|
|
|
|
// Wide exhausted, tight's separate counter is fresh → fall through to tight.
|
|
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "tight pool has its own untouched counter")
|
|
assert.Equal(t, "pol-tight", res.SelectedPolicyID, "selection falls through to the smaller pool once the larger is exhausted")
|
|
|
|
// Drain the tight group's counter too → both exhausted → deny.
|
|
require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-tight", 86_400, 100, 0, 0))
|
|
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
|
require.NoError(t, err)
|
|
assert.False(t, res.Allow, "both group counters exhausted must deny")
|
|
assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode, "deny code names the offending cap kind")
|
|
}
|
|
|
|
// TestSelectPolicy_RealStore_BudgetCapDenies pins budget (USD) enforcement
|
|
// through the real store: once recorded cost reaches the cap, deny.
|
|
func TestSelectPolicy_RealStore_BudgetCapDenies(t *testing.T) {
|
|
mgr, s := newRealSelectorMgr(t)
|
|
ctx := context.Background()
|
|
|
|
p := &types.Policy{
|
|
ID: "pol-budget",
|
|
AccountID: realSelectAccount,
|
|
Enabled: true,
|
|
SourceGroups: []string{"grp-eng"},
|
|
DestinationProviderIDs: []string{"prov-1"},
|
|
Limits: types.PolicyLimits{
|
|
BudgetLimit: types.PolicyBudgetLimit{
|
|
Enabled: true,
|
|
GroupCapUsd: 5.0,
|
|
WindowSeconds: 86_400,
|
|
},
|
|
},
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p))
|
|
|
|
in := PolicySelectionInput{
|
|
AccountID: realSelectAccount,
|
|
UserID: "user-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
}
|
|
|
|
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "fresh budget must allow")
|
|
|
|
require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-eng", 86_400, 0, 0, 5.0))
|
|
|
|
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
|
require.NoError(t, err)
|
|
assert.False(t, res.Allow, "cost at the cap must deny")
|
|
assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode, "budget deny code must be surfaced")
|
|
}
|
|
|
|
// TestSelectPolicy_RealStore_GroupCounterSharedAcrossPolicies pins that two
|
|
// policies on the same group+window read one shared consumption counter: usage
|
|
// recorded once is visible to both, so exhausting the group budget denies
|
|
// regardless of which policy would attribute.
|
|
func TestSelectPolicy_RealStore_GroupCounterSharedAcrossPolicies(t *testing.T) {
|
|
mgr, s := newRealSelectorMgr(t)
|
|
ctx := context.Background()
|
|
|
|
a := capPolicy("pol-a", realSelectAccount, []string{"grp-eng"}, "prov-1", 1_000, 86_400)
|
|
b := capPolicy("pol-b", realSelectAccount, []string{"grp-eng"}, "prov-1", 1_000, 86_400)
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, a))
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, b))
|
|
|
|
in := PolicySelectionInput{
|
|
AccountID: realSelectAccount,
|
|
UserID: "user-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
}
|
|
|
|
require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-eng", 86_400, 1_000, 0, 0))
|
|
|
|
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
|
require.NoError(t, err)
|
|
assert.False(t, res.Allow, "shared group counter at cap denies both equal policies")
|
|
assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode, "token deny code on the shared counter")
|
|
}
|
|
|
|
// TestSelectPolicy_RealStore_DisabledPolicyIgnored pins that a disabled policy
|
|
// is invisible to selection even when it otherwise matches.
|
|
func TestSelectPolicy_RealStore_DisabledPolicyIgnored(t *testing.T) {
|
|
mgr, s := newRealSelectorMgr(t)
|
|
ctx := context.Background()
|
|
|
|
p := capPolicy("pol-disabled", realSelectAccount, []string{"grp-eng"}, "prov-1", 10_000, 86_400)
|
|
p.Enabled = false
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p))
|
|
|
|
res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{
|
|
AccountID: realSelectAccount,
|
|
UserID: "user-1",
|
|
GroupIDs: []string{"grp-eng"},
|
|
ProviderID: "prov-1",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, res.Allow, "no enabled policy applies → pass-through allow")
|
|
assert.Empty(t, res.SelectedPolicyID, "disabled policy must not be selected")
|
|
}
|