diff --git a/BACKLOG.md b/BACKLOG.md index 0562b49..9a4deb2 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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` diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca8b63..f7df86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/console.md b/docs/console.md index 92e86e2..ef24734 100644 --- a/docs/console.md +++ b/docs/console.md @@ -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 diff --git a/internal/cli/tui_actions_test.go b/internal/cli/tui_actions_test.go index 485974f..99e92fa 100644 --- a/internal/cli/tui_actions_test.go +++ b/internal/cli/tui_actions_test.go @@ -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) + } +} diff --git a/internal/cli/tui_audit.go b/internal/cli/tui_audit.go new file mode 100644 index 0000000..e505d3a --- /dev/null +++ b/internal/cli/tui_audit.go @@ -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() +} diff --git a/internal/cli/tui_console.go b/internal/cli/tui_console.go index 6d2fbae..2c1097c 100644 --- a/internal/cli/tui_console.go +++ b/internal/cli/tui_console.go @@ -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: diff --git a/internal/cli/tui_people.go b/internal/cli/tui_people.go index 34c071d..4bd33f4 100644 --- a/internal/cli/tui_people.go +++ b/internal/cli/tui_people.go @@ -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") diff --git a/internal/cli/users.go b/internal/cli/users.go index 43fb597..17c9191 100644 --- a/internal/cli/users.go +++ b/internal/cli/users.go @@ -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 ", + 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 + " ", diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go index a855307..3a61341 100644 --- a/internal/store/audit_test.go +++ b/internal/store/audit_test.go @@ -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") + } +} diff --git a/internal/store/migrations/postgres/0021_staff_role.sql b/internal/store/migrations/postgres/0021_staff_role.sql new file mode 100644 index 0000000..7634ad2 --- /dev/null +++ b/internal/store/migrations/postgres/0021_staff_role.sql @@ -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 ''; diff --git a/internal/store/migrations/sqlite/0021_staff_role.sql b/internal/store/migrations/sqlite/0021_staff_role.sql new file mode 100644 index 0000000..edd1cd9 --- /dev/null +++ b/internal/store/migrations/sqlite/0021_staff_role.sql @@ -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 ''; diff --git a/internal/store/sessions.go b/internal/store/sessions.go index 7b70868..9df041d 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -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 } diff --git a/internal/store/users.go b/internal/store/users.go index 72bd5a1..ca5b90f 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -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 } diff --git a/internal/web/admin_page.go b/internal/web/admin_page.go index e3ade09..eb76dc4 100644 --- a/internal/web/admin_page.go +++ b/internal/web/admin_page.go @@ -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 } diff --git a/internal/web/admin_users.go b/internal/web/admin_users.go index cba7456..f87673f 100644 --- a/internal/web/admin_users.go +++ b/internal/web/admin_users.go @@ -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 diff --git a/internal/web/nav.go b/internal/web/nav.go index 92cb4ce..5bf7d08 100644 --- a/internal/web/nav.go +++ b/internal/web/nav.go @@ -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, diff --git a/internal/web/roles.go b/internal/web/roles.go new file mode 100644 index 0000000..aaf378d --- /dev/null +++ b/internal/web/roles.go @@ -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 +} diff --git a/internal/web/roles_test.go b/internal/web/roles_test.go new file mode 100644 index 0000000..b66c5c7 --- /dev/null +++ b/internal/web/roles_test.go @@ -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) + } +} diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html index 193d250..0b2b43f 100644 --- a/internal/web/templates/nav.html +++ b/internal/web/templates/nav.html @@ -6,7 +6,11 @@ Organization {{- end}} -{{- if .Admin}} +{{- if and .Admin (eq .Role "billing")}} + Usage + Quotas + +{{- else if .Admin}} Cluster {{- /* Limits and usage are tabs of the people page, not pages of their own. */}} People