Files
meshcore-analyzer/cmd/server/admin_audit_handlers.go
T
efitenandClaude Opus 5.5 18a634c115 feat: admin dashboard with overview and audit log (user management part C) (#2138)
Part C of #2128: an admin area with an overview, the user table and a
global audit log. Off by default like the rest of user management: with
`userManagement` off nothing changes, and non-admins get no new UI.

This is the first of 4 stacked PRs (C, D, E, then a small export/backup
follow-up). Each later one contains this branch; review them in order.

## The situation

- An admin could manage users one by one in `#/admin/users`, but nothing
showed whether the instance needs attention: accounts stuck in
activation, bouncing mail, someone guessing a password, an MQTT source
that dropped.
- The audit log existed (`internal/users/audit.go`) but could only be
read per user, and logins were not recorded.

## What this PR adds

**Store (`internal/users`)**
- Schema v3: an index on `audit_log(at)`.
- `AuditList` with filters (action or group prefix like `user.login.*`,
user as actor or target, period) and keyset pagination; `PruneAudit`;
`Stats` for the user figures.

**Server**
- `GET /api/admin/stats` (typed struct): accounts by status, admins,
registrations and active users over 7 and 30 days, logins and failed
logins in 24 hours, mail by final status, and the attention items
computed server-side.
- `GET /api/admin/audit`: filtered, newest first, `next` cursor.
- `GET /api/admin/users` gains `bouncing=1`.
- Logins are audited as `user.login` and `user.login.failed` (reason
`wrong_password`, `pending` or `disabled`). An unknown address writes no
row. The writes are asynchronous, so the login response does not wait on
`users.db`. Login rows are pruned after 90 days.

**Frontend**
- `#/admin?tab=overview|users|audit`: `admin.js` (tab shell),
`admin-overview.js` ("Needs attention", Users card, System card from the
existing health, MQTT and observer endpoints), `admin-audit.js` (filters
in the URL, "Load more"). `admin-users.js` becomes the Users tab; the
old `#/admin/users` link rewrites to it.
- `/api/healthz` is read on open and on Refresh only, not on the
60-second timer, because it walks every packet under a read lock.

Spec:
[`docs/specs/2026-10-07-admin-dashboard-design.md`](https://github.com/efiten/CoreScope/blob/feat/admin-dashboard/docs/specs/2026-10-07-admin-dashboard-design.md).

## Verification

- `internal/users` and `cmd/server`: `go vet` and `go test` pass
locally, 26 new Go tests. One upstream test,
`TestSaveGeoFilterPreservesFileMode`, also fails on Windows on plain
`master` (file modes) and is unrelated.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E with the `e2etest` build: 13 of 13 steps locally.
- Running on our staging and production instance since 7 October 2026.

## Not in this PR

- Restricting existing pages (perf, MQTT status) to logged-in users or
admins. The admin area adds no access mechanism of its own, so that
stays a later router rule plus endpoint check.
- Server-enforced customizer tab restrictions (#1508).
- The 24-hour, 5-attempt and 10-minute attention thresholds are
constants for now (AGENTS.md rule 8: customizer later).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:38:30 +02:00

160 lines
4.1 KiB
Go

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)
}