mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 10:31:29 +02:00
The caller-scoped provider list now reduces each provider's models through the same effective computation the setup answer and the proxy use: allowlist guardrails intersected with the operator's declared models, with bare entries synthesized when the operator declared none. The dashboard's model filter therefore never offers a self-scoped caller a model their own requests could not use. Grant holders keep the full declared lists — their usage view spans everyone's requests, and their own setup page already answers with the caller-scoped effective set.
284 lines
10 KiB
Go
284 lines
10 KiB
Go
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"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"
|
|
)
|
|
|
|
// GetSetupForUser returns the Agent Network setup the calling user's
|
|
// groups authorize. It deliberately performs no role permission check:
|
|
// the result is scoped to the caller's own groups, which is strictly
|
|
// tighter than any role gate, so every authenticated user (any role) may
|
|
// read it. The group source matches enforcement: the proxy authorizes
|
|
// each Agent Network request against the calling user's groups as well —
|
|
// session validation resolves them from the same user record's
|
|
// auto-groups — so this answer and the proxy's verdict are computed from
|
|
// the same memberships.
|
|
func (m *managerImpl) GetSetupForUser(ctx context.Context, accountID, userID string) (*types.EffectiveSetup, error) {
|
|
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get user: %w", err)
|
|
}
|
|
return m.effectiveSetupForGroups(ctx, accountID, user.AutoGroups)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, groupIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(authorized) == 0 {
|
|
return notConfigured, nil
|
|
}
|
|
|
|
var guardrailsByID map[string]*types.Guardrail
|
|
if anyPolicyHasGuardrails(applicable) {
|
|
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// authorizedProvidersForGroups returns the enabled providers referenced
|
|
// by at least one enabled policy whose source groups intersect groupIDs —
|
|
// the providers the caller's own policies authorize — in created_at order
|
|
// with ID tiebreak, the same deterministic order the router synthesizer
|
|
// presents. The applicable policies come back alongside so callers that
|
|
// need per-provider policy context (the setup's model computation) don't
|
|
// re-filter. Both the self-service setup answer and the caller-scoped
|
|
// provider list are built from this selection, so what the dashboard
|
|
// offers and what the proxy enforces never diverge.
|
|
func (m *managerImpl) authorizedProvidersForGroups(ctx context.Context, accountID string, groupIDs []string) ([]*types.Provider, []*types.Policy, error) {
|
|
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("list account policies: %w", err)
|
|
}
|
|
applicable := filterPoliciesByGroups(policies, groupIDs)
|
|
if len(applicable) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("list account providers: %w", err)
|
|
}
|
|
|
|
// filterEnabledProviders carries the enabled filter and the
|
|
// created_at/ID order shared with the router synthesizer.
|
|
enabled := filterEnabledProviders(providers)
|
|
authorized := make([]*types.Provider, 0, len(enabled))
|
|
for _, p := range enabled {
|
|
if len(policiesForProvider(applicable, p.ID)) == 0 {
|
|
continue
|
|
}
|
|
authorized = append(authorized, p)
|
|
}
|
|
return authorized, applicable, 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 {
|
|
// Compare through the canonical id the proxy's parser emits — a
|
|
// Bedrock declaration may carry the region/version form
|
|
// ("eu.anthropic.claude-...-v1:0") while the allowlist holds the
|
|
// stripped id the parser matches at request time, and the raw
|
|
// forms would never intersect. The declared id itself is what
|
|
// gets advertised, matching the router's route claim.
|
|
if _, ok := seen[normaliseModelID(normalizePricingModelID(provider.ProviderID, id))]; ok {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return false, out
|
|
}
|
|
|
|
// providerModelsByID maps effective model ids (as effectiveModelsForProvider
|
|
// returns them) back onto the operator's declared entries, keeping the
|
|
// declared casing and prices. With no operator declaration the ids are the
|
|
// allowlist union and have no declared entry to map to, so bare entries are
|
|
// synthesized — the router claims every model in that case, so those ids are
|
|
// reachable and belong in the answer.
|
|
func providerModelsByID(provider *types.Provider, ids []string) []types.ProviderModel {
|
|
if len(provider.Models) == 0 {
|
|
out := make([]types.ProviderModel, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, types.ProviderModel{ID: id})
|
|
}
|
|
return out
|
|
}
|
|
keep := make(map[string]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
keep[normaliseModelID(id)] = struct{}{}
|
|
}
|
|
out := make([]types.ProviderModel, 0, len(ids))
|
|
for _, m := range provider.Models {
|
|
if _, ok := keep[normaliseModelID(m.ID)]; ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return 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
|
|
}
|
|
|
|
// GetSetupForUser on the mock manager reports "not configured" so tests
|
|
// that don't care about setup still compile.
|
|
func (*mockManager) GetSetupForUser(_ context.Context, _, _ string) (*types.EffectiveSetup, error) {
|
|
return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil
|
|
}
|