[management] Self-scope usage and log reads; give usage_viewer the filter resources

The usage overview and access-log listings no longer deny callers without
the account-wide grant: the filter is pinned to the caller (their own
user id, group filters dropped), so every authenticated user reads their
own usage and requests through the same endpoints the admin dashboard
uses. The dedicated /agent-network/me/usage/overview endpoint is removed
in favor of that fallback.

usage_viewer gains read-only access to the resources the usage view's
filters and columns resolve against: users, groups, peers, and the
provider list (provider and model filter options).
This commit is contained in:
mlsmaycon
2026-08-18 17:20:08 +00:00
parent bd52434e36
commit a3b9853f31
9 changed files with 123 additions and 172 deletions

View File

@@ -81,14 +81,17 @@ Two roles delegate Agent Network access without account-admin rights:
read-only users, groups, peers, and account info (needed to build policies).
Nothing else in the account.
- **`usage_viewer`** — the regular User baseline plus read on
`agent_network.usage` (the aggregated usage and cost overview). No provider
configuration, no policies, no request-level access logs.
`agent_network.usage` (the aggregated usage and cost overview) and read-only
access to the resources the usage filters resolve against: users, groups,
peers, and the provider list. No policies, no request-level access logs.
Every authenticated user, regardless of role, can read the caller-scoped
self-service endpoints: `GET /api/agent-network/me/setup` (the endpoint, providers,
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) and `GET /api/agent-network/me/usage/overview` (the caller's own usage,
aggregated exactly like the admin overview). Role definitions live in
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
definitions live in
[`management/server/permissions/roles/`](../management/server/permissions/roles).
## Documentation

View File

@@ -2,7 +2,6 @@ package handlers
import (
"net/http"
"time"
"github.com/gorilla/mux"
@@ -12,13 +11,14 @@ import (
"github.com/netbirdio/netbird/shared/management/http/util"
)
// addMeEndpoints registers the self-service "My Agent Network" routes.
// Both are available to every authenticated user regardless of role: the
// responses are scoped strictly to the caller, which is tighter than any
// role gate could be.
// addMeEndpoints registers the self-service "My Agent Network" 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
// 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/me/setup", h.getMySetup).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) {
@@ -37,38 +37,6 @@ func (h *handler) getMySetup(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, setupToAPI(setup))
}
// 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
}
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.AgentNetworkUsageBucket, 0, len(buckets))
for _, b := range buckets {
out = append(out, b.ToAPIResponse())
}
util.WriteJSONObject(r.Context(), w, out)
}
func setupToAPI(setup *types.EffectiveSetup) api.AgentNetworkMeSetup {
providers := make([]api.AgentNetworkMeProvider, 0, len(setup.Providers))
for _, p := range setup.Providers {

View File

@@ -84,11 +84,12 @@ type Manager interface {
RecordUsage(ctx context.Context, in RecordUsageInput) error
SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error)
// 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 backs the self-service "My Agent Network" setup
// 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)
GetUsageOverviewForUser(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error)
}
// PolicySelectionInput is the per-request selection envelope. The
@@ -907,8 +908,11 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
// ListAccessLogs returns a paginated, server-side-filtered page of
// agent-network access logs plus the total count matching the filter.
// Callers without the account-wide logs grant get a self-scoped page —
// only their own requests — instead of a denial.
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter)
if err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
@@ -916,18 +920,23 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
// ListAccessLogSessions returns a paginated, server-side-filtered page of
// agent-network access logs grouped by session, plus the total number of
// sessions matching the filter.
// sessions matching the filter. Self-scoped like ListAccessLogs for
// callers without the account-wide logs grant.
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter)
if err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
}
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
// at the requested granularity, oldest-first.
// at the requested granularity, oldest-first. Callers without the
// account-wide usage grant get their own rows aggregated instead of a
// denial, so the dashboard serves "my usage" from the same endpoint.
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkUsage, filter)
if err != nil {
return nil, err
}
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
@@ -937,6 +946,25 @@ func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID st
return types.AggregateUsageByGranularity(rows, granularity), nil
}
// scopeFilterToCaller applies the account-wide read gate for module and,
// when the caller lacks the grant, pins the filter to the caller instead
// of denying: their own user id replaces any requested one and group
// filters are dropped. A caller may always see their own rows — strictly
// tighter than any role gate — which is what lets every authenticated
// user read their usage and requests through the regular endpoints.
// Validation errors (not denials) still fail closed.
func (m *managerImpl) scopeFilterToCaller(ctx context.Context, accountID, userID string, module modules.Module, filter types.AgentNetworkAccessLogFilter) (types.AgentNetworkAccessLogFilter, error) {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, operations.Read)
if err != nil {
return filter, status.NewPermissionValidationError(err)
}
if !ok {
filter.UserID = &userID
filter.GroupIDs = nil
}
return filter, nil
}
// StartAccessLogCleanup launches a background sweep that periodically deletes
// each account's agent-network access-log rows older than that account's
// AccessLogRetentionDays. Usage records are never swept. A non-positive

View File

@@ -24,22 +24,6 @@ func (m *managerImpl) GetSetupForUser(ctx context.Context, accountID, userID str
return m.effectiveSetupForGroups(ctx, accountID, user.AutoGroups)
}
// 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
}
return types.AggregateUsageByGranularity(rows, granularity), nil
}
// effectiveSetupForGroups 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
@@ -253,8 +237,3 @@ func declaredModelIDs(provider *types.Provider) []string {
func (*mockManager) GetSetupForUser(_ context.Context, _, _ string) (*types.EffectiveSetup, error) {
return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil
}
// 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

@@ -10,6 +10,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
)
@@ -279,14 +280,24 @@ func TestGetSetupForUser_RealStore(t *testing.T) {
assert.False(t, setupOut.Configured, "user outside the policy's source groups gets the not-configured answer")
}
// 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) {
// TestGetUsageOverview_RealStore_SelfScoped pins the self-scope fallback:
// a caller without the account-wide usage grant gets the same aggregation
// the admin overview serves, but only ever their own rows — a user_id
// 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.permissionsManager = permissions.NewManager(s)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
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,
}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
}))
own1 := newIngestTestEntry()
own1.ID, own1.UserId = "log-own-1", "user-a"
@@ -300,9 +311,14 @@ func TestGetUsageOverviewForUser_RealStore(t *testing.T) {
otherID := "user-b"
filter := types.AgentNetworkAccessLogFilter{UserID: &otherID}
buckets, err := mgr.GetUsageOverviewForUser(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity(""))
buckets, err := mgr.GetUsageOverview(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity(""))
require.NoError(t, err)
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)
adminBuckets, err := mgr.GetUsageOverview(ctx, testAccountID, "admin", types.AgentNetworkAccessLogFilter{}, types.ParseUsageGranularity(""))
require.NoError(t, err)
require.Len(t, adminBuckets, 1)
assert.Equal(t, int64(300), adminBuckets[0].InputTokens, "the account-wide grant keeps the unscoped view")
}

View File

@@ -63,9 +63,10 @@ func TestAgentNetworkAdminRole(t *testing.T) {
}
// TestUsageViewerRole pins the least-privilege cost role: read on the
// aggregated usage overview and nothing else — no providers, no policies,
// no request-level logs (which can contain captured prompts), nothing in
// the rest of the account.
// aggregated usage overview plus read-only on the resources its filters
// and display columns resolve against (users, groups, peers, the provider
// list) — no policies, no request-level logs (which can contain captured
// prompts), nothing else in the account.
func TestUsageViewerRole(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
@@ -73,23 +74,30 @@ func TestUsageViewerRole(t *testing.T) {
role, ok := roles.RolesMap[types.UserRoleUsageViewer]
require.True(t, ok, "usage_viewer must exist in RolesMap")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"usage_viewer must read the usage overview")
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, op),
"usage_viewer must not have %s on usage", op)
readOnly := []modules.Module{
modules.AgentNetworkUsage,
modules.AgentNetworkProviders,
modules.Users,
modules.Groups,
modules.Peers,
}
for _, m := range readOnly {
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
"usage_viewer must read %s for the usage view and its filters", m)
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"usage_viewer must not have %s on %s", op, m)
}
}
denied := []modules.Module{
modules.AgentNetwork,
modules.AgentNetworkProviders,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
modules.Networks,
modules.Users,
modules.SetupKeys,
}
for _, m := range denied {

View File

@@ -7,10 +7,12 @@ import (
)
// UsageViewer is the regular User baseline plus read access to the
// aggregated Agent Network usage and cost overview. It sees no provider
// configuration, no policies, and no request-level access logs (which can
// contain captured prompts): usage rows carry user and group display names
// in the response itself, so no team-wide read access is needed.
// aggregated Agent Network usage and cost overview, and read-only access
// to the resources the usage filters and display columns resolve against:
// users and groups (identity filters and name resolution), peers (agent
// principals in the caller column), and the provider list (provider and
// model filter options). It sees no policies and no request-level access
// logs (which can contain captured prompts).
var UsageViewer = RolePermissions{
Role: types.UserRoleUsageViewer,
AutoAllowNew: map[operations.Operation]bool{
@@ -26,5 +28,29 @@ var UsageViewer = RolePermissions{
operations.Update: false,
operations.Delete: false,
},
modules.AgentNetworkProviders: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Users: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Groups: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Peers: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
},
}

View File

@@ -13444,7 +13444,7 @@ paths:
/api/agent-network/access-logs:
get:
summary: List Agent Network access logs
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained.
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained. Callers without the account-wide grant are not denied - the response is scoped to their own requests (any user_id or group_id filter is overridden).
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13559,7 +13559,7 @@ paths:
/api/agent-network/access-log-sessions:
get:
summary: List Agent Network access logs grouped by session
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled.
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled. Callers without the account-wide grant are not denied - the response is scoped to their own requests (any user_id or group_id filter is overridden).
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13674,7 +13674,7 @@ paths:
/api/agent-network/usage/overview:
get:
summary: Agent Network usage overview
description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection).
description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection). Callers without the account-wide grant are not denied - the response is scoped to their own usage (any user_id or group_id filter is overridden).
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13793,47 +13793,6 @@ paths:
"$ref": "#/components/responses/requires_authentication"
'500':
"$ref": "#/components/responses/internal_error"
/api/agent-network/me/usage/overview:
get:
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 usage buckets for the calling user
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/AgentNetworkUsageBucket'
'401':
"$ref": "#/components/responses/requires_authentication"
'500':
"$ref": "#/components/responses/internal_error"
/api/agent-network/settings:
get:
summary: Retrieve Agent Network settings

View File

@@ -1316,27 +1316,6 @@ func (e GetApiAgentNetworkAccessLogsParamsSortOrder) Valid() bool {
}
}
// Defines values for GetApiAgentNetworkMeUsageOverviewParamsGranularity.
const (
GetApiAgentNetworkMeUsageOverviewParamsGranularityDay GetApiAgentNetworkMeUsageOverviewParamsGranularity = "day"
GetApiAgentNetworkMeUsageOverviewParamsGranularityMonth GetApiAgentNetworkMeUsageOverviewParamsGranularity = "month"
GetApiAgentNetworkMeUsageOverviewParamsGranularityWeek GetApiAgentNetworkMeUsageOverviewParamsGranularity = "week"
)
// Valid indicates whether the value is a known member of the GetApiAgentNetworkMeUsageOverviewParamsGranularity enum.
func (e GetApiAgentNetworkMeUsageOverviewParamsGranularity) Valid() bool {
switch e {
case GetApiAgentNetworkMeUsageOverviewParamsGranularityDay:
return true
case GetApiAgentNetworkMeUsageOverviewParamsGranularityMonth:
return true
case GetApiAgentNetworkMeUsageOverviewParamsGranularityWeek:
return true
default:
return false
}
}
// Defines values for GetApiAgentNetworkUsageOverviewParamsGranularity.
const (
GetApiAgentNetworkUsageOverviewParamsGranularityDay GetApiAgentNetworkUsageOverviewParamsGranularity = "day"
@@ -5990,21 +5969,6 @@ type GetApiAgentNetworkAccessLogsParamsSortBy string
// GetApiAgentNetworkAccessLogsParamsSortOrder defines parameters for GetApiAgentNetworkAccessLogs.
type GetApiAgentNetworkAccessLogsParamsSortOrder string
// GetApiAgentNetworkMeUsageOverviewParams defines parameters for GetApiAgentNetworkMeUsageOverview.
type GetApiAgentNetworkMeUsageOverviewParams struct {
// Granularity Time bucket width. Defaults to day.
Granularity *GetApiAgentNetworkMeUsageOverviewParamsGranularity `form:"granularity,omitempty" json:"granularity,omitempty"`
// StartDate Filter by timestamp >= start_date (RFC3339 format).
StartDate *time.Time `form:"start_date,omitempty" json:"start_date,omitempty"`
// EndDate Filter by timestamp <= end_date (RFC3339 format).
EndDate *time.Time `form:"end_date,omitempty" json:"end_date,omitempty"`
}
// GetApiAgentNetworkMeUsageOverviewParamsGranularity defines parameters for GetApiAgentNetworkMeUsageOverview.
type GetApiAgentNetworkMeUsageOverviewParamsGranularity string
// GetApiAgentNetworkUsageOverviewParams defines parameters for GetApiAgentNetworkUsageOverview.
type GetApiAgentNetworkUsageOverviewParams struct {
// Granularity Time bucket width. Defaults to day.