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)
}
}
+11
View File
@@ -0,0 +1,11 @@
package api
import (
"errors"
"github.com/andydixon/noema/internal/storage"
)
func isStorageErr(err error) bool {
return errors.Is(err, storage.ErrNotFound) || errors.Is(err, storage.ErrConflict)
}
+140
View File
@@ -0,0 +1,140 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"runtime/debug"
"strconv"
"time"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/observability"
)
type requestIDKey struct{}
// RequestID returns the id assigned to the request.
func RequestID(r *http.Request) string {
id, _ := r.Context().Value(requestIDKey{}).(string)
return id
}
func logger(r *http.Request) *slog.Logger { return observability.Logger(r.Context()) }
// chain applies middleware in the order given (first is outermost).
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func (s *Server) requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b [8]byte
rand.Read(b[:]) //nolint:errcheck
id := hex.EncodeToString(b[:])
ip := auth.ClientIP(r, false)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
ctx = observability.WithLogger(ctx, s.deps.Log.With("request_id", id))
ctx = audit.WithIP(ctx, ip)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) { w.status = code; w.ResponseWriter.WriteHeader(code) }
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = 200
}
return w.ResponseWriter.Write(b)
}
// Flush lets SSE handlers flush through the wrapper.
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w}
next.ServeHTTP(sw, r)
route := r.Pattern
if route == "" {
route = "unmatched"
}
d := time.Since(start)
s.deps.Metrics.HTTPRequests.WithLabelValues(route, strconv.Itoa(sw.status)).Inc()
s.deps.Metrics.HTTPDuration.WithLabelValues(route).Observe(d.Seconds())
logger(r).Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "ms", d.Milliseconds())
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler {
panic(rec)
}
logger(r).Error("handler panicked", "panic", rec, "stack", string(debug.Stack()))
writeError(w, r, http.StatusInternalServerError, "internal", "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
// securityHeaders sets a strict CSP (no inline scripts, self only) and the
// usual hardening headers (T10).
func (s *Server) securityHeaders(next http.Handler) http.Handler {
csp := "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", csp)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if s.deps.Config.Production() {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func (s *Server) bodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, s.deps.Config.MaxRequestBody)
}
next.ServeHTTP(w, r)
})
}
// apiAuthFail is the RequireRole failure handler for JSON routes.
func apiAuthFail(w http.ResponseWriter, r *http.Request, authenticated bool) {
if authenticated {
writeError(w, r, http.StatusForbidden, "forbidden", "insufficient role")
return
}
writeError(w, r, http.StatusUnauthorized, "unauthenticated", "authentication required")
}
// require wraps a handler with a role check for API routes.
func require(min auth.Role, h http.HandlerFunc) http.Handler {
return auth.RequireRole(min, apiAuthFail)(h)
}
+75
View File
@@ -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"`
}
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/auth"
)
// Server composes the API routes, the web UI and the middleware chain.
type Server struct {
deps app.Deps
mux *http.ServeMux
}
// New builds the server. ui registers the HTML routes on the same mux so that
// a single middleware chain (auth, CSRF, headers, limits) covers both.
func New(deps app.Deps, ui interface{ Register(*http.ServeMux) }) *Server {
s := &Server{deps: deps, mux: http.NewServeMux()}
s.routes()
if ui != nil {
ui.Register(s.mux)
}
return s
}
// Handler returns the fully wrapped root handler.
func (s *Server) Handler() http.Handler {
return chain(s.mux, s.requestID, s.recoverer, s.securityHeaders, s.bodyLimit, s.logging, s.deps.Auth.Middleware)
}
func (s *Server) routes() {
m := s.mux
m.HandleFunc("GET /healthz", s.healthz)
m.HandleFunc("GET /readyz", s.readyz)
// Metrics need a token with at least observer role; Prometheus supports bearer_token.
m.Handle("GET /metrics", require(auth.RoleObserver, promhttp.HandlerFor(s.deps.Metrics.Registry, promhttp.HandlerOpts{}).ServeHTTP))
m.Handle("GET /api/v1/system/status", require(auth.RoleObserver, s.systemStatus))
m.Handle("GET /api/v1/audit", require(auth.RoleOperator, s.auditList))
m.Handle("GET /api/v1/users", require(auth.RoleAdministrator, s.usersList))
m.Handle("POST /api/v1/users", require(auth.RoleAdministrator, s.usersCreate))
m.Handle("PATCH /api/v1/users/{id}", require(auth.RoleAdministrator, s.usersPatch))
m.Handle("GET /api/v1/me", require(auth.RoleObserver, s.me))
m.Handle("GET /api/v1/me/sessions", require(auth.RoleObserver, s.mySessions))
m.Handle("DELETE /api/v1/me/sessions/{id}", require(auth.RoleObserver, s.mySessionRevoke))
m.Handle("POST /api/v1/me/tokens", require(auth.RoleObserver, s.myTokenCreate))
m.Handle("GET /api/v1/me/tokens", require(auth.RoleObserver, s.myTokens))
m.Handle("DELETE /api/v1/me/tokens/{id}", require(auth.RoleObserver, s.myTokenRevoke))
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
})
}
+99
View File
@@ -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})
}
+199
View File
@@ -0,0 +1,199 @@
package api
import (
"net/http"
"time"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/pkg/ids"
)
type userOut struct {
ID ids.ID `json:"id"`
Username string `json:"username"`
Role auth.Role `json:"role"`
Disabled bool `json:"disabled"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt time.Time `json:"created_at"`
}
func toUserOut(u auth.User) userOut {
return userOut{ID: u.ID, Username: u.Username, Role: u.Role, Disabled: u.Disabled, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt}
}
func (s *Server) usersList(w http.ResponseWriter, r *http.Request) {
users, err := s.deps.Auth.ListUsers(r.Context())
if err != nil {
fail(w, r, err)
return
}
out := make([]userOut, 0, len(users))
for _, u := range users {
out = append(out, toUserOut(u))
}
writeJSON(w, http.StatusOK, page[userOut]{Items: out})
}
func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
var in struct {
Username string `json:"username"`
Password string `json:"password"`
Role auth.Role `json:"role"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
u, err := s.deps.Auth.CreateUser(r.Context(), in.Username, in.Password, in.Role)
if err != nil {
fail(w, r, wrapInput(err))
return
}
s.deps.Audit.Log(r.Context(), "user.create", "user", u.ID.String(), map[string]any{"username": u.Username, "role": u.Role})
writeJSON(w, http.StatusCreated, toUserOut(u))
}
func (s *Server) usersPatch(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("invalid id"))
return
}
var in struct {
Role *auth.Role `json:"role"`
Disabled *bool `json:"disabled"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
p, _ := auth.PrincipalFrom(r.Context())
if p.User.ID == id && ((in.Disabled != nil && *in.Disabled) || (in.Role != nil && *in.Role != auth.RoleAdministrator)) {
fail(w, r, ValidationError("you cannot disable or demote your own account"))
return
}
if in.Role != nil {
if err := s.deps.Auth.SetUserRole(r.Context(), id, *in.Role); err != nil {
fail(w, r, wrapInput(err))
return
}
s.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": *in.Role})
}
if in.Disabled != nil {
if err := s.deps.Auth.SetUserDisabled(r.Context(), id, *in.Disabled); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "user.disabled", "user", id.String(), map[string]any{"disabled": *in.Disabled})
}
u, err := s.deps.Auth.GetUser(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, toUserOut(u))
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
writeJSON(w, http.StatusOK, toUserOut(p.User))
}
func (s *Server) mySessions(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
list, err := s.deps.Auth.ListSessions(r.Context(), p.User.ID)
if err != nil {
fail(w, r, err)
return
}
type out struct {
ID ids.ID `json:"id"`
Current bool `json:"current"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ExpiresAt time.Time `json:"expires_at"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
}
items := make([]out, 0, len(list))
for _, x := range list {
items = append(items, out{x.ID, x.ID == p.SessionID, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.IP, x.UserAgent})
}
writeJSON(w, http.StatusOK, page[out]{Items: items})
}
func (s *Server) mySessionRevoke(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("invalid id"))
return
}
if err := s.deps.Auth.RevokeSession(r.Context(), p.User.ID, id); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "session.revoke", "session", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) myTokenCreate(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
var in struct {
Name string `json:"name"`
ExpiresAt *time.Time `json:"expires_at"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
if in.Name == "" {
fail(w, r, ValidationError("name is required"))
return
}
tok, err := s.deps.Auth.CreateAPIToken(r.Context(), p.User.ID, in.Name, in.ExpiresAt)
if err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": in.Name})
writeJSON(w, http.StatusCreated, map[string]string{"token": tok, "note": "store this now; it is not shown again"})
}
func (s *Server) myTokens(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
list, err := s.deps.Auth.ListAPITokens(r.Context(), p.User.ID)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
func (s *Server) myTokenRevoke(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("invalid id"))
return
}
if err := s.deps.Auth.RevokeAPIToken(r.Context(), p.User.ID, id); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "token.revoke", "token", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
// wrapInput turns validation-style errors from services into 400s. Services
// return plain errors for bad input; anything wrapping storage sentinels is
// left alone.
func wrapInput(err error) error {
if err == nil {
return nil
}
if isStorageErr(err) {
return err
}
return ValidationError(err.Error())
}