feat: add authentication, audit log, HTTP API and base web UI
Phase 1 foundation. Local auth per ADR-0006: Argon2id passwords, opaque hashed session tokens with idle/absolute expiry, CSRF token plus Origin check on cookie sessions, per-IP token bucket and per-account exponential lockout, three roles enforced by middleware, personal API tokens for noemactl. Audit log writer used by every admin mutation. API: request ids, panic recovery, strict CSP and security headers, body limits, single JSON error envelope, health/ready/status, users, sessions, tokens, audit, token-protected /metrics. UI: embedded html/template pages (login, first-run setup, dashboard, system, users, account, audit, interpretation boundary), light/dark theme, dependency-free app.js with CSRF-aware fetch and SSE helper. Binaries: noemad with graceful shutdown, migrations at start, bootstrap admin from environment, supervised background tasks; noemactl with status, migrate, user create and meaningful exit codes.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/internal/version"
|
||||
)
|
||||
|
||||
// SystemStatus is the payload of GET /api/v1/system/status.
|
||||
type SystemStatus struct {
|
||||
Version string `json:"version"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
Database DatabaseStatus `json:"database"`
|
||||
Migrations []MigrationInfo `json:"migrations"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
HeapBytes uint64 `json:"heap_bytes"`
|
||||
SysBytes uint64 `json:"sys_bytes"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
// DatabaseStatus reports connectivity and pool usage.
|
||||
type DatabaseStatus struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
PoolTotal int32 `json:"pool_total"`
|
||||
PoolIdle int32 `json:"pool_idle"`
|
||||
PoolMax int32 `json:"pool_max"`
|
||||
PGVector bool `json:"pgvector"`
|
||||
}
|
||||
|
||||
// MigrationInfo is one migration's state.
|
||||
type MigrationInfo struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Applied bool `json:"applied"`
|
||||
AppliedAt string `json:"applied_at,omitempty"`
|
||||
Drift bool `json:"drift"`
|
||||
}
|
||||
|
||||
// Status collects the system status; shared with the UI system page.
|
||||
func (s *Server) Status(ctx context.Context) SystemStatus {
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
st := SystemStatus{Version: version.Version, UptimeSeconds: int64(time.Since(s.deps.Started).Seconds()),
|
||||
Goroutines: runtime.NumGoroutine(), HeapBytes: ms.HeapAlloc, SysBytes: ms.Sys, NumCPU: runtime.NumCPU(), GoVersion: runtime.Version()}
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
if err := s.deps.DB.QueryRow(ctx, `SELECT version()`).Scan(&st.Database.Version); err != nil {
|
||||
st.Database.Error = err.Error()
|
||||
} else {
|
||||
st.Database.OK = true
|
||||
s.deps.DB.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname='vector')`).Scan(&st.Database.PGVector) //nolint:errcheck
|
||||
}
|
||||
stat := s.deps.DB.Stat()
|
||||
st.Database.PoolTotal, st.Database.PoolIdle, st.Database.PoolMax = stat.TotalConns(), stat.IdleConns(), stat.MaxConns()
|
||||
if rows, err := storage.Status(ctx, s.deps.DB, s.deps.Migrations); err == nil {
|
||||
for _, m := range rows {
|
||||
st.Migrations = append(st.Migrations, MigrationInfo{Version: m.Version, Name: m.Name, Applied: m.Applied, AppliedAt: m.AppliedAt, Drift: m.Drift})
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (s *Server) systemStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.Status(r.Context()))
|
||||
}
|
||||
|
||||
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write([]byte("ok")) //nolint:errcheck
|
||||
}
|
||||
|
||||
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.deps.DB.Ping(ctx); err != nil {
|
||||
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("ready")) //nolint:errcheck
|
||||
}
|
||||
|
||||
func (s *Server) auditList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
entries, err := s.deps.Audit.List(r.Context(), before, limit, q.Get("target_kind"), q.Get("target_id"))
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
|
||||
}
|
||||
Reference in New Issue
Block a user