[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.
This commit is contained in:
mlsmaycon
2026-08-18 13:02:02 +00:00
parent e2a09a648b
commit 2310ce7487
8 changed files with 98 additions and 46 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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,
},
},
}

View File

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