mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-27 14:59:09 +01:00
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:
@@ -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"
|
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
|
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
|
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
|
providers their own policies authorize, reduced to the display surface, with
|
||||||
is what feeds the dashboard's provider filter. Role
|
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
|
definitions live in
|
||||||
[`management/server/permissions/roles/`](../management/server/permissions/roles).
|
[`management/server/permissions/roles/`](../management/server/permissions/roles).
|
||||||
|
|
||||||
|
|||||||
@@ -239,13 +239,30 @@ func (m *managerImpl) callerScopedProviders(ctx context.Context, accountID, user
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
out := make([]*types.Provider, 0, len(authorized))
|
||||||
for _, p := range 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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||||
@@ -186,3 +187,62 @@ func TestGetProvider_SelfScopedForPlainUser(t *testing.T) {
|
|||||||
assertNotFound("prov-disabled")
|
assertNotFound("prov-disabled")
|
||||||
assertNotFound("prov-does-not-exist")
|
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
|
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
|
// declaredModelIDs returns the models a provider exposes: the operator's
|
||||||
// curated list when present, otherwise the catalog entry's models (an
|
// curated list when present, otherwise the catalog entry's models (an
|
||||||
// empty operator list means "all catalog models"). Gateway/custom catalog
|
// empty operator list means "all catalog models"). Gateway/custom catalog
|
||||||
|
|||||||
Reference in New Issue
Block a user