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---
This commit is contained in:
@@ -53,21 +53,19 @@ func computeEntryHash(prevHash, timestamp, actor, action, resource, result, errM
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// getLastEntryHash returns the entry_hash of the most recent audit_log
|
||||
// entry, or "" if the table is empty.
|
||||
func (r *AuditRepo) getLastEntryHash(ctx context.Context) (string, error) {
|
||||
var prevHash string
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get last entry hash: %w", err)
|
||||
}
|
||||
return prevHash, 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()
|
||||
@@ -78,19 +76,50 @@ func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
metaJSON, _ := json.Marshal(e.Metadata)
|
||||
tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
// Compute the hash chain (REQ-125, F2).
|
||||
prevHash, err := r.getLastEntryHash(ctx)
|
||||
// Pin a single connection so the transaction state is consistent.
|
||||
conn, err := r.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit hash chain: %w", err)
|
||||
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 = r.db.ExecContext(ctx,
|
||||
_, 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
|
||||
}
|
||||
|
||||
@@ -135,6 +164,22 @@ func (r *AuditRepo) VerifyChain(ctx context.Context) error {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user