From 9286c58ed27dd918f2db41bf5b5d6657ee6e72db Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Thu, 27 Aug 2026 12:10:05 +0000 Subject: [PATCH] Cut self-scoped provider model lists to the caller's effective set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent-network/README.md | 7 ++- .../internals/modules/agentnetwork/manager.go | 21 ++++++- .../agentnetwork/provider_redaction_test.go | 60 +++++++++++++++++++ .../internals/modules/agentnetwork/setup.go | 27 +++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/agent-network/README.md b/agent-network/README.md index 940384f5e..c5211e2a0 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -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). diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index 28a7bf120..f3dbcaf18 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -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 } diff --git a/management/internals/modules/agentnetwork/provider_redaction_test.go b/management/internals/modules/agentnetwork/provider_redaction_test.go index c69656150..544e1c479 100644 --- a/management/internals/modules/agentnetwork/provider_redaction_test.go +++ b/management/internals/modules/agentnetwork/provider_redaction_test.go @@ -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) +} diff --git a/management/internals/modules/agentnetwork/setup.go b/management/internals/modules/agentnetwork/setup.go index 282aa08df..3139a030d 100644 --- a/management/internals/modules/agentnetwork/setup.go +++ b/management/internals/modules/agentnetwork/setup.go @@ -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