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).
101 lines
3.6 KiB
Go
101 lines
3.6 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
)
|
|
|
|
// VerifyResult reports whether the chain is intact and, if not, the
|
|
// first row where it breaks -- everything after that point is
|
|
// untrustworthy regardless of whether later rows individually
|
|
// "verify," since a break means the chain was forked or rows were
|
|
// altered/removed at that point.
|
|
type VerifyResult struct {
|
|
OK bool
|
|
FirstBadID int64 // 0 if OK
|
|
RowsChecked int64
|
|
}
|
|
|
|
// VerifyChain walks audit_log in id order, recomputing each row's hash
|
|
// from its own fields plus the previous row's hash, and confirms it
|
|
// matches the stored row_hash and that prev_hash matches the actual
|
|
// previous row -- catching both in-place tampering (a row's fields
|
|
// changed, its stored row_hash no longer matches what recomputing it
|
|
// produces) and forgery (a row inserted with a prev_hash that doesn't
|
|
// match what actually preceded it).
|
|
//
|
|
// This proves internal consistency only. It cannot detect an attacker
|
|
// who deletes the whole table and replays a self-consistent chain from
|
|
// row 1 -- that's what checkpoint.go's external anchoring is for. Run
|
|
// both in the runbook/threat-model verification, not just this one.
|
|
func (s *Store) VerifyChain(ctx context.Context) (VerifyResult, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, tenant_id, user_id, source, event_type, query_text, row_count,
|
|
duration_ms, status, error_message, detail, prev_hash, row_hash
|
|
FROM audit_log ORDER BY id ASC`)
|
|
if err != nil {
|
|
return VerifyResult{}, fmt.Errorf("audit: querying for verification: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var expectedPrevHash *string
|
|
var checked int64
|
|
for rows.Next() {
|
|
var rec Record
|
|
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.UserID, &rec.Source, &rec.EventType,
|
|
&rec.QueryText, &rec.RowCount, &rec.DurationMS, &rec.Status, &rec.ErrorMessage,
|
|
&rec.Detail, &rec.PrevHash, &rec.RowHash); err != nil {
|
|
return VerifyResult{}, fmt.Errorf("audit: scanning row for verification: %w", err)
|
|
}
|
|
checked++
|
|
|
|
if !hashPtrEqual(rec.PrevHash, expectedPrevHash) {
|
|
return VerifyResult{OK: false, FirstBadID: rec.ID, RowsChecked: checked}, nil
|
|
}
|
|
recomputed := computeHash(rec.PrevHash, rec.Entry)
|
|
if recomputed != rec.RowHash {
|
|
return VerifyResult{OK: false, FirstBadID: rec.ID, RowsChecked: checked}, nil
|
|
}
|
|
hash := rec.RowHash
|
|
expectedPrevHash = &hash
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return VerifyResult{}, fmt.Errorf("audit: reading verification rows: %w", err)
|
|
}
|
|
|
|
return VerifyResult{OK: true, RowsChecked: checked}, nil
|
|
}
|
|
|
|
func hashPtrEqual(a, b *string) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
// ListForTenant reads a tenant's audit trail, most recent first --
|
|
// what a tenant Admin/Owner sees per /docs/phase-4-rbac-design.md's
|
|
// permission matrix.
|
|
func (s *Store) ListForTenant(ctx context.Context, tenantID string, limit int) ([]Record, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, tenant_id, user_id, source, event_type, query_text, row_count,
|
|
duration_ms, status, error_message, detail, prev_hash, row_hash
|
|
FROM audit_log WHERE tenant_id = $1 ORDER BY id DESC LIMIT $2`, tenantID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("audit: listing for tenant: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Record
|
|
for rows.Next() {
|
|
var rec Record
|
|
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.UserID, &rec.Source, &rec.EventType,
|
|
&rec.QueryText, &rec.RowCount, &rec.DurationMS, &rec.Status, &rec.ErrorMessage,
|
|
&rec.Detail, &rec.PrevHash, &rec.RowHash); err != nil {
|
|
return nil, fmt.Errorf("audit: scanning row: %w", err)
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out, rows.Err()
|
|
}
|