feat(P04): audit log + persistence hardening

Implements Phase 4 of v0.1 Foundation:
- internal/store/migrations/0003_audit_log.sql: audit_log table with indexes
- internal/store/audit_repo.go: AuditRepo (Append + List)
- internal/store/audit_repo_test.go: 2 tests for audit persistence
- internal/engine/audit.go: Audit wrapper that persists to SQLite AND logs via slog
- internal/cli/audit.go: orca audit list command (text + JSON)
- Registry now records every join/leave/forget with actor/action/resource/result

Verified: audit entries persist across restarts, JSON output includes metadata,
node operations emit audit records. All tests pass with -race.

---ci---
project: orca
phase: 4
milestone: v0.1
status: execute
req_covered:
  - REQ-005
  - REQ-006
  - REQ-008
  - REQ-017
  - REQ-018
---/ci---
This commit is contained in:
Jon Chery
2026-06-03 12:48:31 +00:00
parent bb6b5b3e83
commit b25e074e1d
7 changed files with 294 additions and 5 deletions
+52
View File
@@ -0,0 +1,52 @@
package engine
import (
"context"
"log/slog"
"git.cloudinit.dev/coreci/orca/internal/store"
)
// Audit wraps a slog.Logger and persists structured audit entries to SQLite.
type Audit struct {
repo *store.AuditRepo
log *slog.Logger
}
func NewAudit(repo *store.AuditRepo, log *slog.Logger) *Audit {
if log == nil {
log = slog.Default()
}
return &Audit{repo: repo, log: log}
}
func (a *Audit) Record(ctx context.Context, actor, action, resource, result string, err error, meta map[string]any) {
entry := &store.AuditEntry{
Actor: actor,
Action: action,
Resource: resource,
Result: result,
Metadata: meta,
}
if err != nil {
entry.Error = err.Error()
}
if persistErr := a.repo.Append(ctx, entry); persistErr != nil {
a.log.Error("audit persist failed",
slog.String("action", action),
slog.String("resource", resource),
slog.String("error", persistErr.Error()))
}
attrs := []any{
slog.String("actor", actor),
slog.String("action", action),
slog.String("resource", resource),
slog.String("result", result),
}
if err != nil {
attrs = append(attrs, slog.String("error", err.Error()))
a.log.Warn("audit", attrs...)
} else {
a.log.Info("audit", attrs...)
}
}
+17 -4
View File
@@ -10,21 +10,30 @@ import (
)
type NodeRegistry struct {
repo *store.NodeRepo
log *slog.Logger
repo *store.NodeRepo
audit *Audit
log *slog.Logger
}
func NewNodeRegistry(repo *store.NodeRepo, log *slog.Logger) *NodeRegistry {
func NewNodeRegistry(repo *store.NodeRepo, audit *Audit, log *slog.Logger) *NodeRegistry {
if log == nil {
log = slog.Default()
}
return &NodeRegistry{repo: repo, log: log}
return &NodeRegistry{repo: repo, audit: audit, log: log}
}
func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
if err := r.repo.Insert(ctx, n); err != nil {
r.audit.Record(ctx, "cli", "node.join", n.ID, "failure", err, map[string]any{
"name": n.Name,
"address": n.Address,
})
return fmt.Errorf("join node: %w", err)
}
r.audit.Record(ctx, "cli", "node.join", n.ID, "success", nil, map[string]any{
"name": n.Name,
"address": n.Address,
})
r.log.Info("node joined",
slog.String("node_id", n.ID),
slog.String("name", n.Name),
@@ -34,16 +43,20 @@ func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
func (r *NodeRegistry) Leave(ctx context.Context, id string) error {
if err := r.repo.UpdateState(ctx, id, model.NodeStateLeft); err != nil {
r.audit.Record(ctx, "cli", "node.leave", id, "failure", err, nil)
return fmt.Errorf("leave node: %w", err)
}
r.audit.Record(ctx, "cli", "node.leave", id, "success", nil, nil)
r.log.Info("node left", slog.String("node_id", id))
return nil
}
func (r *NodeRegistry) Forget(ctx context.Context, id string) error {
if err := r.repo.Delete(ctx, id); err != nil {
r.audit.Record(ctx, "cli", "node.forget", id, "failure", err, nil)
return fmt.Errorf("forget node: %w", err)
}
r.audit.Record(ctx, "cli", "node.forget", id, "success", nil, nil)
r.log.Info("node removed from registry", slog.String("node_id", id))
return nil
}