RBAC (api/internal/authz) is live on /query and /dashboards, backed by a new enterprise/ module (session issuance, audit logging, RBAC storage, OIDC/SAML protocol wiring) that core never imports -- only calls over HTTP. Found and fixed a real cross-tenant vulnerability in dashboards (no tenant_id filtering at all) while writing the threat model doc. Two things are explicitly NOT done, documented rather than hidden: tenant isolation for log data itself (/query still shares one ClickHouse connection and Tantivy index across every tenant -- RBAC controls who can query, not what a query can see), and human SSO login (protocol wiring exists, no HTTP handler calls it yet). See docs/security/threat-model.md and docs/phase-4-runbook.md. Also adds deploy/ (Go Operator + Helm chart, validated offline only -- no cluster was reachable in this environment).
186 lines
5.8 KiB
Go
186 lines
5.8 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"
|
|
)
|
|
|
|
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)
|
|
}
|