From 80c05c861c8c4b9df0d2f073f21a60807af71cf7 Mon Sep 17 00:00:00 2001 From: braginini Date: Mon, 31 Aug 2026 14:41:23 +0200 Subject: [PATCH] Hand the agent config to every account member The agent-config answer withheld the endpoint from callers no policy covers, so "account not set up" and "you have no access" were the same shape. The dashboard shows every user the same connection config, so Configured now tracks the account: once it has an endpoint every member gets it, with an empty providers list for those no policy covers. That list stays caller-scoped, and the endpoint authorizes nothing on its own since the gateway still refuses every request no policy permits. Rename the files and symbols the endpoint outgrew when it became /agent-network/agent-config: me_handler.go, setup.go and types/setup.go become agent_config.go, GetSetupForUser becomes GetAgentConfigForUser, and the AgentNetworkMeSetup schema becomes AgentNetworkAgentConfig. Regenerating types.gen.go from the untouched spec first reproduced it byte for byte, so the generated diff is only these renames. --- .../{setup.go => agent_config.go} | 46 ++++---- ...test.go => agent_config_realstore_test.go} | 102 +++++++++--------- ...{me_handler.go => agent_config_handler.go} | 24 ++--- .../handlers/providers_handler.go | 2 +- .../internals/modules/agentnetwork/manager.go | 6 +- .../agentnetwork/provider_redaction_test.go | 2 +- .../types/{setup.go => agent_config.go} | 22 ++-- shared/management/http/api/openapi.yml | 20 ++-- shared/management/http/api/types.gen.go | 60 +++++------ 9 files changed, 147 insertions(+), 137 deletions(-) rename management/internals/modules/agentnetwork/{setup.go => agent_config.go} (86%) rename management/internals/modules/agentnetwork/{setup_realstore_test.go => agent_config_realstore_test.go} (78%) rename management/internals/modules/agentnetwork/handlers/{me_handler.go => agent_config_handler.go} (53%) rename management/internals/modules/agentnetwork/types/{setup.go => agent_config.go} (65%) diff --git a/management/internals/modules/agentnetwork/setup.go b/management/internals/modules/agentnetwork/agent_config.go similarity index 86% rename from management/internals/modules/agentnetwork/setup.go rename to management/internals/modules/agentnetwork/agent_config.go index 3139a030d..5571fd159 100644 --- a/management/internals/modules/agentnetwork/setup.go +++ b/management/internals/modules/agentnetwork/agent_config.go @@ -9,7 +9,7 @@ import ( "github.com/netbirdio/netbird/management/server/store" ) -// GetSetupForUser returns the Agent Network setup the calling user's +// GetAgentConfigForUser returns the Agent Network setup the calling user's // groups authorize. It deliberately performs no role permission check: // the result is scoped to the caller's own groups, which is strictly // tighter than any role gate, so every authenticated user (any role) may @@ -18,15 +18,15 @@ import ( // session validation resolves them from the same user record's // auto-groups — so this answer and the proxy's verdict are computed from // the same memberships. -func (m *managerImpl) GetSetupForUser(ctx context.Context, accountID, userID string) (*types.EffectiveSetup, error) { +func (m *managerImpl) GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error) { user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) if err != nil { return nil, fmt.Errorf("get user: %w", err) } - return m.effectiveSetupForGroups(ctx, accountID, user.AutoGroups) + return m.agentConfigForGroups(ctx, accountID, user.AutoGroups) } -// effectiveSetupForGroups computes the effective Agent Network setup for +// agentConfigForGroups computes the effective Agent Network setup for // a set of caller groups: the account endpoint plus, per authorized // provider, the effective model set. It mirrors what the proxy enforces // at request time — the policy filter matches filterApplicablePolicies, @@ -35,12 +35,16 @@ func (m *managerImpl) GetSetupForUser(ctx context.Context, accountID, userID str // the router synthesizer omits them — so the answer never advertises // anything the proxy would refuse. // -// Every "nothing available" shape returns Configured=false rather than -// an error, and "account not set up" is indistinguishable from "caller -// has no access" by design: the response must not leak what exists for -// others. -func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.EffectiveSetup, error) { - notConfigured := &types.EffectiveSetup{Providers: []types.EffectiveProvider{}} +// Configured tracks the account, not the caller: once the account has an +// endpoint every member gets it, with Providers empty for those no policy +// covers yet. The dashboard shows each user the same connection config +// regardless of role, and an empty provider list tells them to ask for +// access. Only the account having no Agent Network at all reads as not +// configured. Providers stays caller-scoped either way — the endpoint on +// its own authorizes nothing, and the proxy still refuses every request +// no policy permits. +func (m *managerImpl) agentConfigForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.AgentConfig, error) { + notConfigured := &types.AgentConfig{Providers: []types.AgentConfigProvider{}} settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) switch { @@ -58,8 +62,14 @@ func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID str if err != nil { return nil, err } + + out := &types.AgentConfig{ + Configured: true, + Endpoint: "https://" + settings.Endpoint(), + Providers: make([]types.AgentConfigProvider, 0, len(authorized)), + } if len(authorized) == 0 { - return notConfigured, nil + return out, nil } var guardrailsByID map[string]*types.Guardrail @@ -69,19 +79,13 @@ func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID str return nil, err } } - - out := &types.EffectiveSetup{ - Configured: true, - Endpoint: "https://" + settings.Endpoint(), - Providers: make([]types.EffectiveProvider, 0, len(authorized)), - } for _, p := range authorized { allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID) flavor := "" if entry, ok := catalog.Lookup(p.ProviderID); ok { flavor = entry.ParserID } - out.Providers = append(out.Providers, types.EffectiveProvider{ + out.Providers = append(out.Providers, types.AgentConfigProvider{ Name: p.Name, CatalogID: p.ProviderID, APIFlavor: flavor, @@ -276,8 +280,8 @@ func declaredModelIDs(provider *types.Provider) []string { return out } -// GetSetupForUser on the mock manager reports "not configured" so tests +// GetAgentConfigForUser on the mock manager reports "not configured" so tests // that don't care about setup still compile. -func (*mockManager) GetSetupForUser(_ context.Context, _, _ string) (*types.EffectiveSetup, error) { - return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil +func (*mockManager) GetAgentConfigForUser(_ context.Context, _, _ string) (*types.AgentConfig, error) { + return &types.AgentConfig{Providers: []types.AgentConfigProvider{}}, nil } diff --git a/management/internals/modules/agentnetwork/setup_realstore_test.go b/management/internals/modules/agentnetwork/agent_config_realstore_test.go similarity index 78% rename from management/internals/modules/agentnetwork/setup_realstore_test.go rename to management/internals/modules/agentnetwork/agent_config_realstore_test.go index 03540a444..9a66e1190 100644 --- a/management/internals/modules/agentnetwork/setup_realstore_test.go +++ b/management/internals/modules/agentnetwork/agent_config_realstore_test.go @@ -23,7 +23,7 @@ import ( // model logic matches policyPermitsModel, and orphan providers are // omitted like the router synthesizer omits them. -func newSetupTestMgr(t *testing.T) (*managerImpl, store.Store) { +func newAgentConfigTestMgr(t *testing.T) (*managerImpl, store.Store) { t.Helper() ctx := context.Background() s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) @@ -44,18 +44,18 @@ func newSetupTestGuardrail(id string, models ...string) *types.Guardrail { } } -func TestEffectiveSetup_RealStore_NoSettingsRow(t *testing.T) { - mgr, _ := newSetupTestMgr(t) +func TestAgentConfig_RealStore_NoSettingsRow(t *testing.T) { + mgr, _ := newAgentConfigTestMgr(t) - setup, err := mgr.effectiveSetupForGroups(context.Background(), testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(context.Background(), testAccountID, []string{"grp-eng"}) require.NoError(t, err) assert.False(t, setup.Configured, "account without settings must read as not configured") assert.Empty(t, setup.Endpoint) assert.Empty(t, setup.Providers) } -func TestEffectiveSetup_RealStore_NoApplicablePolicy(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_NoApplicablePolicy(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -63,15 +63,15 @@ func TestEffectiveSetup_RealStore_NoApplicablePolicy(t *testing.T) { require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-other"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-other"}) require.NoError(t, err) - assert.False(t, setup.Configured, "caller outside every policy's source groups must read as not configured") - assert.Empty(t, setup.Endpoint, "no-access answer must not leak the endpoint") - assert.Empty(t, setup.Providers) + assert.True(t, setup.Configured, "the account is set up, so every member reads as configured") + assert.Equal(t, "https://"+testEndpoint, setup.Endpoint, "every member gets the same connection config") + assert.Empty(t, setup.Providers, "a caller no policy covers is authorized for nothing") } -func TestEffectiveSetup_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -79,7 +79,7 @@ func TestEffectiveSetup_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testi require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) assert.True(t, setup.Configured) assert.Equal(t, "https://"+testEndpoint, setup.Endpoint) @@ -92,8 +92,8 @@ func TestEffectiveSetup_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testi assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy") } -func TestEffectiveSetup_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -106,7 +106,7 @@ func TestEffectiveSetup_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1"))) require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1"))) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) require.Len(t, setup.Providers, 1) p := setup.Providers[0] @@ -114,8 +114,8 @@ func TestEffectiveSetup_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing") } -func TestEffectiveSetup_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -135,7 +135,7 @@ func TestEffectiveSetup_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically( require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "anthropic.claude-sonnet-4-5"))) require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1"))) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) require.Len(t, setup.Providers, 1) p := setup.Providers[0] @@ -144,8 +144,8 @@ func TestEffectiveSetup_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically( "the allowlisted canonical id must admit the declared region/version form, and only it") } -func TestEffectiveSetup_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -158,15 +158,15 @@ func TestEffectiveSetup_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testin open.ID = "pol-2" require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open)) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) require.Len(t, setup.Providers, 1) assert.True(t, setup.Providers[0].AllModelsAllowed, "one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it") } -func TestEffectiveSetup_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -181,7 +181,7 @@ func TestEffectiveSetup_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) { p2.ID = "pol-2" require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2)) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) require.Len(t, setup.Providers, 1) p := setup.Providers[0] @@ -189,8 +189,8 @@ func TestEffectiveSetup_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) { assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies") } -func TestEffectiveSetup_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -205,14 +205,14 @@ func TestEffectiveSetup_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled)) require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", ""))) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) - assert.False(t, setup.Configured, "neither an orphan nor a disabled provider is reachable, so nothing is configured for the caller") - assert.Empty(t, setup.Providers) + assert.True(t, setup.Configured) + assert.Empty(t, setup.Providers, "neither an orphan nor a disabled provider is reachable for the caller") } -func TestEffectiveSetup_RealStore_DisabledPolicyIgnored(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_DisabledPolicyIgnored(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -222,13 +222,14 @@ func TestEffectiveSetup_RealStore_DisabledPolicyIgnored(t *testing.T) { policy.Enabled = false require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) - assert.False(t, setup.Configured) + assert.True(t, setup.Configured) + assert.Empty(t, setup.Providers, "a disabled policy authorizes nothing") } -func TestEffectiveSetup_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -242,7 +243,7 @@ func TestEffectiveSetup_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5"))) require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1"))) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) require.Len(t, setup.Providers, 1) p := setup.Providers[0] @@ -250,8 +251,8 @@ func TestEffectiveSetup_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models) } -func TestEffectiveSetup_RealStore_ProvidersInCreatedAtOrder(t *testing.T) { - mgr, s := newSetupTestMgr(t) +func TestAgentConfig_RealStore_ProvidersInCreatedAtOrder(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -270,19 +271,20 @@ func TestEffectiveSetup_RealStore_ProvidersInCreatedAtOrder(t *testing.T) { policy.DestinationProviderIDs = []string{newer.ID, older.ID} require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) - setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"}) + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) require.NoError(t, err) require.Len(t, setup.Providers, 2) assert.Equal(t, "Older", setup.Providers[0].Name) assert.Equal(t, "Newer", setup.Providers[1].Name) } -// TestGetSetupForUser_RealStore pins the self-service entry point: the +// TestGetAgentConfigForUser_RealStore pins the self-service entry point: the // user's group memberships (AutoGroups — the same groups the user's peers -// carry) scope the answer, and users outside every policy get the -// indistinguishable not-configured shape. -func TestGetSetupForUser_RealStore(t *testing.T) { - mgr, s := newSetupTestMgr(t) +// carry) scope the providers, while the account's endpoint reaches every +// member — a user outside every policy gets the config with nothing +// authorized in it. +func TestGetAgentConfigForUser_RealStore(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) ctx := context.Background() require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) @@ -300,14 +302,16 @@ func TestGetSetupForUser_RealStore(t *testing.T) { Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-other"}, })) - setupIn, err := mgr.GetSetupForUser(ctx, testAccountID, "user-in") + setupIn, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-in") require.NoError(t, err) assert.True(t, setupIn.Configured) require.Len(t, setupIn.Providers, 1) - setupOut, err := mgr.GetSetupForUser(ctx, testAccountID, "user-out") + setupOut, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-out") require.NoError(t, err) - assert.False(t, setupOut.Configured, "user outside the policy's source groups gets the not-configured answer") + assert.True(t, setupOut.Configured, "the account is set up, so the user reads as configured") + assert.Equal(t, "https://"+testEndpoint, setupOut.Endpoint) + assert.Empty(t, setupOut.Providers, "user outside the policy's source groups is authorized for nothing") } // TestGetUsageOverview_RealStore_SelfScoped pins the self-scope fallback: @@ -316,7 +320,7 @@ func TestGetSetupForUser_RealStore(t *testing.T) { // filter for someone else must be overridden, not honored, and never // denied. A caller holding the grant keeps the account-wide view. func TestGetUsageOverview_RealStore_SelfScoped(t *testing.T) { - mgr, s := newSetupTestMgr(t) + mgr, s := newAgentConfigTestMgr(t) mgr.permissionsManager = permissions.NewManager(s) ctx := context.Background() diff --git a/management/internals/modules/agentnetwork/handlers/me_handler.go b/management/internals/modules/agentnetwork/handlers/agent_config_handler.go similarity index 53% rename from management/internals/modules/agentnetwork/handlers/me_handler.go rename to management/internals/modules/agentnetwork/handlers/agent_config_handler.go index 9b05a5031..0d6c45110 100644 --- a/management/internals/modules/agentnetwork/handlers/me_handler.go +++ b/management/internals/modules/agentnetwork/handlers/agent_config_handler.go @@ -11,36 +11,36 @@ import ( "github.com/netbirdio/netbird/shared/management/http/util" ) -// addMeEndpoints registers the self-service "My Agent Network" route. +// addAgentConfigEndpoints registers the self-service agent-config route. // It is available to every authenticated user regardless of role: the -// response is scoped strictly to the caller, which is tighter than any -// role gate could be. The caller's own usage and requests are served by +// providers in the response are scoped strictly to the caller, which is +// tighter than any role gate could be. The caller's own usage and requests are served by // the regular usage/logs endpoints, which self-scope for callers without // the account-wide grants. -func (h *handler) addMeEndpoints(router *mux.Router) { - router.HandleFunc("/agent-network/agent-config", h.getMySetup).Methods("GET", "OPTIONS") +func (h *handler) addAgentConfigEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/agent-config", h.getAgentConfig).Methods("GET", "OPTIONS") } -func (h *handler) getMySetup(w http.ResponseWriter, r *http.Request) { +func (h *handler) getAgentConfig(w http.ResponseWriter, r *http.Request) { userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) if err != nil { util.WriteError(r.Context(), err, w) return } - setup, err := h.manager.GetSetupForUser(r.Context(), userAuth.AccountId, userAuth.UserId) + setup, err := h.manager.GetAgentConfigForUser(r.Context(), userAuth.AccountId, userAuth.UserId) if err != nil { util.WriteError(r.Context(), err, w) return } - util.WriteJSONObject(r.Context(), w, setupToAPI(setup)) + util.WriteJSONObject(r.Context(), w, agentConfigToAPI(setup)) } -func setupToAPI(setup *types.EffectiveSetup) api.AgentNetworkMeSetup { - providers := make([]api.AgentNetworkMeProvider, 0, len(setup.Providers)) +func agentConfigToAPI(setup *types.AgentConfig) api.AgentNetworkAgentConfig { + providers := make([]api.AgentNetworkAgentConfigProvider, 0, len(setup.Providers)) for _, p := range setup.Providers { - providers = append(providers, api.AgentNetworkMeProvider{ + providers = append(providers, api.AgentNetworkAgentConfigProvider{ Name: p.Name, CatalogId: p.CatalogID, ApiFlavor: p.APIFlavor, @@ -48,7 +48,7 @@ func setupToAPI(setup *types.EffectiveSetup) api.AgentNetworkMeSetup { Models: p.Models, }) } - return api.AgentNetworkMeSetup{ + return api.AgentNetworkAgentConfig{ Configured: setup.Configured, Endpoint: setup.Endpoint, Providers: providers, diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go index b3cd53c58..ef4b93dac 100644 --- a/management/internals/modules/agentnetwork/handlers/providers_handler.go +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -46,7 +46,7 @@ func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) { h.addConsumptionEndpoints(router) h.addAccessLogEndpoints(router) h.addBudgetRuleEndpoints(router) - h.addMeEndpoints(router) + h.addAgentConfigEndpoints(router) } func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) { diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index f1e119b12..98aca7f5d 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -86,12 +86,12 @@ type Manager interface { RecordUsage(ctx context.Context, in RecordUsageInput) error SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error) - // GetSetupForUser backs the self-service "My Agent Network" setup - // endpoint. Caller-scoped, so it skips the role permission gate; see + // GetAgentConfigForUser backs the self-service agent-config endpoint. + // Caller-scoped, so it skips the role permission gate; see // the implementation. The caller's own usage and requests come // through GetUsageOverview / ListAccessLogs, which self-scope when // the account-wide grant is missing. - GetSetupForUser(ctx context.Context, accountID, userID string) (*types.EffectiveSetup, error) + GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error) } // PolicySelectionInput is the per-request selection envelope. The diff --git a/management/internals/modules/agentnetwork/provider_redaction_test.go b/management/internals/modules/agentnetwork/provider_redaction_test.go index 246f4db1e..d6a749fb9 100644 --- a/management/internals/modules/agentnetwork/provider_redaction_test.go +++ b/management/internals/modules/agentnetwork/provider_redaction_test.go @@ -103,7 +103,7 @@ func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) { // other's rows. func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) { t.Helper() - mgr, s := newSetupTestMgr(t) + mgr, s := newAgentConfigTestMgr(t) mgr.permissionsManager = permissions.NewManager(s) ctx := context.Background() diff --git a/management/internals/modules/agentnetwork/types/setup.go b/management/internals/modules/agentnetwork/types/agent_config.go similarity index 65% rename from management/internals/modules/agentnetwork/types/setup.go rename to management/internals/modules/agentnetwork/types/agent_config.go index e6724f6e9..a154efed3 100644 --- a/management/internals/modules/agentnetwork/types/setup.go +++ b/management/internals/modules/agentnetwork/types/agent_config.go @@ -1,27 +1,29 @@ package types -// EffectiveSetup is the caller-scoped answer to "what may this caller +// AgentConfig is the caller-scoped answer to "what may this caller // use on the Agent Network?" — the account's proxy endpoint plus the // providers and models the caller's groups authorize. It intentionally // carries display metadata only: no keys, no upstream URLs, no policy or // guardrail structure, and no hint of providers the caller cannot reach. -type EffectiveSetup struct { - // Configured is false when the account has no Agent Network set up or - // when nothing is authorized for the caller's groups — the two cases - // are deliberately indistinguishable so the response leaks nothing - // about what exists for others. +type AgentConfig struct { + // Configured is false only when the account has no Agent Network set + // up. A caller no policy covers yet still reads as configured, with an + // empty Providers list: every member gets the same connection config, + // and the empty list is what tells them to ask for access. Configured bool // Endpoint is the account's proxy base URL // ("https://."), reachable over the NetBird tunnel - // only. Empty when Configured is false. + // only. Empty when Configured is false. Handing it to a member the + // policies do not cover authorizes nothing on its own — the proxy + // still refuses every request no policy permits. Endpoint string // Providers lists the providers at least one applicable policy // authorizes for the caller, in the account's created_at order. - Providers []EffectiveProvider + Providers []AgentConfigProvider } -// EffectiveProvider is one authorized provider in an EffectiveSetup. -type EffectiveProvider struct { +// AgentConfigProvider is one authorized provider in an AgentConfig. +type AgentConfigProvider struct { // Name is the operator-assigned label, e.g. "Bedrock prod". Name string // CatalogID names the catalog entry, e.g. "anthropic_api". diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 4cb9129f5..19cb18307 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5807,27 +5807,27 @@ components: required: - name - checks - AgentNetworkMeSetup: + AgentNetworkAgentConfig: type: object - description: The caller-scoped Agent Network connection info backing the "My Agent Network" self-service view. Available to every authenticated user; the answer is computed from the caller's own groups and carries display metadata only. + description: The caller-scoped Agent Network connection config backing the self-service "Connect your agent" view. Available to every authenticated user; the providers are computed from the caller's own groups and the answer carries display metadata only. properties: configured: type: boolean - description: False when the account has no Agent Network set up or the caller's groups authorize none of it. The two cases are deliberately indistinguishable. + description: False only when the account has no Agent Network set up. A caller no policy covers yet still reads as configured, with an empty providers list. endpoint: type: string - description: The account's Agent Network base URL, reachable over the NetBird tunnel only. Empty when configured is false. + description: The account's Agent Network base URL, reachable over the NetBird tunnel only. Returned to every member of a configured account - it authorizes nothing on its own, since the gateway still refuses every request no policy permits. Empty when configured is false. example: https://calm-otter.proxy.example.com providers: type: array - description: The providers at least one of the caller's policies authorizes, in creation order. + description: The providers at least one of the caller's policies authorizes, in creation order. Empty when no policy covers the caller. items: - $ref: '#/components/schemas/AgentNetworkMeProvider' + $ref: '#/components/schemas/AgentNetworkAgentConfigProvider' required: - configured - endpoint - providers - AgentNetworkMeProvider: + AgentNetworkAgentConfigProvider: type: object description: One provider the caller may use, reduced to what a local tool needs for configuration. properties: @@ -13854,7 +13854,7 @@ paths: "$ref": "#/components/responses/internal_error" /api/agent-network/agent-config: get: - summary: Retrieve the caller's Agent Network setup + summary: Retrieve the caller's Agent Network agent config description: Returns everything the caller needs to configure a local AI tool and nothing more - the account's Agent Network endpoint plus the providers and models the caller's own policies allow. Available to every authenticated user regardless of role; the response never contains provider credentials, policy or guardrail configuration, or providers the caller cannot reach. tags: [ Agent Network ] security: @@ -13862,11 +13862,11 @@ paths: - TokenAuth: [ ] responses: '200': - description: The caller-scoped Agent Network connection info + description: The caller-scoped Agent Network agent config content: application/json: schema: - $ref: '#/components/schemas/AgentNetworkMeSetup' + $ref: '#/components/schemas/AgentNetworkAgentConfig' '401': "$ref": "#/components/responses/requires_authentication" '500': diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 6779cdb8e..1d574c857 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1931,6 +1931,36 @@ type AgentNetworkAccessLogsResponse struct { TotalRecords int `json:"total_records"` } +// AgentNetworkAgentConfig The caller-scoped Agent Network connection config backing the self-service "Connect your agent" view. Available to every authenticated user; the providers are computed from the caller's own groups and the answer carries display metadata only. +type AgentNetworkAgentConfig struct { + // Configured False only when the account has no Agent Network set up. A caller no policy covers yet still reads as configured, with an empty providers list. + Configured bool `json:"configured"` + + // Endpoint The account's Agent Network base URL, reachable over the NetBird tunnel only. Returned to every member of a configured account - it authorizes nothing on its own, since the gateway still refuses every request no policy permits. Empty when configured is false. + Endpoint string `json:"endpoint"` + + // Providers The providers at least one of the caller's policies authorizes, in creation order. Empty when no policy covers the caller. + Providers []AgentNetworkAgentConfigProvider `json:"providers"` +} + +// AgentNetworkAgentConfigProvider One provider the caller may use, reduced to what a local tool needs for configuration. +type AgentNetworkAgentConfigProvider struct { + // AllModelsAllowed True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy. + AllModelsAllowed bool `json:"all_models_allowed"` + + // ApiFlavor Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead. + ApiFlavor string `json:"api_flavor"` + + // CatalogId Catalog entry id naming the provider type. + CatalogId string `json:"catalog_id"` + + // Models The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true). + Models []string `json:"models"` + + // Name Operator-assigned provider label. + Name string `json:"name"` +} + // AgentNetworkBudgetRule Account-level budget rule. A limit-only rule bound to groups and/or users that applies across all policies as a min-wins ceiling. Empty targets means it applies to every caller. type AgentNetworkBudgetRule struct { CreatedAt *time.Time `json:"created_at,omitempty"` @@ -2194,36 +2224,6 @@ type AgentNetworkGuardrailRequest struct { Name string `json:"name"` } -// AgentNetworkMeProvider One provider the caller may use, reduced to what a local tool needs for configuration. -type AgentNetworkMeProvider struct { - // AllModelsAllowed True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy. - AllModelsAllowed bool `json:"all_models_allowed"` - - // ApiFlavor Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead. - ApiFlavor string `json:"api_flavor"` - - // CatalogId Catalog entry id naming the provider type. - CatalogId string `json:"catalog_id"` - - // Models The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true). - Models []string `json:"models"` - - // Name Operator-assigned provider label. - Name string `json:"name"` -} - -// AgentNetworkMeSetup The caller-scoped Agent Network connection info backing the "My Agent Network" self-service view. Available to every authenticated user; the answer is computed from the caller's own groups and carries display metadata only. -type AgentNetworkMeSetup struct { - // Configured False when the account has no Agent Network set up or the caller's groups authorize none of it. The two cases are deliberately indistinguishable. - Configured bool `json:"configured"` - - // Endpoint The account's Agent Network base URL, reachable over the NetBird tunnel only. Empty when configured is false. - Endpoint string `json:"endpoint"` - - // Providers The providers at least one of the caller's policies authorizes, in creation order. - Providers []AgentNetworkMeProvider `json:"providers"` -} - // AgentNetworkModelDiscoveryRequest defines model for AgentNetworkModelDiscoveryRequest. type AgentNetworkModelDiscoveryRequest struct { // ApiKey Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id.