Cut self-scoped provider model lists to the caller's effective set

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.
This commit is contained in:
mlsmaycon
2026-08-27 12:10:05 +00:00
parent 5294f5be35
commit 9286c58ed2
4 changed files with 111 additions and 4 deletions
@@ -239,13 +239,30 @@ func (m *managerImpl) callerScopedProviders(ctx context.Context, accountID, user
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
authorized, _, err := m.authorizedProvidersForGroups(ctx, accountID, user.AutoGroups)
authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, user.AutoGroups)
if err != nil {
return nil, err
}
var guardrailsByID map[string]*types.Guardrail
if anyPolicyHasGuardrails(applicable) {
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
if err != nil {
return nil, err
}
}
out := make([]*types.Provider, 0, len(authorized))
for _, p := range authorized {
out = append(out, p.RedactedForViewer())
r := p.RedactedForViewer()
// The model list follows the same effective computation the setup
// answer and the proxy use: a caller whose policies carry a model
// allowlist sees only the models those guardrails permit, so the
// dashboard's model filter never offers a model the caller's own
// requests could not use. Grant holders keep the full declared
// lists — their usage view spans everyone's requests.
if allAllowed, effective := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID); !allAllowed {
r.Models = providerModelsByID(p, effective)
}
out = append(out, r)
}
return out, nil
}
@@ -8,6 +8,7 @@ import (
"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/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -186,3 +187,62 @@ func TestGetProvider_SelfScopedForPlainUser(t *testing.T) {
assertNotFound("prov-disabled")
assertNotFound("prov-does-not-exist")
}
func TestGetAllProviders_SelfScopedModelsFollowGuardrails(t *testing.T) {
ctx := context.Background()
mgr, s := newSelfScopeProvidersFixture(t)
// Re-declare the granted provider with two models and restrict the
// caller's policy with 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-granted"
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")
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 := newSelfScopeProvidersFixture(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-granted"
granted.Name = "Granted"
granted.Models = nil
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-models", "gpt-5.4")))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(granted.ID, "grp-eng", "guard-models")))
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)
}
@@ -228,6 +228,33 @@ func effectiveModelsForProvider(provider *types.Provider, policies []*types.Poli
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