From 9ae870e6ce058c282ad98c2ca94b9486494a2317 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 12:00:56 +0200 Subject: [PATCH 01/38] docs(specs): admin dashboard for optional user management (sub-project C) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- .../2026-10-07-admin-dashboard-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/specs/2026-10-07-admin-dashboard-design.md diff --git a/docs/specs/2026-10-07-admin-dashboard-design.md b/docs/specs/2026-10-07-admin-dashboard-design.md new file mode 100644 index 000000000..0c19091f3 --- /dev/null +++ b/docs/specs/2026-10-07-admin-dashboard-design.md @@ -0,0 +1,203 @@ +# Optional User Management, Sub-project C: Admin Dashboard, Design Spec + +**Status:** approved in conversation 2026-10-07, written for review. +**Builds on:** sub-project A (`docs/specs/2026-10-06-user-management-design.md`) and B +(`docs/specs/2026-10-06-user-settings-sync-design.md`), both merged (#2129, #2130). +Roadmap: #2128, part C. + +## Problem + +An admin can manage users one by one in `#/admin/users`, but nothing shows the state of +the instance at a glance: how many people registered, who is stuck in activation, whose +mail bounces, whether someone is guessing a password, whether an MQTT source dropped. +The audit log exists (`internal/users/audit.go`) but can only be read per user, and +logins are not recorded in it. + +## Goals + +1. One admin area with an overview that answers "does anything need my attention?" and + links straight to the user or audit entries behind each item. +2. A global, filterable audit log, including logins. +3. A compact system status (server, MQTT sources, observers) next to the user figures. +4. With user management off, nothing changes. Logged-out visitors and non-admins see no + new UI, and the new endpoints refuse them. + +## Non-goals + +- Restricting existing pages (perf, MQTT status, others) to logged-in users or admins. + This is a likely next step; this design keeps it possible (see "Prepared for page + restriction") but does not build it. +- Changing the public perf, health and MQTT endpoints. +- Role-aware customizer tabs. `customizer.disabledTabs` (#1508, `config.go:213-218`) + stays as it is. +- Statistics about analyzer data (packets, nodes): those pages exist already. + +## Decisions (from the design conversation) + +| # | Topic | Decision | +|---|---|---| +| 1 | Purpose | Operator overview first. Page restriction later, out of scope here. | +| 2 | Scope | User figures, global audit log (with logins), and system status, all three. | +| 3 | Login audit | Successful and failed logins, no IP address. Login rows are deleted after 90 days; other audit actions are kept as today. | +| 4 | Structure | One admin page with tabs: Overview, Users, Audit (`#/admin?tab=...`). User figures and audit through two new admin endpoints; system status composed in the browser from the existing endpoints. | + +## Architecture + +``` +public/admin.js tab shell for page 'admin' (#/admin?tab=overview|users|audit) +public/admin-overview.js Overview tab: attention list, Users card, System card +public/admin-audit.js Audit tab: filters, table, "Load more" +public/admin-users.js existing user table, mounted as the Users tab + │ GET /api/admin/stats, GET /api/admin/audit (withAdmin) + │ GET /api/health, /api/healthz, /api/mqtt/status, /api/observers (existing, public) +cmd/server/admin_stats_handlers.go, admin_audit_handlers.go +internal/users: Stats(now), AuditList(filter), PruneAudit(actions, maxAge), schema v3 +``` + +`cmd/server` keeps writing only `users.db` through `internal/users` (AGENTS.md read/write +separation). + +## Overview tab + +### "Needs attention" + +Shown only when at least one item applies. Each item links to a filtered tab. + +| Item | Rule | Link | +|---|---|---| +| Stuck activations | pending accounts created more than 24 hours ago | Users, `status=pending` | +| Mail problems | users with `email_bouncing` set | Users, `bouncing=1` (a new Users filter) | +| Password guessing | accounts with 5 or more `user.login.failed` rows in the last 24 hours | Audit, `action=user.login.failed&user=` | +| MQTT source down | a source in `/api/mqtt/status` that is not connected, or whose last message is older than 10 minutes | the existing MQTT status panel | + +The 24-hour, 5-attempt and 10-minute thresholds are constants in this version (AGENTS.md +rule 8: they belong in the customizer or config later; noted for a follow-up). + +### Users card (`GET /api/admin/stats`) + +- Total accounts; by status (active, pending, disabled); number of admins. +- New registrations in the last 7 and 30 days, plus a per-day count for the last 30 days + drawn as a small bar chart. +- Active users in the last 7 and 30 days: `last_login_at` or a session `last_seen_at` + inside the window, counted once per user. +- Logins and failed logins in the last 24 hours (from the audit log). +- Mail sent in the last 7 days by final status: delivered, bounced (hard and soft), + blocked, spam, pending (no event yet). + +### System card (existing endpoints, read in the browser) + +- Version, commit and uptime from `/api/health`; ready or warming up from `/api/healthz`. +- MQTT sources from `/api/mqtt/status`: connected or not, and the age of the last message. +- Observers online and total from `/api/observers`. +- Links to the existing Perf page and MQTT status panel. + +### Refresh + +On open, with a "Refresh" button, and every 60 seconds while the tab is visible. + +## Audit tab + +### Logging logins (`cmd/server/auth_handlers.go`, through the existing `a.audit`) + +- `user.login`: a successful login; target is the user. +- `user.login.failed`: a failed login for an existing account; target is that account; + detail `reason` is `wrong_password`, `pending` or `disabled`. +- A failed login for an unknown address writes no row, so typed addresses of + non-users are never stored. +- Logout is not logged. +- The login response stays identical in all failure cases (A's enumeration rule); the + audit write happens after the response is decided and its failure is logged, not + returned. + +### Retention + +The hourly janitor (`auth_service.go`) calls `PruneAudit([]string{"user.login", +"user.login.failed"}, 90 days)`. Other actions are kept. + +### Store (`internal/users`) + +- Schema v3: `CREATE INDEX audit_at ON audit_log(at)`. +- `AuditList(f AuditFilter) ([]AuditEntry, error)`, where `AuditFilter` has `Actions + []string`, `UserID *int64` (matches actor or target), `From`, `To *time.Time`, + `BeforeID int64` (keyset: only rows with `id < BeforeID`) and `Limit int` (default 100, + max 500). Newest first. +- `PruneAudit(actions []string, maxAge time.Duration) (int64, error)`. +- `Stats(now time.Time) (Stats, error)`: the Users card figures as one typed struct. + +### API + +- `GET /api/admin/stats` → the `Stats` struct (typed, no `map[string]interface{}`). +- `GET /api/admin/audit?action=&user=&from=&to=&before=&limit=` → `{entries:[...], + next: }`. Each entry: id, time, action, actor and target as `{id, + displayName, email}` looked up in `users`, or `{id, deleted: true}` when the account is + gone, and `detail`. `action` accepts one action or a group prefix ending in `.*` + (`user.login.*`). Invalid parameters give 400. +- Both routes: `withAdmin`, registered only when user management is on, OpenAPI entries + under the `users` tag. + +### Tab + +Filters (action or group, user, period), a table newest first, "Load more" using +`next`. Filters live in the URL (`#/admin?tab=audit&action=user.login.failed&user=12`). +A user name opens that user in the Users tab. + +## Admin area and navigation + +- Page `admin`, tabs Overview (default), Users, Audit at `#/admin?tab=...`. Users keeps + its filters (`status`, `role`, `q`, `id`, plus the new `bouncing`) next to `tab=users`. +- `#/admin/users?...` keeps working: it rewrites to `#/admin?tab=users&...` with + `history.replaceState`. +- Account menu: "Users" becomes "Admin" (Overview). The account page button "Manage + users" becomes "Admin". On phones the existing account entry in the More sheet and + drawer leads to the account page, which carries the button. +- Non-admins opening the URL see "Admins only"; the server refuses their requests. + +### Prepared for page restriction + +The admin area adds no access mechanism of its own: it relies on the existing server-side +role check (`withAdmin`) and the client's `CSAuth.isAdmin()`. A later "page X only for +logged-in users or admins" can then be one router rule plus endpoint enforcement, without +changing this dashboard. + +## Error handling + +- A card whose source fails shows "Could not load" with a retry button; the other cards + keep working. +- 401 and 403 follow the existing paths (logged out, or "Admins only"). +- Invalid filters in the URL are ignored, the same way the Users tab's `readHash` + validation does it. + +## Security + +- Both endpoints are admin-only and read-only. +- Every value rendered in HTML goes through `escapeHtml`, including audit details. +- No addresses of non-users are stored; no new personal data in the server log. + +## Performance + +`Stats` is a handful of `COUNT` queries on `users.db` (rows per account, no analyzer +data). The audit list is keyset-paginated with an index on `at` and on actor/target +(existing). The System card reuses endpoints that are already cached server-side. No +analyzer hot path is touched. + +## Testing + +**Go.** +- `internal/users`: `AuditList` filters (action, group prefix, user as actor or target, + period) and keyset pagination; `PruneAudit` removes only the listed actions older than + the age; migration v2 → v3 on an existing database and a fresh one; `Stats` against a + fixed dataset with every counter checked. +- `cmd/server`: `/api/admin/stats` and `/api/admin/audit` (filters, limit cap, deleted + user, 400 on bad parameters, 403 for a non-admin, 401 without session); a successful + login writes `user.login`, a wrong password writes `user.login.failed` with the reason, + an unknown address writes nothing, and the login responses stay identical; feature + off: routes absent; OpenAPI completeness. + +**Frontend unit** (vm, real modules). Attention items derived from fixed data; +tab and filter state to URL and back; the `#/admin/users` rewrite; XSS payload in audit +details and user names rendered inert; a failing source shows its error only in its +own card. + +**Playwright** (e2etest build). An admin opens the overview and sees the user figures and +a failed login in "Needs attention", follows it to the audit tab and sees the row; the +old `#/admin/users` link lands on the Users tab. Axe on all three tabs. From e5971e82a74b44261381ea771d9b7e8ad1b84a07 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 14:09:34 +0200 Subject: [PATCH 02/38] docs(specs): admin dashboard amendments from the implementation plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- .../2026-10-07-admin-dashboard-design.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/specs/2026-10-07-admin-dashboard-design.md b/docs/specs/2026-10-07-admin-dashboard-design.md index 0c19091f3..d83cf63b7 100644 --- a/docs/specs/2026-10-07-admin-dashboard-design.md +++ b/docs/specs/2026-10-07-admin-dashboard-design.md @@ -201,3 +201,24 @@ own card. **Playwright** (e2etest build). An admin opens the overview and sees the user figures and a failed login in "Needs attention", follows it to the audit tab and sees the row; the old `#/admin/users` link lands on the Users tab. Axe on all three tabs. + +## Amendments from the implementation plan (approved 2026-10-07) + +These rules override the sections above where they differ. + +1. **Registrations** in the Users card are counted from `user.register` audit rows, not + from `users.created_at`: the janitor prunes pending accounts after 48 hours, so the + users table forgets them. +2. **Mail buckets** add a sixth bucket, `other`, for error and unknown events. +3. **Admins** counts active admins only. +4. **The attention rules are computed by the server** and returned in the stats + (`stuckPending`, `bouncing`, `guessing`); the browser only adds the MQTT rule from + `/api/mqtt/status`. A source that never delivered a message counts as down. The MQTT + link goes to `#/observers`, where the MQTT status panel lives. +5. **Audit entries** use `at` for the time, like the existing per-user audit JSON. +6. **`/api/healthz` is read on open and on "Refresh" only**, not on the 60-second timer: + it walks every packet under a read lock. The timer refreshes `/api/admin/stats`, + `/api/health`, `/api/mqtt/status` and `/api/observers`. +7. **Timing.** Writing a `user.login.failed` row for an existing account makes that + response marginally slower than one for an unknown address. Argon2 dominates the + response time, so this is accepted. From dd3bc4d86ee3fbcd6beff217dc320da785540c80 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 14:24:27 +0200 Subject: [PATCH 03/38] feat(users): global audit list with keyset paging, login-row pruning, schema v3 AuditList filters by action (exact or a ".*" group), user as actor or target, and an inclusive period, newest first, keyset-paginated on id (default 100, at most 500). PruneAudit deletes rows of the listed actions older than an age and keeps every other action. UsersByID resolves the users of a page in one query. Schema v3 adds an index on audit_log(at). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- internal/users/audit.go | 91 ++++++++++++++ internal/users/audit_list_test.go | 198 ++++++++++++++++++++++++++++++ internal/users/schema.go | 3 + internal/users/settings_test.go | 7 +- internal/users/users.go | 27 ++++ 5 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 internal/users/audit_list_test.go diff --git a/internal/users/audit.go b/internal/users/audit.go index c637a18a4..8b1780aae 100644 --- a/internal/users/audit.go +++ b/internal/users/audit.go @@ -3,6 +3,7 @@ package users import ( "database/sql" "encoding/json" + "strings" "time" ) @@ -16,6 +17,24 @@ type AuditEntry struct { Detail map[string]string } +// AuditListMax caps one AuditList page; auditListDefault applies when the +// filter sets no limit. +const ( + AuditListMax = 500 + auditListDefault = 100 +) + +// AuditFilter narrows AuditList. Zero values mean "any". +type AuditFilter struct { + // Actions matches any listed action. An entry ending in ".*" is a + // group: "user.login.*" matches "user.login" and "user.login.". + Actions []string + UserID *int64 // the user as actor or target + From, To *time.Time // both inclusive + BeforeID int64 // keyset paging: only rows with id < BeforeID; 0 = from the newest + Limit int // default 100, capped at AuditListMax +} + // Audit records an action. detail may be nil. func (s *Store) Audit(actor *int64, action string, target *int64, detail map[string]string) error { if detail == nil { @@ -40,6 +59,78 @@ func (s *Store) AuditFor(userID int64, limit int) ([]AuditEntry, error) { if err != nil { return nil, err } + return scanAudit(rows) +} + +// AuditList returns the entries matching f, newest (highest id) first. +func (s *Store) AuditList(f AuditFilter) ([]AuditEntry, error) { + q := `SELECT id, at, actor_user_id, action, target_user_id, detail FROM audit_log WHERE 1=1` + var args []any + if len(f.Actions) > 0 { + var ors []string + for _, a := range f.Actions { + if group, ok := strings.CutSuffix(a, ".*"); ok { + ors = append(ors, `action = ? OR action LIKE ? ESCAPE '\'`) + args = append(args, group, escapeLike(group)+".%") + } else { + ors = append(ors, `action = ?`) + args = append(args, a) + } + } + q += ` AND (` + strings.Join(ors, ` OR `) + `)` + } + if f.UserID != nil { + q += ` AND (actor_user_id = ? OR target_user_id = ?)` + args = append(args, *f.UserID, *f.UserID) + } + if f.From != nil { + q += ` AND at >= ?` + args = append(args, unix(*f.From)) + } + if f.To != nil { + q += ` AND at <= ?` + args = append(args, unix(*f.To)) + } + if f.BeforeID > 0 { + q += ` AND id < ?` + args = append(args, f.BeforeID) + } + limit := f.Limit + if limit <= 0 { + limit = auditListDefault + } + if limit > AuditListMax { + limit = AuditListMax + } + q += ` ORDER BY id DESC LIMIT ?` + args = append(args, limit) + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + return scanAudit(rows) +} + +// PruneAudit deletes rows of the listed actions older than maxAge. Rows of +// other actions are kept however old they are. +func (s *Store) PruneAudit(actions []string, maxAge time.Duration) (int64, error) { + if len(actions) == 0 { + return 0, nil + } + args := make([]any, 0, len(actions)+1) + for _, a := range actions { + args = append(args, a) + } + args = append(args, unix(s.now())-int64(maxAge/time.Second)) + ph := strings.TrimSuffix(strings.Repeat("?,", len(actions)), ",") + res, err := s.db.Exec(`DELETE FROM audit_log WHERE action IN (`+ph+`) AND at < ?`, args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func scanAudit(rows *sql.Rows) ([]AuditEntry, error) { defer rows.Close() var out []AuditEntry for rows.Next() { diff --git a/internal/users/audit_list_test.go b/internal/users/audit_list_test.go new file mode 100644 index 000000000..67559827e --- /dev/null +++ b/internal/users/audit_list_test.go @@ -0,0 +1,198 @@ +package users + +import ( + "database/sql" + "fmt" + "path/filepath" + "strings" + "testing" + "time" +) + +func mustAudit(t *testing.T, st *Store, actor *int64, action string, target *int64, detail map[string]string) { + t.Helper() + if err := st.Audit(actor, action, target, detail); err != nil { + t.Fatalf("Audit(%s): %v", action, err) + } +} + +func actionsOf(list []AuditEntry) string { + parts := make([]string, len(list)) + for i, e := range list { + parts[i] = e.Action + } + return strings.Join(parts, ",") +} + +func mustList(t *testing.T, st *Store, f AuditFilter) []AuditEntry { + t.Helper() + list, err := st.AuditList(f) + if err != nil { + t.Fatalf("AuditList(%+v): %v", f, err) + } + return list +} + +func TestAuditListFiltersAndPages(t *testing.T) { + st, clk := newTestStore(t) + base := clk.Now() + at := func(d time.Duration) { clk.t = base.Add(d) } + a := mustCreate(t, st, "a@example.org", "A") + b := mustCreate(t, st, "b@example.org", "B") + aid, bid := a.ID, b.ID + at(-48 * time.Hour) + mustAudit(t, st, nil, "user.register", &aid, nil) + at(-30 * time.Hour) + mustAudit(t, st, nil, "user.login", &aid, nil) + at(-2 * time.Hour) + mustAudit(t, st, nil, "user.login.failed", &bid, map[string]string{"reason": "wrong_password"}) + at(-1 * time.Hour) + mustAudit(t, st, &aid, "user.disable", &bid, nil) + at(0) + mustAudit(t, st, nil, "user.loginx", &aid, nil) // not in the user.login group + + from3h, from31h, to2h := base.Add(-3*time.Hour), base.Add(-31*time.Hour), base.Add(-2*time.Hour) + type listCase struct { + name string + f AuditFilter + want string + } + for _, c := range []listCase{ + {"all, newest first", AuditFilter{}, "user.loginx,user.disable,user.login.failed,user.login,user.register"}, + {"group", AuditFilter{Actions: []string{"user.login.*"}}, "user.login.failed,user.login"}, + {"exact", AuditFilter{Actions: []string{"user.login"}}, "user.login"}, + {"two actions", AuditFilter{Actions: []string{"user.disable", "user.register"}}, "user.disable,user.register"}, + {"user b as actor or target", AuditFilter{UserID: &bid}, "user.disable,user.login.failed"}, + {"user a as actor or target", AuditFilter{UserID: &aid}, "user.loginx,user.disable,user.login,user.register"}, + {"from", AuditFilter{From: &from3h}, "user.loginx,user.disable,user.login.failed"}, + {"from and inclusive to", AuditFilter{From: &from31h, To: &to2h}, "user.login.failed,user.login"}, + } { + if got := actionsOf(mustList(t, st, c.f)); got != c.want { + t.Errorf("%s: got %s; want %s", c.name, got, c.want) + } + } + + failed := mustList(t, st, AuditFilter{Actions: []string{"user.login.failed"}}) + if failed[0].Detail["reason"] != "wrong_password" || failed[0].TargetUserID == nil || *failed[0].TargetUserID != bid { + t.Fatalf("failed entry = %+v", failed[0]) + } + + p1 := mustList(t, st, AuditFilter{Limit: 2}) + p2 := mustList(t, st, AuditFilter{Limit: 2, BeforeID: p1[1].ID}) + p3 := mustList(t, st, AuditFilter{Limit: 2, BeforeID: p2[1].ID}) + if actionsOf(p1) != "user.loginx,user.disable" || actionsOf(p2) != "user.login.failed,user.login" || actionsOf(p3) != "user.register" { + t.Fatalf("pages = %s | %s | %s", actionsOf(p1), actionsOf(p2), actionsOf(p3)) + } +} + +func TestAuditListGroupEscapesLike(t *testing.T) { + st, _ := newTestStore(t) + mustAudit(t, st, nil, "aXb.c", nil, nil) + mustAudit(t, st, nil, "a_b.c", nil, nil) + if got := actionsOf(mustList(t, st, AuditFilter{Actions: []string{"a_b.*"}})); got != "a_b.c" { + t.Fatalf("group a_b.* matched %s", got) + } +} + +func TestAuditListLimitBounds(t *testing.T) { + st, _ := newTestStore(t) + for i := 0; i < AuditListMax+1; i++ { + mustAudit(t, st, nil, fmt.Sprintf("x.n%d", i), nil, nil) + } + if n := len(mustList(t, st, AuditFilter{})); n != 100 { + t.Fatalf("default page = %d; want 100", n) + } + if n := len(mustList(t, st, AuditFilter{Limit: 100000})); n != AuditListMax { + t.Fatalf("capped page = %d; want %d", n, AuditListMax) + } +} + +func TestPruneAuditRemovesOnlyListedOldActions(t *testing.T) { + st, clk := newTestStore(t) + base := clk.Now() + u := mustCreate(t, st, "u@example.org", "U") + uid := u.ID + clk.t = base.Add(-91 * 24 * time.Hour) + mustAudit(t, st, nil, "user.login", &uid, nil) + mustAudit(t, st, nil, "user.login.failed", &uid, nil) + mustAudit(t, st, nil, "user.register", &uid, nil) + clk.t = base.Add(-89 * 24 * time.Hour) + mustAudit(t, st, nil, "user.login", &uid, nil) + clk.t = base + n, err := st.PruneAudit([]string{"user.login", "user.login.failed"}, 90*24*time.Hour) + if err != nil || n != 2 { + t.Fatalf("PruneAudit = %d, %v; want 2", n, err) + } + if got := actionsOf(mustList(t, st, AuditFilter{})); got != "user.login,user.register" { + t.Fatalf("left = %s", got) + } +} + +func TestUsersByID(t *testing.T) { + st, _ := newTestStore(t) + a := mustCreate(t, st, "a@example.org", "A") + b := mustCreate(t, st, "b@example.org", "B") + if err := st.Delete(b.ID); err != nil { + t.Fatal(err) + } + got, err := st.UsersByID([]int64{a.ID, b.ID, 999}) + if err != nil || len(got) != 1 || got[a.ID].Email != "a@example.org" { + t.Fatalf("UsersByID = %+v, %v", got, err) + } + if empty, err := st.UsersByID(nil); err != nil || len(empty) != 0 { + t.Fatalf("UsersByID(nil) = %+v, %v", empty, err) + } +} + +func hasIndex(t *testing.T, st *Store, name string) bool { + t.Helper() + var n int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, name).Scan(&n); err != nil { + t.Fatal(err) + } + return n == 1 +} + +func TestFreshDatabaseHasAuditTimeIndex(t *testing.T) { + st, _ := newTestStore(t) + if !hasIndex(t, st, "audit_at") { + t.Fatal("audit_at index missing on a fresh database") + } +} + +// A users.db written by a v2 binary gains the audit_at index and keeps its rows. +func TestMigrateV2DatabaseToV3(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + stmts := []string{ + `CREATE TABLE schema_version (version INTEGER NOT NULL)`, + `INSERT INTO schema_version (version) VALUES (2)`, + } + stmts = append(stmts, migrations[0]...) + stmts = append(stmts, migrations[1]...) + stmts = append(stmts, `INSERT INTO audit_log (at, action, detail) VALUES (1, 'user.register', '{}')`) + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("build v2 db: %v", err) + } + } + db.Close() + + st, err := Open(path) + if err != nil { + t.Fatalf("Open v2 db: %v", err) + } + defer st.Close() + if v, err := st.SchemaVersion(); err != nil || v != 3 { + t.Fatalf("SchemaVersion = %d, %v; want 3", v, err) + } + if !hasIndex(t, st, "audit_at") { + t.Fatal("audit_at index missing after migration") + } + if got := actionsOf(mustList(t, st, AuditFilter{})); got != "user.register" { + t.Fatalf("v2 audit rows after migration = %q", got) + } +} diff --git a/internal/users/schema.go b/internal/users/schema.go index 8e4b87ba1..3724df7d5 100644 --- a/internal/users/schema.go +++ b/internal/users/schema.go @@ -83,6 +83,9 @@ var migrations = [][]string{ updated_at INTEGER NOT NULL )`, }, + { // v3: sub-project C, global audit list ordered and filtered by time + `CREATE INDEX audit_at ON audit_log(at)`, + }, } func (s *Store) migrate() error { diff --git a/internal/users/settings_test.go b/internal/users/settings_test.go index c2c75ad33..bf25a10ad 100644 --- a/internal/users/settings_test.go +++ b/internal/users/settings_test.go @@ -136,9 +136,6 @@ func TestSettingsSurviveDisable(t *testing.T) { // A users.db written by a v1 binary gains user_settings and keeps its rows. func TestMigrateV1DatabaseToV2(t *testing.T) { - if len(migrations) != 2 { - t.Fatalf("len(migrations) = %d; this test pins the v1 to v2 step", len(migrations)) - } path := filepath.Join(t.TempDir(), "users.db") db, err := sql.Open("sqlite", path) if err != nil { @@ -161,8 +158,8 @@ func TestMigrateV1DatabaseToV2(t *testing.T) { t.Fatalf("Open v1 db: %v", err) } defer st.Close() - if v, err := st.SchemaVersion(); err != nil || v != 2 { - t.Fatalf("SchemaVersion = %d, %v; want 2", v, err) + if v, err := st.SchemaVersion(); err != nil || v != len(migrations) { + t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations)) } u, err := st.GetByEmail("old@example.org") if err != nil { diff --git a/internal/users/users.go b/internal/users/users.go index 23080fd43..6e5e31fbd 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -214,6 +214,33 @@ func (s *Store) CountActiveAdmins() (int, error) { return n, err } +// UsersByID loads the users with the given ids in one query. Ids that no +// longer exist are absent from the map. +func (s *Store) UsersByID(ids []int64) (map[int64]User, error) { + out := make(map[int64]User, len(ids)) + if len(ids) == 0 { + return out, nil + } + args := make([]any, len(ids)) + for i, id := range ids { + args[i] = id + } + ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + rows, err := s.db.Query(`SELECT `+userCols+` FROM users WHERE id IN (`+ph+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out[u.ID] = *u + } + return out, rows.Err() +} + // PruneStalePending deletes pending accounts older than maxAge that have no // unused, unexpired activation token left. func (s *Store) PruneStalePending(maxAge time.Duration) (int64, error) { From ca25630285388ec5a9cd6f4508cf579ea48f25e7 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 14:25:41 +0200 Subject: [PATCH 04/38] feat(users): Stats for the admin overview Account counts by status, active admins, stuck pending accounts (older than 24 hours), bouncing addresses, registrations over 7 and 30 days and per UTC day for 30 days (from user.register audit rows), active users, logins and failed logins in 24 hours, mail of the last 7 days by latest delivery event, and accounts with 5 or more failed logins in 24 hours. Thresholds are constants for now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- internal/users/stats.go | 185 +++++++++++++++++++++++++++++++++++ internal/users/stats_test.go | 125 +++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 internal/users/stats.go create mode 100644 internal/users/stats_test.go diff --git a/internal/users/stats.go b/internal/users/stats.go new file mode 100644 index 000000000..d6b22731c --- /dev/null +++ b/internal/users/stats.go @@ -0,0 +1,185 @@ +package users + +import "time" + +// Admin overview thresholds (docs/specs/2026-10-07-admin-dashboard-design.md). +// Constants in this version; they belong in config later (AGENTS.md rule 8). +const ( + StuckPendingAfter = 24 * time.Hour // a pending account this old counts as stuck + GuessingWindow = 24 * time.Hour + GuessingThreshold = 5 // failed logins on one account inside GuessingWindow + + statsPerDayDays = 30 + statsGuessingLimit = 50 +) + +// DayCount is the count for one UTC day; Day is YYYY-MM-DD. +type DayCount struct { + Day string + Count int +} + +// MailCounts buckets mails by their latest delivery event. +type MailCounts struct { + Delivered int // delivered, opened, clicked, unsubscribed + Bounced int // hard_bounce, soft_bounce, invalid_email + Blocked int + Spam int + Pending int // sent or deferred: no final event yet + Other int // error, or an event this version does not know +} + +// GuessedAccount is an account with at least GuessingThreshold failed +// logins inside GuessingWindow. +type GuessedAccount struct { + UserID int64 + DisplayName string // empty when the account no longer exists + Failed int +} + +// Stats holds the figures of the admin overview's Users card and its +// attention rules. +type Stats struct { + Total, Active, Pending, Disabled int + Admins int // active admins + StuckPending int // pending for longer than StuckPendingAfter + Bouncing int + New7d, New30d int // user.register audit rows + NewPerDay []DayCount // statsPerDayDays entries, oldest first, the last is today (UTC) + Active7d, Active30d int // last login or session activity inside the window + Logins24h, FailedLogins24h int + Mail7d MailCounts + Guessing []GuessedAccount // most failures first, never nil +} + +func secs(d time.Duration) int64 { return int64(d / time.Second) } + +// Stats computes the overview figures at now: a handful of COUNT queries +// on users.db. +func (s *Store) Stats(now time.Time) (Stats, error) { + const day = 24 * time.Hour + t := unix(now) + var st Stats + if err := s.db.QueryRow(`SELECT COUNT(*), + COALESCE(SUM(status = 'active'), 0), + COALESCE(SUM(status = 'pending'), 0), + COALESCE(SUM(status = 'disabled'), 0), + COALESCE(SUM(role = 'admin' AND status = 'active'), 0), + COALESCE(SUM(status = 'pending' AND created_at < ?), 0), + COALESCE(SUM(email_bouncing != 0), 0) + FROM users`, t-secs(StuckPendingAfter)). + Scan(&st.Total, &st.Active, &st.Pending, &st.Disabled, &st.Admins, &st.StuckPending, &st.Bouncing); err != nil { + return Stats{}, err + } + if err := s.db.QueryRow(`SELECT COALESCE(SUM(at >= ?), 0), COUNT(*) FROM audit_log + WHERE action = 'user.register' AND at >= ? AND at <= ?`, t-secs(7*day), t-secs(30*day), t). + Scan(&st.New7d, &st.New30d); err != nil { + return Stats{}, err + } + perDay, err := s.registrationsPerDay(now) + if err != nil { + return Stats{}, err + } + st.NewPerDay = perDay + if err := s.db.QueryRow(`SELECT COALESCE(SUM(last >= ?), 0), COUNT(*) FROM ( + SELECT MAX(COALESCE(u.last_login_at, 0), + COALESCE((SELECT MAX(last_seen_at) FROM sessions s WHERE s.user_id = u.id), 0)) AS last + FROM users u + ) WHERE last >= ?`, t-secs(7*day), t-secs(30*day)). + Scan(&st.Active7d, &st.Active30d); err != nil { + return Stats{}, err + } + if err := s.db.QueryRow(`SELECT COALESCE(SUM(action = 'user.login'), 0), COALESCE(SUM(action = 'user.login.failed'), 0) + FROM audit_log WHERE action IN ('user.login', 'user.login.failed') AND at >= ?`, t-secs(day)). + Scan(&st.Logins24h, &st.FailedLogins24h); err != nil { + return Stats{}, err + } + if st.Mail7d, err = s.mailCounts(t - secs(7*day)); err != nil { + return Stats{}, err + } + if st.Guessing, err = s.guessedAccounts(t - secs(GuessingWindow)); err != nil { + return Stats{}, err + } + return st, nil +} + +// registrationsPerDay counts user.register rows per UTC day for the last +// statsPerDayDays days, today included. +func (s *Store) registrationsPerDay(now time.Time) ([]DayCount, error) { + first := now.UTC().Truncate(24*time.Hour).AddDate(0, 0, -(statsPerDayDays - 1)) + rows, err := s.db.Query(`SELECT (at - ?) / 86400, COUNT(*) FROM audit_log + WHERE action = 'user.register' AND at >= ? AND at <= ? GROUP BY 1`, unix(first), unix(first), unix(now)) + if err != nil { + return nil, err + } + defer rows.Close() + counts := make([]int, statsPerDayDays) + for rows.Next() { + var d int64 + var n int + if err := rows.Scan(&d, &n); err != nil { + return nil, err + } + counts[d] = n + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]DayCount, statsPerDayDays) + for i, n := range counts { + out[i] = DayCount{Day: first.AddDate(0, 0, i).Format("2006-01-02"), Count: n} + } + return out, nil +} + +func (s *Store) mailCounts(since int64) (MailCounts, error) { + var mc MailCounts + rows, err := s.db.Query(`SELECT last_event, COUNT(*) FROM mail_log WHERE sent_at >= ? GROUP BY last_event`, since) + if err != nil { + return mc, err + } + defer rows.Close() + for rows.Next() { + var ev string + var n int + if err := rows.Scan(&ev, &n); err != nil { + return mc, err + } + switch ev { + case "delivered", "opened", "clicked", "unsubscribed": + mc.Delivered += n + case "hard_bounce", "soft_bounce", "invalid_email": + mc.Bounced += n + case "blocked": + mc.Blocked += n + case "spam": + mc.Spam += n + case "sent", "deferred": + mc.Pending += n + default: + mc.Other += n + } + } + return mc, rows.Err() +} + +func (s *Store) guessedAccounts(since int64) ([]GuessedAccount, error) { + rows, err := s.db.Query(`SELECT a.target_user_id, COALESCE(u.display_name, ''), COUNT(*) AS n + FROM audit_log a LEFT JOIN users u ON u.id = a.target_user_id + WHERE a.action = 'user.login.failed' AND a.at >= ? AND a.target_user_id IS NOT NULL + GROUP BY a.target_user_id HAVING n >= ? ORDER BY n DESC, a.target_user_id LIMIT ?`, + since, GuessingThreshold, statsGuessingLimit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []GuessedAccount{} + for rows.Next() { + var g GuessedAccount + if err := rows.Scan(&g.UserID, &g.DisplayName, &g.Failed); err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} diff --git a/internal/users/stats_test.go b/internal/users/stats_test.go new file mode 100644 index 000000000..1c6460be6 --- /dev/null +++ b/internal/users/stats_test.go @@ -0,0 +1,125 @@ +package users + +import ( + "fmt" + "reflect" + "testing" + "time" +) + +func TestStatsCountsEveryFigure(t *testing.T) { + st, clk := newTestStore(t) + now := clk.Now() // 2026-10-06 12:00 UTC + at := func(d time.Duration) { clk.t = now.Add(d) } + day := 24 * time.Hour + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + + at(-48 * time.Hour) + mustCreate(t, st, "stuck@example.org", "Stuck") // pending for more than 24 hours + at(-2 * time.Hour) + mustCreate(t, st, "fresh@example.org", "Fresh") // pending, recent + at(-40 * day) + act := mustCreate(t, st, "act@example.org", "Act") + adm := mustCreate(t, st, "adm@example.org", "Adm") + dis := mustCreate(t, st, "dis@example.org", "Dis") + for _, u := range []*User{act, adm, dis} { + must(st.Activate(u.ID, RoleUser, nil)) + } + must(st.SetRole(adm.ID, RoleAdmin)) + must(st.SetStatus(dis.ID, StatusDisabled)) + must(st.SetEmailBouncing(dis.ID, true)) + at(-3 * day) + must(st.TouchLogin(act.ID)) // active within 7 days + at(-10 * day) + _, _, err := st.CreateSession(adm.ID, 30*day, "") // active within 30 days only + must(err) + + for _, d := range []time.Duration{-time.Hour, -3 * day, -20 * day, -40 * day} { + at(d) + mustAudit(t, st, nil, "user.register", nil, nil) + } + aid, mid := act.ID, adm.ID + at(-30 * time.Hour) + mustAudit(t, st, nil, "user.login", &aid, nil) + at(-25 * time.Hour) + mustAudit(t, st, nil, "user.login.failed", &mid, nil) + at(-time.Hour) + mustAudit(t, st, nil, "user.login", &aid, nil) + at(-2 * time.Hour) + for i := 0; i < 5; i++ { + mustAudit(t, st, nil, "user.login.failed", &mid, map[string]string{"reason": "wrong_password"}) + } + for i := 0; i < 4; i++ { + mustAudit(t, st, nil, "user.login.failed", &aid, map[string]string{"reason": "wrong_password"}) + } + + at(-day) + for i, ev := range []string{"delivered", "opened", "hard_bounce", "soft_bounce", "blocked", "spam", "", "deferred", "error"} { + id := fmt.Sprintf("m%d", i) + _, err := st.LogMail(&aid, "act@example.org", "activate", id) + must(err) + if ev != "" { + _, _, err := st.RecordMailEvent(id, ev, clk.Now(), "") + must(err) + } + } + at(-8 * day) + _, err = st.LogMail(&aid, "act@example.org", "activate", "old") + must(err) + _, _, err = st.RecordMailEvent("old", "delivered", clk.Now(), "") + must(err) + at(0) + + got, err := st.Stats(now) + if err != nil { + t.Fatal(err) + } + perDay := got.NewPerDay + got.NewPerDay = nil + want := Stats{Total: 5, Active: 2, Pending: 2, Disabled: 1, Admins: 1, StuckPending: 1, Bouncing: 1, + New7d: 2, New30d: 3, Active7d: 1, Active30d: 2, Logins24h: 1, FailedLogins24h: 9, + Mail7d: MailCounts{Delivered: 2, Bounced: 2, Blocked: 1, Spam: 1, Pending: 2, Other: 1}, + Guessing: []GuessedAccount{{UserID: adm.ID, DisplayName: "Adm", Failed: 5}}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Stats =\n%+v\nwant\n%+v", got, want) + } + if len(perDay) != 30 || perDay[0].Day != "2026-09-07" || perDay[29].Day != "2026-10-06" { + t.Fatalf("NewPerDay bounds = %d entries, %+v .. %+v", len(perDay), perDay[0], perDay[len(perDay)-1]) + } + nonZero := map[string]int{} + for _, d := range perDay { + if d.Count != 0 { + nonZero[d.Day] = d.Count + } + } + if !reflect.DeepEqual(nonZero, map[string]int{"2026-09-16": 1, "2026-10-03": 1, "2026-10-06": 1}) { + t.Fatalf("NewPerDay non-zero days = %v", nonZero) + } +} + +func TestStatsOnEmptyDatabase(t *testing.T) { + st, clk := newTestStore(t) + got, err := st.Stats(clk.Now()) + if err != nil { + t.Fatal(err) + } + if got.Total != 0 || got.New30d != 0 || got.Active30d != 0 || got.FailedLogins24h != 0 || got.Mail7d != (MailCounts{}) { + t.Fatalf("empty Stats = %+v", got) + } + if got.Guessing == nil || len(got.Guessing) != 0 { + t.Fatalf("Guessing = %#v; want an empty, non-nil slice", got.Guessing) + } + if len(got.NewPerDay) != 30 { + t.Fatalf("NewPerDay has %d entries; want 30", len(got.NewPerDay)) + } + for _, d := range got.NewPerDay { + if d.Count != 0 { + t.Fatalf("NewPerDay = %+v; want zeros", got.NewPerDay) + } + } +} From 32a8a11ee7bfaee5c9686ef0ba96f00080efd53c Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 14:28:39 +0200 Subject: [PATCH 05/38] feat(users): audit logins, keep login rows 90 days A successful login writes user.login; a refused login for an existing account writes user.login.failed with reason wrong_password, pending or disabled. An unknown address writes nothing and no IP is stored. The audit write follows the decided answer and a failure is only logged, so every failure answer stays identical. The hourly janitor deletes login rows older than 90 days and keeps all other actions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- cmd/server/auth_handlers.go | 24 +++++- cmd/server/auth_login_audit_test.go | 126 ++++++++++++++++++++++++++++ cmd/server/auth_service.go | 4 + 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 cmd/server/auth_login_audit_test.go diff --git a/cmd/server/auth_handlers.go b/cmd/server/auth_handlers.go index c34a80ca3..587079000 100644 --- a/cmd/server/auth_handlers.go +++ b/cmd/server/auth_handlers.go @@ -150,15 +150,29 @@ func (s *Server) handleActivate(w http.ResponseWriter, r *http.Request) { } // startSession creates a session, sets the cookie and answers with /me. -func (a *authService) startSession(w http.ResponseWriter, r *http.Request, u *users.User) { +// It reports whether the session was started. +func (a *authService) startSession(w http.ResponseWriter, r *http.Request, u *users.User) bool { raw, sess, err := a.st.CreateSession(u.ID, a.set.sessionTTL, r.UserAgent()) if err != nil { writeError(w, http.StatusInternalServerError, "internal error") - return + return false } _ = a.st.TouchLogin(u.ID) a.setSessionCookie(w, raw, sess.ExpiresAt) writeJSON(w, meFrom(u, sess)) + return true +} + +// loginFailReason is the audit reason of a refused login for an existing +// account. A wrong password wins over the account state. +func loginFailReason(passwordOK bool, st users.Status) string { + if !passwordOK { + return "wrong_password" + } + if st == users.StatusPending { + return "pending" + } + return "disabled" } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { @@ -187,6 +201,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { ok, err := users.VerifyPassword(u.PasswordHash, req.Password) if err != nil || !ok || u.Status != users.StatusActive { writeError(w, http.StatusUnauthorized, msgBadLogin) + // After the answer is decided; a failed write is logged by a.audit. + a.audit(nil, "user.login.failed", idPtr(u.ID), map[string]string{"reason": loginFailReason(err == nil && ok, u.Status)}) return } // Config wins: an address in adminEmails is always admin. @@ -196,7 +212,9 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { a.audit(nil, "user.role.config", idPtr(u.ID), map[string]string{"role": "admin"}) } } - a.startSession(w, r, u) + if a.startSession(w, r, u) { + a.audit(nil, "user.login", idPtr(u.ID), nil) + } } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/server/auth_login_audit_test.go b/cmd/server/auth_login_audit_test.go new file mode 100644 index 000000000..560bcf976 --- /dev/null +++ b/cmd/server/auth_login_audit_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/meshcore-analyzer/users" +) + +func TestLoginIsAudited(t *testing.T) { + f := newAuthFixture(t) + dave := f.registerAndActivate(t, "dave@example.org", "Dave", pw) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "pen@example.org", DisplayName: "Pen", Password: pw}), 200) + pen, err := f.st.GetByEmail("pen@example.org") + if err != nil { + t.Fatal(err) + } + + f.login(t, "dave@example.org", pw) + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "dave@example.org", Password: "wrong password!"}), 401) + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "nobody@example.org", Password: pw}), 401) + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "pen@example.org", Password: pw}), 401) + if err := f.st.SetStatus(dave.me.ID, users.StatusDisabled); err != nil { + t.Fatal(err) + } + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "dave@example.org", Password: pw}), 401) + + type row struct { + action string + target int64 + reason string + } + want := []row{ + {"user.login.failed", dave.me.ID, "disabled"}, + {"user.login.failed", pen.ID, "pending"}, + {"user.login.failed", dave.me.ID, "wrong_password"}, + {"user.login", dave.me.ID, ""}, + } + got, err := f.st.AuditList(users.AuditFilter{Actions: []string{"user.login.*"}}) + if err != nil { + t.Fatal(err) + } + if len(got) != len(want) { + t.Fatalf("login rows = %+v; want %d (the unknown address writes none)", got, len(want)) + } + for i, e := range got { + if e.Action != want[i].action || e.ActorUserID != nil || e.TargetUserID == nil || + *e.TargetUserID != want[i].target || e.Detail["reason"] != want[i].reason { + t.Errorf("row %d = %+v; want %+v", i, e, want[i]) + } + } + all, _ := f.st.AuditList(users.AuditFilter{}) + for _, e := range all { + for _, v := range e.Detail { + if strings.Contains(v, "nobody") { + t.Fatalf("an unknown address reached the audit log: %+v", e) + } + } + } +} + +func TestLoginFailureResponsesAreIdentical(t *testing.T) { + f := newAuthFixture(t) + dis := f.registerAndActivate(t, "dis@example.org", "Dis", pw) + if err := f.st.SetStatus(dis.me.ID, users.StatusDisabled); err != nil { + t.Fatal(err) + } + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "pen@example.org", DisplayName: "Pen", Password: pw}), 200) + bodies := map[string]string{} + for name, req := range map[string]loginRequest{ + "unknown": {Email: "nobody@example.org", Password: pw}, + "wrong": {Email: "dis@example.org", Password: "wrong password!"}, + "pending": {Email: "pen@example.org", Password: pw}, + "disabled": {Email: "dis@example.org", Password: pw}, + } { + w := f.do("POST", "/api/auth/login", req) + if w.Code != 401 { + t.Errorf("%s: status %d; want 401", name, w.Code) + } + bodies[name] = w.Body.String() + } + for name, b := range bodies { + if b != bodies["unknown"] { + t.Errorf("%s body %q differs from the unknown-address body %q", name, b, bodies["unknown"]) + } + } +} + +func TestLoginAuditFailureDoesNotChangeTheAnswer(t *testing.T) { + f := newAuthFixture(t) + f.registerAndActivate(t, "dave@example.org", "Dave", pw) + ref := f.do("POST", "/api/auth/login", loginRequest{Email: "nobody@example.org", Password: pw}).Body.String() + f.breakTable(t, "audit_log") + w := f.do("POST", "/api/auth/login", loginRequest{Email: "dave@example.org", Password: "wrong password!"}) + if w.Code != 401 || w.Body.String() != ref { + t.Fatalf("with a broken audit log: %d %q; want 401 %q", w.Code, w.Body.String(), ref) + } + f.login(t, "dave@example.org", pw) +} + +func TestJanitorPrunesOldLoginAudit(t *testing.T) { + f := newAuthFixture(t) + dave := f.registerAndActivate(t, "dave@example.org", "Dave", pw) + id := dave.me.ID + old := time.Now().Add(-91 * 24 * time.Hour) + f.st.SetClock(func() time.Time { return old }) + for _, a := range []string{"user.login", "user.login.failed", "user.password.change"} { + if err := f.st.Audit(nil, a, &id, nil); err != nil { + t.Fatal(err) + } + } + f.st.SetClock(time.Now) + f.srv.auth.prune() + list, err := f.st.AuditList(users.AuditFilter{UserID: &id}) + if err != nil { + t.Fatal(err) + } + var acts []string + for _, e := range list { + acts = append(acts, e.Action) + } + if got := strings.Join(acts, ","); got != "user.password.change,user.activate,user.register" { + t.Fatalf("after prune: %s", got) + } +} diff --git a/cmd/server/auth_service.go b/cmd/server/auth_service.go index 2ef23c20d..1d0c525de 100644 --- a/cmd/server/auth_service.go +++ b/cmd/server/auth_service.go @@ -127,6 +127,10 @@ func (a *authService) prune() { if _, err := a.st.PruneMail(90 * 24 * time.Hour); err != nil { log.Printf("[users] prune mail log: %v", err) } + // Login rows only; every other audit action is kept (admin-dashboard spec). + if _, err := a.st.PruneAudit([]string{"user.login", "user.login.failed"}, 90*24*time.Hour); err != nil { + log.Printf("[users] prune login audit: %v", err) + } a.login.gc() a.signup.gc() a.hook.gc() From 3a8341d364306b37228f8b4785b981886b1dea0e Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 14:31:01 +0200 Subject: [PATCH 06/38] feat(users): GET /api/admin/audit, the global audit log Admin-only and registered only with user management on. Filters by action or a ".*" group, user as actor or target, and an RFC 3339 period; keyset-paginated with next/before, default 100, capped at 500. Actor and target are resolved in one query, a removed account becomes {id, deleted: true}. Invalid parameters answer 400. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- cmd/server/admin_audit_handlers.go | 159 +++++++++++++++++++++++++++++ cmd/server/admin_audit_test.go | 115 +++++++++++++++++++++ cmd/server/auth_routes.go | 1 + cmd/server/openapi.go | 1 + cmd/server/user_mgmt_off_test.go | 2 +- docs/api-spec.md | 1 + 6 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 cmd/server/admin_audit_handlers.go create mode 100644 cmd/server/admin_audit_test.go diff --git a/cmd/server/admin_audit_handlers.go b/cmd/server/admin_audit_handlers.go new file mode 100644 index 000000000..b16969d55 --- /dev/null +++ b/cmd/server/admin_audit_handlers.go @@ -0,0 +1,159 @@ +package main + +import ( + "errors" + "log" + "net/http" + "net/url" + "regexp" + "strconv" + "time" + + "github.com/meshcore-analyzer/users" +) + +// auditUserRef names an actor or target. Deleted accounts keep only the id. +type auditUserRef struct { + ID int64 `json:"id"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Deleted bool `json:"deleted,omitempty"` +} + +type auditListEntryJSON struct { + ID int64 `json:"id"` + At string `json:"at"` + Action string `json:"action"` + Actor *auditUserRef `json:"actor"` + Target *auditUserRef `json:"target"` + Detail map[string]string `json:"detail"` +} + +type auditListJSON struct { + Entries []auditListEntryJSON `json:"entries"` + Next *int64 `json:"next"` +} + +// One action ("user.login.failed") or a group ending in ".*" ("user.login.*"). +var auditActionRE = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*(\.\*)?$`) + +func parseAuditTime(q url.Values, name string) (*time.Time, error) { + v := q.Get(name) + if v == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return nil, errors.New("invalid " + name + ": use RFC 3339") + } + return &t, nil +} + +func parsePositiveID(v, name string) (int64, error) { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + return 0, errors.New("invalid " + name) + } + return id, nil +} + +// parseAuditFilter reads the query of GET /api/admin/audit. Any invalid +// parameter is an error (answered 400); limit above the cap is capped. +func parseAuditFilter(q url.Values) (users.AuditFilter, error) { + f := users.AuditFilter{Limit: 100} + if a := q.Get("action"); a != "" { + if len(a) > 64 || !auditActionRE.MatchString(a) { + return f, errors.New("invalid action filter") + } + f.Actions = []string{a} + } + if v := q.Get("user"); v != "" { + id, err := parsePositiveID(v, "user filter") + if err != nil { + return f, err + } + f.UserID = &id + } + var err error + if f.From, err = parseAuditTime(q, "from"); err != nil { + return f, err + } + if f.To, err = parseAuditTime(q, "to"); err != nil { + return f, err + } + if f.From != nil && f.To != nil && f.From.After(*f.To) { + return f, errors.New("from is after to") + } + if v := q.Get("before"); v != "" { + if f.BeforeID, err = parsePositiveID(v, "before"); err != nil { + return f, err + } + } + if v := q.Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return f, errors.New("invalid limit") + } + if n > users.AuditListMax { + n = users.AuditListMax + } + f.Limit = n + } + return f, nil +} + +// auditUserIDs lists the distinct actor and target ids of a page. +func auditUserIDs(list []users.AuditEntry) []int64 { + seen := map[int64]bool{} + var ids []int64 + for _, e := range list { + for _, p := range []*int64{e.ActorUserID, e.TargetUserID} { + if p != nil && !seen[*p] { + seen[*p] = true + ids = append(ids, *p) + } + } + } + return ids +} + +func auditRef(id *int64, known map[int64]users.User) *auditUserRef { + if id == nil { + return nil + } + u, ok := known[*id] + if !ok { + return &auditUserRef{ID: *id, Deleted: true} + } + return &auditUserRef{ID: u.ID, DisplayName: u.DisplayName, Email: u.Email} +} + +func (s *Server) handleAdminAudit(w http.ResponseWriter, r *http.Request, _ *users.User, _ *users.Session) { + f, err := parseAuditFilter(r.URL.Query()) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + list, err := s.auth.st.AuditList(f) + if err != nil { + log.Printf("[users] admin audit list: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + known, err := s.auth.st.UsersByID(auditUserIDs(list)) + if err != nil { + log.Printf("[users] admin audit users: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + out := auditListJSON{Entries: make([]auditListEntryJSON, 0, len(list))} + for _, e := range list { + out.Entries = append(out.Entries, auditListEntryJSON{ID: e.ID, At: rfc3339(e.At), Action: e.Action, + Actor: auditRef(e.ActorUserID, known), Target: auditRef(e.TargetUserID, known), Detail: e.Detail}) + } + if len(list) == f.Limit { + next := list[len(list)-1].ID + out.Next = &next + } + writeJSON(w, out) +} diff --git a/cmd/server/admin_audit_test.go b/cmd/server/admin_audit_test.go new file mode 100644 index 000000000..7f0012cca --- /dev/null +++ b/cmd/server/admin_audit_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "fmt" + "net/url" + "strings" + "testing" + + "github.com/meshcore-analyzer/users" +) + +func auditPage(t *testing.T, f *authFixture, c *client, query string) auditListJSON { + t.Helper() + w := f.do("GET", "/api/admin/audit"+query, nil, as(c)) + expectStatus(t, w, 200) + return decode[auditListJSON](t, w) +} + +func entryActions(p auditListJSON) string { + parts := make([]string, len(p.Entries)) + for i, e := range p.Entries { + parts[i] = e.Action + } + return strings.Join(parts, ",") +} + +func TestAdminAuditRequiresAdmin(t *testing.T) { + f, _, uma := adminFixture(t) + expectStatus(t, f.do("GET", "/api/admin/audit", nil), 401) + expectStatus(t, f.do("GET", "/api/admin/audit", nil, as(uma)), 403) +} + +func TestAdminAuditFiltersAndUsers(t *testing.T) { + f, boss, uma := adminFixture(t) + for i := 0; i < 2; i++ { + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "uma@example.org", Password: "wrong password!"}), 401) + } + f.login(t, "uma@example.org", pw) + + p := auditPage(t, f, boss, "?action=user.login.failed") + if len(p.Entries) != 2 || p.Next != nil { + t.Fatalf("failed logins = %+v", p) + } + e := p.Entries[0] + if e.Actor != nil || e.Target == nil || e.Target.ID != uma.me.ID || e.Target.DisplayName != "Uma" || + e.Target.Email != "uma@example.org" || e.Target.Deleted || e.Detail["reason"] != "wrong_password" || e.At == "" { + t.Fatalf("entry = %+v (target %+v)", e, e.Target) + } + if got := entryActions(auditPage(t, f, boss, "?action=user.login.*")); got != "user.login,user.login.failed,user.login.failed" { + t.Fatalf("group = %s", got) + } + if got := entryActions(auditPage(t, f, boss, fmt.Sprintf("?user=%d", boss.me.ID))); got != "user.activate,user.register" { + t.Fatalf("user filter = %s", got) + } + future := url.QueryEscape("2100-01-01T00:00:00Z") + if p := auditPage(t, f, boss, "?from="+future); len(p.Entries) != 0 || p.Next != nil { + t.Fatalf("future period = %+v", p) + } +} + +func TestAdminAuditPaginates(t *testing.T) { + f, boss, _ := adminFixture(t) // 4 rows: register and activate for each user + first := auditPage(t, f, boss, "?limit=3") + if len(first.Entries) != 3 || first.Next == nil || *first.Next != first.Entries[2].ID { + t.Fatalf("first page = %+v", first) + } + second := auditPage(t, f, boss, fmt.Sprintf("?limit=3&before=%d", *first.Next)) + if len(second.Entries) != 1 || second.Next != nil || second.Entries[0].ID >= *first.Next { + t.Fatalf("second page = %+v", second) + } +} + +func TestAdminAuditDeletedUser(t *testing.T) { + f, boss, uma := adminFixture(t) + expectStatus(t, f.do("DELETE", userPath(uma.me.ID, ""), nil, as(boss)), 200) + p := auditPage(t, f, boss, fmt.Sprintf("?user=%d&action=user.register", uma.me.ID)) + if len(p.Entries) != 1 || p.Entries[0].Target == nil || !p.Entries[0].Target.Deleted || + p.Entries[0].Target.ID != uma.me.ID || p.Entries[0].Target.Email != "" || p.Entries[0].Target.DisplayName != "" { + t.Fatalf("deleted target = %+v", p) + } + del := auditPage(t, f, boss, "?action=user.delete") + if len(del.Entries) != 1 || del.Entries[0].Actor == nil || del.Entries[0].Actor.ID != boss.me.ID || del.Entries[0].Actor.Deleted { + t.Fatalf("delete row = %+v", del) + } +} + +func TestAdminAuditRejectsBadParameters(t *testing.T) { + f, boss, _ := adminFixture(t) + for _, q := range []string{ + "?action=DROP%20TABLE", "?action=user.*.x", "?action=*", "?user=abc", "?user=0", + "?from=yesterday", "?to=2026-13-01T00:00:00Z", "?before=-1", "?before=x", "?limit=0", "?limit=ten", + "?from=2026-10-02T00:00:00Z&to=2026-10-01T00:00:00Z", + } { + if w := f.do("GET", "/api/admin/audit"+q, nil, as(boss)); w.Code != 400 { + t.Errorf("%s = %d; want 400", q, w.Code) + } + } +} + +func TestParseAuditFilterDefaultsAndCap(t *testing.T) { + flt, err := parseAuditFilter(url.Values{"limit": {"100000"}}) + if err != nil || flt.Limit != users.AuditListMax { + t.Fatalf("limit 100000 = %+v, %v; want %d", flt, err, users.AuditListMax) + } + flt, err = parseAuditFilter(url.Values{}) + if err != nil || flt.Limit != 100 || flt.Actions != nil || flt.UserID != nil || flt.From != nil || flt.BeforeID != 0 { + t.Fatalf("defaults = %+v, %v", flt, err) + } +} + +func TestAdminAuditStoreErrorIs500(t *testing.T) { + f, boss, _ := adminFixture(t) + f.breakTable(t, "audit_log") + expectStatus(t, f.do("GET", "/api/admin/audit", nil, as(boss)), 500) +} diff --git a/cmd/server/auth_routes.go b/cmd/server/auth_routes.go index fa7b10352..32cdf2940 100644 --- a/cmd/server/auth_routes.go +++ b/cmd/server/auth_routes.go @@ -36,6 +36,7 @@ func (s *Server) registerAuthRoutes(r *mux.Router) { r.HandleFunc("/api/admin/users/{id}/resend-activation", s.withAdmin(s.handleAdminResendActivation)).Methods("POST") r.HandleFunc("/api/admin/users/{id}/activate", s.withAdmin(s.handleAdminActivate)).Methods("POST") r.HandleFunc("/api/admin/users/{id}/mail/{mailId}/refresh", s.withAdmin(s.handleAdminMailRefresh)).Methods("POST") + r.HandleFunc("/api/admin/audit", s.withAdmin(s.handleAdminAudit)).Methods("GET") // The webhook exists only when a secret is configured. if s.auth.set.webhookSecret != "" { r.HandleFunc("/api/mail/brevo/webhook", s.handleBrevoWebhook).Methods("POST") diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index ea923eaf7..d165e5ff4 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -85,6 +85,7 @@ func routeDescriptions() map[string]routeMeta { "POST /api/admin/users/{id}/resend-activation": {Summary: "Resend the activation mail (admin)", Tag: "users", Session: true}, "POST /api/admin/users/{id}/activate": {Summary: "Activate a pending user manually (admin)", Description: "For when mail keeps failing. The address stays unverified; recorded as activatedBy + audit row.", Tag: "users", Session: true}, "POST /api/admin/users/{id}/mail/{mailId}/refresh": {Summary: "Pull delivery events for one mail from the provider (admin)", Tag: "users", Session: true}, + "GET /api/admin/audit": {Summary: "Global audit log (admin)", Description: "Newest first, keyset-paginated: {entries, next}. Pass next as before for the following page; next is null on the last page. Entry: {id, at, action, actor, target, detail}; actor and target are {id, displayName, email}, {id, deleted: true} for a removed account, or null for the system. Login rows (user.login, user.login.failed) are kept 90 days. 400 on an invalid parameter.", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "action", Description: "One action, or a group ending in .* (user.login.* matches user.login and user.login.failed)", Type: "string"}, {Name: "user", Description: "User id, as actor or target", Type: "integer"}, {Name: "from", Description: "RFC 3339 time, inclusive", Type: "string"}, {Name: "to", Description: "RFC 3339 time, inclusive", Type: "string"}, {Name: "before", Description: "Only entries with a smaller id (next of the previous page)", Type: "integer"}, {Name: "limit", Description: "Page size, default 100, capped at 500", Type: "integer"}}}, "POST /api/mail/brevo/webhook": {Summary: "Brevo delivery-event webhook", Description: "Authenticated with Authorization: Bearer . Registered only when the secret is set.", Tag: "users"}, // Packets diff --git a/cmd/server/user_mgmt_off_test.go b/cmd/server/user_mgmt_off_test.go index febd46592..18d1841bf 100644 --- a/cmd/server/user_mgmt_off_test.go +++ b/cmd/server/user_mgmt_off_test.go @@ -15,7 +15,7 @@ func TestUserManagementOffIsUnchanged(t *testing.T) { if srv.auth != nil { t.Fatal("auth built without config") } - for _, p := range []string{"/api/auth/me", "/api/admin/users", "/api/account/sessions", "/api/account/settings"} { + for _, p := range []string{"/api/auth/me", "/api/admin/users", "/api/admin/audit", "/api/account/sessions", "/api/account/settings"} { w := httptest.NewRecorder() router.ServeHTTP(w, httptest.NewRequest("GET", p, nil)) if w.Code != 404 { diff --git a/docs/api-spec.md b/docs/api-spec.md index be4649f8f..2ac3566f7 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -133,6 +133,7 @@ These routes are registered only when `userManagement.enabled` is true. When it | `POST /api/admin/users/{id}/resend-activation` | admin | -> adminUser. Pending accounts only | | `POST /api/admin/users/{id}/activate` | admin | -> adminUser. Pending accounts only; the address stays unverified | | `POST /api/admin/users/{id}/mail/{mailId}/refresh` | admin | -> mail record with `events`, pulled from the provider. `502` provider unavailable | +| `GET /api/admin/audit?action=&user=&from=&to=&before=&limit=` | admin | -> `{entries, next}`, newest first. `action` is one action or a group ending in `.*` (`user.login.*`); `user` matches actor or target; `from`/`to` are RFC 3339 and inclusive; pass `next` as `before` for the next page (`null` on the last page); `limit` defaults to 100, capped at 500. Entry: `{id, at, action, actor, target, detail}`, with actor and target `{id, displayName, email}`, `{id, deleted: true}` or `null` (system). Login rows are kept 90 days. `400` on an invalid parameter | | `POST /api/mail/brevo/webhook` | `Authorization: Bearer ` | Brevo transactional event payload -> `{ok}`. `401` on a wrong secret | `adminUser` = `{id, email, displayName, role, status, createdAt, activatedAt, activatedManually, activatedBy, lastLoginAt, emailBouncing, configAdmin, lastMail}`. `status` is `pending`, `active` or `disabled`. From 41617f9aa64c3c81b5ed9c78e9f4aea38d8a0494 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 14:32:52 +0200 Subject: [PATCH 07/38] feat(users): GET /api/admin/stats for the admin overview Admin-only and registered only with user management on. Serves the users.Stats figures as a typed struct; newPerDay always holds 30 days and guessing is always an array. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- cmd/server/admin_stats_handlers.go | 76 ++++++++++++++++++++++++++++++ cmd/server/admin_stats_test.go | 46 ++++++++++++++++++ cmd/server/auth_routes.go | 1 + cmd/server/openapi.go | 1 + cmd/server/user_mgmt_off_test.go | 2 +- docs/api-spec.md | 1 + 6 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 cmd/server/admin_stats_handlers.go create mode 100644 cmd/server/admin_stats_test.go diff --git a/cmd/server/admin_stats_handlers.go b/cmd/server/admin_stats_handlers.go new file mode 100644 index 000000000..803154ff4 --- /dev/null +++ b/cmd/server/admin_stats_handlers.go @@ -0,0 +1,76 @@ +package main + +import ( + "log" + "net/http" + "time" + + "github.com/meshcore-analyzer/users" +) + +// The field sets match users.DayCount, users.MailCounts and +// users.GuessedAccount, so plain type conversions fill them. +type dayCountJSON struct { + Day string `json:"day"` + Count int `json:"count"` +} + +type mailCountsJSON struct { + Delivered int `json:"delivered"` + Bounced int `json:"bounced"` + Blocked int `json:"blocked"` + Spam int `json:"spam"` + Pending int `json:"pending"` + Other int `json:"other"` +} + +type guessedAccountJSON struct { + UserID int64 `json:"userId"` + DisplayName string `json:"displayName"` + Failed int `json:"failed"` +} + +type adminStatsJSON struct { + Total int `json:"total"` + Active int `json:"active"` + Pending int `json:"pending"` + Disabled int `json:"disabled"` + Admins int `json:"admins"` + StuckPending int `json:"stuckPending"` + Bouncing int `json:"bouncing"` + New7d int `json:"new7d"` + New30d int `json:"new30d"` + NewPerDay []dayCountJSON `json:"newPerDay"` + Active7d int `json:"active7d"` + Active30d int `json:"active30d"` + Logins24h int `json:"logins24h"` + FailedLogins24h int `json:"failedLogins24h"` + Mail7d mailCountsJSON `json:"mail7d"` + Guessing []guessedAccountJSON `json:"guessing"` +} + +func adminStatsFrom(st users.Stats) adminStatsJSON { + out := adminStatsJSON{Total: st.Total, Active: st.Active, Pending: st.Pending, Disabled: st.Disabled, + Admins: st.Admins, StuckPending: st.StuckPending, Bouncing: st.Bouncing, New7d: st.New7d, New30d: st.New30d, + Active7d: st.Active7d, Active30d: st.Active30d, Logins24h: st.Logins24h, FailedLogins24h: st.FailedLogins24h, + Mail7d: mailCountsJSON(st.Mail7d), + NewPerDay: make([]dayCountJSON, 0, len(st.NewPerDay)), + Guessing: make([]guessedAccountJSON, 0, len(st.Guessing))} + for _, d := range st.NewPerDay { + out.NewPerDay = append(out.NewPerDay, dayCountJSON(d)) + } + for _, g := range st.Guessing { + out.Guessing = append(out.Guessing, guessedAccountJSON(g)) + } + return out +} + +func (s *Server) handleAdminStats(w http.ResponseWriter, _ *http.Request, _ *users.User, _ *users.Session) { + st, err := s.auth.st.Stats(time.Now()) + if err != nil { + log.Printf("[users] admin stats: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, adminStatsFrom(st)) +} diff --git a/cmd/server/admin_stats_test.go b/cmd/server/admin_stats_test.go new file mode 100644 index 000000000..e93838229 --- /dev/null +++ b/cmd/server/admin_stats_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "strings" + "testing" +) + +func TestAdminStatsRequiresAdmin(t *testing.T) { + f, _, uma := adminFixture(t) + expectStatus(t, f.do("GET", "/api/admin/stats", nil), 401) + expectStatus(t, f.do("GET", "/api/admin/stats", nil, as(uma)), 403) +} + +func TestAdminStats(t *testing.T) { + f, boss, uma := adminFixture(t) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "pending@example.org", DisplayName: "Pen", Password: pw}), 200) + for i := 0; i < 5; i++ { + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "uma@example.org", Password: "wrong password!"}), 401) + } + w := f.do("GET", "/api/admin/stats", nil, as(boss)) + expectStatus(t, w, 200) + st := decode[adminStatsJSON](t, w) + if st.Total != 3 || st.Active != 2 || st.Pending != 1 || st.Admins != 1 || st.StuckPending != 0 || + st.New7d != 3 || st.New30d != 3 || len(st.NewPerDay) != 30 || st.Active7d != 2 || + st.Logins24h != 0 || st.FailedLogins24h != 5 || st.Mail7d.Pending != 3 { + t.Fatalf("stats = %+v", st) + } + if len(st.Guessing) != 1 || st.Guessing[0].UserID != uma.me.ID || st.Guessing[0].DisplayName != "Uma" || st.Guessing[0].Failed != 5 { + t.Fatalf("guessing = %+v", st.Guessing) + } +} + +func TestAdminStatsEmptyListsAreArrays(t *testing.T) { + f, boss, _ := adminFixture(t) + w := f.do("GET", "/api/admin/stats", nil, as(boss)) + expectStatus(t, w, 200) + if !strings.Contains(w.Body.String(), `"guessing":[]`) { + t.Fatalf("body = %s; want \"guessing\":[]", w.Body.String()) + } +} + +func TestAdminStatsStoreErrorIs500(t *testing.T) { + f, boss, _ := adminFixture(t) + f.breakTable(t, "mail_log") + expectStatus(t, f.do("GET", "/api/admin/stats", nil, as(boss)), 500) +} diff --git a/cmd/server/auth_routes.go b/cmd/server/auth_routes.go index 32cdf2940..e985d83f6 100644 --- a/cmd/server/auth_routes.go +++ b/cmd/server/auth_routes.go @@ -37,6 +37,7 @@ func (s *Server) registerAuthRoutes(r *mux.Router) { r.HandleFunc("/api/admin/users/{id}/activate", s.withAdmin(s.handleAdminActivate)).Methods("POST") r.HandleFunc("/api/admin/users/{id}/mail/{mailId}/refresh", s.withAdmin(s.handleAdminMailRefresh)).Methods("POST") r.HandleFunc("/api/admin/audit", s.withAdmin(s.handleAdminAudit)).Methods("GET") + r.HandleFunc("/api/admin/stats", s.withAdmin(s.handleAdminStats)).Methods("GET") // The webhook exists only when a secret is configured. if s.auth.set.webhookSecret != "" { r.HandleFunc("/api/mail/brevo/webhook", s.handleBrevoWebhook).Methods("POST") diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index d165e5ff4..5be530b9a 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -86,6 +86,7 @@ func routeDescriptions() map[string]routeMeta { "POST /api/admin/users/{id}/activate": {Summary: "Activate a pending user manually (admin)", Description: "For when mail keeps failing. The address stays unverified; recorded as activatedBy + audit row.", Tag: "users", Session: true}, "POST /api/admin/users/{id}/mail/{mailId}/refresh": {Summary: "Pull delivery events for one mail from the provider (admin)", Tag: "users", Session: true}, "GET /api/admin/audit": {Summary: "Global audit log (admin)", Description: "Newest first, keyset-paginated: {entries, next}. Pass next as before for the following page; next is null on the last page. Entry: {id, at, action, actor, target, detail}; actor and target are {id, displayName, email}, {id, deleted: true} for a removed account, or null for the system. Login rows (user.login, user.login.failed) are kept 90 days. 400 on an invalid parameter.", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "action", Description: "One action, or a group ending in .* (user.login.* matches user.login and user.login.failed)", Type: "string"}, {Name: "user", Description: "User id, as actor or target", Type: "integer"}, {Name: "from", Description: "RFC 3339 time, inclusive", Type: "string"}, {Name: "to", Description: "RFC 3339 time, inclusive", Type: "string"}, {Name: "before", Description: "Only entries with a smaller id (next of the previous page)", Type: "integer"}, {Name: "limit", Description: "Page size, default 100, capped at 500", Type: "integer"}}}, + "GET /api/admin/stats": {Summary: "User figures for the admin overview (admin)", Description: "Accounts by status, active admins, pending accounts older than 24 hours (stuckPending), bouncing addresses, registrations in 7 and 30 days and per UTC day for 30 days (newPerDay, oldest first), users active in 7 and 30 days, logins and failed logins in 24 hours, mail sent in 7 days by latest delivery status (mail7d), and accounts with 5 or more failed logins in 24 hours (guessing: [{userId, displayName, failed}]).", Tag: "users", Session: true}, "POST /api/mail/brevo/webhook": {Summary: "Brevo delivery-event webhook", Description: "Authenticated with Authorization: Bearer . Registered only when the secret is set.", Tag: "users"}, // Packets diff --git a/cmd/server/user_mgmt_off_test.go b/cmd/server/user_mgmt_off_test.go index 18d1841bf..83eb2bb41 100644 --- a/cmd/server/user_mgmt_off_test.go +++ b/cmd/server/user_mgmt_off_test.go @@ -15,7 +15,7 @@ func TestUserManagementOffIsUnchanged(t *testing.T) { if srv.auth != nil { t.Fatal("auth built without config") } - for _, p := range []string{"/api/auth/me", "/api/admin/users", "/api/admin/audit", "/api/account/sessions", "/api/account/settings"} { + for _, p := range []string{"/api/auth/me", "/api/admin/users", "/api/admin/audit", "/api/admin/stats", "/api/account/sessions", "/api/account/settings"} { w := httptest.NewRecorder() router.ServeHTTP(w, httptest.NewRequest("GET", p, nil)) if w.Code != 404 { diff --git a/docs/api-spec.md b/docs/api-spec.md index 2ac3566f7..a20f03a5c 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -134,6 +134,7 @@ These routes are registered only when `userManagement.enabled` is true. When it | `POST /api/admin/users/{id}/activate` | admin | -> adminUser. Pending accounts only; the address stays unverified | | `POST /api/admin/users/{id}/mail/{mailId}/refresh` | admin | -> mail record with `events`, pulled from the provider. `502` provider unavailable | | `GET /api/admin/audit?action=&user=&from=&to=&before=&limit=` | admin | -> `{entries, next}`, newest first. `action` is one action or a group ending in `.*` (`user.login.*`); `user` matches actor or target; `from`/`to` are RFC 3339 and inclusive; pass `next` as `before` for the next page (`null` on the last page); `limit` defaults to 100, capped at 500. Entry: `{id, at, action, actor, target, detail}`, with actor and target `{id, displayName, email}`, `{id, deleted: true}` or `null` (system). Login rows are kept 90 days. `400` on an invalid parameter | +| `GET /api/admin/stats` | admin | -> `{total, active, pending, disabled, admins, stuckPending, bouncing, new7d, new30d, newPerDay, active7d, active30d, logins24h, failedLogins24h, mail7d, guessing}`. `newPerDay` is `[{day, count}]` for 30 UTC days, oldest first; registrations count `user.register` audit rows. `stuckPending` counts pending accounts older than 24 hours. `mail7d` is `{delivered, bounced, blocked, spam, pending, other}` by latest delivery event. `guessing` is `[{userId, displayName, failed}]`: accounts with 5 or more failed logins in 24 hours | | `POST /api/mail/brevo/webhook` | `Authorization: Bearer ` | Brevo transactional event payload -> `{ok}`. `401` on a wrong secret | `adminUser` = `{id, email, displayName, role, status, createdAt, activatedAt, activatedManually, activatedBy, lastLoginAt, emailBouncing, configAdmin, lastMail}`. `status` is `pending`, `active` or `disabled`. From 4e4758ab92dd4a77d5dec9b314f4958e58785aa0 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 14:33:58 +0200 Subject: [PATCH 08/38] fix(users): write login audit rows in the background The login audit INSERT ran before the buffered answer reached the client, so a refusal for an existing account took one extra SQLite commit (up to the 5 s busy timeout on a locked users.db) that an unknown address never paid: a timing signal for account existence. user.login and user.login.failed are now written by auditAsync after the answer is decided, best-effort, tracked by a WaitGroup that closeUserManagement drains after stopping the janitor. Response bytes, session creation, rate limiting and the dummy hash are unchanged. Spec amendment 7 now says so, including that a row can be lost if the process stops right after a login. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- cmd/server/auth_fixture_test.go | 17 +++++++- cmd/server/auth_handlers.go | 7 ++-- cmd/server/auth_login_audit_test.go | 40 +++++++++++++++++++ cmd/server/auth_service.go | 16 ++++++++ .../2026-10-07-admin-dashboard-design.md | 7 ++-- 5 files changed, 79 insertions(+), 8 deletions(-) diff --git a/cmd/server/auth_fixture_test.go b/cmd/server/auth_fixture_test.go index 556346cbf..00ea6d0ae 100644 --- a/cmd/server/auth_fixture_test.go +++ b/cmd/server/auth_fixture_test.go @@ -54,9 +54,13 @@ func newTestAuthService(t *testing.T, adminEmails ...string) (*authService, *mai if err != nil { t.Fatal(err) } - t.Cleanup(func() { st.Close() }) fake := &mailer.Fake{} - return newAuthService(set, st, fake), fake + a := newAuthService(set, st, fake) + t.Cleanup(func() { + a.waitAudits() + st.Close() + }) + return a, fake } // newAuthFixture builds a Server with auth on and only the auth routes. @@ -85,7 +89,16 @@ func as(c *client) reqMod { func header(k, v string) reqMod { return func(r *http.Request) { r.Header.Set(k, v) } } func fromIP(ip string) reqMod { return func(r *http.Request) { r.RemoteAddr = ip + ":5555" } } +// do serves one request and then waits for its background audit writes, +// so tests see audit rows in request order. func (f *authFixture) do(method, path string, body any, mods ...reqMod) *httptest.ResponseRecorder { + w := f.serve(method, path, body, mods...) + f.srv.auth.waitAudits() + return w +} + +// serve serves one request and returns without waiting for audit writes. +func (f *authFixture) serve(method, path string, body any, mods ...reqMod) *httptest.ResponseRecorder { var rd io.Reader if body != nil { b, _ := json.Marshal(body) diff --git a/cmd/server/auth_handlers.go b/cmd/server/auth_handlers.go index 587079000..f559978f3 100644 --- a/cmd/server/auth_handlers.go +++ b/cmd/server/auth_handlers.go @@ -201,8 +201,9 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { ok, err := users.VerifyPassword(u.PasswordHash, req.Password) if err != nil || !ok || u.Status != users.StatusActive { writeError(w, http.StatusUnauthorized, msgBadLogin) - // After the answer is decided; a failed write is logged by a.audit. - a.audit(nil, "user.login.failed", idPtr(u.ID), map[string]string{"reason": loginFailReason(err == nil && ok, u.Status)}) + // In the background after the answer is decided, so the write never + // changes response timing. + a.auditAsync(nil, "user.login.failed", idPtr(u.ID), map[string]string{"reason": loginFailReason(err == nil && ok, u.Status)}) return } // Config wins: an address in adminEmails is always admin. @@ -213,7 +214,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } } if a.startSession(w, r, u) { - a.audit(nil, "user.login", idPtr(u.ID), nil) + a.auditAsync(nil, "user.login", idPtr(u.ID), nil) } } diff --git a/cmd/server/auth_login_audit_test.go b/cmd/server/auth_login_audit_test.go index 560bcf976..493c7542e 100644 --- a/cmd/server/auth_login_audit_test.go +++ b/cmd/server/auth_login_audit_test.go @@ -1,6 +1,8 @@ package main import ( + "context" + "database/sql" "strings" "testing" "time" @@ -124,3 +126,41 @@ func TestJanitorPrunesOldLoginAudit(t *testing.T) { t.Fatalf("after prune: %s", got) } } + +// A locked users.db must not hold the login answer: the audit row is +// written in the background and lands once the lock is gone. A synchronous +// write would block on the lock and then fail, leaving no row. +func TestLoginAnswersBeforeTheAuditWrite(t *testing.T) { + f := newAuthFixture(t) + dave := f.registerAndActivate(t, "dave@example.org", "Dave", pw) + + db, err := sql.Open("sqlite", f.srv.auth.set.dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn, err := db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.ExecContext(context.Background(), "BEGIN IMMEDIATE"); err != nil { + t.Fatal(err) + } + + w := f.serve("POST", "/api/auth/login", loginRequest{Email: "dave@example.org", Password: "wrong password!"}) + if w.Code != 401 { + t.Fatalf("status %d; want 401", w.Code) + } + if _, err := conn.ExecContext(context.Background(), "ROLLBACK"); err != nil { + t.Fatal(err) + } + f.srv.auth.waitAudits() + got, err := f.st.AuditList(users.AuditFilter{Actions: []string{"user.login.failed"}}) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].TargetUserID == nil || *got[0].TargetUserID != dave.me.ID { + t.Fatalf("login rows after the lock = %+v; want one for dave", got) + } +} diff --git a/cmd/server/auth_service.go b/cmd/server/auth_service.go index 1d0c525de..a87c37bef 100644 --- a/cmd/server/auth_service.go +++ b/cmd/server/auth_service.go @@ -27,6 +27,7 @@ type authService struct { stop chan struct{} stopOnce sync.Once wg sync.WaitGroup // the janitor + auditWG sync.WaitGroup // in-flight auditAsync writes } func newAuthService(set *userMgmtSettings, st *users.Store, m mailer.Mailer) *authService { @@ -80,6 +81,7 @@ func (s *Server) closeUserManagement() { s.auth.stopOnce.Do(func() { close(s.auth.stop) s.auth.wg.Wait() + s.auth.waitAudits() if err := s.auth.st.Close(); err != nil { log.Printf("[users] close: %v", err) } @@ -153,6 +155,20 @@ func (a *authService) audit(actor *int64, action string, target *int64, detail m } } +// auditAsync writes an audit row in the background, so neither the write +// nor a locked users.db delays the response. Best-effort: a failure is +// logged by a.audit, and a row is lost if the process stops first. +func (a *authService) auditAsync(actor *int64, action string, target *int64, detail map[string]string) { + a.auditWG.Add(1) + go func() { + defer a.auditWG.Done() + a.audit(actor, action, target, detail) + }() +} + +// waitAudits blocks until every auditAsync write has finished. +func (a *authService) waitAudits() { a.auditWG.Wait() } + func idPtr(id int64) *int64 { return &id } // invalidateTokens burns uid's outstanding links of each purpose. A failure diff --git a/docs/specs/2026-10-07-admin-dashboard-design.md b/docs/specs/2026-10-07-admin-dashboard-design.md index d83cf63b7..327fe75fc 100644 --- a/docs/specs/2026-10-07-admin-dashboard-design.md +++ b/docs/specs/2026-10-07-admin-dashboard-design.md @@ -219,6 +219,7 @@ These rules override the sections above where they differ. 6. **`/api/healthz` is read on open and on "Refresh" only**, not on the 60-second timer: it walks every packet under a read lock. The timer refreshes `/api/admin/stats`, `/api/health`, `/api/mqtt/status` and `/api/observers`. -7. **Timing.** Writing a `user.login.failed` row for an existing account makes that - response marginally slower than one for an unknown address. Argon2 dominates the - response time, so this is accepted. +7. **Timing.** Login audit rows (`user.login` and `user.login.failed`) are written + asynchronously after the response is decided, best-effort, so neither the extra write + nor a locked `users.db` changes response timing. A row can be lost if the process + stops right after a login. From e3f01142f5f8997ed22772db2f8754e69af536a0 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 7 Oct 2026 14:35:56 +0200 Subject: [PATCH 09/38] feat(admin): audit tab module Filters (action or group, period, numeric user id) live in the hash and are validated on read; filter changes use replaceState. Rows are rendered newest first, Load more appends the next keyset page and hides at the end. Account names link to the Users tab. Not reachable yet: the admin shell mounts it in a later commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71 --- public/admin-audit.js | 143 ++++++++++++++++++ test-all.sh | 1 + tests/unit/test-admin-dashboard-ui.js | 210 ++++++++++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 public/admin-audit.js create mode 100644 tests/unit/test-admin-dashboard-ui.js diff --git a/public/admin-audit.js b/public/admin-audit.js new file mode 100644 index 000000000..97992a037 --- /dev/null +++ b/public/admin-audit.js @@ -0,0 +1,143 @@ +/* Audit tab of #/admin (docs/specs/2026-10-07-admin-dashboard-design.md): + * the global audit log, newest first, with "Load more" (keyset paging on the + * server's next id). Filters live in the hash: + * #/admin?tab=audit&action=&user=&period= (invalid values are ignored). + * Mounted by admin.js after its admin check. Every dynamic value goes + * through escapeHtml. */ +(function () { + 'use strict'; + var ACTIONS = [ + ['', 'All actions'], + ['user.login.*', 'Logins (all)'], + ['user.login', 'Successful logins'], + ['user.login.failed', 'Failed logins'], + ['user.register', 'Registrations'], + ['user.activate.*', 'Activations'], + ['user.activation.resend', 'Activation mail resent'], + ['user.disable', 'Disabled'], + ['user.enable', 'Enabled'], + ['user.delete.*', 'Deletions'], + ['user.role.*', 'Role changes'], + ['user.password.*', 'Password changes and resets'], + ['user.email.*', 'Address changes'], + ['user.mail.refresh', 'Mail status refreshed'] + ]; + var DAY_MS = 24 * 60 * 60 * 1000; + var PERIODS = [['', 'Any time', 0], ['24h', 'Last 24 hours', DAY_MS], ['7d', 'Last 7 days', 7 * DAY_MS], ['30d', 'Last 30 days', 30 * DAY_MS]]; + var PAGE = 100; + var state = { filters: { action: '', user: '', period: '' }, next: null, seq: 0 }; + + function known(list, v) { + for (var i = 0; i < list.length; i++) if (list[i][0] === v) return list[i]; + return null; + } + + function readHash(hash) { + var p = new URLSearchParams(String(hash || '').split('?')[1] || ''); + var a = known(ACTIONS, p.get('action')); + var per = known(PERIODS, p.get('period')); + var user = p.get('user') || ''; + return { action: a ? a[0] : '', user: /^\d+$/.test(user) ? user : '', period: per ? per[0] : '' }; + } + + function hashFor(f) { + var p = new URLSearchParams(); + p.set('tab', 'audit'); + if (f.action) p.set('action', f.action); + if (f.user) p.set('user', f.user); + if (f.period) p.set('period', f.period); + return '#/admin?' + p.toString(); + } + + function apiPath(f, before, nowMs) { + var p = new URLSearchParams(); + if (f.action) p.set('action', f.action); + if (f.user) p.set('user', f.user); + var per = f.period ? known(PERIODS, f.period) : null; + if (per) p.set('from', new Date(nowMs - per[2]).toISOString()); + if (before) p.set('before', String(before)); + p.set('limit', String(PAGE)); + return '/api/admin/audit?' + p.toString(); + } + + function refHtml(ref) { + if (!ref) return ''; + var id = escapeHtml(String(ref.id)); + if (ref.deleted) return '#' + id + ' '; + return '' + escapeHtml(ref.displayName) + ' '; + } + + function detailText(d) { + return Object.keys(d || {}).sort().map(function (k) { return k + '=' + d[k]; }).join(', '); + } + + function rowHtml(e) { + return '' + + '' + escapeHtml(new Date(e.at).toLocaleString()) + '' + + '' + escapeHtml(e.action) + '' + + '' + refHtml(e.actor) + '' + + '' + refHtml(e.target) + '' + + '' + escapeHtml(detailText(e.detail)) + ''; + } + + function optionsHtml(list) { + return list.map(function (o) { return ''; }).join(''); + } + + function say(text) { CSAuth.say('auditMsg', text, false); } + + function load(more) { + var seq = ++state.seq; + return CSAuth.request('GET', apiPath(state.filters, more ? state.next : null, Date.now())).then(function (r) { + var body = document.getElementById('auditBody'); + if (!body || seq !== state.seq) return; + if (!r.ok) { say(CSAuth.errText(r)); return; } + var html = ''; + r.data.entries.forEach(function (e) { html += rowHtml(e); }); + // Appending keeps the rows on screen; a page is at most PAGE rows. + if (more) body.insertAdjacentHTML('beforeend', html); + else body.innerHTML = html || 'No entries match.'; + state.next = r.data.next; + document.getElementById('auditMore').hidden = r.data.next == null; + }).catch(function () { if (seq === state.seq) say('Network error, try again.'); }); + } + + function setFilter(key, value) { + state.filters[key] = value; + var h = hashFor(state.filters); + if (h !== location.hash) history.replaceState(null, '', h); + return load(false); + } + + function mount(container) { + state.filters = readHash(location.hash); + state.next = null; + container.innerHTML = '
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '' + + '
TimeActionByAccountDetail
' + + '
'; + document.getElementById('auditAction').value = state.filters.action; + document.getElementById('auditPeriod').value = state.filters.period; + document.getElementById('auditUser').value = state.filters.user; + document.getElementById('auditAction').addEventListener('change', function (ev) { setFilter('action', ev.target.value); }); + document.getElementById('auditPeriod').addEventListener('change', function (ev) { setFilter('period', ev.target.value); }); + document.getElementById('auditUser').addEventListener('change', function (ev) { + var v = String(ev.target.value || '').trim(); + if (v === '' || /^\d+$/.test(v)) setFilter('user', v); + else ev.target.value = state.filters.user; + }); + document.getElementById('auditMore').addEventListener('click', function () { load(true); }); + return load(false); + } + + function unmount() { state.seq++; state.next = null; } + + window.CSAdminAudit = { mount: mount, unmount: unmount, + _test: { readHash: readHash, hashFor: hashFor, apiPath: apiPath, rowHtml: rowHtml, refHtml: refHtml, detailText: detailText } }; +})(); diff --git a/test-all.sh b/test-all.sh index 198787942..2bfe9f7b9 100755 --- a/test-all.sh +++ b/test-all.sh @@ -15,6 +15,7 @@ node tests/unit/test-a11y-1719-contrast-root-causes-e2e.js node tests/unit/test-a11y-1996-scope-audit-chips.js node tests/unit/test-a11y-axe-1668-selftest.js node tests/unit/test-a11y-axe-routes-coverage.js +node tests/unit/test-admin-dashboard-ui.js node tests/unit/test-aging.js node tests/unit/test-analytics-channels-integration.js node tests/unit/test-anl1-tooltip-render.js diff --git a/tests/unit/test-admin-dashboard-ui.js b/tests/unit/test-admin-dashboard-ui.js new file mode 100644 index 000000000..e04737657 --- /dev/null +++ b/tests/unit/test-admin-dashboard-ui.js @@ -0,0 +1,210 @@ +/* Unit tests for the admin area (docs/specs/2026-10-07-admin-dashboard-design.md): + * public/admin-audit.js, public/admin-overview.js and public/admin.js, each + * loaded from disk into a vm sandbox (same pattern as + * test-user-management-ui.js). */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.resolve(__dirname, '..', '..'); +let passed = 0, failed = 0; +const pending = []; +function test(name, fn) { + const p = Promise.resolve().then(fn).then( + () => { passed++; console.log(' ok ' + name); }, + (e) => { failed++; console.log(' FAIL ' + name + ': ' + e.message); }); + pending.push(p); + return p; +} +const tick = () => new Promise((r) => setTimeout(r, 5)); +const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8'); +const XSS = ''; + +// escapeHtml comes from the real app.js, not a copy. +function loadEscapeHtml() { + const m = src('public/app.js').match(/function escapeHtml\(s\) \{[\s\S]*?\n\}/); + assert(m, 'escapeHtml not found in app.js'); + return vm.runInNewContext('(' + m[0].replace(/^function escapeHtml/, 'function') + ')'); +} + +// Elements are created on first lookup and remember handlers and content. +function makeDom() { + const els = {}; + const mk = (id) => { + const el = { id, value: '', checked: false, textContent: '', innerHTML: '', hidden: false, handlers: {} }; + el.addEventListener = (t, fn) => { el.handlers[t] = fn; }; + el.insertAdjacentHTML = (pos, html) => { el.innerHTML += html; }; + el.querySelector = (sel) => els[id + ' ' + sel] || (els[id + ' ' + sel] = mk(id + ' ' + sel)); + return el; + }; + const document = { + visibilityState: 'visible', + getElementById(id) { return els[id] || (els[id] = mk(id)); }, + querySelector(sel) { return els[sel] || (els[sel] = mk(sel)); }, + }; + return { els, mk, document }; +} + +// loadTab runs the given public files with window === the sandbox global, +// a fake CSAuth whose request() answers from routes(path), a recording +// history.replaceState and recordable intervals. +function loadTab(files, hash, routes, extra) { + const dom = makeDom(); + const loc = { hash }; + const replaced = []; + const calls = []; + const timers = []; + const CSAuth = { + request(method, p) { calls.push(p); return Promise.resolve().then(() => routes(p)); }, + say(id, text, ok) { const el = dom.document.getElementById(id); el.textContent = text; el.ok = !!ok; }, + errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); }, + }; + const ctx = Object.assign({ + document: dom.document, location: loc, URLSearchParams, Promise, String, Number, Object, Math, Date, JSON, console, CSAuth, + history: { replaceState(a, b, h) { replaced.push(h); loc.hash = h; } }, + escapeHtml: loadEscapeHtml(), + setInterval(fn, ms) { timers.push({ fn, ms, cleared: false }); return timers.length; }, + clearInterval(id) { if (timers[id - 1]) timers[id - 1].cleared = true; }, + }, extra || {}); + ctx.window = ctx; + vm.createContext(ctx); + [].concat(files).forEach((f) => vm.runInContext(src(f), ctx)); + return { ctx, dom, els: dom.els, loc, replaced, calls, timers }; +} + +const NOW = Date.UTC(2026, 9, 7, 12, 0, 0); +const OK = (data) => ({ ok: true, status: 200, data }); + +console.log('admin-audit.js'); + +const E = (o) => Object.assign({ id: 10, at: '2026-10-07T10:00:00Z', action: 'user.login.failed', actor: null, + target: { id: 7, displayName: 'Eve', email: 'eve@example.org' }, detail: { reason: 'wrong_password' } }, o); +const auditEnv = (hash, routes) => loadTab('public/admin-audit.js', hash, routes); +const auditT = () => auditEnv('', () => OK({ entries: [], next: null })).ctx.CSAdminAudit._test; +const rows = (html) => (html.match(/ { + const t = auditT(); + const rh = (h) => JSON.parse(JSON.stringify(t.readHash(h))); + assert.deepStrictEqual(rh('#/admin?tab=audit&action=user.login.failed&user=12&period=7d'), + { action: 'user.login.failed', user: '12', period: '7d' }); + assert.deepStrictEqual(rh('#/admin?tab=audit&action=drop&user=1x&period=1y'), { action: '', user: '', period: '' }); + assert.strictEqual(rh('#/admin?tab=audit&action=user.login.*').action, 'user.login.*'); +}); + +test('hashFor writes tab=audit and only the set filters', () => { + const t = auditT(); + assert.strictEqual(t.hashFor({ action: 'user.login.*', user: '12', period: '' }), '#/admin?tab=audit&action=user.login.*&user=12'); + assert.strictEqual(t.hashFor({ action: '', user: '', period: '30d' }), '#/admin?tab=audit&period=30d'); +}); + +test('apiPath turns the period into from, adds before and the page size', () => { + const t = auditT(); + const from = encodeURIComponent(new Date(NOW - 864e5).toISOString()); + assert.strictEqual(t.apiPath({ action: 'user.login.failed', user: '12', period: '24h' }, null, NOW), + '/api/admin/audit?action=user.login.failed&user=12&from=' + from + '&limit=100'); + assert.strictEqual(t.apiPath({ action: '', user: '', period: '' }, 55, NOW), '/api/admin/audit?before=55&limit=100'); +}); + +test('rows escape actions, names, emails and details; deleted and system refs', () => { + const t = auditT(); + const html = t.rowHtml(E({ action: XSS, actor: { id: 3, displayName: XSS, email: XSS }, detail: { [XSS]: XSS } })); + assert(html.indexOf('