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:
@@ -5,4 +5,5 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog;
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Phase 1 foundation: configuration, structured logging, metrics, migrations, local authentication (Argon2id, sessions, CSRF, lockout, roles, API tokens), audit log, JSON API skeleton, server-rendered admin UI with light/dark theme, `noemad` and `noemactl`.
|
||||||
- Repository foundation: plan, architecture, security model, threat model, ADRs, Makefile, container files.
|
- Repository foundation: plan, architecture, security model, threat model, ADRs, Makefile, container files.
|
||||||
|
|||||||
@@ -35,20 +35,20 @@ An administrator can: start Noema → log in → create a mind → configure per
|
|||||||
- ☑ `go.mod`, compose and Dockerfile skeletons
|
- ☑ `go.mod`, compose and Dockerfile skeletons
|
||||||
- ☑ First commit
|
- ☑ First commit
|
||||||
|
|
||||||
### Phase 1 – Foundation: config, logging, storage, migrations, auth, base UI
|
### Phase 1 – Foundation: config, logging, storage, migrations, auth, base UI ☑
|
||||||
- ☐ `internal/config`: defaults → JSON file → env; validation; no secrets in `String()`
|
- ☑ `internal/config`: defaults → JSON file → env; validation; redaction
|
||||||
- ☐ `internal/observability`: slog setup, redacting handler, request logging, Prometheus registry, `/metrics`
|
- ☑ `internal/observability`: slog with redacting handler, Prometheus registry, `/metrics` (token-protected)
|
||||||
- ☐ `pkg/ids`: UUIDv7
|
- ☑ `pkg/ids`: UUIDv7
|
||||||
- ☐ `internal/storage`: pgxpool, migration runner (advisory lock, checksums, up/down/status), test helper with per-run schema
|
- ☑ `internal/storage`: pgxpool with statement timeout, migration runner (advisory lock, checksums, up/down/status), per-test schema helper
|
||||||
- ☐ `migrations/0001_foundation`: users, sessions, api_tokens, login_attempts, audit_log (append-only trigger), settings
|
- ☑ `migrations/0001_foundation`: append-only guard trigger, users, sessions, api_tokens, recovery_codes, audit_log, settings
|
||||||
- ☐ `internal/auth`: Argon2id, sessions, CSRF, rate limit + lockout, roles, middleware, bootstrap admin
|
- ☑ `internal/auth`: Argon2id, opaque hashed sessions with idle/absolute expiry, CSRF (token + Origin check), per-IP limiter, per-account lockout, roles, middleware, API tokens
|
||||||
- ☐ `internal/audit`: audit writer used by all admin mutations
|
- ☑ `internal/audit`: audit writer and listing
|
||||||
- ☐ `internal/api`: server, router, middleware chain (request id, recover, logging, security headers, CSP, body limits), JSON error format, health endpoints
|
- ☑ `internal/api`: server, middleware chain (request id, recover, CSP/security headers, body limits, logging/metrics, auth), JSON error envelope, health, status, users, sessions, tokens, audit
|
||||||
- ☐ `web/`: layout, theme (light/dark), navigation, login, users, sessions, system page skeleton, `app.js` (theme, SSE helper)
|
- ☑ `web/` + `internal/ui`: layout, light/dark theme, navigation, login, first-run setup, dashboard, system, users, account (sessions, tokens, password), audit, about; `app.js` (theme, CSRF fetch, SSE helper, keyboard shortcuts)
|
||||||
- ☐ `cmd/noemad`, `cmd/noemactl` (`status`, `migrate up|down|status`, `user create`)
|
- ☑ `cmd/noemad` (graceful shutdown, bootstrap admin, supervised background tasks), `cmd/noemactl` (`status`, `migrate up|down|status`, `user create`, exit codes)
|
||||||
- ☐ `deploy/`: compose (db + app), multi-stage Dockerfile
|
- ☑ `deploy/`: compose (pgvector db + app), multi-stage Dockerfile
|
||||||
- ☐ Tests: config precedence, migration up/down, auth hashing, session lifecycle, CSRF, lockout, role middleware
|
- ☑ Tests: config precedence, migration up/down + append-only trigger, hashing, session lifecycle, lockout, CSRF, roles, API envelope
|
||||||
- ☐ Commit(s)
|
- ☐ TOTP + recovery codes (moved to Phase 16 with the rest of hardening)
|
||||||
|
|
||||||
### Phase 2 – Mind model, identity, personality, self-model
|
### Phase 2 – Mind model, identity, personality, self-model
|
||||||
- ☐ `migrations/0002_minds`: minds, mind_config, personalities, personality_versions (append-only), personality_templates, self_models
|
- ☐ `migrations/0002_minds`: minds, mind_config, personalities, personality_versions (append-only), personality_templates, self_models
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ go 1.26.1
|
|||||||
require (
|
require (
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/prometheus/client_golang v1.24.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
|
golang.org/x/crypto v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -17,8 +18,8 @@ require (
|
|||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.70.1 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/procfs v0.21.1 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,12 +36,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Package app holds the dependency bundle shared by the HTTP API, the web UI
|
||||||
|
// and the CLI-facing services. It exists so that constructors take one typed
|
||||||
|
// value instead of a growing parameter list, without resorting to globals.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/andydixon/noema/internal/audit"
|
||||||
|
"github.com/andydixon/noema/internal/auth"
|
||||||
|
"github.com/andydixon/noema/internal/config"
|
||||||
|
"github.com/andydixon/noema/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deps is everything a handler may need. Fields are added per phase.
|
||||||
|
type Deps struct {
|
||||||
|
Config config.Config
|
||||||
|
Log *slog.Logger
|
||||||
|
DB *pgxpool.Pool
|
||||||
|
Auth *auth.Service
|
||||||
|
Audit *audit.Logger
|
||||||
|
Metrics *observability.Metrics
|
||||||
|
Migrations fs.FS
|
||||||
|
Started time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package audit records administrative actions in the append-only audit_log.
|
||||||
|
// Every mutation performed by an operator through the UI, API or CLI goes
|
||||||
|
// through Log so that the Audit page is complete.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/andydixon/noema/internal/auth"
|
||||||
|
"github.com/andydixon/noema/internal/storage"
|
||||||
|
"github.com/andydixon/noema/pkg/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one audit record.
|
||||||
|
type Entry struct {
|
||||||
|
ID ids.ID `json:"id"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
At string `json:"at"`
|
||||||
|
ActorKind string `json:"actor_kind"`
|
||||||
|
ActorID string `json:"actor_id"`
|
||||||
|
ActorName string `json:"actor_name"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
TargetKind string `json:"target_kind"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
Details json.RawMessage `json:"details"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger writes audit entries.
|
||||||
|
type Logger struct {
|
||||||
|
db storage.Querier
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an audit logger.
|
||||||
|
func New(db storage.Querier, log *slog.Logger) *Logger { return &Logger{db: db, log: log} }
|
||||||
|
|
||||||
|
// Log records an action. The actor is taken from the request principal when
|
||||||
|
// present, otherwise the action is attributed to the system. Failures are
|
||||||
|
// logged but never block the underlying operation. Details must not contain
|
||||||
|
// secrets; callers pass structured, already-redacted values.
|
||||||
|
func (l *Logger) Log(ctx context.Context, action, targetKind, targetID string, details any) {
|
||||||
|
l.LogAs(ctx, "", "", action, targetKind, targetID, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogAs records an action on behalf of a named actor kind ("mind" for
|
||||||
|
// cognitive actions surfaced to the audit trail) when no principal applies.
|
||||||
|
func (l *Logger) LogAs(ctx context.Context, actorKind, actorID, action, targetKind, targetID string, details any) {
|
||||||
|
kind, id, name, ip := "system", "", "", ""
|
||||||
|
if p, ok := auth.PrincipalFrom(ctx); ok {
|
||||||
|
kind, id, name = "user", p.User.ID.String(), p.User.Username
|
||||||
|
}
|
||||||
|
if actorKind != "" {
|
||||||
|
kind, id = actorKind, actorID
|
||||||
|
}
|
||||||
|
if v, ok := ctx.Value(ipKey{}).(string); ok {
|
||||||
|
ip = v
|
||||||
|
}
|
||||||
|
if details == nil {
|
||||||
|
details = map[string]any{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(details)
|
||||||
|
if err != nil {
|
||||||
|
raw = []byte(`{"marshal_error":true}`)
|
||||||
|
}
|
||||||
|
_, err = l.db.Exec(ctx, `INSERT INTO audit_log (id, actor_kind, actor_id, actor_name, action, target_kind, target_id, details, ip)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, ids.New(), kind, id, name, action, targetKind, targetID, raw, ip)
|
||||||
|
if err != nil {
|
||||||
|
l.log.Error("audit write failed", "action", action, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipKey struct{}
|
||||||
|
|
||||||
|
// WithIP attaches the client address so entries record where actions came from.
|
||||||
|
func WithIP(ctx context.Context, ip string) context.Context {
|
||||||
|
return context.WithValue(ctx, ipKey{}, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns entries newest first, paginated by seq cursor (0 = latest).
|
||||||
|
func (l *Logger) List(ctx context.Context, beforeSeq int64, limit int, targetKind, targetID string) ([]Entry, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := l.db.Query(ctx, `SELECT id, seq, at::text, actor_kind, actor_id, actor_name, action, target_kind, target_id, details, ip
|
||||||
|
FROM audit_log WHERE ($1 = 0 OR seq < $1) AND ($3 = '' OR target_kind = $3) AND ($4 = '' OR target_id = $4)
|
||||||
|
ORDER BY seq DESC LIMIT $2`, beforeSeq, limit, targetKind, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Entry
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entry
|
||||||
|
if err := rows.Scan(&e.ID, &e.Seq, &e.At, &e.ActorKind, &e.ActorID, &e.ActorName, &e.Action, &e.TargetKind, &e.TargetID, &e.Details, &e.IP); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andydixon/noema/internal/storage"
|
||||||
|
"github.com/andydixon/noema/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordHashing(t *testing.T) {
|
||||||
|
h, err := HashPassword("correct horse battery")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(h, "$argon2id$") {
|
||||||
|
t.Fatal("not argon2id")
|
||||||
|
}
|
||||||
|
if ok, _ := VerifyPassword(h, "correct horse battery"); !ok {
|
||||||
|
t.Fatal("should verify")
|
||||||
|
}
|
||||||
|
if ok, _ := VerifyPassword(h, "wrong"); ok {
|
||||||
|
t.Fatal("should not verify")
|
||||||
|
}
|
||||||
|
if _, err := HashPassword("short"); err == nil {
|
||||||
|
t.Fatal("short password accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockoutSchedule(t *testing.T) {
|
||||||
|
if lockoutDuration(4) != 0 || lockoutDuration(5) != 30*time.Second || lockoutDuration(7) != 2*time.Minute || lockoutDuration(40) != time.Hour {
|
||||||
|
t.Fatal("unexpected lockout schedule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPLimiter(t *testing.T) {
|
||||||
|
l := newIPLimiter(60, 3)
|
||||||
|
now := time.Now()
|
||||||
|
l.now = func() time.Time { return now }
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("burst should allow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.Allow("a") {
|
||||||
|
t.Fatal("should be limited")
|
||||||
|
}
|
||||||
|
if !l.Allow("b") {
|
||||||
|
t.Fatal("other key unaffected")
|
||||||
|
}
|
||||||
|
now = now.Add(2 * time.Second)
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("should refill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSessionLifecycle(t *testing.T) {
|
||||||
|
pool := storage.TestPool(t, migrations.FS)
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := NewService(pool, time.Hour, 2*time.Hour)
|
||||||
|
if _, err := svc.CreateUser(ctx, "andy", "a very long password", RoleAdministrator); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.CreateUser(ctx, "ANDY", "a very long password", RoleObserver); !errors.Is(err, storage.ErrConflict) {
|
||||||
|
t.Fatalf("expected conflict on case-insensitive duplicate, got %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.Login(ctx, "andy", "nope nope nope", "10.0.0.1", "t"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("expected invalid credentials, got %v", err)
|
||||||
|
}
|
||||||
|
sess, token, err := svc.Login(ctx, "Andy", "a very long password", "10.0.0.1", "t")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := svc.Authenticate(ctx, token)
|
||||||
|
if err != nil || p.User.Username != "andy" || p.SessionID != sess.ID || !p.ViaCookie {
|
||||||
|
t.Fatalf("authenticate: %v %+v", err, p)
|
||||||
|
}
|
||||||
|
if _, err := svc.Authenticate(ctx, "bogus"); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatal("bogus token accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lockout after repeated failures.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
svc.Login(ctx, "andy", "wrong wrong wrong", "10.0.0.2", "t") //nolint:errcheck
|
||||||
|
}
|
||||||
|
if _, _, err := svc.Login(ctx, "andy", "a very long password", "10.0.0.2", "t"); !errors.Is(err, ErrLocked) {
|
||||||
|
t.Fatalf("expected lock, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle expiry.
|
||||||
|
svc.now = func() time.Time { return time.Now().Add(61 * time.Minute) }
|
||||||
|
if _, err := svc.Authenticate(ctx, token); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatal("expired session accepted")
|
||||||
|
}
|
||||||
|
svc.now = time.Now
|
||||||
|
|
||||||
|
// API token.
|
||||||
|
raw, err := svc.CreateAPIToken(ctx, p.User.ID, "cli", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ap, err := svc.AuthenticateToken(ctx, strings.TrimPrefix(raw, "noema_"))
|
||||||
|
if err != nil || ap.ViaCookie || ap.User.Role != RoleAdministrator {
|
||||||
|
t.Fatalf("api token: %v %+v", err, ap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabling revokes sessions.
|
||||||
|
sess2, token2, _ := svc.Login(ctx, "andy", "a very long password", "10.0.0.3", "t")
|
||||||
|
_ = sess2
|
||||||
|
if err := svc.SetUserDisabled(ctx, p.User.ID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Authenticate(ctx, token2); err == nil {
|
||||||
|
t.Fatal("disabled user session accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareCSRFAndRoles(t *testing.T) {
|
||||||
|
pool := storage.TestPool(t, migrations.FS)
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := NewService(pool, time.Hour, 2*time.Hour)
|
||||||
|
svc.CreateUser(ctx, "op", "a very long password", RoleOperator) //nolint:errcheck
|
||||||
|
sess, token, err := svc.Login(ctx, "op", "a very long password", "127.0.0.1", "t")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var failed bool
|
||||||
|
onFail := func(w http.ResponseWriter, r *http.Request, authed bool) { failed = true; w.WriteHeader(401) }
|
||||||
|
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||||
|
h := svc.Middleware(RequireRole(RoleOperator, onFail)(ok))
|
||||||
|
adminOnly := svc.Middleware(RequireRole(RoleAdministrator, onFail)(ok))
|
||||||
|
|
||||||
|
do := func(handler http.Handler, method, csrf, origin string, cookie bool) int {
|
||||||
|
req := httptest.NewRequest(method, "http://noema.local/x", nil)
|
||||||
|
if cookie {
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
|
||||||
|
}
|
||||||
|
if csrf != "" {
|
||||||
|
req.Header.Set(CSRFHeader, csrf)
|
||||||
|
}
|
||||||
|
if origin != "" {
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
if do(h, "GET", "", "", false) != 401 || !failed {
|
||||||
|
t.Fatal("unauthenticated GET should fail")
|
||||||
|
}
|
||||||
|
if do(h, "GET", "", "", true) != 200 {
|
||||||
|
t.Fatal("authenticated GET should pass")
|
||||||
|
}
|
||||||
|
if do(h, "POST", "", "", true) != 403 {
|
||||||
|
t.Fatal("POST without CSRF should be forbidden")
|
||||||
|
}
|
||||||
|
if do(h, "POST", sess.CSRFToken, "", true) != 200 {
|
||||||
|
t.Fatal("POST with CSRF should pass")
|
||||||
|
}
|
||||||
|
if do(h, "POST", sess.CSRFToken, "http://evil.example", true) != 403 {
|
||||||
|
t.Fatal("cross-origin POST should be forbidden")
|
||||||
|
}
|
||||||
|
if do(h, "POST", "wrong", "", true) != 403 {
|
||||||
|
t.Fatal("wrong CSRF should be forbidden")
|
||||||
|
}
|
||||||
|
if do(adminOnly, "GET", "", "", true) != 401 {
|
||||||
|
t.Fatal("operator should not reach admin route")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ipLimiter is a token bucket per client address for login attempts. It lives
|
||||||
|
// in memory because it only needs to slow down a single daemon's attackers;
|
||||||
|
// per-account lockout is persisted in the database.
|
||||||
|
type ipLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets map[string]*bucket
|
||||||
|
rate float64 // tokens per second
|
||||||
|
burst float64
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
tokens float64
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIPLimiter(perMinute, burst int) *ipLimiter {
|
||||||
|
return &ipLimiter{buckets: map[string]*bucket{}, rate: float64(perMinute) / 60, burst: float64(burst), now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow consumes a token for key if one is available.
|
||||||
|
func (l *ipLimiter) Allow(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := l.now()
|
||||||
|
b := l.buckets[key]
|
||||||
|
if b == nil {
|
||||||
|
b = &bucket{tokens: l.burst, last: now}
|
||||||
|
l.buckets[key] = b
|
||||||
|
}
|
||||||
|
b.tokens += now.Sub(b.last).Seconds() * l.rate
|
||||||
|
if b.tokens > l.burst {
|
||||||
|
b.tokens = l.burst
|
||||||
|
}
|
||||||
|
b.last = now
|
||||||
|
if b.tokens < 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sweep drops buckets that have refilled, bounding memory (T18).
|
||||||
|
func (l *ipLimiter) Sweep() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := l.now()
|
||||||
|
for k, b := range l.buckets {
|
||||||
|
if b.tokens+now.Sub(b.last).Seconds()*l.rate >= l.burst {
|
||||||
|
delete(l.buckets, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockoutDuration grows exponentially with consecutive failures, capped at
|
||||||
|
// one hour, and starts after five failures so typos are not punished.
|
||||||
|
func lockoutDuration(failed int) time.Duration {
|
||||||
|
if failed < 5 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if failed > 12 { // 2^7 * 30s already exceeds the cap; avoid shift overflow
|
||||||
|
return time.Hour
|
||||||
|
}
|
||||||
|
d := time.Duration(1<<uint(failed-5)) * 30 * time.Second
|
||||||
|
if d > time.Hour {
|
||||||
|
d = time.Hour
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CookieName is the session cookie.
|
||||||
|
const CookieName = "noema_session"
|
||||||
|
|
||||||
|
// CSRFHeader carries the CSRF token for fetch/XHR requests; forms use the
|
||||||
|
// hidden field named CSRFField.
|
||||||
|
const (
|
||||||
|
CSRFHeader = "X-CSRF-Token"
|
||||||
|
CSRFField = "_csrf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type principalKey struct{}
|
||||||
|
|
||||||
|
// PrincipalFrom returns the authenticated principal, if any.
|
||||||
|
func PrincipalFrom(ctx context.Context) (Principal, bool) {
|
||||||
|
p, ok := ctx.Value(principalKey{}).(Principal)
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrincipal is exported for tests and internal callers that construct contexts.
|
||||||
|
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
||||||
|
return context.WithValue(ctx, principalKey{}, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware resolves the session cookie or bearer token into a Principal and
|
||||||
|
// enforces CSRF for cookie sessions on unsafe methods. Unauthenticated requests
|
||||||
|
// pass through without a principal; route guards decide what to do.
|
||||||
|
func (s *Service) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p Principal
|
||||||
|
var authed bool
|
||||||
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer noema_") {
|
||||||
|
if pp, err := s.AuthenticateToken(r.Context(), strings.TrimPrefix(h, "Bearer noema_")); err == nil {
|
||||||
|
p, authed = pp, true
|
||||||
|
}
|
||||||
|
} else if c, err := r.Cookie(CookieName); err == nil && c.Value != "" {
|
||||||
|
if pp, err := s.Authenticate(r.Context(), c.Value); err == nil {
|
||||||
|
p, authed = pp, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if authed && p.ViaCookie && !safeMethod(r.Method) {
|
||||||
|
if !s.csrfOK(r, p) {
|
||||||
|
http.Error(w, "CSRF token missing or invalid", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if authed {
|
||||||
|
r = r.WithContext(WithPrincipal(r.Context(), p))
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeMethod(m string) bool {
|
||||||
|
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) csrfOK(r *http.Request, p Principal) bool {
|
||||||
|
// Origin/Referer must match the host when present (defence in depth).
|
||||||
|
if o := r.Header.Get("Origin"); o != "" {
|
||||||
|
if u, err := url.Parse(o); err != nil || !strings.EqualFold(u.Host, r.Host) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if ref := r.Header.Get("Referer"); ref != "" {
|
||||||
|
if u, err := url.Parse(ref); err != nil || !strings.EqualFold(u.Host, r.Host) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want, err := s.CSRFToken(r.Context(), p.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got := r.Header.Get(CSRFHeader)
|
||||||
|
if got == "" {
|
||||||
|
if ct := r.Header.Get("Content-Type"); strings.HasPrefix(ct, "application/x-www-form-urlencoded") || strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
got = r.PostFormValue(CSRFField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRole returns middleware that rejects requests below min. onFail is
|
||||||
|
// invoked for unauthenticated or under-privileged requests so the UI can
|
||||||
|
// redirect to login while the API returns JSON.
|
||||||
|
func RequireRole(min Role, onFail func(w http.ResponseWriter, r *http.Request, authenticated bool)) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, ok := PrincipalFrom(r.Context())
|
||||||
|
if !ok {
|
||||||
|
onFail(w, r, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !p.User.Role.AtLeast(min) {
|
||||||
|
onFail(w, r, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIP extracts the peer address. Proxy headers are deliberately ignored
|
||||||
|
// unless the daemon is told it sits behind a trusted proxy; secure default.
|
||||||
|
func ClientIP(r *http.Request, trustProxy bool) string {
|
||||||
|
if trustProxy {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionCookie writes the session cookie with secure attributes.
|
||||||
|
func SetSessionCookie(w http.ResponseWriter, token string, secure bool, maxAge int) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSessionCookie expires the session cookie.
|
||||||
|
func ClearSessionCookie(w http.ResponseWriter, secure bool) {
|
||||||
|
SetSessionCookie(w, "", secure, -1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Package auth implements local authentication: Argon2id passwords, opaque
|
||||||
|
// sessions, CSRF tokens, login throttling, roles and HTTP middleware
|
||||||
|
// (ADR-0006).
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Argon2id parameters: 64 MiB, 3 passes, 2 lanes. Roughly 100 ms on the
|
||||||
|
// development machine, which throttles offline guessing without hurting login.
|
||||||
|
const (
|
||||||
|
argonTime = 3
|
||||||
|
argonMemory = 64 * 1024
|
||||||
|
argonThreads = 2
|
||||||
|
argonKeyLen = 32
|
||||||
|
saltLen = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinPasswordLength is the shortest password accepted anywhere.
|
||||||
|
const MinPasswordLength = 12
|
||||||
|
|
||||||
|
// HashPassword returns a PHC-formatted Argon2id hash.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
if len(password) < MinPasswordLength {
|
||||||
|
return "", fmt.Errorf("password must be at least %d characters", MinPasswordLength)
|
||||||
|
}
|
||||||
|
salt := make([]byte, saltLen)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword reports whether password matches the PHC hash. It parses the
|
||||||
|
// parameters from the hash so they can be raised later without invalidating
|
||||||
|
// existing users.
|
||||||
|
func VerifyPassword(hash, password string) (bool, error) {
|
||||||
|
parts := strings.Split(hash, "$")
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return false, errors.New("unsupported hash format")
|
||||||
|
}
|
||||||
|
var m uint32
|
||||||
|
var t uint32
|
||||||
|
var p uint8
|
||||||
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
||||||
|
return false, fmt.Errorf("bad hash params: %w", err)
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dummyHash is verified against when a username does not exist so that the
|
||||||
|
// response time does not reveal whether the account exists.
|
||||||
|
var dummyHash, _ = HashPassword("noema-timing-equalisation-password")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
// Role is a coarse permission level. Higher roles include lower ones.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
// Roles in ascending order of privilege.
|
||||||
|
const (
|
||||||
|
RoleObserver Role = "observer"
|
||||||
|
RoleOperator Role = "operator"
|
||||||
|
RoleAdministrator Role = "administrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
var roleRank = map[Role]int{RoleObserver: 1, RoleOperator: 2, RoleAdministrator: 3}
|
||||||
|
|
||||||
|
// Valid reports whether r is a known role.
|
||||||
|
func (r Role) Valid() bool { return roleRank[r] > 0 }
|
||||||
|
|
||||||
|
// AtLeast reports whether r grants everything min grants.
|
||||||
|
func (r Role) AtLeast(min Role) bool { return roleRank[r] >= roleRank[min] }
|
||||||
|
|
||||||
|
// AllRoles lists valid roles for forms and validation.
|
||||||
|
func AllRoles() []Role { return []Role{RoleObserver, RoleOperator, RoleAdministrator} }
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/andydixon/noema/internal/storage"
|
||||||
|
"github.com/andydixon/noema/pkg/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is an operator account.
|
||||||
|
type User struct {
|
||||||
|
ID ids.ID
|
||||||
|
Username string
|
||||||
|
Role Role
|
||||||
|
Disabled bool
|
||||||
|
TOTPEnabled bool
|
||||||
|
LockedUntil *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is an authenticated browser or CLI session.
|
||||||
|
type Session struct {
|
||||||
|
ID ids.ID
|
||||||
|
UserID ids.ID
|
||||||
|
CSRFToken string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastSeenAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Principal is the authenticated identity attached to a request context.
|
||||||
|
type Principal struct {
|
||||||
|
User User
|
||||||
|
SessionID ids.ID // empty for API-token authentication
|
||||||
|
ViaCookie bool // cookie sessions need CSRF checks; bearer tokens do not
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors returned by the service.
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
ErrLocked = errors.New("account temporarily locked")
|
||||||
|
ErrDisabled = errors.New("account disabled")
|
||||||
|
ErrRateLimited = errors.New("too many attempts")
|
||||||
|
ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service performs authentication operations against the database.
|
||||||
|
type Service struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
idleTTL time.Duration
|
||||||
|
absoluteTTL time.Duration
|
||||||
|
limiter *ipLimiter
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates the authentication service.
|
||||||
|
func NewService(db *pgxpool.Pool, idleTTL, absoluteTTL time.Duration) *Service {
|
||||||
|
return &Service{db: db, idleTTL: idleTTL, absoluteTTL: absoluteTTL, limiter: newIPLimiter(20, 10), now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepLimiter releases idle rate-limit buckets; call periodically.
|
||||||
|
func (s *Service) SweepLimiter() { s.limiter.Sweep() }
|
||||||
|
|
||||||
|
// CreateUser adds an account. Usernames are unique case-insensitively.
|
||||||
|
func (s *Service) CreateUser(ctx context.Context, username, password string, role Role) (User, error) {
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
if len(username) < 2 || len(username) > 64 {
|
||||||
|
return User{}, errors.New("username must be 2-64 characters")
|
||||||
|
}
|
||||||
|
if !role.Valid() {
|
||||||
|
return User{}, fmt.Errorf("invalid role %q", role)
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
u := User{ID: ids.New(), Username: username, Role: role, CreatedAt: s.now()}
|
||||||
|
_, err = s.db.Exec(ctx, `INSERT INTO users (id, username, password_hash, role, created_at) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
u.ID, u.Username, hash, string(u.Role), u.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, storage.Translate(err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUsers is used to decide whether bootstrap is needed.
|
||||||
|
func (s *Service) CountUsers(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns all accounts ordered by name.
|
||||||
|
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `SELECT id, username, role, disabled, totp_enabled, locked_until, created_at FROM users ORDER BY lower(username)`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
var role string
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &role, &u.Disabled, &u.TOTPEnabled, &u.LockedUntil, &u.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.Role = Role(role)
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser fetches one account.
|
||||||
|
func (s *Service) GetUser(ctx context.Context, id ids.ID) (User, error) {
|
||||||
|
var u User
|
||||||
|
var role string
|
||||||
|
err := s.db.QueryRow(ctx, `SELECT id, username, role, disabled, totp_enabled, locked_until, created_at FROM users WHERE id=$1`, id).
|
||||||
|
Scan(&u.ID, &u.Username, &role, &u.Disabled, &u.TOTPEnabled, &u.LockedUntil, &u.CreatedAt)
|
||||||
|
u.Role = Role(role)
|
||||||
|
return u, storage.Translate(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserRole changes an account's role.
|
||||||
|
func (s *Service) SetUserRole(ctx context.Context, id ids.ID, role Role) error {
|
||||||
|
if !role.Valid() {
|
||||||
|
return fmt.Errorf("invalid role %q", role)
|
||||||
|
}
|
||||||
|
tag, err := s.db.Exec(ctx, `UPDATE users SET role=$2, updated_at=now() WHERE id=$1`, id, string(role))
|
||||||
|
if err == nil && tag.RowsAffected() == 0 {
|
||||||
|
return storage.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserDisabled enables or disables an account and revokes its sessions when disabling.
|
||||||
|
func (s *Service) SetUserDisabled(ctx context.Context, id ids.ID, disabled bool) error {
|
||||||
|
tag, err := s.db.Exec(ctx, `UPDATE users SET disabled=$2, updated_at=now() WHERE id=$1`, id, disabled)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return storage.ErrNotFound
|
||||||
|
}
|
||||||
|
if disabled {
|
||||||
|
_, err = s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, id)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword sets a new password and revokes every other session (T12).
|
||||||
|
func (s *Service) ChangePassword(ctx context.Context, id ids.ID, newPassword string, keepSession ids.ID) error {
|
||||||
|
hash, err := HashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(ctx, `UPDATE users SET password_hash=$2, password_changed_at=now(), updated_at=now() WHERE id=$1`, id, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL`, id, keepSession)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login verifies credentials, applying per-IP and per-account throttling, and
|
||||||
|
// returns a new session plus the raw token to set as a cookie.
|
||||||
|
func (s *Service) Login(ctx context.Context, username, password, ip, userAgent string) (Session, string, error) {
|
||||||
|
if !s.limiter.Allow(clientKey(ip)) {
|
||||||
|
return Session{}, "", ErrRateLimited
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
u User
|
||||||
|
role string
|
||||||
|
hash string
|
||||||
|
failed int
|
||||||
|
disabled bool
|
||||||
|
)
|
||||||
|
err := s.db.QueryRow(ctx, `SELECT id, username, role, password_hash, disabled, failed_attempts, locked_until, totp_enabled FROM users WHERE lower(username)=lower($1)`, strings.TrimSpace(username)).
|
||||||
|
Scan(&u.ID, &u.Username, &role, &hash, &disabled, &failed, &u.LockedUntil, &u.TOTPEnabled)
|
||||||
|
if err != nil {
|
||||||
|
VerifyPassword(dummyHash, password) //nolint:errcheck // timing equalisation only
|
||||||
|
return Session{}, "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
u.Role = Role(role)
|
||||||
|
if u.LockedUntil != nil && u.LockedUntil.After(s.now()) {
|
||||||
|
return Session{}, "", ErrLocked
|
||||||
|
}
|
||||||
|
ok, err := VerifyPassword(hash, password)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
failed++
|
||||||
|
var until *time.Time
|
||||||
|
if d := lockoutDuration(failed); d > 0 {
|
||||||
|
t := s.now().Add(d)
|
||||||
|
until = &t
|
||||||
|
}
|
||||||
|
s.db.Exec(ctx, `UPDATE users SET failed_attempts=$2, locked_until=$3 WHERE id=$1`, u.ID, failed, until) //nolint:errcheck
|
||||||
|
if until != nil {
|
||||||
|
return Session{}, "", ErrLocked
|
||||||
|
}
|
||||||
|
return Session{}, "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if disabled {
|
||||||
|
return Session{}, "", ErrDisabled
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(ctx, `UPDATE users SET failed_attempts=0, locked_until=NULL WHERE id=$1`, u.ID); err != nil {
|
||||||
|
return Session{}, "", err
|
||||||
|
}
|
||||||
|
return s.createSession(ctx, u.ID, ip, userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createSession(ctx context.Context, userID ids.ID, ip, userAgent string) (Session, string, error) {
|
||||||
|
token, hash := newToken()
|
||||||
|
csrf, _ := newToken()
|
||||||
|
now := s.now()
|
||||||
|
sess := Session{ID: ids.New(), UserID: userID, CSRFToken: csrf, CreatedAt: now, LastSeenAt: now,
|
||||||
|
ExpiresAt: now.Add(s.idleTTL), IP: clientKey(ip), UserAgent: truncate(userAgent, 256)}
|
||||||
|
_, err := s.db.Exec(ctx, `INSERT INTO sessions (id, user_id, token_hash, csrf_token, created_at, last_seen_at, expires_at, ip, user_agent)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, sess.ID, sess.UserID, hash, sess.CSRFToken, sess.CreatedAt, sess.LastSeenAt, sess.ExpiresAt, sess.IP, sess.UserAgent)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, "", err
|
||||||
|
}
|
||||||
|
return sess, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate resolves a session token to a principal, sliding the idle
|
||||||
|
// expiry forward. Absolute expiry is measured from session creation.
|
||||||
|
func (s *Service) Authenticate(ctx context.Context, token string) (Principal, error) {
|
||||||
|
var (
|
||||||
|
p Principal
|
||||||
|
role string
|
||||||
|
created time.Time
|
||||||
|
expires time.Time
|
||||||
|
lastSeen time.Time
|
||||||
|
)
|
||||||
|
err := s.db.QueryRow(ctx, `SELECT s.id, s.csrf_token, s.created_at, s.last_seen_at, s.expires_at, u.id, u.username, u.role, u.disabled, u.totp_enabled, u.created_at
|
||||||
|
FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.revoked_at IS NULL`, hashToken(token)).
|
||||||
|
Scan(&p.SessionID, new(string), &created, &lastSeen, &expires, &p.User.ID, &p.User.Username, &role, &p.User.Disabled, &p.User.TOTPEnabled, &p.User.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
now := s.now()
|
||||||
|
if now.After(expires) || now.After(created.Add(s.absoluteTTL)) || p.User.Disabled {
|
||||||
|
return Principal{}, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
p.User.Role = Role(role)
|
||||||
|
p.ViaCookie = true
|
||||||
|
// Touch at most once a minute to avoid a write per request.
|
||||||
|
if now.Sub(lastSeen) > time.Minute {
|
||||||
|
s.db.Exec(ctx, `UPDATE sessions SET last_seen_at=$2, expires_at=$3 WHERE id=$1`, p.SessionID, now, now.Add(s.idleTTL)) //nolint:errcheck
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateToken resolves a personal API token.
|
||||||
|
func (s *Service) AuthenticateToken(ctx context.Context, token string) (Principal, error) {
|
||||||
|
var (
|
||||||
|
p Principal
|
||||||
|
role string
|
||||||
|
tokenID ids.ID
|
||||||
|
expires *time.Time
|
||||||
|
)
|
||||||
|
err := s.db.QueryRow(ctx, `SELECT t.id, t.expires_at, u.id, u.username, u.role, u.disabled, u.totp_enabled, u.created_at
|
||||||
|
FROM api_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=$1 AND t.revoked_at IS NULL`, hashToken(token)).
|
||||||
|
Scan(&tokenID, &expires, &p.User.ID, &p.User.Username, &role, &p.User.Disabled, &p.User.TOTPEnabled, &p.User.CreatedAt)
|
||||||
|
if err != nil || p.User.Disabled || (expires != nil && expires.Before(s.now())) {
|
||||||
|
return Principal{}, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
p.User.Role = Role(role)
|
||||||
|
s.db.Exec(ctx, `UPDATE api_tokens SET last_used_at=now() WHERE id=$1`, tokenID) //nolint:errcheck
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRFToken returns the CSRF token bound to a session.
|
||||||
|
func (s *Service) CSRFToken(ctx context.Context, sessionID ids.ID) (string, error) {
|
||||||
|
var t string
|
||||||
|
err := s.db.QueryRow(ctx, `SELECT csrf_token FROM sessions WHERE id=$1 AND revoked_at IS NULL`, sessionID).Scan(&t)
|
||||||
|
return t, storage.Translate(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout revokes one session.
|
||||||
|
func (s *Service) Logout(ctx context.Context, sessionID ids.ID) error {
|
||||||
|
_, err := s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE id=$1 AND revoked_at IS NULL`, sessionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession revokes a session belonging to userID; returns ErrNotFound otherwise.
|
||||||
|
func (s *Service) RevokeSession(ctx context.Context, userID, sessionID ids.ID) error {
|
||||||
|
tag, err := s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, sessionID, userID)
|
||||||
|
if err == nil && tag.RowsAffected() == 0 {
|
||||||
|
return storage.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions returns a user's live sessions, newest first.
|
||||||
|
func (s *Service) ListSessions(ctx context.Context, userID ids.ID) ([]Session, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `SELECT id, user_id, created_at, last_seen_at, expires_at, ip, user_agent FROM sessions
|
||||||
|
WHERE user_id=$1 AND revoked_at IS NULL AND expires_at > now() ORDER BY last_seen_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Session
|
||||||
|
for rows.Next() {
|
||||||
|
var x Session
|
||||||
|
if err := rows.Scan(&x.ID, &x.UserID, &x.CreatedAt, &x.LastSeenAt, &x.ExpiresAt, &x.IP, &x.UserAgent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAPIToken issues a personal token; the raw value is returned once.
|
||||||
|
func (s *Service) CreateAPIToken(ctx context.Context, userID ids.ID, name string, expires *time.Time) (string, error) {
|
||||||
|
token, hash := newToken()
|
||||||
|
_, err := s.db.Exec(ctx, `INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
ids.New(), userID, truncate(name, 64), hash, expires)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "noema_" + token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIToken is a personal token's metadata (never the token itself).
|
||||||
|
type APIToken struct {
|
||||||
|
ID ids.ID
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAPITokens lists a user's live tokens.
|
||||||
|
func (s *Service) ListAPITokens(ctx context.Context, userID ids.ID) ([]APIToken, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `SELECT id, name, created_at, last_used_at, expires_at FROM api_tokens WHERE user_id=$1 AND revoked_at IS NULL ORDER BY created_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []APIToken
|
||||||
|
for rows.Next() {
|
||||||
|
var t APIToken
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAPIToken revokes a token owned by userID.
|
||||||
|
func (s *Service) RevokeAPIToken(ctx context.Context, userID, tokenID ids.ID) error {
|
||||||
|
tag, err := s.db.Exec(ctx, `UPDATE api_tokens SET revoked_at=now() WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, tokenID, userID)
|
||||||
|
if err == nil && tag.RowsAffected() == 0 {
|
||||||
|
return storage.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeExpiredSessions deletes sessions long past expiry. Sessions are not
|
||||||
|
// cognitive history, so removing them is fine; audit_log keeps the record.
|
||||||
|
func (s *Service) PurgeExpiredSessions(ctx context.Context) error {
|
||||||
|
_, err := s.db.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now() - interval '30 days' OR revoked_at < now() - interval '30 days'`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientKey(ip string) string {
|
||||||
|
if a, err := netip.ParseAddr(ip); err == nil {
|
||||||
|
if a.Is6() {
|
||||||
|
return netip.PrefixFrom(a, 64).Masked().String() // one bucket per /64
|
||||||
|
}
|
||||||
|
return a.String()
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) > n {
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newToken returns a 256-bit random token in URL-safe base64 and its SHA-256,
|
||||||
|
// which is what gets stored. A database leak therefore does not leak sessions.
|
||||||
|
func newToken() (token string, hash []byte) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic("auth: crypto/rand unavailable: " + err.Error())
|
||||||
|
}
|
||||||
|
token = base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
return token, hashToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(token string) []byte {
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/andydixon/noema/internal/auth"
|
||||||
|
"github.com/andydixon/noema/internal/storage"
|
||||||
|
"github.com/andydixon/noema/pkg/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u *UI) loginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := auth.PrincipalFrom(r.Context()); ok {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, err := u.deps.Auth.CountUsers(r.Context()); err == nil && n == 0 {
|
||||||
|
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.render(w, r, "login", "Sign in", map[string]any{"Next": safeNext(r.URL.Query().Get("next"))})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.errorPage(w, r, http.StatusBadRequest, "Bad form.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := auth.ClientIP(r, false)
|
||||||
|
sess, token, err := u.deps.Auth.Login(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), ip, r.UserAgent())
|
||||||
|
if err != nil {
|
||||||
|
u.deps.Metrics.AuthFailures.Inc()
|
||||||
|
msg := "Invalid username or password."
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, auth.ErrLocked):
|
||||||
|
msg = "Account temporarily locked after repeated failures. Try again later."
|
||||||
|
case errors.Is(err, auth.ErrRateLimited):
|
||||||
|
msg = "Too many attempts from this address. Slow down."
|
||||||
|
case errors.Is(err, auth.ErrDisabled):
|
||||||
|
msg = "This account is disabled."
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "login.failed", "user", "", map[string]any{"username": r.PostFormValue("username"), "reason": err.Error()})
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
u.render(w, r, "login", "Sign in", map[string]any{"Error": msg, "Next": safeNext(r.PostFormValue("next"))})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.SetSessionCookie(w, token, u.deps.Config.Production(), int(u.deps.Config.SessionAbsoluteTTL.Seconds()))
|
||||||
|
ctx := auth.WithPrincipal(r.Context(), auth.Principal{User: auth.User{ID: sess.UserID, Username: r.PostFormValue("username")}, SessionID: sess.ID})
|
||||||
|
u.deps.Audit.Log(ctx, "login.success", "session", sess.ID.String(), nil)
|
||||||
|
to := safeNext(r.PostFormValue("next"))
|
||||||
|
if to == "" {
|
||||||
|
to = "/"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if p, ok := auth.PrincipalFrom(r.Context()); ok && !p.SessionID.IsZero() {
|
||||||
|
u.deps.Auth.Logout(r.Context(), p.SessionID) //nolint:errcheck
|
||||||
|
u.deps.Audit.Log(r.Context(), "logout", "session", p.SessionID.String(), nil)
|
||||||
|
}
|
||||||
|
auth.ClearSessionCookie(w, u.deps.Config.Production())
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup is only reachable while no users exist; it is the browser equivalent
|
||||||
|
// of the bootstrap environment variables.
|
||||||
|
func (u *UI) setupForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if n, err := u.deps.Auth.CountUsers(r.Context()); err != nil || n > 0 {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.render(w, r, "setup", "Setup", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) setupSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if n, err := u.deps.Auth.CountUsers(r.Context()); err != nil || n > 0 {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.ParseForm() //nolint:errcheck
|
||||||
|
if r.PostFormValue("password") != r.PostFormValue("confirm") {
|
||||||
|
u.render(w, r, "setup", "Setup", map[string]any{"Error": "Passwords do not match."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := u.deps.Auth.CreateUser(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), auth.RoleAdministrator)
|
||||||
|
if err != nil {
|
||||||
|
u.render(w, r, "setup", "Setup", map[string]any{"Error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "user.bootstrap", "user", user.ID.String(), map[string]any{"username": user.Username, "via": "setup page"})
|
||||||
|
u.flash(w, r, "ok", "Administrator created. Sign in.", "/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, _ := u.deps.Audit.List(r.Context(), 0, 8, "", "")
|
||||||
|
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": 0, "Status": u.statusF(r), "Audit": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) system(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u.render(w, r, "system", "System", u.statusF(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) audit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
|
||||||
|
entries, err := u.deps.Audit.List(r.Context(), before, 100, r.URL.Query().Get("target_kind"), r.URL.Query().Get("target_id"))
|
||||||
|
if err != nil {
|
||||||
|
u.errorPage(w, r, 500, "Could not load audit log.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var next int64
|
||||||
|
if len(entries) == 100 {
|
||||||
|
next = entries[len(entries)-1].Seq
|
||||||
|
}
|
||||||
|
u.render(w, r, "audit", "Audit", map[string]any{"Entries": entries, "Next": next})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) account(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, _ := auth.PrincipalFrom(r.Context())
|
||||||
|
sessions, _ := u.deps.Auth.ListSessions(r.Context(), p.User.ID)
|
||||||
|
tokens, _ := u.deps.Auth.ListAPITokens(r.Context(), p.User.ID)
|
||||||
|
var newToken string
|
||||||
|
if c, err := r.Cookie("noema_newtoken"); err == nil {
|
||||||
|
newToken = c.Value
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Path: "/account", MaxAge: -1, HttpOnly: true})
|
||||||
|
}
|
||||||
|
u.render(w, r, "account", "Account", map[string]any{"Sessions": sessions, "Tokens": tokens, "NewToken": newToken})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) accountPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, _ := auth.PrincipalFrom(r.Context())
|
||||||
|
r.ParseForm() //nolint:errcheck
|
||||||
|
if r.PostFormValue("password") != r.PostFormValue("confirm") {
|
||||||
|
u.flash(w, r, "error", "New passwords do not match.", "/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-verify the current password before changing it.
|
||||||
|
if _, _, err := u.deps.Auth.Login(r.Context(), p.User.Username, r.PostFormValue("current"), auth.ClientIP(r, false), "password-change"); err != nil {
|
||||||
|
u.flash(w, r, "error", "Current password incorrect.", "/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := u.deps.Auth.ChangePassword(r.Context(), p.User.ID, r.PostFormValue("password"), p.SessionID); err != nil {
|
||||||
|
u.flash(w, r, "error", err.Error(), "/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "user.password_change", "user", p.User.ID.String(), nil)
|
||||||
|
u.flash(w, r, "ok", "Password changed. Other sessions were signed out.", "/account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) accountSessionRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, _ := auth.PrincipalFrom(r.Context())
|
||||||
|
id, err := ids.Parse(r.PathValue("id"))
|
||||||
|
if err != nil || u.deps.Auth.RevokeSession(r.Context(), p.User.ID, id) != nil {
|
||||||
|
u.flash(w, r, "error", "Session not found.", "/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "session.revoke", "session", id.String(), nil)
|
||||||
|
u.flash(w, r, "ok", "Session revoked.", "/account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) accountTokenCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, _ := auth.PrincipalFrom(r.Context())
|
||||||
|
r.ParseForm() //nolint:errcheck
|
||||||
|
name := strings.TrimSpace(r.PostFormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
u.flash(w, r, "error", "Token name required.", "/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok, err := u.deps.Auth.CreateAPIToken(r.Context(), p.User.ID, name, nil)
|
||||||
|
if err != nil {
|
||||||
|
u.flash(w, r, "error", "Could not create token.", "/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": name})
|
||||||
|
// Shown exactly once on the next page load, then the cookie is cleared.
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Value: tok, Path: "/account", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()})
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, _ := auth.PrincipalFrom(r.Context())
|
||||||
|
id, err := ids.Parse(r.PathValue("id"))
|
||||||
|
if err != nil || u.deps.Auth.RevokeAPIToken(r.Context(), p.User.ID, id) != nil {
|
||||||
|
u.flash(w, r, "error", "Token not found.", "/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "token.revoke", "token", id.String(), nil)
|
||||||
|
u.flash(w, r, "ok", "Token revoked.", "/account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) users(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := u.deps.Auth.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
u.errorPage(w, r, 500, "Could not load users.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.render(w, r, "users", "Users", map[string]any{"Users": users})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) usersCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.ParseForm() //nolint:errcheck
|
||||||
|
user, err := u.deps.Auth.CreateUser(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), auth.Role(r.PostFormValue("role")))
|
||||||
|
if err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
if errors.Is(err, storage.ErrConflict) {
|
||||||
|
msg = "That username already exists."
|
||||||
|
}
|
||||||
|
u.flash(w, r, "error", msg, "/admin/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "user.create", "user", user.ID.String(), map[string]any{"username": user.Username, "role": user.Role})
|
||||||
|
u.flash(w, r, "ok", "User created.", "/admin/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) usersUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, _ := auth.PrincipalFrom(r.Context())
|
||||||
|
id, err := ids.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
u.flash(w, r, "error", "Invalid user.", "/admin/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if id == p.User.ID {
|
||||||
|
u.flash(w, r, "error", "You cannot change your own account here.", "/admin/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.ParseForm() //nolint:errcheck
|
||||||
|
switch r.PostFormValue("action") {
|
||||||
|
case "role":
|
||||||
|
role := auth.Role(r.PostFormValue("role"))
|
||||||
|
if err := u.deps.Auth.SetUserRole(r.Context(), id, role); err != nil {
|
||||||
|
u.flash(w, r, "error", err.Error(), "/admin/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": role})
|
||||||
|
case "disable", "enable":
|
||||||
|
disabled := r.PostFormValue("action") == "disable"
|
||||||
|
if err := u.deps.Auth.SetUserDisabled(r.Context(), id, disabled); err != nil {
|
||||||
|
u.flash(w, r, "error", err.Error(), "/admin/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.deps.Audit.Log(r.Context(), "user.disabled", "user", id.String(), map[string]any{"disabled": disabled})
|
||||||
|
default:
|
||||||
|
u.flash(w, r, "error", "Unknown action.", "/admin/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.flash(w, r, "ok", "User updated.", "/admin/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeNext only allows same-site relative paths for post-login redirects.
|
||||||
|
func safeNext(s string) string {
|
||||||
|
if s == "" || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(s); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Package ui serves the server-rendered administration interface. Every page
|
||||||
|
// is a Go handler plus an html/template; live updates reuse the same templates
|
||||||
|
// through SSE fragments (ADR-0007).
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andydixon/noema/internal/app"
|
||||||
|
"github.com/andydixon/noema/internal/auth"
|
||||||
|
"github.com/andydixon/noema/internal/version"
|
||||||
|
"github.com/andydixon/noema/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UI holds handlers and the parsed template set.
|
||||||
|
type UI struct {
|
||||||
|
deps app.Deps
|
||||||
|
pages map[string]*template.Template
|
||||||
|
static fs.FS
|
||||||
|
statusF func(r *http.Request) any // system status provider injected by api
|
||||||
|
}
|
||||||
|
|
||||||
|
// View is the data passed to every template.
|
||||||
|
type View struct {
|
||||||
|
Title string
|
||||||
|
Path string
|
||||||
|
Principal *auth.Principal
|
||||||
|
CSRF string
|
||||||
|
Flash *Flash
|
||||||
|
Version string
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash is a one-shot message shown on the next page load.
|
||||||
|
type Flash struct{ Kind, Message string }
|
||||||
|
|
||||||
|
const flashCookie = "noema_flash"
|
||||||
|
|
||||||
|
// New parses templates. In development, when ./web exists on disk, templates
|
||||||
|
// and static files are read from disk so edits show without a rebuild.
|
||||||
|
func New(deps app.Deps, statusF func(r *http.Request) any) (*UI, error) {
|
||||||
|
tmplFS, staticFS := web.Templates(), web.Static()
|
||||||
|
if !deps.Config.Production() {
|
||||||
|
if st, err := os.Stat("web/templates"); err == nil && st.IsDir() {
|
||||||
|
tmplFS, staticFS = os.DirFS("web/templates"), os.DirFS("web/static")
|
||||||
|
deps.Log.Info("ui: serving templates and static files from ./web (development)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u := &UI{deps: deps, static: staticFS, statusF: statusF}
|
||||||
|
if err := u.parse(tmplFS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) parse(fsys fs.FS) error {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"hasPrefix": strings.HasPrefix,
|
||||||
|
"roles": auth.AllRoles,
|
||||||
|
"date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") },
|
||||||
|
"ago": ago,
|
||||||
|
"duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() },
|
||||||
|
"bytes": humanBytes,
|
||||||
|
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
|
||||||
|
"conf": func(f float64) string { return fmt.Sprintf("%.2f", f) },
|
||||||
|
}
|
||||||
|
base, err := template.New("base").Funcs(funcs).ParseFS(fsys, "layout.html")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ui: parse layout: %w", err)
|
||||||
|
}
|
||||||
|
pages, err := fs.Glob(fsys, "pages/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.pages = map[string]*template.Template{}
|
||||||
|
for _, p := range pages {
|
||||||
|
t, err := base.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := t.ParseFS(fsys, p); err != nil {
|
||||||
|
return fmt.Errorf("ui: parse %s: %w", p, err)
|
||||||
|
}
|
||||||
|
u.pages[strings.TrimPrefix(strings.TrimSuffix(p, ".html"), "pages/")] = t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// render executes a page template. Rendering into a buffer first means a
|
||||||
|
// template error yields a proper 500 instead of a half-written page.
|
||||||
|
func (u *UI) render(w http.ResponseWriter, r *http.Request, page, title string, data any) {
|
||||||
|
t, ok := u.pages[page]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "template not found: "+page, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := View{Title: title, Path: r.URL.Path, Version: version.Version, Data: data}
|
||||||
|
if p, ok := auth.PrincipalFrom(r.Context()); ok {
|
||||||
|
v.Principal = &p
|
||||||
|
if tok, err := u.deps.Auth.CSRFToken(r.Context(), p.SessionID); err == nil {
|
||||||
|
v.CSRF = tok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c, err := r.Cookie(flashCookie); err == nil && c.Value != "" {
|
||||||
|
if raw, err := base64.RawURLEncoding.DecodeString(c.Value); err == nil {
|
||||||
|
if kind, msg, ok := strings.Cut(string(raw), "|"); ok {
|
||||||
|
v.Flash = &Flash{Kind: kind, Message: msg}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: flashCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.ExecuteTemplate(&buf, "layout", v); err != nil {
|
||||||
|
u.deps.Log.Error("template execution failed", "page", page, "err", err)
|
||||||
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
buf.WriteTo(w) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// flash stores a one-shot message and redirects.
|
||||||
|
func (u *UI) flash(w http.ResponseWriter, r *http.Request, kind, msg, to string) {
|
||||||
|
val := base64.RawURLEncoding.EncodeToString([]byte(kind + "|" + msg))
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: flashCookie, Value: val, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: u.deps.Config.Production()})
|
||||||
|
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) errorPage(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
u.render(w, r, "error", http.StatusText(status), map[string]any{"Status": status, "Message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiAuthFail redirects anonymous users to login and shows 403 to others.
|
||||||
|
func (u *UI) uiAuthFail(w http.ResponseWriter, r *http.Request, authenticated bool) {
|
||||||
|
if authenticated {
|
||||||
|
u.errorPage(w, r, http.StatusForbidden, "Your role does not permit this page.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path != "/" {
|
||||||
|
next = "?next=" + url.QueryEscape(r.URL.RequestURI())
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login"+next, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) require(min auth.Role, h http.HandlerFunc) http.Handler {
|
||||||
|
return auth.RequireRole(min, u.uiAuthFail)(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts all UI routes.
|
||||||
|
func (u *UI) Register(m *http.ServeMux) {
|
||||||
|
m.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(http.FileServerFS(u.static))))
|
||||||
|
m.HandleFunc("GET /login", u.loginForm)
|
||||||
|
m.HandleFunc("POST /login", u.loginSubmit)
|
||||||
|
m.HandleFunc("POST /logout", u.logout)
|
||||||
|
m.HandleFunc("GET /setup", u.setupForm)
|
||||||
|
m.HandleFunc("POST /setup", u.setupSubmit)
|
||||||
|
m.HandleFunc("GET /about", func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "about", "Interpretation boundary", nil) })
|
||||||
|
|
||||||
|
m.Handle("GET /{$}", u.require(auth.RoleObserver, u.dashboard))
|
||||||
|
m.Handle("GET /system", u.require(auth.RoleObserver, u.system))
|
||||||
|
m.Handle("GET /audit", u.require(auth.RoleOperator, u.audit))
|
||||||
|
m.Handle("GET /account", u.require(auth.RoleObserver, u.account))
|
||||||
|
m.Handle("POST /account/password", u.require(auth.RoleObserver, u.accountPassword))
|
||||||
|
m.Handle("POST /account/sessions/{id}/revoke", u.require(auth.RoleObserver, u.accountSessionRevoke))
|
||||||
|
m.Handle("POST /account/tokens", u.require(auth.RoleObserver, u.accountTokenCreate))
|
||||||
|
m.Handle("POST /account/tokens/{id}/revoke", u.require(auth.RoleObserver, u.accountTokenRevoke))
|
||||||
|
m.Handle("GET /admin/users", u.require(auth.RoleAdministrator, u.users))
|
||||||
|
m.Handle("POST /admin/users", u.require(auth.RoleAdministrator, u.usersCreate))
|
||||||
|
m.Handle("POST /admin/users/{id}", u.require(auth.RoleAdministrator, u.usersUpdate))
|
||||||
|
|
||||||
|
for _, p := range []struct{ path, title, note string }{
|
||||||
|
{"/minds", "Minds", "Mind management arrives in phase 2."},
|
||||||
|
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},
|
||||||
|
{"/llm", "LLM", "Provider configuration arrives in phase 7."},
|
||||||
|
{"/prompts", "Prompts", "The prompt manager arrives in phase 7."},
|
||||||
|
{"/integrations", "Integrations", "Integrations arrive in phase 13."},
|
||||||
|
{"/policies", "Policies", "The policy matrix arrives in phase 12."},
|
||||||
|
} {
|
||||||
|
m.Handle("GET "+p.path, u.require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "placeholder", p.title, p.note) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheStatic(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ago(t any) string {
|
||||||
|
var tm time.Time
|
||||||
|
switch v := t.(type) {
|
||||||
|
case time.Time:
|
||||||
|
tm = v
|
||||||
|
case *time.Time:
|
||||||
|
if v == nil {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
tm = *v
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
d := time.Since(tm)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||||
|
case d < 48*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanBytes(n uint64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if n < unit {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
div, exp := uint64(unit), 0
|
||||||
|
for m := n / unit; m >= unit; m /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Package web embeds the templates and static assets of the administration
|
||||||
|
// interface (ADR-0007). Handlers live in internal/ui.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates static
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
// Templates returns the template tree.
|
||||||
|
func Templates() fs.FS { f, _ := fs.Sub(files, "templates"); return f }
|
||||||
|
|
||||||
|
// Static returns the static asset tree.
|
||||||
|
func Static() fs.FS { f, _ := fs.Sub(files, "static"); return f }
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/* Noema administration interface. Design tokens first, then layout, then components.
|
||||||
|
Light theme on :root, dark via data-theme or system preference. */
|
||||||
|
:root {
|
||||||
|
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
--mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
--fs: 15px;
|
||||||
|
--bg: #f4f6f9; --surface: #ffffff; --surface-2: #eef1f5; --text: #1a1f26; --muted: #5d6673; --border: #dfe4ea;
|
||||||
|
--accent: #2f6fed; --accent-ink: #ffffff; --accent-soft: #e6eefc;
|
||||||
|
--ok: #178a5b; --ok-soft: #e1f5ec; --warn: #b7791f; --warn-soft: #fbf1dc; --danger: #c93b3b; --danger-soft: #fbe5e5; --info: #2b6cb0;
|
||||||
|
--shadow: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.08);
|
||||||
|
--radius: 10px; --sidenav: 232px;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg:#0f1318; --surface:#161b22; --surface-2:#1d242d; --text:#e6eaf0; --muted:#8d97a5; --border:#28303b; --accent:#6c9cff; --accent-ink:#0b1220; --accent-soft:#1a2740; --ok:#3ecf8e; --ok-soft:#14301f; --warn:#e0b04c; --warn-soft:#34290f; --danger:#ff6b6b; --danger-soft:#3a1717; --info:#7ab8ff; --shadow:0 1px 2px rgba(0,0,0,.4); color-scheme: dark; } }
|
||||||
|
:root[data-theme="dark"] { --bg:#0f1318; --surface:#161b22; --surface-2:#1d242d; --text:#e6eaf0; --muted:#8d97a5; --border:#28303b; --accent:#6c9cff; --accent-ink:#0b1220; --accent-soft:#1a2740; --ok:#3ecf8e; --ok-soft:#14301f; --warn:#e0b04c; --warn-soft:#34290f; --danger:#ff6b6b; --danger-soft:#3a1717; --info:#7ab8ff; --shadow:0 1px 2px rgba(0,0,0,.4); color-scheme: dark; }
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html { font-size: var(--fs); }
|
||||||
|
body { margin: 0; font-family: var(--font); color: var(--text); background: var(--bg); line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
code, pre, .mono { font-family: var(--mono); font-size: .92em; }
|
||||||
|
h1 { font-size: 1.6rem; margin: 0 0 .25rem; letter-spacing: -.01em; }
|
||||||
|
h2 { font-size: 1.05rem; margin: 0 0 .75rem; }
|
||||||
|
h3 { font-size: .95rem; margin: 0 0 .5rem; }
|
||||||
|
p { margin: 0 0 .75rem; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: .85rem; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.truncate { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ok { color: var(--ok); } .warn { color: var(--warn); } .danger { color: var(--danger); }
|
||||||
|
.skip { position: absolute; left: -999px; } .skip:focus { left: 8px; top: 8px; background: var(--surface); padding: .5rem; z-index: 99; }
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Shell */
|
||||||
|
.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: .75rem; height: 52px; padding: 0 1rem; background: var(--surface); border-bottom: 1px solid var(--border); }
|
||||||
|
.topbar-spacer { flex: 1; }
|
||||||
|
.brand { display: inline-flex; align-items: center; gap: .5rem; font-weight: 650; color: var(--text); letter-spacing: -.01em; }
|
||||||
|
.brand.big { font-size: 1.4rem; margin-bottom: .5rem; }
|
||||||
|
.brand-mark { width: 18px; height: 18px; border-radius: 50%; background: radial-gradient(circle at center, var(--surface) 0 3px, var(--accent) 4px 6px, transparent 7px), var(--accent); box-shadow: inset 0 0 0 3px var(--accent); }
|
||||||
|
.shell { display: grid; grid-template-columns: var(--sidenav) 1fr; min-height: calc(100vh - 52px - 40px); }
|
||||||
|
.sidenav { border-right: 1px solid var(--border); padding: 1rem .75rem; background: var(--surface); position: sticky; top: 52px; height: calc(100vh - 52px); overflow-y: auto; }
|
||||||
|
.navgroup { margin-bottom: 1.1rem; }
|
||||||
|
.navhead { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 0 .6rem .35rem; }
|
||||||
|
.sidenav a { display: block; padding: .42rem .6rem; border-radius: 7px; color: var(--text); font-size: .93rem; }
|
||||||
|
.sidenav a:hover { background: var(--surface-2); text-decoration: none; }
|
||||||
|
.sidenav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
.content { padding: 1.5rem 2rem 3rem; max-width: 1400px; width: 100%; }
|
||||||
|
.footer { text-align: center; padding: .75rem; border-top: 1px solid var(--border); }
|
||||||
|
.nav-toggle { display: none; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.sidenav { display: none; position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.sidenav.open { display: block; }
|
||||||
|
.nav-toggle { display: inline-flex; }
|
||||||
|
.content { padding: 1rem; }
|
||||||
|
}
|
||||||
|
.bare-main { min-height: calc(100vh - 40px); display: grid; place-items: center; padding: 1rem; }
|
||||||
|
|
||||||
|
/* Components */
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.25rem; box-shadow: var(--shadow); }
|
||||||
|
.card.highlight { border-color: var(--accent); }
|
||||||
|
.auth-card { width: min(420px, 100%); }
|
||||||
|
.grid { display: grid; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||||
|
.cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||||
|
.cols-4 { grid-template-columns: repeat(4, minmax(0,1fr)); }
|
||||||
|
.cols-3-1 { grid-template-columns: 3fr 1fr; }
|
||||||
|
@media (max-width: 1100px) { .cols-3, .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } .cols-3-1 { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 700px) { .cols-2, .cols-3, .cols-4 { grid-template-columns: 1fr; } }
|
||||||
|
.pagehead { margin-bottom: 1.25rem; }
|
||||||
|
.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
||||||
|
.stat-value { font-size: 1.9rem; font-weight: 650; letter-spacing: -.02em; line-height: 1.2; margin: .15rem 0; }
|
||||||
|
.stat-value.small { font-size: 1.25rem; }
|
||||||
|
.badge { display: inline-block; font-size: .74rem; font-weight: 600; padding: .1rem .5rem; border-radius: 999px; background: var(--surface-2); color: var(--muted); vertical-align: middle; }
|
||||||
|
.badge.ok { background: var(--ok-soft); color: var(--ok); } .badge.warn { background: var(--warn-soft); color: var(--warn); } .badge.danger { background: var(--danger-soft); color: var(--danger); } .badge.accent { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.flash { padding: .6rem .9rem; border-radius: 8px; margin-bottom: 1rem; border: 1px solid var(--border); background: var(--surface); }
|
||||||
|
.flash-ok { border-color: var(--ok); background: var(--ok-soft); } .flash-error { border-color: var(--danger); background: var(--danger-soft); } .flash-warn { border-color: var(--warn); background: var(--warn-soft); }
|
||||||
|
.error { color: var(--danger); }
|
||||||
|
.prose { max-width: 70ch; }
|
||||||
|
.steps li { margin-bottom: .4rem; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.stack { display: flex; flex-direction: column; gap: .8rem; }
|
||||||
|
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-top: .5rem; }
|
||||||
|
.inline { display: inline; }
|
||||||
|
label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem; font-weight: 500; }
|
||||||
|
input, select, textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 7px; padding: .5rem .65rem; }
|
||||||
|
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: 2px solid var(--accent-soft); }
|
||||||
|
textarea { min-height: 6rem; resize: vertical; }
|
||||||
|
.btn { font: inherit; font-size: .9rem; font-weight: 550; padding: .45rem .9rem; border-radius: 7px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: inline-flex; align-items: center; gap: .35rem; }
|
||||||
|
.btn:hover { background: var(--surface-2); text-decoration: none; }
|
||||||
|
.btn.small { padding: .25rem .6rem; font-size: .8rem; }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
||||||
|
.btn-primary:hover { filter: brightness(1.08); background: var(--accent); }
|
||||||
|
.btn-danger { color: var(--danger); border-color: var(--danger); }
|
||||||
|
.btn-ghost { border-color: transparent; background: transparent; }
|
||||||
|
.iconbtn { font: inherit; width: 34px; height: 34px; border-radius: 7px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
/* Tables and definition lists */
|
||||||
|
.table { width: 100%; border-collapse: collapse; font-size: .92rem; }
|
||||||
|
.table th { text-align: left; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .45rem .5rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.table td { padding: .55rem .5rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
.table tr:last-child td { border-bottom: 0; }
|
||||||
|
.table.compact td, .table.compact th { padding: .35rem .45rem; }
|
||||||
|
.kv { display: grid; grid-template-columns: max-content 1fr; gap: .35rem 1rem; margin: 0; }
|
||||||
|
.kv dt { color: var(--muted); font-size: .85rem; } .kv dd { margin: 0; }
|
||||||
|
pre.token { background: var(--surface-2); padding: .75rem; border-radius: 8px; overflow-x: auto; user-select: all; }
|
||||||
|
|
||||||
|
/* Confidence bar: inline meter used across belief/memory views */
|
||||||
|
.conf { display: inline-flex; align-items: center; gap: .4rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.conf-bar { width: 64px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; }
|
||||||
|
.conf-bar i { display: block; height: 100%; background: var(--accent); }
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/* Noema UI enhancements. No frameworks, no inline scripts (CSP). */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
const root = document.documentElement;
|
||||||
|
const csrf = () => (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
|
||||||
|
|
||||||
|
// Theme: auto → light → dark, remembered per browser.
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("noema.theme");
|
||||||
|
if (saved) root.setAttribute("data-theme", saved);
|
||||||
|
} catch (e) { /* storage unavailable */ }
|
||||||
|
function cycleTheme() {
|
||||||
|
const order = ["auto", "light", "dark"];
|
||||||
|
const cur = root.getAttribute("data-theme") || "auto";
|
||||||
|
const next = order[(order.indexOf(cur) + 1) % order.length];
|
||||||
|
root.setAttribute("data-theme", next);
|
||||||
|
try { localStorage.setItem("noema.theme", next); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch wrapper that carries the CSRF token and same-origin credentials.
|
||||||
|
async function api(method, url, body) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method, credentials: "same-origin",
|
||||||
|
headers: Object.assign({ "X-CSRF-Token": csrf() }, body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error((data.error && data.error.message) || res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-Sent Events helper: handlers keyed by event name; html payloads are
|
||||||
|
// inserted into [data-stream-target] containers by the caller.
|
||||||
|
function stream(url, handlers, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
let es, retry = 1000;
|
||||||
|
function connect() {
|
||||||
|
es = new EventSource(url, { withCredentials: true });
|
||||||
|
es.onopen = () => { retry = 1000; document.body.classList.remove("stream-offline"); };
|
||||||
|
es.onerror = () => { es.close(); document.body.classList.add("stream-offline"); setTimeout(connect, retry); retry = Math.min(retry * 2, 30000); };
|
||||||
|
Object.keys(handlers).forEach((name) => es.addEventListener(name, (ev) => {
|
||||||
|
let data = ev.data;
|
||||||
|
try { data = JSON.parse(ev.data); } catch (e) { /* plain text */ }
|
||||||
|
handlers[name](data, ev);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
return { close: () => es && es.close() };
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const tt = document.querySelector("[data-theme-toggle]");
|
||||||
|
if (tt) tt.addEventListener("click", cycleTheme);
|
||||||
|
const nt = document.querySelector("[data-nav-toggle]");
|
||||||
|
const nav = document.querySelector("[data-nav]");
|
||||||
|
if (nt && nav) nt.addEventListener("click", () => { const open = nav.classList.toggle("open"); nt.setAttribute("aria-expanded", String(open)); });
|
||||||
|
|
||||||
|
// Confirmation on destructive buttons.
|
||||||
|
document.addEventListener("click", (ev) => {
|
||||||
|
const el = ev.target.closest("[data-confirm]");
|
||||||
|
if (el && !window.confirm(el.getAttribute("data-confirm"))) { ev.preventDefault(); ev.stopImmediatePropagation(); }
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Auto-dismiss flashes.
|
||||||
|
document.querySelectorAll(".flash").forEach((f) => setTimeout(() => f.remove(), 8000));
|
||||||
|
|
||||||
|
// Keyboard: "t" toggles theme, "g d" dashboard, "g m" minds, "g s" system.
|
||||||
|
let pending = "";
|
||||||
|
document.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.target.matches("input, textarea, select") || ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
||||||
|
if (ev.key === "t" && !pending) { cycleTheme(); return; }
|
||||||
|
if (ev.key === "g") { pending = "g"; setTimeout(() => (pending = ""), 800); return; }
|
||||||
|
if (pending === "g") {
|
||||||
|
const map = { d: "/", m: "/minds", s: "/system", a: "/audit", l: "/llm", p: "/approvals" };
|
||||||
|
if (map[ev.key]) location.href = map[ev.key];
|
||||||
|
pending = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.noema = { api, stream, csrf };
|
||||||
|
})();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#2f6fed"/><circle cx="16" cy="16" r="6" fill="none" stroke="#fff" stroke-width="2.5"/><circle cx="16" cy="16" r="1.8" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 235 B |
@@ -0,0 +1,60 @@
|
|||||||
|
{{define "layout"}}<!doctype html>
|
||||||
|
<html lang="en" data-theme="auto">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{.CSRF}}">
|
||||||
|
<title>{{if .Title}}{{.Title}} · {{end}}Noema</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="{{if .Principal}}app{{else}}bare{{end}}">
|
||||||
|
{{if .Principal}}
|
||||||
|
<a class="skip" href="#main">Skip to content</a>
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="iconbtn nav-toggle" aria-label="Toggle navigation" aria-expanded="false" data-nav-toggle>☰</button>
|
||||||
|
<a class="brand" href="/"><span class="brand-mark" aria-hidden="true"></span>Noema</a>
|
||||||
|
<div class="topbar-spacer"></div>
|
||||||
|
<span class="muted small">{{.Principal.User.Username}} · {{.Principal.User.Role}}</span>
|
||||||
|
<button class="iconbtn" data-theme-toggle aria-label="Toggle colour theme" title="Toggle theme (t)">◐</button>
|
||||||
|
<form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button class="btn btn-ghost small" type="submit">Sign out</button></form>
|
||||||
|
</header>
|
||||||
|
<div class="shell">
|
||||||
|
<nav class="sidenav" aria-label="Primary" data-nav>
|
||||||
|
{{template "nav" .}}
|
||||||
|
</nav>
|
||||||
|
<main id="main" class="content" tabindex="-1">
|
||||||
|
{{with .Flash}}<div class="flash flash-{{.Kind}}" role="status">{{.Message}}</div>{{end}}
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<main id="main" class="bare-main">
|
||||||
|
{{with .Flash}}<div class="flash flash-{{.Kind}}" role="status">{{.Message}}</div>{{end}}
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
|
<footer class="footer muted small">Noema {{.Version}} · models cognitive processes; it does not claim consciousness. <a href="/about">Interpretation boundary</a></footer>
|
||||||
|
</body>
|
||||||
|
</html>{{end}}
|
||||||
|
|
||||||
|
{{define "nav"}}
|
||||||
|
<div class="navgroup"><div class="navhead">Observe</div>
|
||||||
|
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Dashboard</a>
|
||||||
|
<a href="/minds" {{if hasPrefix .Path "/minds"}}aria-current="page"{{end}}>Minds</a>
|
||||||
|
<a href="/approvals" {{if hasPrefix .Path "/approvals"}}aria-current="page"{{end}}>Approvals</a>
|
||||||
|
</div>
|
||||||
|
<div class="navgroup"><div class="navhead">Configure</div>
|
||||||
|
<a href="/llm" {{if hasPrefix .Path "/llm"}}aria-current="page"{{end}}>LLM</a>
|
||||||
|
<a href="/prompts" {{if hasPrefix .Path "/prompts"}}aria-current="page"{{end}}>Prompts</a>
|
||||||
|
<a href="/integrations" {{if hasPrefix .Path "/integrations"}}aria-current="page"{{end}}>Integrations</a>
|
||||||
|
<a href="/policies" {{if hasPrefix .Path "/policies"}}aria-current="page"{{end}}>Policies</a>
|
||||||
|
</div>
|
||||||
|
<div class="navgroup"><div class="navhead">System</div>
|
||||||
|
<a href="/system" {{if eq .Path "/system"}}aria-current="page"{{end}}>System</a>
|
||||||
|
<a href="/audit" {{if eq .Path "/audit"}}aria-current="page"{{end}}>Audit</a>
|
||||||
|
{{if .Principal.User.Role.AtLeast "administrator"}}<a href="/admin/users" {{if hasPrefix .Path "/admin/users"}}aria-current="page"{{end}}>Users</a>{{end}}
|
||||||
|
<a href="/account" {{if eq .Path "/account"}}aria-current="page"{{end}}>Account</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Interpretation boundary</h1>
|
||||||
|
<div class="prose">
|
||||||
|
<p>Noema models cognitive processes. It does not prove, and its authors do not claim, that it is conscious, sentient, self-aware or a person.</p>
|
||||||
|
<p>Components are named after psychological concepts (attention, working memory, emotion-like state, curiosity, drives, self-model, reflection, dreaming) because those names describe the functional role each plays in the architecture. They are ordinary data structures and deterministic rules, sometimes assisted by language models.</p>
|
||||||
|
<p>When a mind reports that it is "curious" or "concerned", it is reporting the value of a stored scalar that the architecture updates by rule. When it says "I believe", it refers to a belief record with evidence links. Such reports are useful because they are grounded in inspectable state, not because they indicate experience.</p>
|
||||||
|
<p>Treat resemblance to emotion, preference or identity as a property of the model being run, to be studied. Rely on the evidence and provenance views rather than on fluent self-description.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="pagehead"><h1>Account</h1><p class="muted">Signed in as <strong>{{.Principal.User.Username}}</strong> ({{.Principal.User.Role}}).</p></div>
|
||||||
|
{{with .Data.NewToken}}<div class="card highlight"><h2>New API token</h2><p>Copy it now. It will not be shown again.</p><pre class="token"><code>{{.}}</code></pre><p class="muted small">Use it as <code>Authorization: Bearer …</code> or <code>NOEMA_API_TOKEN</code> for noemactl.</p></div>{{end}}
|
||||||
|
<section class="grid cols-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sessions</h2>
|
||||||
|
<table class="table compact">
|
||||||
|
<thead><tr><th>Where</th><th>Client</th><th>Last seen</th><th></th></tr></thead>
|
||||||
|
<tbody>{{range .Data.Sessions}}<tr>
|
||||||
|
<td>{{.IP}}{{if eq .ID $.Principal.SessionID}} <span class="badge">current</span>{{end}}</td>
|
||||||
|
<td class="small muted truncate" title="{{.UserAgent}}">{{.UserAgent}}</td>
|
||||||
|
<td class="small muted nowrap">{{ago .LastSeenAt}}</td>
|
||||||
|
<td>{{if ne .ID $.Principal.SessionID}}<form method="post" action="/account/sessions/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form>{{end}}</td>
|
||||||
|
</tr>{{end}}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Change password</h2>
|
||||||
|
<form method="post" action="/account/password" class="stack">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
|
<label>Current password <input type="password" name="current" required autocomplete="current-password"></label>
|
||||||
|
<label>New password <input type="password" name="password" required minlength="12" autocomplete="new-password"></label>
|
||||||
|
<label>Confirm <input type="password" name="confirm" required minlength="12" autocomplete="new-password"></label>
|
||||||
|
<button class="btn btn-primary" type="submit">Change password</button>
|
||||||
|
<p class="muted small">Other sessions are signed out when the password changes.</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>API tokens</h2>
|
||||||
|
<table class="table compact">
|
||||||
|
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead>
|
||||||
|
<tbody>{{range .Data.Tokens}}<tr><td>{{.Name}}</td><td class="small muted">{{date .CreatedAt}}</td><td class="small muted">{{if .LastUsedAt}}{{ago .LastUsedAt}}{{else}}never{{end}}</td>
|
||||||
|
<td><form method="post" action="/account/tokens/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form></td></tr>{{else}}<tr><td colspan="4" class="muted">No tokens.</td></tr>{{end}}</tbody>
|
||||||
|
</table>
|
||||||
|
<form method="post" action="/account/tokens" class="row">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
|
<input name="name" placeholder="Token name (e.g. laptop cli)" required maxlength="64">
|
||||||
|
<button class="btn" type="submit">Create token</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="pagehead"><h1>Audit</h1><p class="muted">Every administrative change, append-only.</p></div>
|
||||||
|
<div class="card">
|
||||||
|
<table class="table compact">
|
||||||
|
<thead><tr><th>#</th><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Details</th><th>IP</th></tr></thead>
|
||||||
|
<tbody>{{range .Data.Entries}}<tr>
|
||||||
|
<td class="muted">{{.Seq}}</td><td class="nowrap muted small">{{.At}}</td>
|
||||||
|
<td>{{if .ActorName}}{{.ActorName}}{{else}}<span class="muted">{{.ActorKind}}</span>{{end}}</td>
|
||||||
|
<td><code>{{.Action}}</code></td><td class="small">{{.TargetKind}} <span class="muted">{{.TargetID}}</span></td>
|
||||||
|
<td><code class="small">{{printf "%s" .Details}}</code></td><td class="muted small">{{.IP}}</td>
|
||||||
|
</tr>{{else}}<tr><td colspan="7" class="muted">No entries.</td></tr>{{end}}</tbody>
|
||||||
|
</table>
|
||||||
|
{{with .Data.Next}}<p><a class="btn" href="/audit?before={{.}}">Older</a></p>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="pagehead"><h1>Dashboard</h1><p class="muted">System overview. Minds, cognition and approvals appear here as they come online.</p></div>
|
||||||
|
<section class="grid cols-3">
|
||||||
|
<div class="card stat"><div class="stat-label">Running minds</div><div class="stat-value">{{.Data.Minds}}</div><div class="muted small">No minds yet. Create one under Minds.</div></div>
|
||||||
|
<div class="card stat"><div class="stat-label">System health</div><div class="stat-value">{{if .Data.Status.Database.OK}}<span class="ok">Healthy</span>{{else}}<span class="danger">Degraded</span>{{end}}</div><div class="muted small">Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}</div></div>
|
||||||
|
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">0</div><div class="muted small">Capability layer arrives in a later phase.</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="grid cols-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent administrative activity</h2>
|
||||||
|
{{if .Data.Audit}}
|
||||||
|
<table class="table compact">
|
||||||
|
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead>
|
||||||
|
<tbody>{{range .Data.Audit}}<tr><td class="nowrap muted">{{.At}}</td><td>{{.ActorName}}{{if not .ActorName}}<span class="muted">{{.ActorKind}}</span>{{end}}</td><td><code>{{.Action}}</code></td><td class="muted small">{{.TargetKind}} {{.TargetID}}</td></tr>{{end}}</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}<p class="muted">Nothing yet.</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Getting started</h2>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>Add an LLM provider under <a href="/llm">LLM</a>.</li>
|
||||||
|
<li>Create a mind under <a href="/minds">Minds</a> and shape its personality.</li>
|
||||||
|
<li>Open its conversation page and watch the thought stream.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="card auth-card">
|
||||||
|
<h1>{{.Data.Status}}</h1>
|
||||||
|
<p>{{.Data.Message}}</p>
|
||||||
|
<p><a href="/">Back to dashboard</a></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="card auth-card">
|
||||||
|
<div class="brand big"><span class="brand-mark" aria-hidden="true"></span>Noema</div>
|
||||||
|
<p class="muted">Sign in to the cognitive runtime.</p>
|
||||||
|
<form method="post" action="/login" class="stack">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
|
<input type="hidden" name="next" value="{{.Data.Next}}">
|
||||||
|
<label>Username <input name="username" autocomplete="username" required autofocus></label>
|
||||||
|
<label>Password <input type="password" name="password" autocomplete="current-password" required></label>
|
||||||
|
{{with .Data.Error}}<p class="error" role="alert">{{.}}</p>{{end}}
|
||||||
|
<button class="btn btn-primary" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="pagehead"><h1>{{.Title}}</h1><p class="muted">{{.Data}}</p></div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="card auth-card">
|
||||||
|
<div class="brand big"><span class="brand-mark" aria-hidden="true"></span>Noema</div>
|
||||||
|
<h1>First-run setup</h1>
|
||||||
|
<p class="muted">No accounts exist yet. Create the first administrator. This page disappears once an account exists.</p>
|
||||||
|
<form method="post" action="/setup" class="stack">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
|
<label>Username <input name="username" autocomplete="username" required minlength="2" maxlength="64" autofocus></label>
|
||||||
|
<label>Password <input type="password" name="password" autocomplete="new-password" required minlength="12"></label>
|
||||||
|
<label>Confirm password <input type="password" name="confirm" autocomplete="new-password" required minlength="12"></label>
|
||||||
|
{{with .Data.Error}}<p class="error" role="alert">{{.}}</p>{{end}}
|
||||||
|
<button class="btn btn-primary" type="submit">Create administrator</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="pagehead"><h1>System</h1><p class="muted">Runtime, database and migration state.</p></div>
|
||||||
|
{{$s := .Data}}
|
||||||
|
<section class="grid cols-4">
|
||||||
|
<div class="card stat"><div class="stat-label">Version</div><div class="stat-value small">{{$s.Version}}</div><div class="muted small">{{$s.GoVersion}}</div></div>
|
||||||
|
<div class="card stat"><div class="stat-label">Uptime</div><div class="stat-value small">{{duration $s.UptimeSeconds}}</div></div>
|
||||||
|
<div class="card stat"><div class="stat-label">Goroutines</div><div class="stat-value small">{{$s.Goroutines}}</div><div class="muted small">{{$s.NumCPU}} CPUs</div></div>
|
||||||
|
<div class="card stat"><div class="stat-label">Memory</div><div class="stat-value small">{{bytes $s.HeapBytes}}</div><div class="muted small">heap · {{bytes $s.SysBytes}} from OS</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="grid cols-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Database</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Status</dt><dd>{{if $s.Database.OK}}<span class="badge ok">connected</span>{{else}}<span class="badge danger">error</span> {{$s.Database.Error}}{{end}}</dd>
|
||||||
|
<dt>Server</dt><dd class="small">{{$s.Database.Version}}</dd>
|
||||||
|
<dt>pgvector</dt><dd>{{if $s.Database.PGVector}}<span class="badge ok">available</span>{{else}}<span class="badge warn">not installed</span> <span class="muted small">semantic retrieval falls back to text and graph</span>{{end}}</dd>
|
||||||
|
<dt>Pool</dt><dd>{{$s.Database.PoolTotal}} open · {{$s.Database.PoolIdle}} idle · {{$s.Database.PoolMax}} max</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Migrations</h2>
|
||||||
|
<table class="table compact">
|
||||||
|
<thead><tr><th>#</th><th>Name</th><th>State</th><th>Applied</th></tr></thead>
|
||||||
|
<tbody>{{range $s.Migrations}}<tr><td>{{printf "%04d" .Version}}</td><td><code>{{.Name}}</code></td><td>{{if .Drift}}<span class="badge danger">drift</span>{{else if .Applied}}<span class="badge ok">applied</span>{{else}}<span class="badge warn">pending</span>{{end}}</td><td class="muted small">{{.AppliedAt}}</td></tr>{{end}}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="pagehead"><h1>Users</h1><p class="muted">Operator accounts. Roles: observer (read), operator (act), administrator (configure).</p></div>
|
||||||
|
<section class="grid cols-3-1">
|
||||||
|
<div class="card">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Username</th><th>Role</th><th>State</th><th>MFA</th><th>Created</th><th></th></tr></thead>
|
||||||
|
<tbody>{{range .Data.Users}}<tr>
|
||||||
|
<td><strong>{{.Username}}</strong>{{if eq .ID $.Principal.User.ID}} <span class="muted small">(you)</span>{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/users/{{.ID}}" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||||
|
<select name="role" aria-label="Role for {{.Username}}" {{if eq .ID $.Principal.User.ID}}disabled{{end}}>
|
||||||
|
{{range $r := roles}}<option value="{{$r}}" {{if eq $r .Role}}selected{{end}}>{{$r}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
{{if ne .ID $.Principal.User.ID}}<button class="btn small" type="submit" name="action" value="role">Set</button>{{end}}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>{{if .Disabled}}<span class="badge danger">disabled</span>{{else}}<span class="badge ok">active</span>{{end}}{{if .LockedUntil}} <span class="badge warn" title="Locked after failed logins">locked</span>{{end}}</td>
|
||||||
|
<td>{{if .TOTPEnabled}}<span class="badge ok">TOTP</span>{{else}}<span class="muted">off</span>{{end}}</td>
|
||||||
|
<td class="muted small nowrap">{{date .CreatedAt}}</td>
|
||||||
|
<td>{{if ne .ID $.Principal.User.ID}}<form method="post" action="/admin/users/{{.ID}}" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||||
|
{{if .Disabled}}<button class="btn small" name="action" value="enable">Enable</button>{{else}}<button class="btn small btn-danger" name="action" value="disable" data-confirm="Disable {{.Username}} and revoke their sessions?">Disable</button>{{end}}</form>{{end}}</td>
|
||||||
|
</tr>{{end}}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Create user</h2>
|
||||||
|
<form method="post" action="/admin/users" class="stack">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
|
<label>Username <input name="username" required minlength="2" maxlength="64" autocomplete="off"></label>
|
||||||
|
<label>Password <input type="password" name="password" required minlength="12" autocomplete="new-password"></label>
|
||||||
|
<label>Role <select name="role">{{range roles}}<option value="{{.}}" {{if eq . "observer"}}selected{{end}}>{{.}}</option>{{end}}</select></label>
|
||||||
|
<button class="btn btn-primary" type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user