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() }