mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-05 13:09:07 +02:00
[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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user