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
+11
View File
@@ -0,0 +1,11 @@
package main
import "github.com/jackc/pgx/v5/pgxpool"
type dbPool struct{ Pool *pgxpool.Pool }
func (d dbPool) Close() {
if d.Pool != nil {
d.Pool.Close()
}
}
+253
View File
@@ -0,0 +1,253 @@
// Command noemactl administers a running Noema daemon over its HTTP API and
// performs a few offline operations (migrations, first user) directly against
// the database.
//
// Environment: NOEMA_URL (default http://127.0.0.1:8080), NOEMA_API_TOKEN for
// API commands; NOEMA_DATABASE_URL for offline commands.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/version"
"github.com/andydixon/noema/migrations"
)
// Exit codes: 0 ok, 1 general failure, 2 usage error, 3 not found, 4 unauthorised.
const (
exitOK = iota
exitFail
exitUsage
exitNotFound
exitUnauth
)
type command struct {
name, help string
run func(args []string) int
}
var commands []command
func main() {
commands = []command{
{"status", "show daemon status", cmdStatus},
{"migrate", "up|down|status – manage database schema (offline)", cmdMigrate},
{"user", "create <username> [-role r] – manage users (offline)", cmdUser},
{"version", "print version", func([]string) int { fmt.Println(version.Version); return exitOK }},
}
if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" || os.Args[1] == "help" {
usage()
os.Exit(exitUsage)
}
for _, c := range commands {
if c.name == os.Args[1] {
os.Exit(c.run(os.Args[2:]))
}
}
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
usage()
os.Exit(exitUsage)
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: noemactl <command> [args]\n\ncommands:")
for _, c := range commands {
fmt.Fprintf(os.Stderr, " %-10s %s\n", c.name, c.help)
}
fmt.Fprintln(os.Stderr, "\nenvironment: NOEMA_URL, NOEMA_API_TOKEN (API commands); NOEMA_DATABASE_URL (offline commands)")
}
// ---- API client ----
func apiRequest(method, path string, body any, out any) (int, error) {
base := os.Getenv("NOEMA_URL")
if base == "" {
base = "http://127.0.0.1:8080"
}
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = strings.NewReader(string(b))
}
req, err := http.NewRequest(method, strings.TrimRight(base, "/")+path, rd)
if err != nil {
return 0, err
}
if tok := os.Getenv("NOEMA_API_TOKEN"); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return 0, err
}
defer res.Body.Close()
data, _ := io.ReadAll(io.LimitReader(res.Body, 8<<20))
if res.StatusCode >= 400 {
var e struct {
Error struct{ Code, Message string }
}
json.Unmarshal(data, &e) //nolint:errcheck
return res.StatusCode, fmt.Errorf("%s: %s", res.Status, e.Error.Message)
}
if out != nil {
return res.StatusCode, json.Unmarshal(data, out)
}
return res.StatusCode, nil
}
func exitFor(status int, err error) int {
fmt.Fprintln(os.Stderr, "error:", err)
switch status {
case http.StatusNotFound:
return exitNotFound
case http.StatusUnauthorized, http.StatusForbidden:
return exitUnauth
}
return exitFail
}
func cmdStatus(args []string) int {
fs := flag.NewFlagSet("status", flag.ContinueOnError)
asJSON := fs.Bool("json", false, "raw JSON output")
if fs.Parse(args) != nil {
return exitUsage
}
var st map[string]any
if code, err := apiRequest("GET", "/api/v1/system/status", nil, &st); err != nil {
return exitFor(code, err)
}
if *asJSON {
json.NewEncoder(os.Stdout).Encode(st) //nolint:errcheck
return exitOK
}
db, _ := st["database"].(map[string]any)
fmt.Printf("version: %v\nuptime: %vs\ngoroutines: %v\ndatabase: ok=%v pgvector=%v\n", st["version"], st["uptime_seconds"], st["goroutines"], db["ok"], db["pgvector"])
return exitOK
}
// ---- offline commands ----
func openDB(ctx context.Context) (dbPool, int) {
url := os.Getenv("NOEMA_DATABASE_URL")
if url == "" {
fmt.Fprintln(os.Stderr, "NOEMA_DATABASE_URL is required for this command")
return dbPool{}, exitUsage
}
pool, err := storage.Open(ctx, url)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return dbPool{}, exitFail
}
return dbPool{pool}, exitOK
}
func cmdMigrate(args []string) int {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: noemactl migrate up|down|status")
return exitUsage
}
ctx := context.Background()
db, code := openDB(ctx)
if code != exitOK {
return code
}
defer db.Close()
switch args[0] {
case "up":
applied, err := storage.MigrateUp(ctx, db.Pool, migrations.FS)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitFail
}
if len(applied) == 0 {
fmt.Println("database is up to date")
}
for _, a := range applied {
fmt.Println("applied", a)
}
case "down":
name, err := storage.MigrateDown(ctx, db.Pool, migrations.FS)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitFail
}
if name == "" {
fmt.Println("nothing to roll back")
} else {
fmt.Println("rolled back", name)
}
case "status":
st, err := storage.Status(ctx, db.Pool, migrations.FS)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitFail
}
for _, m := range st {
state := "pending"
if m.Applied {
state = "applied " + m.AppliedAt
}
if m.Drift {
state += " DRIFT"
}
fmt.Printf("%04d %-30s %s\n", m.Version, m.Name, state)
}
default:
fmt.Fprintln(os.Stderr, "usage: noemactl migrate up|down|status")
return exitUsage
}
return exitOK
}
func cmdUser(args []string) int {
if len(args) < 2 || args[0] != "create" {
fmt.Fprintln(os.Stderr, "usage: noemactl user create <username> [-role administrator|operator|observer]\n password is read from NOEMA_PASSWORD or stdin")
return exitUsage
}
fs := flag.NewFlagSet("user create", flag.ContinueOnError)
role := fs.String("role", "administrator", "role")
if fs.Parse(args[2:]) != nil {
return exitUsage
}
password := os.Getenv("NOEMA_PASSWORD")
if password == "" {
fmt.Fprint(os.Stderr, "password: ")
b, _ := io.ReadAll(io.LimitReader(os.Stdin, 1024))
password = strings.TrimRight(string(b), "\r\n")
}
ctx := context.Background()
db, code := openDB(ctx)
if code != exitOK {
return code
}
defer db.Close()
svc := auth.NewService(db.Pool, time.Hour, time.Hour)
u, err := svc.CreateUser(ctx, args[1], password, auth.Role(*role))
if err != nil {
if errors.Is(err, storage.ErrConflict) {
fmt.Fprintln(os.Stderr, "error: username already exists")
} else {
fmt.Fprintln(os.Stderr, "error:", err)
}
return exitFail
}
db.Pool.Exec(ctx, `INSERT INTO audit_log (id, actor_kind, actor_name, action, target_kind, target_id, details) VALUES (gen_random_uuid(),'system','noemactl','user.create','user',$1,$2)`, //nolint:errcheck
u.ID, fmt.Sprintf(`{"username":%q,"role":%q}`, u.Username, u.Role))
fmt.Println("created user", u.Username, "with role", u.Role, "id", u.ID)
return exitOK
}
+133
View File
@@ -0,0 +1,133 @@
// Command noemad is the Noema daemon: API server, web UI, scheduler,
// integration manager and cognitive workers in one process.
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/andydixon/noema/internal/api"
"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/scheduler"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/ui"
"github.com/andydixon/noema/internal/version"
"github.com/andydixon/noema/migrations"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "noemad:", err)
os.Exit(1)
}
}
func run() error {
var (
cfgPath = flag.String("config", "", "path to JSON config file (optional)")
migrate = flag.Bool("migrate", true, "apply pending migrations at start")
show = flag.Bool("version", false, "print version and exit")
)
flag.Parse()
if *show {
fmt.Println(version.Version)
return nil
}
cfg, err := config.Load(*cfgPath, os.LookupEnv)
if err != nil {
return err
}
log := observability.NewLogger(os.Stderr, cfg.LogLevel, cfg.LogFormat)
log.Info("starting noemad", "version", version.Version, "env", cfg.Env, "addr", cfg.HTTPAddr)
log.Debug("configuration", "config", fmt.Sprintf("%+v", cfg.Redacted()))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := storage.Open(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
if *migrate {
applied, err := storage.MigrateUp(ctx, db, migrations.FS)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
if len(applied) > 0 {
log.Info("applied migrations", "migrations", applied)
}
}
metrics := observability.NewMetrics()
authSvc := auth.NewService(db, cfg.SessionIdleTTL, cfg.SessionAbsoluteTTL)
auditLog := audit.New(db, log)
deps := app.Deps{Config: cfg, Log: log, DB: db, Auth: authSvc, Audit: auditLog, Metrics: metrics, Migrations: migrations.FS, Started: time.Now()}
if err := bootstrapAdmin(ctx, deps); err != nil {
return err
}
var srv *api.Server
uiHandler, err := ui.New(deps, func(r *http.Request) any { return srv.Status(r.Context()) })
if err != nil {
return err
}
srv = api.New(deps, uiHandler)
group := scheduler.NewGroup(ctx, log)
httpSrv := &http.Server{Addr: cfg.HTTPAddr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 64 << 10}
group.Go("http", func(ctx context.Context) error {
errc := make(chan error, 1)
go func() { errc <- httpSrv.ListenAndServe() }() // owned: joined via errc below
select {
case err := <-errc:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
}
})
group.Every("auth.sweep", 5*time.Minute, func(context.Context) error { authSvc.SweepLimiter(); return nil })
group.Every("auth.purge", time.Hour, authSvc.PurgeExpiredSessions)
log.Info("noemad ready", "url", "http://"+cfg.HTTPAddr)
<-ctx.Done()
log.Info("shutting down")
return group.Stop()
}
// bootstrapAdmin creates the first administrator from the environment when the
// users table is empty, so headless deployments can start without the setup page.
func bootstrapAdmin(ctx context.Context, d app.Deps) error {
n, err := d.Auth.CountUsers(ctx)
if err != nil {
return err
}
if n > 0 || d.Config.BootstrapAdminPassword == "" {
return nil
}
u, err := d.Auth.CreateUser(ctx, d.Config.BootstrapAdminUser, d.Config.BootstrapAdminPassword, auth.RoleAdministrator)
if err != nil {
return fmt.Errorf("bootstrap admin: %w", err)
}
d.Audit.Log(ctx, "user.bootstrap", "user", u.ID.String(), map[string]any{"username": u.Username, "via": "environment"})
d.Log.Info("created bootstrap administrator", "username", u.Username)
return nil
}