mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 11:19:08 +02:00
290 lines
11 KiB
Go
290 lines
11 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/internals/modules/permissions"
|
|
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
|
|
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/shared/management/status"
|
|
)
|
|
|
|
// These tests pin the provider read surface per grant: a caller holding
|
|
// providers read together with update (managers) gets the full record,
|
|
// while read-only viewers (usage_viewer) get the display surface only —
|
|
// connection configuration is redacted before it reaches the wire layer.
|
|
|
|
func TestGetAllProviders_RedactsConnectionConfigForReadOnlyViewer(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
|
|
saved := newSynthTestProvider()
|
|
saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"}
|
|
saved.IdentityHeaderUserID = "X-User"
|
|
saved.IdentityHeaderGroups = "X-Groups"
|
|
saved.SkipTLSVerification = true
|
|
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
|
|
|
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true)
|
|
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false)
|
|
|
|
providers, err := f.manager.GetAllProviders(ctx, testAccountID, "viewer")
|
|
require.NoError(t, err)
|
|
require.Len(t, providers, 1)
|
|
p := providers[0]
|
|
assert.Equal(t, saved.ID, p.ID, "identity survives redaction")
|
|
assert.Equal(t, saved.Name, p.Name)
|
|
assert.Equal(t, saved.ProviderID, p.ProviderID)
|
|
assert.Equal(t, saved.Models, p.Models, "the model list backs the usage filters and stays")
|
|
assert.True(t, p.Enabled)
|
|
assert.Empty(t, p.UpstreamURL, "upstream URL is connection config")
|
|
assert.Empty(t, p.ExtraValues, "operator-typed header values are connection config")
|
|
assert.Empty(t, p.IdentityHeaderUserID)
|
|
assert.Empty(t, p.IdentityHeaderGroups)
|
|
assert.False(t, p.SkipTLSVerification)
|
|
assert.Empty(t, p.APIKey)
|
|
assert.Empty(t, p.SessionPrivateKey)
|
|
|
|
stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, testAccountID, saved.ID)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, stored.UpstreamURL, "redaction must not write back to the store")
|
|
}
|
|
|
|
func TestGetProvider_FullConfigForManagingCaller(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
|
|
saved := newSynthTestProvider()
|
|
saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"}
|
|
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
|
|
|
f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Read, true)
|
|
f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Update, true)
|
|
|
|
p, err := f.manager.GetProvider(ctx, testAccountID, "admin", saved.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, saved.UpstreamURL, p.UpstreamURL, "a caller who can edit the provider sees its config")
|
|
assert.Equal(t, saved.ExtraValues, p.ExtraValues)
|
|
}
|
|
|
|
func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
|
|
saved := newSynthTestProvider()
|
|
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
|
|
|
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true)
|
|
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false)
|
|
|
|
p, err := f.manager.GetProvider(ctx, testAccountID, "viewer", saved.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, saved.ID, p.ID)
|
|
assert.Empty(t, p.UpstreamURL)
|
|
}
|
|
|
|
// The self-scope tests drive the real permissions manager over the real
|
|
// store, so role resolution is the production one: a plain user holds no
|
|
// providers grant and must fall back to the caller-scoped list — the same
|
|
// selection the self-service setup answer derives from — while an admin
|
|
// keeps the account-wide view with full config.
|
|
|
|
// newSelfScopeStore seeds the account and its users only, so each test
|
|
// declares exactly the providers and policies it asserts on — the store
|
|
// rejects re-saving a policy id on MySQL, so tests never overwrite each
|
|
// other's rows.
|
|
func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) {
|
|
t.Helper()
|
|
mgr, s := newAgentConfigTestMgr(t)
|
|
mgr.permissionsManager = permissions.NewManager(s)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
|
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
|
Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
|
}))
|
|
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
|
Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser,
|
|
}))
|
|
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
|
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
|
|
}))
|
|
return mgr, s
|
|
}
|
|
|
|
func newSelfScopeProvidersFixture(t *testing.T) (*managerImpl, store.Store) {
|
|
t.Helper()
|
|
mgr, s := newSelfScopeStore(t)
|
|
ctx := context.Background()
|
|
|
|
granted := newSynthTestProvider()
|
|
granted.ID = "prov-granted"
|
|
granted.Name = "Granted"
|
|
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
|
|
|
other := newSynthTestProvider()
|
|
other.ID = "prov-other"
|
|
other.Name = "Other"
|
|
other.CreatedAt = granted.CreatedAt.Add(time.Hour)
|
|
require.NoError(t, s.SaveAgentNetworkProvider(ctx, other))
|
|
|
|
disabled := newSynthTestProvider()
|
|
disabled.ID = "prov-disabled"
|
|
disabled.Name = "Disabled"
|
|
disabled.Enabled = false
|
|
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
|
|
|
|
// user-a's group authorizes the granted and the disabled provider; the
|
|
// disabled one must still not surface (the proxy never routes it).
|
|
policy := newSynthTestPolicy(granted.ID, "grp-eng", "")
|
|
policy.DestinationProviderIDs = []string{granted.ID, disabled.ID}
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
|
|
|
return mgr, s
|
|
}
|
|
|
|
func TestGetAllProviders_SelfScopedForPlainUser(t *testing.T) {
|
|
ctx := context.Background()
|
|
mgr, _ := newSelfScopeProvidersFixture(t)
|
|
|
|
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
|
require.NoError(t, err, "a caller without the read grant self-scopes instead of being denied")
|
|
require.Len(t, scoped, 1)
|
|
assert.Equal(t, "prov-granted", scoped[0].ID)
|
|
assert.Empty(t, scoped[0].UpstreamURL, "the caller-scoped list is the redacted display surface")
|
|
assert.NotEmpty(t, scoped[0].Models, "model list backs the dashboard filters")
|
|
|
|
empty, err := mgr.GetAllProviders(ctx, testAccountID, "user-out")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, empty, "a caller outside every policy gets an empty list, not an error")
|
|
|
|
all, err := mgr.GetAllProviders(ctx, testAccountID, "admin")
|
|
require.NoError(t, err)
|
|
assert.Len(t, all, 3, "grant holders keep the account-wide list, disabled providers included")
|
|
for _, p := range all {
|
|
if p.ID == "prov-granted" {
|
|
assert.NotEmpty(t, p.UpstreamURL, "a managing caller sees the connection config")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetProvider_SelfScopedForPlainUser(t *testing.T) {
|
|
ctx := context.Background()
|
|
mgr, _ := newSelfScopeProvidersFixture(t)
|
|
|
|
p, err := mgr.GetProvider(ctx, testAccountID, "user-a", "prov-granted")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "prov-granted", p.ID)
|
|
assert.Empty(t, p.UpstreamURL)
|
|
|
|
assertNotFound := func(id string) {
|
|
t.Helper()
|
|
_, err := mgr.GetProvider(ctx, testAccountID, "user-a", id)
|
|
require.Error(t, err)
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.NotFound, sErr.Type(),
|
|
"out-of-scope and nonexistent providers must be indistinguishable")
|
|
}
|
|
assertNotFound("prov-other")
|
|
assertNotFound("prov-disabled")
|
|
assertNotFound("prov-does-not-exist")
|
|
}
|
|
|
|
func TestGetAllProviders_SelfScopedModelsFollowGuardrails(t *testing.T) {
|
|
ctx := context.Background()
|
|
mgr, s := newSelfScopeStore(t)
|
|
|
|
// A provider declaring two models, restricted by an allowlist admitting
|
|
// one declared model plus one the operator never declared (unreachable —
|
|
// the router only claims declared models, so it must not surface).
|
|
granted := newSynthTestProvider()
|
|
granted.ID = "prov-models"
|
|
granted.Name = "Granted"
|
|
granted.Models = []types.ProviderModel{
|
|
{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02},
|
|
{ID: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01},
|
|
}
|
|
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
|
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-models", "gpt-5.4", "gpt-undeclared")))
|
|
policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-models")
|
|
policy.ID = "pol-guard-models"
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
|
|
|
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
|
require.NoError(t, err)
|
|
require.Len(t, scoped, 1)
|
|
require.Len(t, scoped[0].Models, 1,
|
|
"the self-scoped model list is the effective set: allowlist ∩ declared")
|
|
assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID)
|
|
assert.Equal(t, 0.004, scoped[0].Models[0].InputPer1k, "declared entry survives, prices included")
|
|
|
|
all, err := mgr.GetAllProviders(ctx, testAccountID, "admin")
|
|
require.NoError(t, err)
|
|
for _, p := range all {
|
|
if p.ID == granted.ID {
|
|
assert.Len(t, p.Models, 2,
|
|
"grant holders keep the full declared list — their usage view spans everyone's requests")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetAllProviders_SelfScopedAllowlistWithoutDeclaredModels(t *testing.T) {
|
|
ctx := context.Background()
|
|
mgr, s := newSelfScopeStore(t)
|
|
|
|
// No operator declaration: the router claims every model, so the
|
|
// allowlist union is the effective set and comes back as bare entries.
|
|
granted := newSynthTestProvider()
|
|
granted.ID = "prov-bare"
|
|
granted.Name = "Granted"
|
|
granted.Models = nil
|
|
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
|
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-bare", "gpt-5.4")))
|
|
policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-bare")
|
|
policy.ID = "pol-guard-bare"
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
|
|
|
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
|
require.NoError(t, err)
|
|
require.Len(t, scoped, 1)
|
|
require.Len(t, scoped[0].Models, 1)
|
|
assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID)
|
|
}
|
|
|
|
func TestGetAllProviders_SelfScopedUnrestrictedFallsBackToCatalogModels(t *testing.T) {
|
|
ctx := context.Background()
|
|
mgr, s := newSelfScopeStore(t)
|
|
|
|
// Unrestricted policy on a provider without an operator declaration:
|
|
// the setup answer advertises the catalog models, and the scoped
|
|
// provider list must match so the model filter is never emptier than
|
|
// the setup page.
|
|
granted := newSynthTestProvider()
|
|
granted.ID = "prov-catalog"
|
|
granted.Name = "Granted"
|
|
granted.Models = nil
|
|
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
|
policy := newSynthTestPolicy(granted.ID, "grp-eng", "")
|
|
policy.ID = "pol-catalog"
|
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
|
|
|
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
|
require.NoError(t, err)
|
|
require.Len(t, scoped, 1)
|
|
require.NotEmpty(t, scoped[0].Models, "catalog models back the filter when the operator declared none")
|
|
ids := make([]string, 0, len(scoped[0].Models))
|
|
for _, m := range scoped[0].Models {
|
|
ids = append(ids, m.ID)
|
|
}
|
|
assert.Equal(t, declaredModelIDs(granted), ids, "the scoped list mirrors the setup answer's declared/catalog set")
|
|
}
|