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,75 @@
|
||||
// Package api serves the versioned JSON API under /api/v1 and composes the
|
||||
// middleware chain shared with the web UI.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
)
|
||||
|
||||
// ErrorBody is the single error envelope used by every endpoint.
|
||||
type ErrorBody struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(true)
|
||||
enc.Encode(v) //nolint:errcheck // client gone; nothing to do
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, r *http.Request, status int, code, msg string) {
|
||||
var b ErrorBody
|
||||
b.Error.Code, b.Error.Message, b.Error.RequestID = code, msg, RequestID(r)
|
||||
writeJSON(w, status, b)
|
||||
}
|
||||
|
||||
// fail maps common errors to HTTP statuses; unknown errors become 500 without
|
||||
// leaking internals.
|
||||
func fail(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "resource not found")
|
||||
case errors.Is(err, storage.ErrConflict):
|
||||
writeError(w, r, http.StatusConflict, "conflict", "resource already exists")
|
||||
default:
|
||||
var ve ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
writeError(w, r, http.StatusBadRequest, "invalid", ve.Error())
|
||||
return
|
||||
}
|
||||
logger(r).Error("request failed", "err", err)
|
||||
writeError(w, r, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
// ValidationError marks user-correctable input problems.
|
||||
type ValidationError string
|
||||
|
||||
func (v ValidationError) Error() string { return string(v) }
|
||||
|
||||
// decode reads a JSON body with unknown fields rejected and size already
|
||||
// limited by middleware.
|
||||
func decode(r *http.Request, v any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return ValidationError("invalid JSON body: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// page is the standard list envelope.
|
||||
type page[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user