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,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user