9e832387c6
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---
209 lines
6.7 KiB
Go
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()
|
|
}
|