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.
104 lines
3.6 KiB
Go
104 lines
3.6 KiB
Go
// 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()
|
|
}
|