From 2310ce74876eff53f2758199d5f95758b6fb2412 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Tue, 18 Aug 2026 13:02:02 +0000 Subject: [PATCH] [management] Fix agent_network_admin account read and serve own usage overview Field testing the role surfaced two gaps. GET /api/accounts validates Settings read (the settings manager gates account settings), so an agent_network_admin got permission denied on the endpoint the dashboard needs to boot; grant Settings read-only, the same as network_admin. The me/consumption endpoint returned raw per-window counter rows, which reads nothing like the usage overview admins see. Replace it with GET /api/agent-network/me/usage/overview: the same filter parsing, bounds, granularity, and bucket aggregation as the admin overview, with the user filter forced to the caller and group filters dropped, so the dashboard renders My Usage with the exact component of the admin view while never exposing another user's rows. --- agent-network/README.md | 4 +-- .../agentnetwork/handlers/me_handler.go | 25 ++++++++++---- .../internals/modules/agentnetwork/manager.go | 4 +-- .../internals/modules/agentnetwork/setup.go | 27 +++++++-------- .../agentnetwork/setup_realstore_test.go | 34 ++++++++++++------- .../permissions/agent_network_roles_test.go | 8 +++-- .../permissions/roles/agent_network_admin.go | 12 +++++-- shared/management/http/api/openapi.yml | 30 +++++++++++++--- 8 files changed, 98 insertions(+), 46 deletions(-) diff --git a/agent-network/README.md b/agent-network/README.md index 4fa68790f..e9eabb2c9 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -87,8 +87,8 @@ Two roles delegate Agent Network access without account-admin rights: Every authenticated user, regardless of role, can read the caller-scoped self-service endpoints: `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) and `GET /api/agent-network/me/consumption` (the caller's own token and cost -counters). Role definitions live in +more) and `GET /api/agent-network/me/usage/overview` (the caller's own usage, +aggregated exactly like the admin overview). Role definitions live in [`management/server/permissions/roles/`](../management/server/permissions/roles). ## Documentation diff --git a/management/internals/modules/agentnetwork/handlers/me_handler.go b/management/internals/modules/agentnetwork/handlers/me_handler.go index 232e660c2..927fe5bda 100644 --- a/management/internals/modules/agentnetwork/handlers/me_handler.go +++ b/management/internals/modules/agentnetwork/handlers/me_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "github.com/gorilla/mux" @@ -17,7 +18,7 @@ import ( // role gate could be. func (h *handler) addMeEndpoints(router *mux.Router) { router.HandleFunc("/agent-network/me/setup", h.getMySetup).Methods("GET", "OPTIONS") - router.HandleFunc("/agent-network/me/consumption", h.listMyConsumption).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/me/usage/overview", h.getMyUsageOverview).Methods("GET", "OPTIONS") } func (h *handler) getMySetup(w http.ResponseWriter, r *http.Request) { @@ -36,22 +37,34 @@ func (h *handler) getMySetup(w http.ResponseWriter, r *http.Request) { util.WriteJSONObject(r.Context(), w, setupToAPI(setup)) } -func (h *handler) listMyConsumption(w http.ResponseWriter, r *http.Request) { +// getMyUsageOverview mirrors the admin usage overview — same filter +// parsing, bounds, granularity, and bucket response — with the identity +// filters overridden to the caller inside the manager, so the dashboard +// renders "My Usage" with the exact component the admin view uses. +func (h *handler) getMyUsageOverview(w http.ResponseWriter, r *http.Request) { userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) if err != nil { util.WriteError(r.Context(), err, w) return } - rows, err := h.manager.ListConsumptionForUser(r.Context(), userAuth.AccountId, userAuth.UserId) + var filter types.AgentNetworkAccessLogFilter + if err := filter.ParseFromRequest(r); err != nil { + util.WriteError(r.Context(), err, w) + return + } + filter.ApplyUsageOverviewBounds(time.Now()) + granularity := types.ParseUsageGranularity(r.URL.Query().Get("granularity")) + + buckets, err := h.manager.GetUsageOverviewForUser(r.Context(), userAuth.AccountId, userAuth.UserId, filter, granularity) if err != nil { util.WriteError(r.Context(), err, w) return } - out := make([]api.AgentNetworkConsumption, 0, len(rows)) - for _, row := range rows { - out = append(out, consumptionToAPI(row)) + out := make([]api.AgentNetworkUsageBucket, 0, len(buckets)) + for _, b := range buckets { + out = append(out, b.ToAPIResponse()) } util.WriteJSONObject(r.Context(), w, out) } diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index a06d266d0..45e86491d 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -84,11 +84,11 @@ type Manager interface { RecordUsage(ctx context.Context, in RecordUsageInput) error SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error) - // GetSetupForUser and ListConsumptionForUser back the self-service + // GetSetupForUser and GetUsageOverviewForUser back the self-service // "My Agent Network" endpoints. Both are caller-scoped and skip the // role permission gate; see the implementations. GetSetupForUser(ctx context.Context, accountID, userID string) (*types.EffectiveSetup, error) - ListConsumptionForUser(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) + GetUsageOverviewForUser(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) } // PolicySelectionInput is the per-request selection envelope. The diff --git a/management/internals/modules/agentnetwork/setup.go b/management/internals/modules/agentnetwork/setup.go index 51a0e26af..494faa6ee 100644 --- a/management/internals/modules/agentnetwork/setup.go +++ b/management/internals/modules/agentnetwork/setup.go @@ -24,21 +24,20 @@ func (m *managerImpl) GetSetupForUser(ctx context.Context, accountID, userID str return m.effectiveSetupForGroups(ctx, accountID, user.AutoGroups) } -// ListConsumptionForUser returns the caller's own consumption counters: -// the user-dimension rows recorded for userID. Caller-scoped by design — -// no role permission check, mirroring GetSetupForUser. -func (m *managerImpl) ListConsumptionForUser(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) { - rows, err := m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID) +// GetUsageOverviewForUser returns the same aggregated usage buckets the +// admin overview serves, pinned to the caller's own rows: the filter's +// user id is forced to the caller and any group filter is dropped, which +// is tighter than any role gate — so, like GetSetupForUser, no permission +// check. The response shape is identical to GetUsageOverview so the +// dashboard renders both views with the same component. +func (m *managerImpl) GetUsageOverviewForUser(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { + filter.UserID = &userID + filter.GroupIDs = nil + rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter) if err != nil { return nil, err } - own := make([]*types.Consumption, 0) - for _, row := range rows { - if row.DimensionKind == types.DimensionUser && row.DimensionID == userID { - own = append(own, row) - } - } - return own, nil + return types.AggregateUsageByGranularity(rows, granularity), nil } // effectiveSetupForGroups computes the effective Agent Network setup for @@ -255,7 +254,7 @@ func (*mockManager) GetSetupForUser(_ context.Context, _, _ string) (*types.Effe return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil } -// ListConsumptionForUser on the mock manager returns no rows. -func (*mockManager) ListConsumptionForUser(_ context.Context, _, _ string) ([]*types.Consumption, error) { +// GetUsageOverviewForUser on the mock manager returns no buckets. +func (*mockManager) GetUsageOverviewForUser(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter, _ types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { return nil, nil } diff --git a/management/internals/modules/agentnetwork/setup_realstore_test.go b/management/internals/modules/agentnetwork/setup_realstore_test.go index 4c43a109a..2048b1461 100644 --- a/management/internals/modules/agentnetwork/setup_realstore_test.go +++ b/management/internals/modules/agentnetwork/setup_realstore_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/server/store" nbtypes "github.com/netbirdio/netbird/management/server/types" ) @@ -278,21 +279,30 @@ func TestGetSetupForUser_RealStore(t *testing.T) { assert.False(t, setupOut.Configured, "user outside the policy's source groups gets the not-configured answer") } -// TestListConsumptionForUser_RealStore pins the own-consumption scope: only -// the caller's user-dimension rows come back, never another user's rows or -// group rows. -func TestListConsumptionForUser_RealStore(t *testing.T) { +// TestGetUsageOverviewForUser_RealStore pins the own-usage scope: the same +// aggregation the admin overview serves, but only ever the caller's rows — +// a user_id filter for someone else must be overridden, not honored. +func TestGetUsageOverviewForUser_RealStore(t *testing.T) { mgr, s := newSetupTestMgr(t) ctx := context.Background() - now := time.Now().UTC().Truncate(time.Hour) - require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, testAccountID, types.DimensionUser, "user-a", 3600, now, 100, 50, 0.5)) - require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, testAccountID, types.DimensionUser, "user-b", 3600, now, 999, 999, 9.9)) - require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, testAccountID, types.DimensionGroup, "grp-eng", 3600, now, 1, 1, 0.1)) + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) - rows, err := mgr.ListConsumptionForUser(ctx, testAccountID, "user-a") + own1 := newIngestTestEntry() + own1.ID, own1.UserId = "log-own-1", "user-a" + own2 := newIngestTestEntry() + own2.ID, own2.UserId = "log-own-2", "user-a" + other := newIngestTestEntry() + other.ID, other.UserId = "log-other", "user-b" + for _, e := range []*accesslogs.AccessLogEntry{own1, own2, other} { + require.NoError(t, IngestAccessLog(ctx, s, e)) + } + + otherID := "user-b" + filter := types.AgentNetworkAccessLogFilter{UserID: &otherID} + buckets, err := mgr.GetUsageOverviewForUser(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity("")) require.NoError(t, err) - require.Len(t, rows, 1, "only the caller's own user-dimension rows are visible") - assert.Equal(t, "user-a", rows[0].DimensionID) - assert.Equal(t, int64(100), rows[0].TokensInput) + require.Len(t, buckets, 1, "same-day rows aggregate into one daily bucket") + assert.Equal(t, int64(200), buckets[0].InputTokens, "only the caller's two rows count — the foreign user_id filter is overridden") + assert.Equal(t, int64(100), buckets[0].OutputTokens) } diff --git a/management/server/permissions/agent_network_roles_test.go b/management/server/permissions/agent_network_roles_test.go index a82198b01..dc9d747fc 100644 --- a/management/server/permissions/agent_network_roles_test.go +++ b/management/server/permissions/agent_network_roles_test.go @@ -43,16 +43,18 @@ func TestAgentNetworkAdminRole(t *testing.T) { } } - for _, m := range []modules.Module{modules.Users, modules.Groups, modules.Peers, modules.Accounts} { + // Settings read rides along because GET /api/accounts (which the + // dashboard needs to boot) validates it, like network_admin. + for _, m := range []modules.Module{modules.Users, modules.Groups, modules.Peers, modules.Accounts, modules.Settings} { assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read), - "agent_network_admin must read %s to build policies", m) + "agent_network_admin must read %s to build policies and load the dashboard", m) for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} { assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), "agent_network_admin must not have %s on %s", op, m) } } - for _, m := range []modules.Module{modules.Networks, modules.Dns, modules.SetupKeys, modules.Routes, modules.Settings} { + for _, m := range []modules.Module{modules.Networks, modules.Dns, modules.SetupKeys, modules.Routes} { for _, op := range allOps { assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), "agent_network_admin must not have %s on %s", op, m) diff --git a/management/server/permissions/roles/agent_network_admin.go b/management/server/permissions/roles/agent_network_admin.go index 6a0db6ec2..0f48500ec 100644 --- a/management/server/permissions/roles/agent_network_admin.go +++ b/management/server/permissions/roles/agent_network_admin.go @@ -9,8 +9,10 @@ import ( // AgentNetworkAdmin is the delegated administrator for the Agent Network // area: full control over providers, policies, guardrails, budgets, usage, // logs, and its settings, plus read-only visibility into the account -// objects needed to build policies (users, groups, peers). Nothing else in -// the account is visible. +// objects needed to build policies (users, groups, peers) and the account +// settings/meta read the dashboard needs to boot (GET /api/accounts +// validates Settings read, same as network_admin). Nothing else in the +// account is visible. var AgentNetworkAdmin = RolePermissions{ Role: types.UserRoleAgentNetworkAdmin, AutoAllowNew: map[operations.Operation]bool{ @@ -50,5 +52,11 @@ var AgentNetworkAdmin = RolePermissions{ operations.Update: false, operations.Delete: false, }, + modules.Settings: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, }, } diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 38fa0f1cc..7af3684d7 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -13793,23 +13793,43 @@ paths: "$ref": "#/components/responses/requires_authentication" '500': "$ref": "#/components/responses/internal_error" - /api/agent-network/me/consumption: + /api/agent-network/me/usage/overview: get: - summary: List the caller's own Agent Network consumption - description: Returns the caller's own per-window token and cost counters (the user dimension recorded for the calling user), ordered window-newest-first. Available to every authenticated user regardless of role. Empty list when the caller has not consumed anything yet. + summary: The caller's own Agent Network usage overview + description: Returns the same aggregated time-bucket usage as /api/agent-network/usage/overview, pinned server-side to the calling user's own rows (any user_id or group_id filter is overridden). Available to every authenticated user regardless of role. Empty list when the caller has not consumed anything yet. tags: [ Agent Network ] security: - BearerAuth: [ ] - TokenAuth: [ ] + parameters: + - in: query + name: granularity + schema: + type: string + enum: [day, week, month] + default: day + description: Time bucket width. Defaults to day. + - in: query + name: start_date + schema: + type: string + format: date-time + description: Filter by timestamp >= start_date (RFC3339 format). + - in: query + name: end_date + schema: + type: string + format: date-time + description: Filter by timestamp <= end_date (RFC3339 format). responses: '200': - description: A JSON Array of the caller's own consumption counter rows + description: A JSON Array of usage buckets for the calling user content: application/json: schema: type: array items: - $ref: '#/components/schemas/AgentNetworkConsumption' + $ref: '#/components/schemas/AgentNetworkUsageBucket' '401': "$ref": "#/components/responses/requires_authentication" '500':