From b25e074e1d3518f175478184ff8d002ec0d8412c Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 3 Jun 2026 12:48:31 +0000 Subject: [PATCH] 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--- --- internal/cli/audit.go | 60 +++++++++++++++ internal/cli/node.go | 3 +- internal/engine/audit.go | 52 +++++++++++++ internal/engine/registry.go | 21 ++++- internal/store/audit_repo.go | 81 ++++++++++++++++++++ internal/store/audit_repo_test.go | 67 ++++++++++++++++ internal/store/migrations/0003_audit_log.sql | 15 ++++ 7 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 internal/cli/audit.go create mode 100644 internal/engine/audit.go create mode 100644 internal/store/audit_repo.go create mode 100644 internal/store/audit_repo_test.go create mode 100644 internal/store/migrations/0003_audit_log.sql diff --git a/internal/cli/audit.go b/internal/cli/audit.go new file mode 100644 index 0000000..6364bdd --- /dev/null +++ b/internal/cli/audit.go @@ -0,0 +1,60 @@ +package cli + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "git.cloudinit.dev/coreci/orca/internal/store" +) + +var ( + auditLimit int +) + +var auditCmd = &cobra.Command{ + Use: "audit", + Short: "View orca audit log", + Long: "Display the most recent audit log entries (security-first observability).", +} + +var auditListCmd = &cobra.Command{ + Use: "list", + Short: "List recent audit log entries", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + db, closer, err := openDB() + if err != nil { + return err + } + defer closer() + + entries, err := store.NewAuditRepo(db).List(ctx, auditLimit) + if err != nil { + return err + } + if jsonOutput { + return printJSON(entries) + } + if len(entries) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No audit entries.") + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "%-22s %-12s %-20s %-30s %-10s\n", "TIMESTAMP", "ACTOR", "ACTION", "RESOURCE", "RESULT") + for _, e := range entries { + fmt.Fprintf(cmd.OutOrStdout(), "%-22s %-12s %-20s %-30s %-10s\n", + e.Timestamp.Format("2006-01-02T15:04:05Z"), e.Actor, e.Action, e.Resource, e.Result) + } + return nil + }, +} + +func init() { + auditListCmd.Flags().IntVar(&auditLimit, "limit", 50, "max entries to show") + auditCmd.AddCommand(auditListCmd) + rootCmd.AddCommand(auditCmd) +} diff --git a/internal/cli/node.go b/internal/cli/node.go index 1dd63e0..a0257bc 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -43,7 +43,8 @@ func nodeRegistry() (*engine.NodeRegistry, func() error, error) { return nil, nil, err } repo := store.NewNodeRepo(db) - return engine.NewNodeRegistry(repo, newLogger()), closer, nil + audit := engine.NewAudit(store.NewAuditRepo(db), newLogger()) + return engine.NewNodeRegistry(repo, audit, newLogger()), closer, nil } var ( diff --git a/internal/engine/audit.go b/internal/engine/audit.go new file mode 100644 index 0000000..f71eebb --- /dev/null +++ b/internal/engine/audit.go @@ -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...) + } +} diff --git a/internal/engine/registry.go b/internal/engine/registry.go index 63dd52b..05b86db 100644 --- a/internal/engine/registry.go +++ b/internal/engine/registry.go @@ -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 } diff --git a/internal/store/audit_repo.go b/internal/store/audit_repo.go new file mode 100644 index 0000000..63f4377 --- /dev/null +++ b/internal/store/audit_repo.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" +) + +type AuditEntry struct { + ID int64 `json:"id"` + Timestamp time.Time `json:"timestamp"` + Actor string `json:"actor"` + Action string `json:"action"` + Resource string `json:"resource"` + Result string `json:"result"` + Error string `json:"error,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type AuditRepo struct { + db *sql.DB +} + +func NewAuditRepo(db *sql.DB) *AuditRepo { + return &AuditRepo{db: db} +} + +func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error { + if e.Timestamp.IsZero() { + e.Timestamp = time.Now().UTC() + } + if e.Actor == "" { + e.Actor = "system" + } + metaJSON, _ := json.Marshal(e.Metadata) + if e.Error == "" { + _, err := r.db.ExecContext(ctx, + `INSERT INTO audit_log (timestamp, actor, action, resource, result, metadata) VALUES (?, ?, ?, ?, ?, ?)`, + e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, string(metaJSON)) + if err != nil { + return fmt.Errorf("insert audit: %w", err) + } + return nil + } + _, err := r.db.ExecContext(ctx, + `INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`, + e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON)) + if err != nil { + return fmt.Errorf("insert audit (with error): %w", err) + } + return nil +} + +func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) { + if limit <= 0 { + limit = 100 + } + rows, err := r.db.QueryContext(ctx, + `SELECT id, timestamp, actor, action, resource, result, COALESCE(error, ''), COALESCE(metadata, '') FROM audit_log ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("list audit: %w", err) + } + defer rows.Close() + var entries []*AuditEntry + for rows.Next() { + var ( + e AuditEntry + metaJSON string + ) + if err := rows.Scan(&e.ID, &e.Timestamp, &e.Actor, &e.Action, &e.Resource, &e.Result, &e.Error, &metaJSON); err != nil { + return nil, fmt.Errorf("scan audit: %w", err) + } + if metaJSON != "" { + _ = json.Unmarshal([]byte(metaJSON), &e.Metadata) + } + entries = append(entries, &e) + } + return entries, rows.Err() +} diff --git a/internal/store/audit_repo_test.go b/internal/store/audit_repo_test.go new file mode 100644 index 0000000..a3db671 --- /dev/null +++ b/internal/store/audit_repo_test.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "path/filepath" + "testing" +) + +func openAuditTestDB(t *testing.T) (*AuditRepo, func()) { + t.Helper() + path := filepath.Join(t.TempDir(), "audit.db") + db, err := Open(path) + if err != nil { + t.Fatalf("open db: %v", err) + } + return NewAuditRepo(db), func() { _ = db.Close() } +} + +func TestAuditRepo_AppendAndList(t *testing.T) { + repo, cleanup := openAuditTestDB(t) + defer cleanup() + + ctx := context.Background() + for i := 0; i < 5; i++ { + err := repo.Append(ctx, &AuditEntry{ + Actor: "cli", + Action: "node.join", + Resource: "node-1", + Result: "success", + }) + if err != nil { + t.Fatalf("append[%d]: %v", i, err) + } + } + entries, err := repo.List(ctx, 10) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 5 { + t.Errorf("expected 5 entries, got %d", len(entries)) + } +} + +func TestAuditRepo_WithError(t *testing.T) { + repo, cleanup := openAuditTestDB(t) + defer cleanup() + + ctx := context.Background() + err := repo.Append(ctx, &AuditEntry{ + Actor: "system", + Action: "task.run", + Resource: "task-1", + Result: "failure", + Error: "exit status 1", + Metadata: map[string]any{"exit_code": 1}, + }) + if err != nil { + t.Fatalf("append: %v", err) + } + entries, _ := repo.List(ctx, 1) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Error != "exit status 1" { + t.Errorf("expected error 'exit status 1', got %q", entries[0].Error) + } +} diff --git a/internal/store/migrations/0003_audit_log.sql b/internal/store/migrations/0003_audit_log.sql new file mode 100644 index 0000000..5f28a28 --- /dev/null +++ b/internal/store/migrations/0003_audit_log.sql @@ -0,0 +1,15 @@ +-- Audit log for security-first observability +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME NOT NULL, + actor TEXT NOT NULL DEFAULT 'system', + action TEXT NOT NULL, + resource TEXT NOT NULL, + result TEXT NOT NULL, + error TEXT, + metadata TEXT +); + +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_log(resource);