mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-14 07:57:15 +00:00
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>
160 lines
4.1 KiB
Go
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)
|
|
}
|