From fc15704fbca72f1c23eb78ffec7a8811f7f781e4 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Thu, 27 Aug 2026 10:13:17 +0000 Subject: [PATCH] Self-scope the provider list for callers without the read grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The providers endpoints now fall back to the caller's own authorized providers instead of denying, mirroring the usage and log endpoints: a plain user gets the providers their policies reference — the same selection the self-service setup answer derives from, now shared through authorizedProvidersForGroups — reduced to the display surface. A single provider outside that scope answers not-found, indistinguishable from a nonexistent one. This feeds the dashboard's provider and model filters on the self-scoped Usage & Logs view. --- agent-network/README.md | 5 +- .../internals/modules/agentnetwork/manager.go | 57 +++++++++- .../agentnetwork/provider_redaction_test.go | 101 ++++++++++++++++++ .../internals/modules/agentnetwork/setup.go | 77 +++++++------ 4 files changed, 204 insertions(+), 36 deletions(-) diff --git a/agent-network/README.md b/agent-network/README.md index 014874ee6..940384f5e 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -122,7 +122,10 @@ self-service endpoint `GET /api/agent-network/me/setup` (the endpoint, providers and models the caller's own policies allow — what a local AI tool needs and nothing 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. Role +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 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 6adae4814..28a7bf120 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -175,9 +175,19 @@ func NewManager( } } +// GetAllProviders returns the account's providers for callers holding the +// providers read grant (connection config redacted unless they can also +// update). A caller without the grant self-scopes instead of being denied +// — mirroring the usage and log endpoints: they get the providers their +// own policies authorize, redacted to the display surface, which is what +// feeds the dashboard's provider filter for plain users. func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) { - if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil { - return nil, err + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return m.callerScopedProviders(ctx, accountID, userID) } providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -186,9 +196,26 @@ func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID str return m.redactProvidersForViewer(ctx, accountID, userID, providers) } +// GetProvider self-scopes like GetAllProviders: a caller without the read +// grant may fetch a provider their own policies authorize (redacted), and +// gets the same not-found answer for any other id — an out-of-scope +// provider must be indistinguishable from a nonexistent one. func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) { - if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil { - return nil, err + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + scoped, err := m.callerScopedProviders(ctx, accountID, userID) + if err != nil { + return nil, err + } + for _, p := range scoped { + if p.ID == providerID { + return p, nil + } + } + return nil, status.NewAgentNetworkProviderNotFoundError(providerID) } provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) if err != nil { @@ -201,6 +228,28 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid return redacted[0], nil } +// callerScopedProviders returns the providers the caller's own policies +// authorize — the same selection the self-service setup answer and the +// proxy's routing derive from — each reduced to the display surface. No +// role permission is needed: the answer is scoped strictly to the caller, +// and a caller outside every policy gets an empty list, indistinguishable +// from an account with nothing configured. +func (m *managerImpl) callerScopedProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) { + user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + authorized, _, err := m.authorizedProvidersForGroups(ctx, accountID, user.AutoGroups) + if err != nil { + return nil, err + } + out := make([]*types.Provider, 0, len(authorized)) + for _, p := range authorized { + out = append(out, p.RedactedForViewer()) + } + return out, nil +} + // redactProvidersForViewer strips the connection configuration from // providers handed to a caller who holds only the read grant on // agent_network.providers. Update is the managing signal: a role that can diff --git a/management/internals/modules/agentnetwork/provider_redaction_test.go b/management/internals/modules/agentnetwork/provider_redaction_test.go index 96598996a..c69656150 100644 --- a/management/internals/modules/agentnetwork/provider_redaction_test.go +++ b/management/internals/modules/agentnetwork/provider_redaction_test.go @@ -3,13 +3,17 @@ package agentnetwork import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" ) // These tests pin the provider read surface per grant: a caller holding @@ -85,3 +89,100 @@ func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) { assert.Equal(t, saved.ID, p.ID) assert.Empty(t, p.UpstreamURL) } + +// The self-scope tests drive the real permissions manager over the real +// store, so role resolution is the production one: a plain user holds no +// providers grant and must fall back to the caller-scoped list — the same +// selection the self-service setup answer derives from — while an admin +// keeps the account-wide view with full config. + +func newSelfScopeProvidersFixture(t *testing.T) (*managerImpl, store.Store) { + t.Helper() + mgr, s := newSetupTestMgr(t) + mgr.permissionsManager = permissions.NewManager(s) + ctx := context.Background() + + require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID})) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"}, + })) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, + })) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin, + })) + + granted := newSynthTestProvider() + granted.ID = "prov-granted" + granted.Name = "Granted" + require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted)) + + other := newSynthTestProvider() + other.ID = "prov-other" + other.Name = "Other" + other.CreatedAt = granted.CreatedAt.Add(time.Hour) + require.NoError(t, s.SaveAgentNetworkProvider(ctx, other)) + + disabled := newSynthTestProvider() + disabled.ID = "prov-disabled" + disabled.Name = "Disabled" + disabled.Enabled = false + require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled)) + + // user-a's group authorizes the granted and the disabled provider; the + // disabled one must still not surface (the proxy never routes it). + policy := newSynthTestPolicy(granted.ID, "grp-eng", "") + policy.DestinationProviderIDs = []string{granted.ID, disabled.ID} + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + + return mgr, s +} + +func TestGetAllProviders_SelfScopedForPlainUser(t *testing.T) { + ctx := context.Background() + mgr, _ := newSelfScopeProvidersFixture(t) + + scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a") + require.NoError(t, err, "a caller without the read grant self-scopes instead of being denied") + require.Len(t, scoped, 1) + assert.Equal(t, "prov-granted", scoped[0].ID) + assert.Empty(t, scoped[0].UpstreamURL, "the caller-scoped list is the redacted display surface") + assert.NotEmpty(t, scoped[0].Models, "model list backs the dashboard filters") + + empty, err := mgr.GetAllProviders(ctx, testAccountID, "user-out") + require.NoError(t, err) + assert.Empty(t, empty, "a caller outside every policy gets an empty list, not an error") + + all, err := mgr.GetAllProviders(ctx, testAccountID, "admin") + require.NoError(t, err) + assert.Len(t, all, 3, "grant holders keep the account-wide list, disabled providers included") + for _, p := range all { + if p.ID == "prov-granted" { + assert.NotEmpty(t, p.UpstreamURL, "a managing caller sees the connection config") + } + } +} + +func TestGetProvider_SelfScopedForPlainUser(t *testing.T) { + ctx := context.Background() + mgr, _ := newSelfScopeProvidersFixture(t) + + p, err := mgr.GetProvider(ctx, testAccountID, "user-a", "prov-granted") + require.NoError(t, err) + assert.Equal(t, "prov-granted", p.ID) + assert.Empty(t, p.UpstreamURL) + + assertNotFound := func(id string) { + t.Helper() + _, err := mgr.GetProvider(ctx, testAccountID, "user-a", id) + require.Error(t, err) + var sErr *status.Error + require.ErrorAs(t, err, &sErr) + assert.Equal(t, status.NotFound, sErr.Type(), + "out-of-scope and nonexistent providers must be indistinguishable") + } + assertNotFound("prov-other") + assertNotFound("prov-disabled") + assertNotFound("prov-does-not-exist") +} diff --git a/management/internals/modules/agentnetwork/setup.go b/management/internals/modules/agentnetwork/setup.go index 204dcb382..53e7ae47d 100644 --- a/management/internals/modules/agentnetwork/setup.go +++ b/management/internals/modules/agentnetwork/setup.go @@ -55,20 +55,14 @@ func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID str return notConfigured, nil } - policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID) + authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, groupIDs) if err != nil { - return nil, fmt.Errorf("list account policies: %w", err) + return nil, err } - applicable := filterPoliciesByGroups(policies, groupIDs) - if len(applicable) == 0 { + if len(authorized) == 0 { return notConfigured, nil } - providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return nil, fmt.Errorf("list account providers: %w", err) - } - var guardrailsByID map[string]*types.Guardrail if anyPolicyHasGuardrails(applicable) { guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID) @@ -77,28 +71,6 @@ func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID str } } - authorized := make([]*types.Provider, 0, len(providers)) - for _, p := range providers { - if p == nil || !p.Enabled { - continue - } - if len(policiesForProvider(applicable, p.ID)) == 0 { - continue - } - authorized = append(authorized, p) - } - if len(authorized) == 0 { - return notConfigured, nil - } - // created_at order, ID tiebreak — same deterministic order the router - // synthesizer presents. - sort.SliceStable(authorized, func(i, j int) bool { - if !authorized[i].CreatedAt.Equal(authorized[j].CreatedAt) { - return authorized[i].CreatedAt.Before(authorized[j].CreatedAt) - } - return authorized[i].ID < authorized[j].ID - }) - out := &types.EffectiveSetup{ Configured: true, Endpoint: "https://" + settings.Endpoint(), @@ -121,6 +93,49 @@ func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID str 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) + } + + authorized := make([]*types.Provider, 0, len(providers)) + for _, p := range providers { + if p == nil || !p.Enabled { + continue + } + if len(policiesForProvider(applicable, p.ID)) == 0 { + continue + } + authorized = append(authorized, p) + } + sort.SliceStable(authorized, func(i, j int) bool { + if !authorized[i].CreatedAt.Equal(authorized[j].CreatedAt) { + return authorized[i].CreatedAt.Before(authorized[j].CreatedAt) + } + return authorized[i].ID < authorized[j].ID + }) + 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