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

View File

@@ -124,8 +124,11 @@ more). The regular usage and access-log endpoints self-scope instead of denying:
a caller without the account-wide grant gets their own rows back, so "my usage"
and "my requests" are the same endpoints the admin dashboard uses. The provider
list self-scopes the same way — a caller without the providers grant gets the
providers their own policies authorize, reduced to the display surface, which
is what feeds the dashboard's provider filter. Role
providers their own policies authorize, reduced to the display surface, with
each provider's model list cut to what the caller's policy guardrails and the
provider's declared models effectively permit (the same computation the setup
answer and the proxy use). This feeds the dashboard's provider and model
filters. Role
definitions live in
[`management/server/permissions/roles/`](../management/server/permissions/roles).

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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