Files
orca/internal/store/audit_repo.go
T
Jon Chery 9e832387c6 feat(P05): seal/audit CLI + chain race fix + key zeroing (REQ-154)
New CLI commands:
- orca cluster seal: OIDC/CA-derived seal + Shamir 3-of-5 shards
- orca cluster unseal: OIDC/CA unseal + --recovery Shamir path
- orca doctor audit: VerifyChain + chain head report
- orca doctor modes: EnforceFileModes across ORCA_HOME

Fixes:
- audit hash-chain race: Append uses BEGIN IMMEDIATE transaction
  (concurrent appends no longer corrupt tamper-evidence)
- secrets rotate-master: re-seals to OIDC on sealed clusters
  (was writing raw key, docstring claimed re-seal)
- key zeroing: ZeroKey helper + defer after master/namespace key use
  (defense-in-depth against pprof heap extraction)
- store.Open: busy_timeout(5000) pragma (concurrent writers wait)

Tests: 18 new test functions (seal round-trip, Shamir recovery, doctor
audit tamper detection, doctor modes 0644 rejection, concurrent append
chain integrity, rotate-master re-seal, key zeroing).

---ci---
project: orca
phase: 5
milestone: v0.13
status: complete
requirements:
  covered: [154]
---/ci---
2026-08-07 21:06:39 +00:00

209 lines
6.7 KiB
Go

package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"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}
}
// computeEntryHash computes sha256(prev_hash || timestamp || actor ||
// action || resource || result || error || metadata) for the hash
// chain (REQ-125, F2). The prev_hash is the entry_hash of the most
// recent prior entry (empty string for the first entry).
func computeEntryHash(prevHash, timestamp, actor, action, resource, result, errMsg, metaJSON string) string {
h := sha256.New()
h.Write([]byte(prevHash))
h.Write([]byte{0})
h.Write([]byte(timestamp))
h.Write([]byte{0})
h.Write([]byte(actor))
h.Write([]byte{0})
h.Write([]byte(action))
h.Write([]byte{0})
h.Write([]byte(resource))
h.Write([]byte{0})
h.Write([]byte(result))
h.Write([]byte{0})
h.Write([]byte(errMsg))
h.Write([]byte{0})
h.Write([]byte(metaJSON))
return hex.EncodeToString(h.Sum(nil))
}
// Append adds a new audit entry to the log. The read of the previous
// entry's hash and the insert of the new row are wrapped in a single
// BEGIN IMMEDIATE transaction executed on a single dedicated
// connection so concurrent appends serialize: BEGIN IMMEDIATE acquires
// a RESERVED write lock immediately, blocking other writers until
// COMMIT. Without this, two concurrent Append calls could both read
// the same prev_hash and produce two entries with the same prev_hash
// link — corrupting the chain (REQ-125, P05 T4).
//
// We pin a single connection from the pool (db.Conn) and run
// BEGIN IMMEDIATE / SELECT / INSERT / COMMIT on it so the transaction
// state stays on one connection (database/sql does NOT propagate
// transaction state across pooled connections).
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)
tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano)
// Pin a single connection so the transaction state is consistent.
conn, err := r.db.Conn(ctx)
if err != nil {
return fmt.Errorf("audit append: acquire conn: %w", err)
}
defer conn.Close()
// BEGIN IMMEDIATE acquires a RESERVED lock right away, serializing
// concurrent writers. Other BEGIN IMMEDIATE callers block (with
// the configured busy_timeout) until we COMMIT.
if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
return fmt.Errorf("audit append: begin immediate: %w", err)
}
committed := false
defer func() {
if !committed {
_, _ = conn.ExecContext(ctx, "ROLLBACK")
}
}()
// Read the chain head (last entry's hash) within the transaction.
var prevHash string
err = conn.QueryRowContext(ctx,
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
if err == sql.ErrNoRows {
prevHash = ""
} else if err != nil {
return fmt.Errorf("audit append: get last entry hash: %w", err)
}
// Compute the new entry hash (REQ-125, F2).
entryHash := computeEntryHash(prevHash, tsStr, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
_, err = conn.ExecContext(ctx,
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata, prev_hash, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON), prevHash, entryHash)
if err != nil {
return fmt.Errorf("insert audit: %w", err)
}
if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
return fmt.Errorf("audit append: commit: %w", err)
}
committed = true
return nil
}
// VerifyChain recomputes the hash chain from the first entry and
// returns an error if any entry's entry_hash does not match. Used by
// `orca doctor audit` (REQ-125).
func (r *AuditRepo) VerifyChain(ctx context.Context) error {
rows, err := r.db.QueryContext(ctx,
`SELECT id, timestamp, actor, action, resource, result, COALESCE(error, ''), COALESCE(metadata, ''), prev_hash, entry_hash FROM audit_log ORDER BY id ASC`)
if err != nil {
return fmt.Errorf("verify chain: query: %w", err)
}
defer rows.Close()
prevHash := ""
for rows.Next() {
var (
id int64
ts time.Time
actor string
action string
resource string
result string
errMsg string
metaJSON string
storedPrev string
storedHash string
)
if err := rows.Scan(&id, &ts, &actor, &action, &resource, &result, &errMsg, &metaJSON, &storedPrev, &storedHash); err != nil {
return fmt.Errorf("verify chain: scan: %w", err)
}
// Verify the prev_hash link.
if storedPrev != prevHash {
return fmt.Errorf("verify chain: entry %d prev_hash mismatch (expected %q, got %q)", id, prevHash, storedPrev)
}
// Recompute the entry hash.
expected := computeEntryHash(prevHash, ts.UTC().Format(time.RFC3339Nano), actor, action, resource, result, errMsg, metaJSON)
if expected != storedHash {
return fmt.Errorf("verify chain: entry %d hash mismatch (entry may have been tampered)", id)
}
prevHash = storedHash
}
return rows.Err()
}
// ChainHead returns the entry_hash of the most recent audit_log entry,
// or "" if the table is empty. Used by `orca doctor audit` to report
// the chain head hash (REQ-125, P05 T2).
func (r *AuditRepo) ChainHead(ctx context.Context) (string, error) {
var head string
err := r.db.QueryRowContext(ctx,
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&head)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("chain head: %w", err)
}
return head, 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()
}