Files
cairnobs/enterprise/internal/audit/audit.go
T
jcoffey-dev 7d316f92db Phase 7: AI-assisted query authoring (autocomplete, explain, fix, optimize, NL translation)
Adds a self-hosted (Ollama, qwen2.5-coder) model provider abstraction
with a pluggable opt-in cloud adapter, schema grounding, and a shared
cost/safety guard every AI-suggested query is assessed against --
compiling to and executing through the same unchanged Phase 2 IR/
compiler and Phase 4 tenant scoping as a hand-written query, no
parallel execution path.

Track A (built into the query bar): inline ghost-text autocomplete,
"Explain this query", "Fix this query" with a diff view, and a
rule-based "Optimize" suggestion. Track B: natural-language-to-query
translation, always a separate review step from execution, with
`sentryctl query --nl` requiring explicit confirmation to run.
Every accepted/dismissed translate-fix-optimize interaction is logged
into the same append-only audit_log table Phase 4 built.

Two real product bugs were found and fixed via live browser
verification (a Svelte effect re-running on every keystroke that
silently cancelled the ghost-text debounce; a ghost-text widget
positioned at document offset 0 instead of the cursor), and a real
costguard logic bug (unbounded-aggregation vs. raw-row) was caught by
its own test suite. New integration tests wire a real Ollama client
through the real HTTP handler against a mock server matching Ollama's
wire contract (hack/mock-ollama), keeping model-quality verification
out of CI as a disclosed, periodic human-run check instead.

See /docs/phase-7-ai-design.md and /docs/phase-7-runbook.md.
2026-08-16 18:06:27 -07:00

192 lines
6.2 KiB
Go

// Package audit implements the append-only, hash-chained query audit
// log described in /docs/phase-4-isolation-design.md's audit-logging
// section. Two independent defenses back the "no update/delete path
// from the application layer" requirement -- both verified against a
// live Postgres, not just written: audit_writer (this package's own
// Postgres role, via its own connection pool, never the shared `sentry`
// role every other store uses) has only INSERT+SELECT grants, and a
// BEFORE UPDATE OR DELETE trigger (metadata/migrations/0015-0016)
// rejects the operation for *any* role, including the table owner --
// confirmed live: even `sentry` cannot UPDATE a row without first
// disabling the trigger, a privileged operation distinct from ordinary
// application access.
//
// The hash chain (prev_hash/row_hash) proves internal consistency --
// detects tampering with existing rows -- but does not by itself prove
// truth against a privileged attacker who can rewrite the whole table
// and regenerate a self-consistent chain from row 1. See checkpoint.go
// for the external-anchoring half of that guarantee.
package audit
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Source string
const (
SourceAPI Source = "api"
SourceWeb Source = "web"
SourceCLI Source = "cli"
SourceAlerting Source = "alerting"
)
type EventType string
const (
EventQuery EventType = "query"
EventRoleChange EventType = "role_change"
EventGrantChange EventType = "grant_change"
EventSSOConfigChange EventType = "sso_config_change"
EventSecretReveal EventType = "secret_reveal"
// EventAIInteraction (Phase 7 task 12): a translate/fix/optimize
// suggestion's accept-or-dismiss outcome. QueryText carries the
// resulting query (if any); Detail carries the operation, the
// original input, confidence, and whether the user edited the
// suggestion before using it -- see ai_interaction_adapter.go.
EventAIInteraction EventType = "ai_interaction"
)
type Status string
const (
StatusSuccess Status = "success"
StatusError Status = "error"
)
// Entry is what a caller supplies. UserID is nil for system/alerting-
// sourced entries (see /docs/phase-4-isolation-design.md's alerting
// service-identity finding -- alerting evaluations are audited, but
// aren't attributable to a human user).
type Entry struct {
TenantID string
UserID *string
Source Source
EventType EventType
QueryText *string
RowCount *int
DurationMS *int
Status Status
ErrorMessage *string
Detail json.RawMessage
}
// Record is a written entry plus the fields the store assigned.
type Record struct {
Entry
ID int64
PrevHash *string
RowHash string
}
// Store writes via a connection pool authenticated as the audit_writer
// role -- never the shared pool other stores in this repo use. Passing
// a pool opened with any other role's credentials silently defeats the
// grant-restriction half of this package's guarantee; there's no way
// for this package to verify its own pool's role at runtime, so this is
// an integration-time discipline documented here, not something this
// code can enforce on itself.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// advisoryLockKey serializes concurrent Append calls so two writers
// never read the same prev_hash and each compute a hash chained off it
// -- that would fork the chain. Arbitrary fixed value, held only for
// the duration of one transaction (pg_advisory_xact_lock releases
// automatically at commit/rollback).
const advisoryLockKey = 784129035
func (s *Store) Append(ctx context.Context, e Entry) (*Record, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("audit: beginning transaction: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", advisoryLockKey); err != nil {
return nil, fmt.Errorf("audit: acquiring serialization lock: %w", err)
}
var prevHash *string
row := tx.QueryRow(ctx, "SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1")
if err := row.Scan(&prevHash); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("audit: reading previous row hash: %w", err)
}
if len(e.Detail) == 0 {
e.Detail = json.RawMessage(`{}`)
}
rec := &Record{Entry: e, PrevHash: prevHash, RowHash: computeHash(prevHash, e)}
err = tx.QueryRow(ctx, `
INSERT INTO audit_log (tenant_id, user_id, source, event_type, query_text, row_count,
duration_ms, status, error_message, detail, prev_hash, row_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
e.TenantID, e.UserID, e.Source, e.EventType, e.QueryText, e.RowCount,
e.DurationMS, e.Status, e.ErrorMessage, e.Detail, prevHash, rec.RowHash,
).Scan(&rec.ID)
if err != nil {
return nil, fmt.Errorf("audit: inserting row: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("audit: committing: %w", err)
}
return rec, nil
}
// computeHash is deliberately a fixed, explicit field order (not "hash
// the JSON encoding," which is not guaranteed stable across Go versions
// or map key ordering) -- \x00 is used as a field separator since it
// cannot appear in any of these string fields in practice, and even if
// it somehow did, the goal here is deterministic tamper-detection
// against accidental/naive modification, not cryptographic
// collision-resistance against a chosen-plaintext adversary.
func computeHash(prevHash *string, e Entry) string {
h := sha256.New()
write := func(s string) {
h.Write([]byte(s))
h.Write([]byte{0})
}
write(deref(prevHash))
write(e.TenantID)
write(deref(e.UserID))
write(string(e.Source))
write(string(e.EventType))
write(deref(e.QueryText))
write(intToStr(e.RowCount))
write(intToStr(e.DurationMS))
write(string(e.Status))
write(deref(e.ErrorMessage))
write(string(e.Detail))
return hex.EncodeToString(h.Sum(nil))
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
func intToStr(n *int) string {
if n == nil {
return ""
}
return fmt.Sprintf("%d", *n)
}