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':