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:
1 parent
f8fcecd640
commit
b60cf45adf
19 files changed
+352
-24
No files matched your search
+7
-1
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>",
|
||||
|
||||
@@ -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 '';
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user