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:
@@ -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)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user