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:
Andy Dixon
2026-06-15 20:22:19 +01:00
parent 6374e174c6
commit a002a2c100
40 changed files with 3081 additions and 20 deletions
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
)
func testServer(t *testing.T) (*httptest.Server, string) {
t.Helper()
pool := storage.TestPool(t, migrations.FS)
cfg := config.Defaults()
cfg.DatabaseURL = "postgres://test"
log := slog.New(slog.DiscardHandler)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour),
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Migrations: migrations.FS, Started: time.Now()}
ctx := context.Background()
u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator)
if err != nil {
t.Fatal(err)
}
tok, _ := deps.Auth.CreateAPIToken(ctx, u.ID, "t", nil)
srv := httptest.NewServer(New(deps, nil).Handler())
t.Cleanup(srv.Close)
return srv, tok
}
func TestStatusAuthAndErrorEnvelope(t *testing.T) {
srv, tok := testServer(t)
res, _ := http.Get(srv.URL + "/api/v1/system/status")
if res.StatusCode != 401 {
t.Fatalf("expected 401, got %d", res.StatusCode)
}
var e ErrorBody
json.NewDecoder(res.Body).Decode(&e) //nolint:errcheck
if e.Error.Code != "unauthenticated" || e.Error.RequestID == "" {
t.Fatalf("bad error envelope: %+v", e)
}
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/system/status", nil)
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 200 {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
var st SystemStatus
json.NewDecoder(res.Body).Decode(&st) //nolint:errcheck
if !st.Database.OK || len(st.Migrations) == 0 {
t.Fatalf("status incomplete: %+v", st)
}
if res.Header.Get("Content-Security-Policy") == "" {
t.Fatal("missing CSP header")
}
// Unknown JSON fields and body limits are rejected as 400.
req, _ = http.NewRequest("POST", srv.URL+"/api/v1/users", strings.NewReader(`{"username":"x","password":"long-enough-pass","role":"observer","bogus":1}`))
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 400 {
t.Fatalf("expected 400 for unknown field, got %d", res.StatusCode)
}
// Bearer requests are exempt from CSRF but still role-checked.
req, _ = http.NewRequest("POST", srv.URL+"/api/v1/users", strings.NewReader(`{"username":"obs","password":"long-enough-pass","role":"observer"}`))
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 201 {
t.Fatalf("expected 201, got %d", res.StatusCode)
}
}