From 03bc67a5f0dbe69582b7b72edf5d197c55b34682 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 21 Aug 2026 13:21:58 +0100 Subject: [PATCH] feat(admin): expose group provider/model access GET /admin/api/usage's group rows now carry the group's configured Providers/Models/MCPServers/Agents (GroupConfig, llmgateway.go), echoed exactly as configured -- never resolved/expanded to the full catalog server-side. Empty/unset stays omitted (omitempty), matching GroupConfig's own JSON convention and group.allowsX's empty-means-all semantics (auth.go): the dashboard can tell 'restricted to these N' apart from 'unrestricted' without the payload growing with catalog size. groupSummary (auth.go) gains providers/models/mcpServers/agents, carried through from authStore.snapshot unchanged. adminUsageEntryView (admin.go) gains the matching JSON fields, populated only for group entries -- a user or the total scope row never carries them, mirroring GroupName's own user-only convention. The Usage view's group accordion (webui/UsageView.vue) renders an Access block above the existing stat grid: Badge chips for explicit providers, ModelChip for explicit models (both shadcn/existing components, no hand-rolled chips), or muted 'All providers'/'All models' text when unrestricted. Display-only this round -- not wired into the user/group search filter. types/api.ts's AdminUsageEntryView mirrors the new optional fields. Tests: admin_test.go's new table-driven TestAdminUsage_GroupAccessLists covers a restricted group's exact lists, an unrestricted group's empty/omitted lists, and that a user/total row never carries them. --- admin.go | 18 ++++++ admin_test.go | 91 ++++++++++++++++++++++++++++++ auth.go | 21 ++++++- webui/src/components/UsageView.vue | 33 +++++++++++ webui/src/types/api.ts | 13 +++++ 5 files changed, 174 insertions(+), 2 deletions(-) diff --git a/admin.go b/admin.go index a43c625..4185268 100644 --- a/admin.go +++ b/admin.go @@ -415,11 +415,25 @@ func (g *Gateway) serveAdminOverview(w http.ResponseWriter) { // tokensPerDay/tokensPerMonth pair (v0.2 data-layer task, tokens-in/ // tokens-out split) — a client wanting the combined total sums the two // itself. +// +// Providers/Models/MCPServers/Agents (group-access-display task) are set +// only for a group entry — a user or the total entry never populates them, +// same convention as GroupName's own "user-only" field above, mirrored. +// Each carries the group's GroupConfig list exactly as configured: omitted +// (nil) means every provider/model/MCP server/agent is allowed +// (group.allowsX's own empty-list-matches-anything contract, auth.go) — +// this is never resolved/expanded to the full catalog here, so the +// dashboard can tell "explicitly restricted to these N" apart from +// "unrestricted" without the payload growing with catalog size. type adminUsageEntryView struct { Limits *LimitsConfig `json:"limits,omitempty"` Kind string `json:"kind"` ID string `json:"id"` GroupName string `json:"groupName,omitempty"` + Providers []string `json:"providers,omitempty"` + Models []string `json:"models,omitempty"` + MCPServers []string `json:"mcpServers,omitempty"` + Agents []string `json:"agents,omitempty"` RequestsPerMinute int64 `json:"requestsPerMinute"` RequestsPerDay int64 `json:"requestsPerDay"` TokensInPerDay int64 `json:"tokensInPerDay"` @@ -500,6 +514,10 @@ func (g *Gateway) buildAdminUsage() adminUsageResponse { groups := make([]adminUsageEntryView, len(groupUsage)) for i, su := range groupUsage { groups[i] = usageEntryView(su, groupSummaries[i].limits) + groups[i].Providers = groupSummaries[i].providers + groups[i].Models = groupSummaries[i].models + groups[i].MCPServers = groupSummaries[i].mcpServers + groups[i].Agents = groupSummaries[i].agents } total := usageEntryView(totalUsage, nil) diff --git a/admin_test.go b/admin_test.go index 5653c98..ca7d6e7 100644 --- a/admin_test.go +++ b/admin_test.go @@ -496,6 +496,97 @@ func findUsageEntry(t *testing.T, entries []adminUsageEntryView, id string) admi return adminUsageEntryView{} } +// TestAdminUsage_GroupAccessLists proves GET /admin/api/usage's per-group +// rows (group-access-display task) echo GroupConfig's Providers/Models/ +// MCPServers/Agents exactly as configured — no server-side expansion to the +// full provider/model catalog — and that a group with none of those set +// carries empty/omitted lists rather than a resolved "everything" set, +// matching group.allowsX's own empty-means-all contract (auth.go). A user +// row and the synthetic total row must never carry these fields at all — +// they are group-only, same convention as GroupName's own user-only field. +func TestAdminUsage_GroupAccessLists(t *testing.T) { + t.Parallel() + cfg := CreateConfig() + cfg.Admin = &AdminConfig{Enabled: true} + cfg.Providers = map[string]*ProviderConfig{ + "alpha": {Type: "openai", BaseURL: "http://alpha.invalid", APIKey: "sk-alpha", Models: []string{"a-model-1"}}, + } + cfg.MCPServers = map[string]*TargetConfig{"srv1": {URL: "http://srv1.invalid"}} + cfg.Agents = map[string]*AgentConfig{"agent1": {URL: "http://agent1.invalid"}} + cfg.Groups = map[string]*GroupConfig{ + "restricted": { + Providers: []string{"alpha"}, + Models: []string{"alpha/a-model-1", "z-model"}, + MCPServers: []string{"srv1"}, + Agents: []string{"agent1"}, + }, + "open": {}, // no Providers/Models/MCPServers/Agents set: unrestricted + } + cfg.Users = &UsersConfig{Inline: []*UserConfig{ + {Name: "admin1", Group: "open", APIKey: "sk-admin1", Admin: true}, + }} + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsagePath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminUsageResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + + tests := []struct { + name string + group string + wantProviders []string + wantModels []string + wantMCPServers []string + wantAgents []string + }{ + { + name: "restricted group carries its exact configured lists", + group: "restricted", + wantProviders: []string{"alpha"}, + wantModels: []string{"alpha/a-model-1", "z-model"}, + wantMCPServers: []string{"srv1"}, + wantAgents: []string{"agent1"}, + }, + { + name: "unrestricted group carries empty/omitted lists", + group: "open", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + entry := findUsageEntry(t, got.Groups, tc.group) + if !slices.Equal(entry.Providers, tc.wantProviders) { + t.Errorf("providers = %v, want %v", entry.Providers, tc.wantProviders) + } + if !slices.Equal(entry.Models, tc.wantModels) { + t.Errorf("models = %v, want %v", entry.Models, tc.wantModels) + } + if !slices.Equal(entry.MCPServers, tc.wantMCPServers) { + t.Errorf("mcpServers = %v, want %v", entry.MCPServers, tc.wantMCPServers) + } + if !slices.Equal(entry.Agents, tc.wantAgents) { + t.Errorf("agents = %v, want %v", entry.Agents, tc.wantAgents) + } + }) + } + + admin1 := findUsageEntry(t, got.Users, "admin1") + if admin1.Providers != nil || admin1.Models != nil || admin1.MCPServers != nil || admin1.Agents != nil { + t.Errorf("user entry must never carry group access lists, got providers=%v models=%v mcpServers=%v agents=%v", + admin1.Providers, admin1.Models, admin1.MCPServers, admin1.Agents) + } + if got.Total.Providers != nil || got.Total.Models != nil || got.Total.MCPServers != nil || got.Total.Agents != nil { + t.Errorf("total entry must never carry group access lists, got providers=%v models=%v mcpServers=%v agents=%v", + got.Total.Providers, got.Total.Models, got.Total.MCPServers, got.Total.Agents) + } +} + // TestLimiterCurrentUsage_StoreDown drives limiter.currentUsage's // fail-closed path directly: a configured store whose every operation // errors, with failOpen false, must report storeDown and zero values diff --git a/auth.go b/auth.go index cac3772..4d6a08a 100644 --- a/auth.go +++ b/auth.go @@ -334,10 +334,19 @@ type userSummary struct { // groupSummary mirrors userSummary for one configured group, plus its // current member count (how many active users, inline or file-sourced, -// currently reference it). +// currently reference it) and its configured access lists — providers, +// models, mcpServers, agents — carried through exactly as group.allowsX +// itself reads them (group-access-display task): nil/empty means every +// provider/model/MCP server/agent is allowed (matchesGlob's own contract, +// auth.go), never expanded to the full catalog here — the admin dashboard +// echoes the configured glob list, not a resolved membership set. type groupSummary struct { limits *LimitsConfig name string + providers []string + models []string + mcpServers []string + agents []string memberCount int } @@ -359,7 +368,15 @@ func (a *authStore) snapshot() ([]userSummary, []groupSummary) { groups := make([]groupSummary, 0, len(a.groups)) for name, grp := range a.groups { - groups = append(groups, groupSummary{limits: grp.limits, name: name, memberCount: memberCounts[name]}) + groups = append(groups, groupSummary{ + limits: grp.limits, + name: name, + providers: grp.providers, + models: grp.models, + mcpServers: grp.mcpServers, + agents: grp.agents, + memberCount: memberCounts[name], + }) } sort.Slice(groups, func(i, j int) bool { return groups[i].name < groups[j].name }) diff --git a/webui/src/components/UsageView.vue b/webui/src/components/UsageView.vue index ddbe42d..c2fb2fe 100644 --- a/webui/src/components/UsageView.vue +++ b/webui/src/components/UsageView.vue @@ -3,9 +3,11 @@ import type { SortingState } from '@tanstack/vue-table' import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table' import { computed, reactive, ref, watch } from 'vue' +import ModelChip from '@/components/ModelChip.vue' import SearchInput from '@/components/SearchInput.vue' import SortHeaderButton from '@/components/SortHeaderButton.vue' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { valueUpdater } from '@/components/ui/table' import UsageTable from '@/components/UsageTable.vue' @@ -284,6 +286,37 @@ watch(userQuery, (value) => { + +
+
+

Providers

+
+ + {{ providerName }} + +
+

All providers

+
+
+

Models

+
+ +
+

All models

+
+
Limits
diff --git a/webui/src/types/api.ts b/webui/src/types/api.ts index 89db6af..d8a394d 100644 --- a/webui/src/types/api.ts +++ b/webui/src/types/api.ts @@ -67,6 +67,19 @@ export interface AdminUsageEntryView { kind: string id: string groupName?: string + /** + * The group's configured access lists (admin.go: adminUsageEntryView's + * Providers/Models/MCPServers/Agents) — set only on a group entry (kind + * === 'group'), never on a user or the total entry, mirroring groupName's + * own user-only convention above. Undefined/empty means unrestricted + * (GroupConfig's own empty-means-all semantics, llmgateway.go) — this is + * the group's configured glob list exactly as written, never expanded to + * the full provider/model catalog. + */ + providers?: string[] + models?: string[] + mcpServers?: string[] + agents?: string[] requestsPerMinute: number requestsPerDay: number tokensInPerDay: number