Staff roles (viewer, operator, billing) and the console's Audit view

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-04 16:30:56 +02:00
1 parent f8fcecd640
commit b60cf45adf
19 files changed
+352 -24

No files matched your search

+7 -1
View File
@@ -372,7 +372,13 @@ portal is not done — the page shows; the console and ctl change.
## C. Features, ranked by value × feasibility
1. **Audit log and roles (M).** Today "audit" is slog lines
1. **Done (branch `features-c`):** `audit_events`, written by the admin
API middleware and the /admin pages (with each change's result), `ctl
audit`, the console's Audit view; staff roles viewer / operator /
billing / admin. Left: before/after values (the request's fields are
recorded, not the state they replaced); a tenant-admin role that changes
its own organization (managers still only view it); hiding the forms a
viewer cannot submit. **Audit log and roles (M).** Today "audit" is slog lines
(`internal/web/admin_users.go:403-480`) and roles are the `IsAdmin` flag
plus an org "manager" who can view. Add `audit_events` (who, what,
before/after, source) written by the `adminAction`/`tenancyAction`
+14
View File
@@ -23,6 +23,20 @@ APIs change between minors without a deprecation period.
## Unreleased
### Administration
- **An audit log.** Every change made through the admin API (`llmgw ctl`,
the console) or the pages under /admin is recorded: who, when, through
what, the fields it was given with passwords, keys and tokens taken out,
and how it ended — refusals included, in words. So are a person's own new
keys, revoked keys and password changes. Read it with `llmgw ctl audit`
(`--since`, `--actor`, `--contains`, `--detail`), `GET /api/v1/audit`, or
the console's **Audit** view. It moves with the store.
- **Staff roles** besides administrator: `viewer` looks at every
administration page and changes nothing, `operator` also runs the cluster
from them, `billing` sees usage and quotas. Set with `llmgw users role` or
the console's **Staff role…**.
### Guardrails
- **Hooks for guardrails on the request path** (nothing judges yet; B2 adds
+14
View File
@@ -160,6 +160,20 @@ project, their limits, a new password (their sessions close), delete.
issues one; the key is shown once, in the result, and never again. On a row:
its limits, or revoke it.
**Accounts** rows also have **Staff role…**:
- `viewer` looks at every page under /admin and changes nothing;
- `operator` also runs the cluster from them (models through the mirror, a
node's engines);
- `billing` sees usage and quotas;
- `admin` may do anything.
The STAFF column shows each person's role.
**Audit** lists every change of the last week — through `ctl`, this console
or the /admin pages, and people's own keys and passwords — with who made
it, through what, and how it ended. Enter shows the fields it was given,
with anything secret taken out. `llmgw ctl audit` is the same, with filters.
**Organizations** — the tenancy tree: organizations, their teams, the teams'
projects, with members, keys and limits. **A** adds an organization. On a
row: add a team or project inside it, rename, move, limits for the level and
+14
View File
@@ -649,3 +649,17 @@ func TestGuardrailsSayWhereTheyApplyFrom(t *testing.T) {
t.Error("no Guardrails… in the menu")
}
}
func TestTheAuditViewListsChangesAndOpensOne(t *testing.T) {
m, _ := consoleWith(t, cAudit)
m.width, m.height = 160, 40
m.data.audit = []store.AuditEvent{{At: time.Now(), Actor: "ilker", Source: "api",
Action: "POST /api/v1/models", Target: "qwen", Status: 200, Detail: `{"name":"qwen"}`}}
if v := m.View(); !strings.Contains(v, "POST /api/v1/models") || !strings.Contains(v, "ilker") {
t.Fatalf("not listed:\n%s", v)
}
tm := press(m, "enter")
if v := tm.View(); !strings.Contains(v, `"name":"qwen"`) {
t.Errorf("not opened:\n%s", v)
}
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"fmt"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The audit view: who changed what, newest first — the same as `llmgw ctl
// audit`.
func (m consoleModel) auditPanel(pw int) (string, string, string) {
keys := "→ or Enter open · ↑↓ choose · Q leave"
if m.inPanel {
keys = "↑↓ row · Enter what it was given · ← back · Q leave"
}
head := "\n " + sDim.Render("Every change through ctl, this console and the /admin pages, the last week.") + "\n\n"
var rows [][]string
for _, e := range m.data.audit {
status := strconv.Itoa(e.Status)
if e.Status >= 400 {
status = sBad.Render(status)
}
rows = append(rows, []string{ageShort(e.At), e.Actor, e.Source, e.Action, dash(e.Target), status})
}
if len(rows) == 0 {
return "Audit", head + " " + sDim.Render("Nothing changed in the last week, or this control plane has no store."), keys
}
return "Audit", head + table([]string{"WHEN", "WHO", "VIA", "WHAT", "TARGET", "STATUS"}, rows, m.row, pw, m.inPanel), keys
}
// auditDialog is one change in full.
func auditDialog(e store.AuditEvent) (string, string) {
var b strings.Builder
fmt.Fprintf(&b, "%s, by %s through %s\nstatus %d\n", e.At.Format(time.DateTime), e.Actor, e.Source, e.Status)
if e.Target != "" {
fmt.Fprintf(&b, "target %s\n", e.Target)
}
if e.Detail != "" && e.Detail != "{}" {
fmt.Fprintf(&b, "\n%s\n", wrap(e.Detail, 80, ""))
}
b.WriteString("\n" + sDim.Render("any key"))
return e.Action, b.String()
}
+12 -1
View File
@@ -24,6 +24,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/docgen"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The console: what a bare `sudo llmgw` opens on a control plane. Down the
@@ -46,6 +47,7 @@ const (
cAccounts
cKeys
cOrgs
cAudit
cManual
cSettings
cAddNode
@@ -58,7 +60,7 @@ const (
cCount
)
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Manual",
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Audit", "Manual",
"server.yaml", "Add a node", "Replicate the store", "Serve a model", "Upgrade the cluster",
"Restart all replicas", "Back up now", "Leave"}
@@ -75,6 +77,7 @@ type consoleData struct {
models []adminapi.ModelView
replicas []adminapi.ReplicaView
jobs []modelserve.Job
audit []store.AuditEvent
err error
}
@@ -228,6 +231,7 @@ func (m consoleModel) fetch() tea.Cmd {
_ = c.get(ctx, "/api/v1/models", &d.models)
_ = c.get(ctx, "/api/v1/replicas", &d.replicas)
_ = c.get(ctx, "/api/v1/jobs", &d.jobs)
_ = c.get(ctx, "/api/v1/audit?since=7d&limit=300", &d.audit)
return consoleDataMsg(d)
}
}
@@ -474,6 +478,9 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case cJobs:
m.dialog = "error"
m.dialogTitle, m.dialogText = jobDialog(m.data.jobs[m.row])
case cAudit:
m.dialog = "error"
m.dialogTitle, m.dialogText = auditDialog(m.data.audit[m.row])
case cServe:
return m, m.fetchRepo(m.hf.models[m.row].ID)
case cAccounts:
@@ -873,6 +880,8 @@ func (m consoleModel) rowCount() int {
return len(m.mirrorGroups())
case cJobs:
return len(m.data.jobs)
case cAudit:
return len(m.data.audit)
case cServe:
return len(m.hf.models)
case cAccounts:
@@ -1108,6 +1117,8 @@ func (m consoleModel) panel() (title, body, keys string) {
return m.mirrorPanel(pw)
case cJobs:
return m.jobsPanel(pw)
case cAudit:
return m.auditPanel(pw)
case cMonitor:
return m.monitorPanel(pw)
case cServe:
+28 -10
View File
@@ -204,11 +204,10 @@ func (m consoleModel) userMenu(u store.User) *menuDialog {
return nil
}})
}
if u.IsAdmin {
items = append(items, cli("Take away the cluster dashboard", "users", "demote", u.ID))
} else {
items = append(items, cli("Give the cluster dashboard (/admin)", "users", "promote", u.ID))
}
items = append(items, menuItem{"Staff role (" + or(u.StaffRole(), "none") + ")…", func(m *consoleModel) tea.Cmd {
m.menuD = staffRoleMenu(u)
return nil
}})
items = append(items,
menuItem{"Add to an organization, team or project…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Membership for " + who,
@@ -264,6 +263,28 @@ func (m consoleModel) userMenu(u store.User) *menuDialog {
return &menuDialog{title: who, items: items}
}
// staffRoleMenu sets what a person may do on the pages under /admin.
func staffRoleMenu(u store.User) *menuDialog {
about := map[string]string{
store.RoleViewer: "looks at every administration page, changes nothing",
store.RoleOperator: "also runs the cluster: models, the mirror, nodes",
store.RoleBilling: "sees usage and quotas",
store.RoleAdmin: "may do anything",
"none": "no part in running the gateway",
}
var items []menuItem
for _, role := range append(slices.Clone(store.StaffRoles), "none") {
label := role + " — " + about[role]
if role == or(u.StaffRole(), "none") {
label = "✓ " + label
}
items = append(items, menuItem{label, func(m *consoleModel) tea.Cmd {
return m.runCLI("Staff role for "+u.Email, "users", "role", u.ID, role)
}})
}
return &menuDialog{title: "Staff role · " + u.Email, items: items}
}
func (m *consoleModel) createUserForm() {
m.openForm(&formDialog{title: "New account",
note: "Created already approved. The password should be changed by its owner.",
@@ -565,16 +586,13 @@ func (m consoleModel) peoplePanel(pw int) (string, string, string) {
var rows [][]string
switch m.menu {
case cAccounts:
head = []string{"EMAIL", "STATE", "ADMIN", "MEMBER OF", "SIGNED UP", "LAST LOGIN"}
head = []string{"EMAIL", "STATE", "STAFF", "MEMBER OF", "SIGNED UP", "LAST LOGIN"}
for _, u := range m.people.users {
state := string(u.State)
if u.State == store.UserPending {
state = sWarn.Render(state)
}
admin := ""
if u.IsAdmin {
admin = "yes"
}
admin := u.StaffRole()
where := strings.Join(m.people.where[u.ID], "; ")
if where == "" {
where = sDim.Render("on their own")
+38 -1
View File
@@ -37,7 +37,8 @@ func newUsersCmd() *cobra.Command {
newUserCreateCmd(open), newUserResetPasswordCmd(open),
newUserJoinCmd(open), newUserLeaveCmd(open), newUserDeleteCmd(open),
newUserAdminCmd(open, "promote", true, "Let an account see the cluster dashboard at /admin"),
newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard"))
newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard"),
newUserRoleCmd(open))
return cmd
}
@@ -177,6 +178,42 @@ func newUserStateCmd(open userOpener, verb string, state store.UserState, short
}
}
func newUserRoleCmd(open userOpener) *cobra.Command {
return &cobra.Command{
Use: "role <user> <viewer|operator|billing|admin|none>",
Short: "Give an account a part in running the gateway, from the pages under /admin",
Long: "viewer looks at every administration page and changes nothing. operator\n" +
"also runs the cluster from them: models through the mirror, a node's\n" +
"engines. billing sees usage and quotas. admin may do anything. none takes\n" +
"the role away. Every change made from those pages is in `llmgw ctl audit`.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
role := args[1]
if role == "none" {
role = ""
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
u, err := resolveUser(cmd.Context(), s, args[0])
if err != nil {
return err
}
if err := s.SetStaffRole(cmd.Context(), u.ID, role); err != nil {
return err
}
if role == "" {
fmt.Fprintf(cmd.OutOrStdout(), "%s has no part in running the gateway now\n", u.Email)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", u.Email, role)
}
return nil
},
}
}
func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *cobra.Command {
return &cobra.Command{
Use: verb + " <user>",
+21
View File
@@ -32,3 +32,24 @@ func TestAuditEventsAreKeptAndFound(t *testing.T) {
t.Errorf("since: %d", len(got))
}
}
func TestAStaffRoleIsGivenAndTakenAway(t *testing.T) {
db := capacityStore(t)
ctx := t.Context()
if err := db.CreateUser(ctx, User{ID: "u1", Email: "v@example.com", Name: "v", PasswordHash: "x",
State: UserActive, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
for _, role := range []string{RoleViewer, RoleOperator, RoleBilling, RoleAdmin, ""} {
if err := db.SetStaffRole(ctx, "u1", role); err != nil {
t.Fatal(role, err)
}
u, err := db.UserByID(ctx, "u1")
if err != nil || u.StaffRole() != role || u.Staff() != (role != "") || u.IsAdmin != (role == RoleAdmin) {
t.Errorf("%q: %+v %v", role, u, err)
}
}
if db.SetStaffRole(ctx, "u1", "god") == nil {
t.Error("an unknown role was taken")
}
}
@@ -0,0 +1,5 @@
-- Ported from the SQLite migration of the same number.
-- What a person may do on the administration pages besides administering:
-- viewer (look at everything), operator (and run the cluster: models,
-- mirror, nodes), billing (usage and quotas). Administrators keep is_admin.
ALTER TABLE users ADD COLUMN staff_role TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,4 @@
-- What a person may do on the administration pages besides administering:
-- viewer (look at everything), operator (and run the cluster: models,
-- mirror, nodes), billing (usage and quotas). Administrators keep is_admin.
ALTER TABLE users ADD COLUMN staff_role TEXT NOT NULL DEFAULT '';
+2 -2
View File
@@ -58,7 +58,7 @@ func (s *DB) SessionByToken(ctx context.Context, token string) (Session, User, e
row := s.db.QueryRowContext(ctx,
`SELECT s.user_id, s.csrf_hash, s.expires_at, s.last_seen,
u.id, u.email, u.name, u.password_hash, u.state, COALESCE(u.project_id, ''),
u.created_at, u.approved_at, u.approved_by, u.last_login_at, u.is_admin
u.created_at, u.approved_at, u.approved_by, u.last_login_at, u.is_admin, u.staff_role
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?`, HashKey(token))
@@ -72,7 +72,7 @@ func (s *DB) SessionByToken(ctx context.Context, token string) (Session, User, e
if err := row.Scan(&sess.UserID, &sess.CSRFHash, &expires, &lastSeen,
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &state, &u.ProjectID,
&created, &approved, &approvedBy, &lastLogin, &u.IsAdmin); err != nil {
&created, &approved, &approvedBy, &lastLogin, &u.IsAdmin, &u.Role); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Session{}, User{}, ErrNoSession
}
+53 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
@@ -42,9 +43,36 @@ type User struct {
ApprovedAt time.Time
ApprovedBy string
LastLoginAt time.Time
// IsAdmin lets this account see the cluster dashboard. It is not a role
// system: one bit, and everything that changes the cluster stays on the CLI.
// IsAdmin lets this account administer: every page under /admin, and
// every change there.
IsAdmin bool
// Role is a lesser part in running the gateway: RoleViewer looks at
// every administration page, RoleOperator also runs the cluster from them
// (models, the mirror, nodes), RoleBilling sees usage and quotas. Empty
// for everybody else.
Role string
}
// Staff roles.
const (
RoleViewer = "viewer"
RoleOperator = "operator"
RoleBilling = "billing"
RoleAdmin = "admin"
)
// StaffRoles are the roles SetStaffRole takes; "" takes them all away.
var StaffRoles = []string{RoleViewer, RoleOperator, RoleBilling, RoleAdmin}
// Staff reports whether a person has any part in running the gateway.
func (u User) Staff() bool { return u.IsAdmin || u.Role != "" }
// StaffRole is the role a person holds, admin included.
func (u User) StaffRole() string {
if u.IsAdmin {
return RoleAdmin
}
return u.Role
}
// CanSignIn reports whether this account may open a session.
@@ -58,7 +86,7 @@ func NormalizeEmail(email string) string {
}
const userColumns = `id, email, name, password_hash, state, COALESCE(project_id, ''),
created_at, approved_at, approved_by, last_login_at, is_admin`
created_at, approved_at, approved_by, last_login_at, is_admin, staff_role`
// CreateUser records a new, unapproved account.
func (s *DB) CreateUser(ctx context.Context, u User) error {
@@ -192,6 +220,27 @@ func (s *DB) SetAdmin(ctx context.Context, id string, admin bool) error {
return nil
}
// SetStaffRole gives a person a part in running the gateway — viewer,
// operator, billing or admin — or, with "", none.
func (s *DB) SetStaffRole(ctx context.Context, id, role string) error {
admin, rest := 0, role
switch role {
case RoleAdmin:
admin, rest = 1, ""
case "", RoleViewer, RoleOperator, RoleBilling:
default:
return fmt.Errorf("role %q is not one of viewer, operator, billing, admin", role)
}
res, err := s.db.ExecContext(ctx, `UPDATE users SET is_admin = ?, staff_role = ? WHERE id = ?`, admin, rest, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchLogin records a successful sign-in.
func (s *DB) TouchLogin(ctx context.Context, id string, at time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`,
@@ -252,7 +301,7 @@ func (s *DB) scanUser(sc scanner) (User, error) {
var approvedBy sql.NullString
if err := sc.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &state, &u.ProjectID,
&created, &approved, &approvedBy, &lastLogin, &u.IsAdmin); err != nil {
&created, &approved, &approvedBy, &lastLogin, &u.IsAdmin, &u.Role); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
+1 -1
View File
@@ -20,7 +20,7 @@ func (p *AccountPages) AdminPage(dashboard http.HandlerFunc) http.HandlerFunc {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !user.IsAdmin {
if !staffMay(user, r) {
http.NotFound(w, r)
return
}
+1 -1
View File
@@ -442,7 +442,7 @@ func (p *AccountPages) adminOnly(
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !user.IsAdmin {
if !staffMay(user, r) {
// 404 rather than 403: a 403 confirms the page exists.
http.NotFound(w, r)
return
+5 -2
View File
@@ -17,8 +17,10 @@ type Nav struct {
// Current is the page being shown, so its link is marked rather than
// offered: "cluster", "people", "quotas", "usage", "mirror", "chat", "keys".
Current string
// Admin shows the administration links.
// Admin shows the administration links, and Role which of them: a
// billing role sees usage and quotas only.
Admin bool
Role string
// Mirror and Quotas are whether those pages were mounted at all; usage is
// mounted with quotas.
Mirror, Quotas bool
@@ -37,7 +39,8 @@ type navKey struct{}
func (p *AccountPages) navFor(r *http.Request, user store.User, current string) Nav {
n := Nav{
Current: current,
Admin: user.IsAdmin,
Admin: user.Staff(),
Role: user.StaffRole(),
Mirror: p.hasMirror,
Quotas: p.hasQuotas,
Guard: p.hasGuard,
+33
View File
@@ -0,0 +1,33 @@
package web
import (
"net/http"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// staffMay reports whether a person may do this to an administration page.
//
// An administrator may do anything. A viewer may look at every page and
// change nothing. An operator may also run the cluster from them: models
// through the mirror, and a node's engines. Billing may look at usage and
// quotas. Everything that touches people, the tree, limits or keys stays
// with administrators.
func staffMay(u store.User, r *http.Request) bool {
if u.IsAdmin {
return true
}
read := r.Method == http.MethodGet || r.Method == http.MethodHead
path := r.URL.Path
switch u.Role {
case store.RoleViewer:
return read
case store.RoleOperator:
return read || strings.HasPrefix(path, "/admin/mirror/") ||
strings.HasPrefix(path, "/admin/nodes/") && strings.HasSuffix(path, "/engines")
case store.RoleBilling:
return read && (path == "/admin/usage" || path == "/admin/quotas")
}
return false
}
+48
View File
@@ -0,0 +1,48 @@
package web
import (
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func TestEachStaffRoleMayDoWhatItSays(t *testing.T) {
cases := []struct {
role, method, path string
may bool
}{
{store.RoleViewer, "GET", "/admin/users", true},
{store.RoleViewer, "POST", "/admin/users/u1/suspend", false},
{store.RoleViewer, "POST", "/admin/mirror/fetch", false},
{store.RoleOperator, "GET", "/admin", true},
{store.RoleOperator, "POST", "/admin/mirror/fetch", true},
{store.RoleOperator, "POST", "/admin/nodes/n1/engines", true},
{store.RoleOperator, "POST", "/admin/quotas", false},
{store.RoleOperator, "POST", "/admin/tenancy/org", false},
{store.RoleBilling, "GET", "/admin/usage", true},
{store.RoleBilling, "GET", "/admin/quotas", true},
{store.RoleBilling, "GET", "/admin/users", false},
{store.RoleBilling, "POST", "/admin/quotas", false},
{"", "GET", "/admin", false},
}
for _, c := range cases {
u := store.User{Role: c.role}
if got := staffMay(u, httptest.NewRequest(c.method, c.path, nil)); got != c.may {
t.Errorf("%s %s %s: %v", c.role, c.method, c.path, got)
}
}
if !staffMay(store.User{IsAdmin: true}, httptest.NewRequest("POST", "/admin/users/u1/delete", nil)) {
t.Error("an administrator may do anything")
}
}
func TestABillingRoleIsShownUsageAndQuotasOnly(t *testing.T) {
rec := httptest.NewRecorder()
render(rec, "admin_guard", guardPageData{Nav: Nav{Admin: true, Role: store.RoleBilling}})
body := rec.Body.String()
if !strings.Contains(body, `href="/admin/usage"`) || strings.Contains(body, `href="/admin/users"`) {
t.Errorf("menu: %s", body)
}
}
+5 -1
View File
@@ -6,7 +6,11 @@
<a href="/org"{{if eq .Current "org"}} aria-current="page"{{end}}>Organization</a>
<span class="menu-gap" aria-hidden="true"></span>
{{- end}}
{{- if .Admin}}
{{- if and .Admin (eq .Role "billing")}}
<a href="/admin/usage"{{if eq .Current "usage"}} aria-current="page"{{end}}>Usage</a>
<a href="/admin/quotas"{{if eq .Current "quotas"}} aria-current="page"{{end}}>Quotas</a>
<span class="menu-gap" aria-hidden="true"></span>
{{- else if .Admin}}
<a href="/admin"{{if eq .Current "cluster"}} aria-current="page"{{end}}>Cluster</a>
{{- /* Limits and usage are tabs of the people page, not pages of their own. */}}
<a href="/admin/users"{{if eq .Current "people"}} aria-current="page"{{end}}>People</a>