Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30c523c0c7 | |||
| 759b1b519d | |||
| b25e074e1d |
@@ -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
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
repo := store.NewNodeRepo(db)
|
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 (
|
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 {
|
type NodeRegistry struct {
|
||||||
repo *store.NodeRepo
|
repo *store.NodeRepo
|
||||||
log *slog.Logger
|
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 {
|
if log == nil {
|
||||||
log = slog.Default()
|
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 {
|
func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
|
||||||
if err := r.repo.Insert(ctx, n); err != nil {
|
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)
|
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",
|
r.log.Info("node joined",
|
||||||
slog.String("node_id", n.ID),
|
slog.String("node_id", n.ID),
|
||||||
slog.String("name", n.Name),
|
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 {
|
func (r *NodeRegistry) Leave(ctx context.Context, id string) error {
|
||||||
if err := r.repo.UpdateState(ctx, id, model.NodeStateLeft); err != nil {
|
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)
|
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))
|
r.log.Info("node left", slog.String("node_id", id))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *NodeRegistry) Forget(ctx context.Context, id string) error {
|
func (r *NodeRegistry) Forget(ctx context.Context, id string) error {
|
||||||
if err := r.repo.Delete(ctx, id); err != nil {
|
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)
|
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))
|
r.log.Info("node removed from registry", slog.String("node_id", id))
|
||||||
return nil
|
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