mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-07 02:28:53 -04:00
Peers onboarding to the Agent Network have no way to discover which providers and models their groups authorize or which endpoint to call, so they trial-and-error into 403s at the proxy. Add GetAgentNetworkSetup, an EncryptedMessage peer RPC following the Expose service shape: the WireGuard key is the credential and the answer is caller-scoped — strictly what the calling peer's own groups authorize, computed by a new effective-setup routine in the agentnetwork manager that mirrors the proxy's enforcement exactly (policy filter as filterApplicablePolicies, model logic as policyPermitsModel, orphan and disabled providers omitted like the router synthesizer omits them). The response carries display metadata only: endpoint, provider name, catalog id, API flavor, and effective models. No keys, upstream URLs, policy or guardrail structure, and no hint of providers the caller cannot reach; "account not set up" and "caller has no access" are deliberately indistinguishable. Linear: NET-1399
241 lines
8.1 KiB
Go
241 lines
8.1 KiB
Go
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
)
|
|
|
|
// GetSetupForPeer returns the Agent Network setup the calling peer's
|
|
// groups authorize. It deliberately performs no user-permission check:
|
|
// peer RPCs carry no user identity — WireGuard key possession is the
|
|
// credential (same trust model as SelectPolicyForRequest) — and the
|
|
// result is caller-scoped, which is strictly tighter than any role gate.
|
|
// Setup-key/machine peers (no user attached) are fully supported since
|
|
// the computation runs on peer groups, which is what the proxy enforces.
|
|
func (m *managerImpl) GetSetupForPeer(ctx context.Context, accountID, peerID string) (*types.EffectiveSetup, error) {
|
|
groupIDs, err := m.store.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get peer groups: %w", err)
|
|
}
|
|
return m.effectiveSetupForGroups(ctx, accountID, groupIDs)
|
|
}
|
|
|
|
// effectiveSetupForGroups computes the effective Agent Network setup for
|
|
// a set of caller groups: the account endpoint plus, per authorized
|
|
// provider, the effective model set. It mirrors what the proxy enforces
|
|
// at request time — the policy filter matches filterApplicablePolicies,
|
|
// the model logic matches policyPermitsModel, and orphan providers
|
|
// (enabled but referenced by no applicable policy) are omitted just like
|
|
// the router synthesizer omits them — so the answer never advertises
|
|
// anything the proxy would refuse.
|
|
//
|
|
// Every "nothing available" shape returns Configured=false rather than
|
|
// an error, and "account not set up" is indistinguishable from "caller
|
|
// has no access" by design: the response must not leak what exists for
|
|
// others.
|
|
func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.EffectiveSetup, error) {
|
|
notConfigured := &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}
|
|
|
|
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
|
switch {
|
|
case err == nil:
|
|
case isNotFound(err):
|
|
return notConfigured, nil
|
|
default:
|
|
return nil, fmt.Errorf("get agent network settings: %w", err)
|
|
}
|
|
if settings.Endpoint() == "" {
|
|
return notConfigured, nil
|
|
}
|
|
|
|
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list account policies: %w", err)
|
|
}
|
|
applicable := filterPoliciesByGroups(policies, groupIDs)
|
|
if len(applicable) == 0 {
|
|
return notConfigured, nil
|
|
}
|
|
|
|
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list account providers: %w", err)
|
|
}
|
|
|
|
var guardrailsByID map[string]*types.Guardrail
|
|
if anyPolicyHasGuardrails(applicable) {
|
|
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
authorized := make([]*types.Provider, 0, len(providers))
|
|
for _, p := range providers {
|
|
if p == nil || !p.Enabled {
|
|
continue
|
|
}
|
|
if len(policiesForProvider(applicable, p.ID)) == 0 {
|
|
continue
|
|
}
|
|
authorized = append(authorized, p)
|
|
}
|
|
if len(authorized) == 0 {
|
|
return notConfigured, nil
|
|
}
|
|
// created_at order, ID tiebreak — same deterministic order the router
|
|
// synthesizer presents.
|
|
sort.SliceStable(authorized, func(i, j int) bool {
|
|
if !authorized[i].CreatedAt.Equal(authorized[j].CreatedAt) {
|
|
return authorized[i].CreatedAt.Before(authorized[j].CreatedAt)
|
|
}
|
|
return authorized[i].ID < authorized[j].ID
|
|
})
|
|
|
|
out := &types.EffectiveSetup{
|
|
Configured: true,
|
|
Endpoint: "https://" + settings.Endpoint(),
|
|
Providers: make([]types.EffectiveProvider, 0, len(authorized)),
|
|
}
|
|
for _, p := range authorized {
|
|
allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
|
|
flavor := ""
|
|
if entry, ok := catalog.Lookup(p.ProviderID); ok {
|
|
flavor = entry.ParserID
|
|
}
|
|
out.Providers = append(out.Providers, types.EffectiveProvider{
|
|
Name: p.Name,
|
|
CatalogID: p.ProviderID,
|
|
APIFlavor: flavor,
|
|
AllModelsAllowed: allAllowed,
|
|
Models: models,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// filterPoliciesByGroups returns the enabled policies whose SourceGroups
|
|
// intersect the caller's groups. Same group matching as
|
|
// filterApplicablePolicies, without the per-provider filter — the setup
|
|
// answer spans every provider the caller can reach.
|
|
func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy {
|
|
groupSet := make(map[string]struct{}, len(groupIDs))
|
|
for _, g := range groupIDs {
|
|
if g != "" {
|
|
groupSet[g] = struct{}{}
|
|
}
|
|
}
|
|
out := make([]*types.Policy, 0, len(policies))
|
|
for _, p := range policies {
|
|
if p == nil || !p.Enabled {
|
|
continue
|
|
}
|
|
if !anyGroupMatches(p.SourceGroups, groupSet) {
|
|
continue
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// policiesForProvider returns the subset of policies targeting the
|
|
// provider, order preserved.
|
|
func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy {
|
|
out := make([]*types.Policy, 0, len(policies))
|
|
for _, p := range policies {
|
|
if sliceContains(p.DestinationProviderIDs, providerID) {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// effectiveModelsForProvider derives the caller's effective model set for
|
|
// one provider from the applicable policies that target it, mirroring
|
|
// policyPermitsModel: a policy with no allowlist-enabled guardrail is
|
|
// unrestricted, and one unrestricted policy makes the whole provider
|
|
// unrestricted (the proxy would admit any model through it). Otherwise
|
|
// the union of the policies' allowlists applies, intersected with the
|
|
// provider's declared models when the operator declared any — the router
|
|
// only claims declared models, so an allowlisted-but-undeclared model is
|
|
// unreachable and must not be advertised. With no declared models the
|
|
// router claims every model, so the allowlist union stands alone.
|
|
func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) {
|
|
restricted := true
|
|
union := make([]string, 0)
|
|
seen := make(map[string]struct{})
|
|
for _, p := range policies {
|
|
policyRestricted := false
|
|
for _, gID := range p.GuardrailIDs {
|
|
g, ok := guardrailsByID[gID]
|
|
if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
|
|
continue
|
|
}
|
|
policyRestricted = true
|
|
for _, model := range g.Checks.ModelAllowlist.Models {
|
|
key := normaliseModelID(model)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[key]; dup {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
union = append(union, key)
|
|
}
|
|
}
|
|
if !policyRestricted {
|
|
restricted = false
|
|
}
|
|
}
|
|
|
|
declared := declaredModelIDs(provider)
|
|
if !restricted {
|
|
return true, declared
|
|
}
|
|
if len(provider.Models) == 0 {
|
|
// No operator declaration: the router claims every model, so the
|
|
// allowlist union is the effective set as-is.
|
|
return false, union
|
|
}
|
|
out := make([]string, 0, len(declared))
|
|
for _, id := range declared {
|
|
if _, ok := seen[normaliseModelID(id)]; ok {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return false, out
|
|
}
|
|
|
|
// declaredModelIDs returns the models a provider exposes: the operator's
|
|
// curated list when present, otherwise the catalog entry's models (an
|
|
// empty operator list means "all catalog models"). Gateway/custom catalog
|
|
// entries declare no models, so the result may be empty.
|
|
func declaredModelIDs(provider *types.Provider) []string {
|
|
if ids := providerModelIDs(provider); len(ids) > 0 {
|
|
return ids
|
|
}
|
|
entry, ok := catalog.Lookup(provider.ProviderID)
|
|
if !ok {
|
|
return []string{}
|
|
}
|
|
out := make([]string, 0, len(entry.Models))
|
|
for _, m := range entry.Models {
|
|
if m.ID != "" {
|
|
out = append(out, m.ID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetSetupForPeer on the mock manager reports "not configured" so tests
|
|
// that don't care about setup still compile.
|
|
func (*mockManager) GetSetupForPeer(_ context.Context, _, _ string) (*types.EffectiveSetup, error) {
|
|
return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil
|
|
}
|