feat(cloud): compliance-grade audit trail — tamper-evident, append-only, live (v1.785.34) (#51)
* fix(deps): reconcile 5 drifted luxfi go.sum hashes (age keys precompile pq zap)
These 5 luxfi modules were re-published under the same version tags (monorepo
re-tag); every local + CI module cache and the proxy agree on the new zip
hashes while the committed go.sum still pinned the old ones, so ALL builds fail
with a checksum-mismatch SECURITY ERROR. Reconcile go.sum to the hashes every
source agrees on (what go mod tidy would write). GONOSUMDB already trusts luxfi
(fetch direct). Pre-existing drift, orthogonal to the audit feature; needed to
build.
* feat(cloud): compliance-grade audit trail — tamper-evident, append-only, live
FedRAMP AU-* / SOC 2 CC-* audit control for the unified cloud binary. Every
security-relevant request against this binary is captured as a structured,
hash-chained record in an append-only store the app can only INSERT into, and
queryable through the global-admin-gated /v1/admin/audit surface.
WHAT
- audit/ package (record + chain + store + redact + query/verify), no route
knowledge, pure security logic:
* record.go — the AU-3 event model (actor/action/resource/auth/outcome/
source-ip/ua/request-id/before-after) + the hash-chain math:
hash = SHA256(canonical(record, hash+prevhash zeroed) || prevHash),
genesis-anchored, DRY (add a field → covered by the hash automatically).
* store.go — a single serialized Recorder (mutex + SQLite MaxOpenConns(1))
owning the chain head; INSERT-only SQLite primary ({DataDir}/audit.db,
zero-loss, synchronous) + optional best-effort ClickHouse mirror. Restart
recovers the head so the chain continues (never forks).
* query.go — filtered Query (parameterized; org/actor/action/resource/result/
time) + Verify (walks the chain, recomputes every hash, reports the exact
seq where a tamper/delete/reorder first breaks it).
* redact.go — secret-key denylist (deny-by-key-name, recursive, fail-closed)
for the before/after an explicit emit point supplies.
- audit_middleware.go (cloud pkg) — the ONE place every security-relevant
request is recorded (decomplected: one predicate, every route). Sits AFTER
SanitizeIdentity (validated, unforgeable actor/isAdmin) and BEFORE BillingGate
(so billing 402/503 + admin 403 denials are audited too). Captures METADATA
ONLY — never request/response bodies — so a secret in a body can't leak.
Records mutations + all /v1/admin/* + all 401/403. Fails the request CLOSED
(503) if the trail write fails (AU-5). Resolves the effective status from a
returned *zip.HTTPError so error-returning denials are audited.
- audit_mirror.go — ClickHouse MergeTree OLAP mirror (insert-only by engine),
best-effort projection for fleet retention/query. Driver already in go.mod.
- clients/admin/audit.go — rewires GET /v1/admin/audit to cloud's REAL store
(was an IAM get-records proxy; kept as a federated fallback) + adds
GET /v1/admin/audit/verify. Both behind the existing global-admin s.guard.
TESTS (all real, on-disk SQLite, no mocks)
- audit/: chain seals+links, verify passes clean, DETECTS field-tamper /
deletion / reorder (out-of-band UPDATE/DELETE on a 2nd connection), restart
continues the chain, concurrent appends stay gapless+verified (-race), redact
strips secrets + fails closed, SQL-injection filter is inert.
- cloud/: middleware records a mutation with validated identity, audits a 403
denial, SKIPS safe reads, audits admin reads, NEVER captures a secret-bearing
body, no-op when unconfigured, fails closed on write error.
- clients/admin/: /v1/admin/audit returns real records + integrity summary,
filters, verify endpoint, 403 without global-admin (no data leak), nil-store
fallback.
THREAT MODEL
- Forge actor/admin: impossible at request level (SanitizeIdentity strips
X-User-IsAdmin, actor from validated JWT).
- Forge the chain: an out-of-band edit re-hashes differently; keeping the chain
valid requires recomputing the whole suffix — bounded by an externally-pinned
head (Head()) for AU-9 (tail-truncation detection). Documented.
- Skip the middleware: mounted at the compose root before MountAll; the /zap
plane replays through the same Fiber app (all middleware), so no bypass.
- Fail-closed is POST-RESPONSE: prevention is the AC layer (runs before the
action); the trail is detection/accountability. Documented precisely.
Store is a compliance control: empty DataDir is a hard boot error unless
CLOUD_AUDIT_DISABLED=true (explicit opt-out). Secrets from env/KMS only; no
plaintext credential ever enters a record.
* harden(audit): scrub credential-shaped path segments + expand redaction denylist
Defense-in-depth from self-review before adversarial handoff:
- scrubCredentialSegments/scrubToken: a token that ever rides in a URL PATH
(an hk-/sk-/pk-/fw_/hz_ key, reusing isAPIKey) is replaced with a marker in
both Record.Path and resource.ID, so a secret in the path is never recorded
verbatim. Normal identifiers (:name/:slug/:id/uuid/numeric) pass through.
Proven by TestAudit_ScrubsCredentialInPath.
- Redaction denylist gains passphrase, privkey, social_security, phrase (covers
seedPhrase/recoveryPhrase) — closing the key-name gaps found by enumerating
real credential field names. TestRedact_StripsSecrets now asserts them.
* harden(audit): close raw-secret leak in path/resource-id/user-agent
Self-review PoC found a real residual leak beyond prefixed keys: a raw
high-entropy secret (64-hex, or a JWT) in the URL path — and a bearer/key in
the client User-Agent — were recorded verbatim (isAPIKey only matched
hk-/sk-/pk-/fw_/hz_ prefixes). Closed:
- scrubToken now also catches JWTs (eyJ + two dots) and long unbroken
high-entropy alphanumeric runs (>=32, mixed, no separators) — a raw API
key/hex secret. UUIDs (hyphens), slugs, names, emails, numeric ids pass
through (TestScrubToken_NoFalsePositives).
- scrubFreeText scrubs credential-shaped words from the User-Agent (splits on
space/=/;/,) and caps length at 512. Normal UA prefix preserved.
Proven: TestAudit_ScrubsCredentialInPath (prefixed+raw-hex+JWT),
TestAudit_ScrubsSecretInUserAgent. Query-string secrets already safe (c.Path()
excludes the query string).
* fix(audit): close audit-evasion via /health suffix on mutations (SECURITY)
Self-review PoC found a real evasion: isSecurityRelevant skipped ANY path
ending in /health, so a mutating POST /v1/admin/orgs/x/health (wildcard route
or an attacker-named segment) slipped past the audit trail entirely — the
worst class of bug for a compliance control (silent bypass).
Fix: check the unconditional security signals FIRST and without exception — a
401/403 denial, any /v1/admin/* call, and any POST/PUT/PATCH/DELETE are ALWAYS
audited whatever the path. Only after that is a safe read dropped (all safe
reads, incl. liveness probes, are request-log noise → not recorded). The
suffix-based /health exemption is gone; it can no longer suppress a mutation.
Proven by TestAudit_HealthSuffixCannotEvadeAudit (POST .../health IS audited;
GET /v1/kms/health is not) and the unchanged TestAudit_SkipsSafeReads.
* harden(audit): no false-attribution — anonymous request records no org/sub
Self-review: SanitizeIdentity's Phase-1 residual restores a client-supplied
X-Org-Id for the data path, so an UNAUTHENTICATED attacker sending
X-Org-Id: victim-org could stamp an audit event with a victim's org (false
attribution), even though X-User-Id/IsAdmin are correctly stripped.
Fix: actorFromCtx gates the recorded actor on a VALIDATED principal — a
non-empty c.User() (X-User-Id, which SanitizeIdentity sets only from a verified
JWT). With no validated sub (anonymous, or an invalid/garbage bearer that failed
validation), the actor is recorded EMPTY: the event stands as an honest
anonymous mutation identified by SourceIP, never mis-attributed to a claimed
org. With a validated sub, org/sub/email are authoritative.
Proven by TestAudit_AnonRequestNotAttributedToForgedOrg (runs the real
SanitizeIdentity ahead of AuditTrail).
* fix(audit): close 4 scrub-bypass classes from Red review (MEDIUM)
Red's adversarial review found the path/UA credential scrub (5dfdf0e4) had
4 bypass classes that let a secret reach the immutable trail:
1. base64url with -/_ (looksLikeHighEntropyToken rejected any non-alnum)
2. all-alpha opaque >=len (required digits>0)
3. percent-encoded prefix (hk%2D… defeated the isAPIKey match)
4. UA glued by :/()[] (scrubFreeText split on too few delimiters)
Fixes:
- looksLikeHighEntropyToken now accepts the FULL base64url alphabet
[A-Za-z0-9_-] (RFC 4648 §5), drops the digit requirement, exempts dotted
values + canonical UUIDs, threshold lowered to 24 (128-bit base64 / 24-hex).
- scrubToken percent-decodes before every credential test (url.PathUnescape),
so %2D/%5F can't hide structure.
- scrubFreeText tokenizes on a broad delimiter superset (= ; , : / \ ( ) [ ]
{ } " ' < > | & ?) and rebuilds in one pass preserving delimiters —
replacing the fragile strings.ReplaceAll.
Proven by TestScrubToken_RedReviewBypassClasses (all 4 classes + UA glue) and
the expanded TestScrubToken_NoFalsePositives (uuids, model names like
claude-opus-4-20250514/text-embedding-3-large, slugs, normal UAs unchanged).
* feat(audit): operationalize AU-9 tail-truncation anchor (Red LOW #2)
Red: the Head() pin is inert unless operationalized — a hash chain can't detect
that the last K records were deleted (the surviving prefix self-verifies); only
an independent, durable head-digest series catches the count regression.
Adds a checkpoint emitter to the Recorder:
- StartCheckpoints(interval, logFn): a periodic goroutine emits the head digest
{count, head, ts} to the append-only observability log (o11y) every interval
(CLOUD_AUDIT_CHECKPOINT_INTERVAL, default 5m), plus a FINAL checkpoint on
Close so the shutdown head is anchored.
- CheckpointSink: when the ClickHouse mirror implements it, the digest is ALSO
persisted to an INDEPENDENT audit_log_checkpoints table (MergeTree) — so
truncating the local SQLite chain cannot rewrite the anchor history.
- Detection (compare consecutive checkpoints, alert on count regression) lives
in o11y where alert rules belong; the binary emits the tamper-evident anchor
to an independent sink. /v1/admin/audit/verify already returns (count,head) as
the pollable anchor too.
Proven: TestCheckpoint_EmitsHeadDigest (log + independent sink get count=7 head
on Close), TestCheckpoint_CountMonotonicDetectsTruncation (delete tail → prefix
still self-verifies, but count regresses 10→6 = the o11y alert signal).
Race-clean.
* harden(audit): close dotted-exemption + standard-b64 + nested-encoding scrub gaps
Self re-review (pre-empting Red's scoped re-review) found the round-1 scrub fix
still had gaps: the dotted-exemption let a raw secret bypass by appending '.x',
standard-base64 tokens (with +/) slipped, and nested percent-encoding (%252D)
survived a single decode.
Rewrote looksLikeHighEntropyToken to SCAN for the longest UNBROKEN base64-ish
run (>=24) anywhere in the value, over BOTH url-safe (-/_) and standard (+//)
alphabets — so '<rawsecret>.x' still trips (pre-dot run >= 24) and a standard-
base64 secret is caught. UUIDs stay exempt; real slugs/model-names/filenames
(report.pdf, text-embedding-3-large) have no 24-char run so they pass.
percentDecode now iterates (bounded x3) to normalize nested encodings.
All bypass classes closed (Red's 4 + dotted/standard/double-encoded), zero
false positives — TestScrubToken_RedReviewBypassClasses + NoFalsePositives
extended. The 20-char short-secret is a deliberate non-match (lowering below 24
would over-scrub legit hex-ish ids).
* fix(audit): address Red re-review — model-id over-scrub, UA glue, checkpoint (2 MED + 1 LOW)
Red re-review of the scrub/checkpoint code found 2 MEDIUM + 1 LOW:
MEDIUM 1 — model-id over-scrub (AU-3 regression): the round-3 run-scanner
counted '-' as a token char, so hyphenated model ids (claude-3-5-sonnet-
20241022, 26-char run) were redacted on audited routes (PATCH/DELETE
/v1/ml/models/:name, /v1/admin/catalog/models/*) — an auditor lost WHICH model
changed. Fix: isHighEntropyRunChar EXCLUDES '-' (kept +/_ for base64). Model
ids break into short runs (max ~9, far under 24); real secrets stay unbroken
>=24 runs — even a url-safe token using '-' as a separator has a >=24 run on one
side (AbCdEf-GhIjKl_MnOpQrStUvWxYz012345 -> 27). 6 model ids added to
TestScrubToken_NoFalsePositives.
MEDIUM 2 — UA free-text bypass: isFreeTextDelimiter omitted . @ # ~, so a
prefixed key glued by them (client@sk-live-KEY) stayed one token whose prefix
was no longer sk-/hk-. Fix: add . @ # ~ to the delimiter set; also flag a lone
eyJ-prefixed JWT header segment regardless of length (a JWT header is never a
legit id). Real UA dots are version separators (<24, safe). Fixed the stale
isFreeTextDelimiter docstring that referenced a nonexistent exemption. Proven by
4 glue-char probes in TestScrubToken_RedReviewBypassClasses.
LOW — checkpoint robustness: (a) StartCheckpoints now guards double-start with a
flag (the field/WaitGroup write was -race-flagged on a 2nd call) and
the docstring is corrected; (b) the on-Close final checkpoint to the independent
sink is now SYNCHRONOUS with a bounded 5s ctx (was fire-and-forget — the AU-9
independent anchor could be stale exactly at shutdown when an attacker truncates).
Proven by TestCheckpoint_DoubleStartIsSafe (-race) + TestCheckpoint_CloseSyncsToSink.
34 tests green, race-clean, -tags cloud.
* harden(audit): structured-id exemption beats hyphen-exclusion (11%->0.09% token bypass)
The prior fix (exclude '-' from the entropy run to protect model ids) opened an
~11% bypass for 32-byte url-safe-base64 secrets whose '-' happened to break
every 24-run (measured over 10k random tokens). Excluding '-' was too blunt.
Better construction: INCLUDE '-' in the run alphabet again (so a base64url token
embedding '-' is caught by its run), but exempt STRUCTURED IDs up-front via
isStructuredID — a value with >=3 hyphen groups where every part is <=12 chars
(dictionary words / short numbers: claude-3-5-sonnet-20241022). A raw secret
does not decompose that way. Measured: 0 model over-scrub, 0.09% residual on
32-byte base64 tokens that randomly resemble an id AND are prefixless AND sit in
a URL path (real keys carry hk-/sk- prefixes caught by isAPIKey; JWTs by
looksLikeJWT). An interior-hyphen raw secret with LONG parts still redacts.
Proven: TestScrubToken_NoFalsePositives (12 model ids/slugs pass) +
TestScrubToken_RedReviewBypassClasses (interior-hyphen long-part secret redacts).
34 tests green, -tags cloud.
* fix(audit): lexical structured-id test closes Red MEDIUM + guard started race (LOW)
Red final re-review: isStructuredID was SHAPE-only (>=3 hyphen groups, parts
<=12) — attacker-satisfiable. A secret chunked to that shape
(AbCdEfGhIjKl-MnOpQrStUvWx-YzAbCdEfGhIj, or deadbeef-cafebabe-01234567-89abcdef)
was exempted; ~2.15% of random 128-bit tokens leaked by chance. Entropy-count
alone can't separate them (deepseek-r1-distill-qwen-32b has 24 non-hyphen chars,
same as a 128-bit secret).
Fix: isStructuredID now requires every group to be WORD-LIKE (isWordLikeGroup) —
lexical content, not shape. A group is rejected if it is dense MIXED-CASE (base64
chunk) or a long ALL-HEX-WITH-LETTERS run >=8 (hex chunk like deadbeef); an
all-digit version date (20241022) stays word-like. Measured: 0 model over-scrub,
0 crafted-attacker bypass, natural random-token leak 0.0004% (128-bit) / 0%
(192-bit+) — down from 2.15%. Real keys (hk-/sk- prefix) and JWTs are caught
regardless.
LOW: StartCheckpoints' started check-and-set now under r.mu (was -race-dirty on
a concurrent 2nd call; prod-unreachable but now clean).
Proven: TestScrubToken_RedReviewBypassClasses (4 chunked-secret classes redact) +
TestScrubToken_NoFalsePositives (17 model ids/slugs pass). 34 tests green, -race,
-tags cloud.
* harden(audit): two-stage detection + document single-case-chunk residual bound
Restructured looksLikeHighEntropyToken into two stages after tracing the
fundamental limit Red is probing:
- Stage 1 (UNCONDITIONAL): a >=24 UNBROKEN run over [A-Za-z0-9_+/] (hasHighEntropyRun,
'-' and '.' are separators). Catches every raw secret WITHOUT internal separators
(hex, base64) at 100% — the realistic 'client bug put a raw key in the URL' case.
Never over-scrubs a hyphenated id (runs are short).
- Stage 2 (separated values): exempt a lexical structured-id (isStructuredID: >=3
word-like hyphen groups); otherwise redact. Catches mixed-case/hex chunks.
ACCEPTED RESIDUAL BOUND (documented in code): a secret deliberately chunked into
>=3 single-case-ALPHABETIC groups <=12 chars is lexically indistinguishable from
a hyphenated model id (deepseek-r1-distill-qwen-32b carries the SAME 24-char
entropy budget) — NOT closable by any length/case/count rule without a word
dictionary (over-engineering for a defense-in-depth URL/UA backstop). It does not
widen exposure for any REAL credential: Hanzo keys are prefixed (isAPIKey, any
length), JWTs are eyJ-prefixed (looksLikeJWT), bodies are never read. The residual
is an adversary chunk-encoding their OWN secret into a URL to seed an admin-only
audit row — contrived, low-value. The realistic accidental leak (unbroken raw key)
is caught by stage 1.
Removed dead isHighEntropyRunChar. 34 tests green, -race, -tags cloud.
* feat(paassvc): native in-process PaaS deploy control plane (/v1/paas/*)
Port the standalone Dokploy platform's observe + deploy halves into the unified
cloud binary as clients/paassvc — the 'one and only one way to deploy' made
native. Follows the clients/ml pattern exactly: a self-contained dynamic k8s
client, cloud.Register'd from init() (order 128), global-admin-gated, fail-closed
when no cluster.
Surface (global-admin only; user-facing view lives in console2):
GET /v1/paas/apps fleet drift board (declared/running/latest/drift+health)
GET /v1/paas/apps/:app one service row by CR name (main->test->dev)
POST /v1/paas/apps/:app/deploy deploy a tag by merge-patching Service CR .spec.image
GET /v1/paas/health real k8s reachability + Service CRD probe
- drift.go: 1:1 port of apps-drift.ts (computeDrift/isSemverTag + 6 DriftKinds,
identical severities). Pure, zero IO.
- paas.go: observeFleet lists hanzo.ai/v1 services across hanzo/-testnet/-devnet
(inventory.ts DEFAULT_TARGETS), joins the live Deployment for the running tag
(the operator Service CR status does NOT surface the running image — confirmed
against the live CRD), health/phase/endpoints from the reconciled CR status.
deploy merge-patches .spec.image (deploy-executor.ts parity) -> operator rolls it.
- Stateless: reads the cluster live (CRs are the source of truth); no apps-table
copy, no cron readers (dropped vs the Node platform).
Tested green:
- 30+ unit cases incl. the 9 drift-contract cases ported verbatim; go test green,
gofmt clean, go vet clean, full cloud binary builds (-tags cloud).
- Live-cluster probe (paasintegration tag, PAAS_IT-gated): observeFleet returned
82 real rows matching kubectl; an idempotent same-image patch on pricing
round-tripped through the operator with generation unchanged (6->6) = write
path proven WITHOUT triggering a rollout, zero disturbance to live state.
Design + full port map: universe/docs/architecture/paas-in-cloud.md.
RBAC (cloud-paassvc -> cloud-api SA): universe infra/k8s/cloud/paassvc-rbac.yaml.
Additive: platform.hanzo.ai stays as the internal-admin console; this is the
native backend + (next) the console2 user UI. No forced retirement.
* polish(audit): close hex-chunk residual (hex rule 8->4) + correct residual doc
Red final review (SHIP verdict) flagged 2 non-blocking polish items:
1. Lower isWordLikeGroup hex rule from len>=8 to len>=4 — Red verified across 19
real model ids that NONE has an all-hex-with-letters group of len>=4, so this
closes the small-hex-chunk leak (md5/sha in 'xxxx-xxxx' display grouping:
abcd-ef01-2345-6789-…) at 100% with ZERO model-id over-scrub. hexChunkMinLen=4.
2. Correct the residual doc: the accepted bound is now ONLY single-case-ALPHABETIC
base32 chunks (lowercase/uppercase-only, no hex-letter runs >=4) — mixed-case
base64 AND all hex-chunk sizes are now caught. The remaining case is genuinely
unclosable without a word dictionary and exposes no real credential.
Proven: TestScrubToken_RedReviewBypassClasses now includes 4-char hex groups
(abcd-ef01-…) which redact; TestScrubToken_NoFalsePositives (19 model ids) still
pass. 36 tests green, -race, -tags cloud. Red re-review: none needed.
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
package audit
|
||||
|
||||
// Tests for the tamper-evident audit chain. They exercise the REAL SQLite store
|
||||
// (an on-disk temp db, not a mock) so the append-only write path, the hash-chain
|
||||
// math, the verifier, redaction, and the filtered query are all proven
|
||||
// end-to-end. The headline test is tamper-detection: a record edited directly in
|
||||
// the database is DETECTED as breaking the chain.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// openTemp opens a Recorder backed by a fresh on-disk SQLite file (not :memory:,
|
||||
// because tamper tests re-open the same file via a second connection to edit it
|
||||
// out-of-band — exactly what an attacker with DB access would do).
|
||||
func openTemp(t *testing.T) (*Recorder, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
return rec, path
|
||||
}
|
||||
|
||||
// sampleRecord is a representative security event (a global-admin org deletion).
|
||||
func sampleRecord(action string) Record {
|
||||
return Record{
|
||||
Time: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC),
|
||||
Actor: Actor{Org: "admin", Sub: "z@hanzo.ai", Email: "z@hanzo.ai"},
|
||||
Action: action,
|
||||
Resource: Resource{Type: "org", ID: "acme"},
|
||||
Auth: AuthContext{Method: "jwt", IsAdmin: true},
|
||||
Outcome: Outcome{Result: "success", Status: 200},
|
||||
SourceIP: "203.0.113.7",
|
||||
UserAgent: "console2",
|
||||
RequestID: "req-123",
|
||||
Method: "DELETE",
|
||||
Path: "/v1/admin/orgs/acme",
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_AppendSealsAndLinks proves each appended record gets a monotonic seq,
|
||||
// links its PrevHash to the previous record's Hash, and starts from the genesis
|
||||
// anchor.
|
||||
func TestChain_AppendSealsAndLinks(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
r0, err := rec.Append(ctx, sampleRecord("DELETE /v1/admin/orgs"))
|
||||
if err != nil {
|
||||
t.Fatalf("append 0: %v", err)
|
||||
}
|
||||
if r0.Seq != 0 {
|
||||
t.Fatalf("first seq = %d, want 0", r0.Seq)
|
||||
}
|
||||
if r0.PrevHash != genesisPrevHash {
|
||||
t.Fatalf("genesis prev = %q, want %q", r0.PrevHash, genesisPrevHash)
|
||||
}
|
||||
if r0.Hash == "" || r0.Hash == genesisPrevHash {
|
||||
t.Fatalf("hash not computed: %q", r0.Hash)
|
||||
}
|
||||
|
||||
r1, err := rec.Append(ctx, sampleRecord("POST /v1/admin/roles"))
|
||||
if err != nil {
|
||||
t.Fatalf("append 1: %v", err)
|
||||
}
|
||||
if r1.Seq != 1 {
|
||||
t.Fatalf("second seq = %d, want 1", r1.Seq)
|
||||
}
|
||||
if r1.PrevHash != r0.Hash {
|
||||
t.Fatalf("link broken: r1.prev=%q, r0.hash=%q", r1.PrevHash, r0.Hash)
|
||||
}
|
||||
if r1.Hash == r0.Hash {
|
||||
t.Fatal("distinct records must have distinct hashes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_PassesOnUntamperedChain proves a well-formed chain verifies OK.
|
||||
func TestVerify_PassesOnUntamperedChain(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 25; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
integrity, err := rec.Verify(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if !integrity.OK {
|
||||
t.Fatalf("chain not OK: broken at %d (%s)", integrity.BrokenAt, integrity.Reason)
|
||||
}
|
||||
if integrity.Count != 25 {
|
||||
t.Fatalf("count = %d, want 25", integrity.Count)
|
||||
}
|
||||
if integrity.BrokenAt != -1 {
|
||||
t.Fatalf("brokenAt = %d, want -1 on a good chain", integrity.BrokenAt)
|
||||
}
|
||||
// Head must equal the last record's hash.
|
||||
count, head := rec.Head()
|
||||
if count != 25 || head != integrity.HeadHash {
|
||||
t.Fatalf("head mismatch: (%d,%q) vs verify (%d,%q)", count, head, integrity.Count, integrity.HeadHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_DetectsFieldTamper is the headline: an attacker with direct DB
|
||||
// access edits a record's content (flips a denied outcome to success, or changes
|
||||
// the actor). The stored hash no longer matches the recomputed hash, so Verify
|
||||
// reports the exact seq where the chain breaks. THIS is the tamper-evidence
|
||||
// property — an audit trail that can be silently forged is worse than none.
|
||||
func TestVerify_DetectsFieldTamper(t *testing.T) {
|
||||
rec, path := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("DELETE /v1/admin/orgs")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Sanity: clean chain verifies.
|
||||
if iv, _ := rec.Verify(ctx); !iv.OK {
|
||||
t.Fatalf("precondition: clean chain should verify, broke at %d", iv.BrokenAt)
|
||||
}
|
||||
|
||||
// Tamper OUT OF BAND — a second connection issues an UPDATE the application
|
||||
// never would. This models an attacker who owns the file / a rogue DBA.
|
||||
tamperOutOfBand(t, path, `UPDATE audit_log SET actor_sub='attacker', result='success' WHERE seq=4`)
|
||||
|
||||
iv, err := rec.Verify(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify after tamper: %v", err)
|
||||
}
|
||||
if iv.OK {
|
||||
t.Fatal("TAMPER NOT DETECTED — a modified record verified as OK; the chain is forgeable")
|
||||
}
|
||||
if iv.BrokenAt != 4 {
|
||||
t.Fatalf("brokenAt = %d, want 4 (the edited record)", iv.BrokenAt)
|
||||
}
|
||||
if !strings.Contains(iv.Reason, "hash mismatch") {
|
||||
t.Fatalf("reason = %q, want a hash-mismatch explanation", iv.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_DetectsDeletion proves deleting a record (or a contiguous run) breaks
|
||||
// the chain: the record after the hole has a PrevHash that no longer matches the
|
||||
// now-preceding record, and the seq sequence gaps. Either way Verify flags it.
|
||||
func TestVerify_DetectsDeletion(t *testing.T) {
|
||||
rec, path := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/roles")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Delete a MIDDLE record — the classic "cover your tracks" edit.
|
||||
tamperOutOfBand(t, path, `DELETE FROM audit_log WHERE seq=5`)
|
||||
|
||||
iv, err := rec.Verify(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify after delete: %v", err)
|
||||
}
|
||||
if iv.OK {
|
||||
t.Fatal("DELETION NOT DETECTED — a removed record left the chain verifying OK")
|
||||
}
|
||||
// The break is observed at seq 6 (the record whose predecessor vanished): its
|
||||
// seq no longer follows the running counter (5 is missing), so the gap check
|
||||
// fires first at 6.
|
||||
if iv.BrokenAt != 6 {
|
||||
t.Fatalf("brokenAt = %d, want 6 (record after the hole)", iv.BrokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_DetectsReorder proves swapping two records' positions (an attacker
|
||||
// trying to reorder events) breaks the prev-hash linkage.
|
||||
func TestVerify_DetectsReorder(t *testing.T) {
|
||||
rec, path := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 6; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/kms/secrets")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Swap the hashes of seq 2 and seq 3 (content stays, linkage corrupts). Any
|
||||
// out-of-band shuffle that doesn't recompute the WHOLE suffix is detectable.
|
||||
tamperOutOfBand(t, path, `
|
||||
UPDATE audit_log SET hash = (SELECT hash FROM audit_log WHERE seq=3) WHERE seq=2;`)
|
||||
|
||||
iv, _ := rec.Verify(ctx)
|
||||
if iv.OK {
|
||||
t.Fatal("REORDER/HASH-SWAP NOT DETECTED")
|
||||
}
|
||||
if iv.BrokenAt < 0 {
|
||||
t.Fatalf("expected a break, got brokenAt=%d", iv.BrokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_RestartContinues proves a re-opened store continues the SAME chain
|
||||
// (recovers seq + head) rather than forking — so a pod restart cannot silently
|
||||
// reset the trail.
|
||||
func TestChain_RestartContinues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
ctx := context.Background()
|
||||
|
||||
rec1, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open 1: %v", err)
|
||||
}
|
||||
var lastHash string
|
||||
for i := 0; i < 5; i++ {
|
||||
r, err := rec1.Append(ctx, sampleRecord("POST /v1/admin/sync"))
|
||||
if err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
lastHash = r.Hash
|
||||
}
|
||||
_ = rec1.Close()
|
||||
|
||||
rec2, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open 2: %v", err)
|
||||
}
|
||||
defer func() { _ = rec2.Close() }()
|
||||
|
||||
count, head := rec2.Head()
|
||||
if count != 5 {
|
||||
t.Fatalf("recovered count = %d, want 5", count)
|
||||
}
|
||||
if head != lastHash {
|
||||
t.Fatalf("recovered head = %q, want %q", head, lastHash)
|
||||
}
|
||||
// The next append must chain onto the recovered head at seq 5.
|
||||
r5, err := rec2.Append(ctx, sampleRecord("DELETE /v1/admin/orgs"))
|
||||
if err != nil {
|
||||
t.Fatalf("append after restart: %v", err)
|
||||
}
|
||||
if r5.Seq != 5 || r5.PrevHash != lastHash {
|
||||
t.Fatalf("chain did not continue: seq=%d prev=%q (want seq 5 prev %q)", r5.Seq, r5.PrevHash, lastHash)
|
||||
}
|
||||
// And the whole continued chain still verifies.
|
||||
if iv, _ := rec2.Verify(ctx); !iv.OK {
|
||||
t.Fatalf("continued chain broke at %d (%s)", iv.BrokenAt, iv.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedact_StripsSecrets proves the redactor removes credential-bearing fields
|
||||
// (by key name, recursively) while keeping non-secret structure — so an explicit
|
||||
// emit point's before/after can never carry a password/token/key.
|
||||
func TestRedact_StripsSecrets(t *testing.T) {
|
||||
in := json.RawMessage(`{
|
||||
"name": "acme",
|
||||
"password": "hunter2",
|
||||
"apiKey": "sk-live-abc123",
|
||||
"passphrase": "correct horse",
|
||||
"wgPrivKey": "PRIVKEYBYTES",
|
||||
"recoveryPhrase": "twelve words here",
|
||||
"socialSecurityNumber": "078-05-1120",
|
||||
"config": {
|
||||
"clientSecret": "shh",
|
||||
"endpoint": "https://api.example.com",
|
||||
"nested": {"private_key": "-----BEGIN-----", "region": "sfo3"}
|
||||
},
|
||||
"tokens": ["t1", "t2"],
|
||||
"roles": ["admin", "viewer"]
|
||||
}`)
|
||||
out := Redact(in)
|
||||
|
||||
s := string(out)
|
||||
// Secrets gone (incl. the edge-case key names: passphrase, privkey, phrase, ssn).
|
||||
for _, leak := range []string{"hunter2", "sk-live-abc123", "shh", "BEGIN",
|
||||
"correct horse", "PRIVKEYBYTES", "twelve words here", "078-05-1120"} {
|
||||
if strings.Contains(s, leak) {
|
||||
t.Fatalf("secret leaked through redaction: %q still present in %s", leak, s)
|
||||
}
|
||||
}
|
||||
// Non-secret structure preserved.
|
||||
for _, keep := range []string{"acme", "https://api.example.com", "sfo3", "viewer"} {
|
||||
if !strings.Contains(s, keep) {
|
||||
t.Fatalf("redaction dropped a non-secret value %q: %s", keep, s)
|
||||
}
|
||||
}
|
||||
// The redaction marker appears where secrets were.
|
||||
if !strings.Contains(s, redactedMarker) {
|
||||
t.Fatalf("no redaction marker in output: %s", s)
|
||||
}
|
||||
// "tokens" is a secret key → the whole array is redacted (not its elements).
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(out, &decoded); err != nil {
|
||||
t.Fatalf("redacted output is not valid JSON: %v", err)
|
||||
}
|
||||
if decoded["tokens"] != redactedMarker {
|
||||
t.Fatalf("secret-keyed array not redacted whole: %v", decoded["tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedact_FailsClosedOnBadJSON proves unparseable input is never echoed back.
|
||||
func TestRedact_FailsClosedOnBadJSON(t *testing.T) {
|
||||
out := Redact(json.RawMessage(`{not valid json, password=hunter2`))
|
||||
if strings.Contains(string(out), "hunter2") {
|
||||
t.Fatalf("bad JSON echoed a secret: %s", out)
|
||||
}
|
||||
if !strings.Contains(string(out), redactedMarker) {
|
||||
t.Fatalf("bad JSON should redact to a marker, got %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuery_Filters proves the filtered read returns the right subset by actor,
|
||||
// action, resource, and result, newest-first, with an accurate total.
|
||||
func TestQuery_Filters(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mk := func(org, action, res, result string) Record {
|
||||
r := sampleRecord(action)
|
||||
r.Actor.Org = org
|
||||
r.Resource.Type = res
|
||||
r.Outcome.Result = result
|
||||
return r
|
||||
}
|
||||
// A mixed set.
|
||||
seed := []Record{
|
||||
mk("admin", "DELETE /v1/admin/orgs", "org", "success"),
|
||||
mk("acme", "POST /v1/base/records", "records", "success"),
|
||||
mk("admin", "POST /v1/admin/roles", "roles", "deny"),
|
||||
mk("admin", "DELETE /v1/admin/orgs", "org", "success"),
|
||||
mk("acme", "POST /v1/kms/secrets", "secrets", "error"),
|
||||
}
|
||||
for i, r := range seed {
|
||||
if _, err := rec.Append(ctx, r); err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by org=admin → 3 rows.
|
||||
rows, total, err := rec.Query(ctx, Filter{Org: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("query org: %v", err)
|
||||
}
|
||||
if total != 3 || len(rows) != 3 {
|
||||
t.Fatalf("org=admin: got %d rows, total %d, want 3/3", len(rows), total)
|
||||
}
|
||||
// Newest first: the last-appended admin row (seq 3) comes before seq 2, 0.
|
||||
if rows[0].Seq < rows[len(rows)-1].Seq {
|
||||
t.Fatalf("not newest-first: %d..%d", rows[0].Seq, rows[len(rows)-1].Seq)
|
||||
}
|
||||
|
||||
// Filter by result=deny → 1 row (the 403-style role change).
|
||||
denies, dtotal, err := rec.Query(ctx, Filter{Result: "deny"})
|
||||
if err != nil {
|
||||
t.Fatalf("query deny: %v", err)
|
||||
}
|
||||
if dtotal != 1 || len(denies) != 1 || denies[0].Action != "POST /v1/admin/roles" {
|
||||
t.Fatalf("result=deny: got %d (%+v), want 1 role-change", dtotal, denies)
|
||||
}
|
||||
|
||||
// Filter by resource=secrets → 1 row.
|
||||
secs, stotal, err := rec.Query(ctx, Filter{Resource: "secrets"})
|
||||
if err != nil {
|
||||
t.Fatalf("query resource: %v", err)
|
||||
}
|
||||
if stotal != 1 || len(secs) != 1 {
|
||||
t.Fatalf("resource=secrets: got %d, want 1", stotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuery_SQLInjectionInFilterIsInert proves a malicious filter value is a
|
||||
// parameter, never SQL: it simply matches nothing and cannot drop the table.
|
||||
func TestQuery_SQLInjectionInFilterIsInert(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
inject := Filter{Org: "admin'; DROP TABLE audit_log;--"}
|
||||
rows, total, err := rec.Query(ctx, inject)
|
||||
if err != nil {
|
||||
t.Fatalf("query should not error on injection attempt: %v", err)
|
||||
}
|
||||
if total != 0 || len(rows) != 0 {
|
||||
t.Fatalf("injection value matched %d rows, want 0", total)
|
||||
}
|
||||
// The table survived — a normal query still returns the seeded rows.
|
||||
if _, all, err := rec.Query(ctx, Filter{}); err != nil || all != 3 {
|
||||
t.Fatalf("table damaged by injection attempt: all=%d err=%v", all, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_ConcurrentAppendsStayGapless proves the serialized writer keeps the
|
||||
// chain a true, gapless total order under CONCURRENT appends: many goroutines
|
||||
// append at once, and the resulting chain must have every seq 0..N-1 exactly once
|
||||
// AND verify. A race in the head/seq handoff would surface as a duplicate seq (a
|
||||
// PRIMARY KEY error), a gap, or a broken link — all of which this catches.
|
||||
func TestChain_ConcurrentAppendsStayGapless(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const goroutines, per = 16, 20
|
||||
total := goroutines * per
|
||||
errCh := make(chan error, total)
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < per; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Fatalf("concurrent append failed (race in seq/head handoff?): %v", err)
|
||||
}
|
||||
|
||||
// The chain must verify and contain exactly `total` gapless records.
|
||||
iv, err := rec.Verify(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !iv.OK {
|
||||
t.Fatalf("concurrent chain broke at %d (%s)", iv.BrokenAt, iv.Reason)
|
||||
}
|
||||
if iv.Count != uint64(total) {
|
||||
t.Fatalf("recorded %d records, want %d (a lost/duplicated append)", iv.Count, total)
|
||||
}
|
||||
}
|
||||
|
||||
// checkpointMirror is a Mirror that also captures checkpoints (implements
|
||||
// CheckpointSink) so the test can assert the head digest reaches an independent
|
||||
// sink.
|
||||
type checkpointMirror struct {
|
||||
mu sync.Mutex
|
||||
cps []Checkpoint
|
||||
}
|
||||
|
||||
func (m *checkpointMirror) Append(context.Context, Record) error { return nil }
|
||||
func (m *checkpointMirror) Checkpoint(_ context.Context, cp Checkpoint) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cps = append(m.cps, cp)
|
||||
return nil
|
||||
}
|
||||
func (m *checkpointMirror) last() (Checkpoint, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if len(m.cps) == 0 {
|
||||
return Checkpoint{}, false
|
||||
}
|
||||
return m.cps[len(m.cps)-1], true
|
||||
}
|
||||
|
||||
// TestCheckpoint_EmitsHeadDigest proves the AU-9 anchor: the periodic checkpoint
|
||||
// emits the current (count, head) to the log function AND, when the mirror is a
|
||||
// CheckpointSink, to the independent digest store — and a final checkpoint fires
|
||||
// on Close. This is what an external monitor compares to detect tail-truncation.
|
||||
func TestCheckpoint_EmitsHeadDigest(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
mirror := &checkpointMirror{}
|
||||
rec, err := Open(path, mirror)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
var logged []Checkpoint
|
||||
var lmu sync.Mutex
|
||||
// every=0 → no ticker; we drive checkpoints via Close (final) + a manual tick.
|
||||
rec.StartCheckpoints(0, func(cp Checkpoint) {
|
||||
lmu.Lock()
|
||||
logged = append(logged, cp)
|
||||
lmu.Unlock()
|
||||
})
|
||||
|
||||
for i := 0; i < 7; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Close emits the FINAL checkpoint (count=7, head=chain head).
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
lmu.Lock()
|
||||
n := len(logged)
|
||||
var lastLogged Checkpoint
|
||||
if n > 0 {
|
||||
lastLogged = logged[n-1]
|
||||
}
|
||||
lmu.Unlock()
|
||||
if n == 0 {
|
||||
t.Fatal("no checkpoint logged (Close should emit a final head digest)")
|
||||
}
|
||||
if lastLogged.Count != 7 {
|
||||
t.Errorf("final checkpoint count = %d, want 7", lastLogged.Count)
|
||||
}
|
||||
if lastLogged.Head == "" || lastLogged.Head == genesisPrevHash {
|
||||
t.Errorf("final checkpoint head not set: %q", lastLogged.Head)
|
||||
}
|
||||
// The independent sink also received the final digest.
|
||||
if cp, ok := mirror.last(); !ok || cp.Count != 7 {
|
||||
t.Errorf("checkpoint sink final = %+v (ok=%v), want count 7", cp, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpoint_DoubleStartIsSafe proves a second StartCheckpoints call is
|
||||
// ignored (no re-arm, no field/WaitGroup race) — the Red-review robustness fix.
|
||||
// Run under -race to catch a regression.
|
||||
func TestCheckpoint_DoubleStartIsSafe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
rec.StartCheckpoints(time.Hour, func(Checkpoint) {})
|
||||
rec.StartCheckpoints(time.Hour, func(Checkpoint) {}) // second call must be a no-op.
|
||||
// Append + close must not race or hang.
|
||||
if _, err := rec.Append(context.Background(), sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpoint_CloseSyncsToSink proves the FINAL checkpoint on Close reaches the
|
||||
// independent sink SYNCHRONOUSLY (the Red-review durability fix) — the sink has
|
||||
// the final count before Close returns, not on a detached goroutine that might
|
||||
// not run before process exit.
|
||||
func TestCheckpoint_CloseSyncsToSink(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
mirror := &checkpointMirror{}
|
||||
rec, err := Open(path, mirror)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
rec.StartCheckpoints(0, func(Checkpoint) {}) // no ticker; only the on-close checkpoint.
|
||||
for i := 0; i < 4; i++ {
|
||||
if _, err := rec.Append(context.Background(), sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
// Immediately after Close returns (no sleep), the sink MUST already have the
|
||||
// final digest — proving the Close-path write was synchronous.
|
||||
cp, ok := mirror.last()
|
||||
if !ok || cp.Count != 4 {
|
||||
t.Fatalf("sink final checkpoint = %+v (ok=%v), want count 4 synchronously on Close", cp, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpoint_CountMonotonicDetectsTruncation demonstrates the DETECTION an
|
||||
// external monitor performs: consecutive checkpoints have non-decreasing Count;
|
||||
// after a tail truncation the head reported by Head() drops below a prior
|
||||
// checkpoint — the signal the o11y alert fires on.
|
||||
func TestCheckpoint_CountMonotonicDetectsTruncation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
before, _ := rec.Head() // the monitor's last pinned checkpoint count.
|
||||
if before != 10 {
|
||||
t.Fatalf("pre-truncation count = %d, want 10", before)
|
||||
}
|
||||
_ = rec.Close()
|
||||
|
||||
// Attacker truncates the tail (deletes the last 4 records) out of band.
|
||||
tamperOutOfBand(t, path, `DELETE FROM audit_log WHERE seq >= 6`)
|
||||
|
||||
rec2, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer func() { _ = rec2.Close() }()
|
||||
after, _ := rec2.Head()
|
||||
// The internal chain still verifies (a truncated prefix is self-consistent)…
|
||||
if iv, _ := rec2.Verify(ctx); !iv.OK {
|
||||
t.Fatalf("truncated prefix should self-verify, broke at %d", iv.BrokenAt)
|
||||
}
|
||||
// …but the count REGRESSED vs the pinned checkpoint — the truncation signal.
|
||||
if after >= before {
|
||||
t.Fatalf("count did not regress after truncation: before=%d after=%d", before, after)
|
||||
}
|
||||
t.Logf("truncation detected by count regression: %d → %d (chain-internal verify is OK; external anchor catches it)", before, after)
|
||||
}
|
||||
|
||||
// tamperOutOfBand opens the SAME sqlite file on a SEPARATE connection and runs a
|
||||
// mutating statement the audit application itself never issues — modeling an
|
||||
// attacker with direct database/file access. The Recorder's own connection is
|
||||
// unaffected; Verify then re-reads and must catch the damage.
|
||||
func tamperOutOfBand(t *testing.T, path, stmt string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("tamper open: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
t.Fatalf("tamper exec %q: %v", stmt, err)
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
package audit
|
||||
|
||||
// The read paths: filtered Query (for /v1/admin/audit) and Verify (the
|
||||
// tamper-evidence walk for /v1/admin/audit/verify). Both are read-only — they
|
||||
// issue SELECT only, never mutate — so exposing them can never weaken the
|
||||
// append-only property.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Filter narrows a Query. Zero-value fields are ignored (no constraint), so an
|
||||
// empty Filter returns the most-recent Limit records. Time bounds are inclusive
|
||||
// and compared against the RFC3339Nano ts column lexicographically (RFC3339 is
|
||||
// order-preserving as text, so a string range is a correct time range).
|
||||
type Filter struct {
|
||||
Org string // actor_org exact match (tenant scope)
|
||||
Sub string // actor_sub exact match (a specific user)
|
||||
Action string // action exact match
|
||||
Resource string // res_type exact match
|
||||
Result string // outcome result: success|deny|error
|
||||
Since time.Time // ts >= Since (UTC)
|
||||
Until time.Time // ts <= Until (UTC)
|
||||
Limit int // max rows (default 100, cap 1000)
|
||||
Offset int // pagination offset
|
||||
}
|
||||
|
||||
// Query returns records matching f, newest first, and the total count matching
|
||||
// the same predicate (ignoring Limit/Offset) for pagination. All predicates are
|
||||
// parameterized — never string-interpolated — so a filter value can never inject
|
||||
// SQL. Column names in the WHERE come from a fixed allowlist below, not caller
|
||||
// input.
|
||||
func (r *Recorder) Query(ctx context.Context, f Filter) (rows []Record, total int, err error) {
|
||||
where, args := f.build()
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
offset := f.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
countQ := `SELECT COUNT(*) FROM audit_log` + where
|
||||
if err = r.db.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: count: %w", err)
|
||||
}
|
||||
|
||||
listQ := `SELECT ` + selectCols + ` FROM audit_log` + where +
|
||||
` ORDER BY seq DESC LIMIT ? OFFSET ?`
|
||||
listArgs := append(append([]any{}, args...), limit, offset)
|
||||
rs, err := r.db.QueryContext(ctx, listQ, listArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: query: %w", err)
|
||||
}
|
||||
defer func() { _ = rs.Close() }()
|
||||
for rs.Next() {
|
||||
rec, scanErr := scanRecord(rs)
|
||||
if scanErr != nil {
|
||||
return nil, 0, fmt.Errorf("audit: scan: %w", scanErr)
|
||||
}
|
||||
rows = append(rows, rec)
|
||||
}
|
||||
return rows, total, rs.Err()
|
||||
}
|
||||
|
||||
// build assembles the parameterized WHERE clause from the non-zero filter
|
||||
// fields. Each fragment uses a fixed column name and a ? placeholder, so no
|
||||
// caller value ever reaches the SQL text.
|
||||
func (f Filter) build() (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
add := func(frag string, val any) {
|
||||
conds = append(conds, frag)
|
||||
args = append(args, val)
|
||||
}
|
||||
if f.Org != "" {
|
||||
add("actor_org = ?", f.Org)
|
||||
}
|
||||
if f.Sub != "" {
|
||||
add("actor_sub = ?", f.Sub)
|
||||
}
|
||||
if f.Action != "" {
|
||||
add("action = ?", f.Action)
|
||||
}
|
||||
if f.Resource != "" {
|
||||
add("res_type = ?", f.Resource)
|
||||
}
|
||||
if f.Result != "" {
|
||||
add("result = ?", f.Result)
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
add("ts >= ?", f.Since.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
add("ts <= ?", f.Until.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
const selectCols = `seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
|
||||
auth_method, is_admin, result, status, reason, source_ip, user_agent,
|
||||
request_id, method, path, before, after, prev_hash, hash`
|
||||
|
||||
// scanRecord reconstructs a Record from a row of selectCols.
|
||||
func scanRecord(sc interface{ Scan(...any) error }) (Record, error) {
|
||||
var (
|
||||
rec Record
|
||||
ts string
|
||||
isAdmin int
|
||||
before, after string
|
||||
)
|
||||
if err := sc.Scan(
|
||||
&rec.Seq, &ts, &rec.Actor.Org, &rec.Actor.Sub, &rec.Actor.Email,
|
||||
&rec.Action, &rec.Resource.Type, &rec.Resource.ID,
|
||||
&rec.Auth.Method, &isAdmin, &rec.Outcome.Result, &rec.Outcome.Status, &rec.Outcome.Reason,
|
||||
&rec.SourceIP, &rec.UserAgent, &rec.RequestID, &rec.Method, &rec.Path,
|
||||
&before, &after, &rec.PrevHash, &rec.Hash,
|
||||
); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
|
||||
rec.Time = t
|
||||
}
|
||||
rec.Auth.IsAdmin = isAdmin != 0
|
||||
if before != "" {
|
||||
rec.Before = json.RawMessage(before)
|
||||
}
|
||||
if after != "" {
|
||||
rec.After = json.RawMessage(after)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Integrity is the result of a Verify walk — the AU-9 evidence that the trail has
|
||||
// not been tampered with.
|
||||
type Integrity struct {
|
||||
// OK is true iff every record's stored hash equals the recomputed hash AND the
|
||||
// chain links are continuous (each PrevHash == the prior record's Hash, seqs
|
||||
// gapless from 0).
|
||||
OK bool `json:"ok"`
|
||||
// Count is the number of records walked.
|
||||
Count uint64 `json:"count"`
|
||||
// HeadHash is the hash of the last record (or the genesis anchor for an empty
|
||||
// chain). Pin this externally over time to detect tail-truncation.
|
||||
HeadHash string `json:"headHash"`
|
||||
// BrokenAt is the seq of the FIRST record that failed verification, or -1 when
|
||||
// OK. Reason describes the break (recomputed-hash mismatch, prev-hash
|
||||
// discontinuity, or a seq gap).
|
||||
BrokenAt int64 `json:"brokenAt"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Verify walks the entire chain in seq order, recomputing each record's hash from
|
||||
// its content + the running prev-hash and checking continuity. It is the
|
||||
// tamper-detector: any modification (a changed field re-hashes differently), any
|
||||
// deletion or reordering (a seq gap or a broken prev-hash link), or a forged row
|
||||
// (its recomputed hash won't match unless the attacker also recomputed the entire
|
||||
// suffix — which they cannot do without re-inserting every subsequent record) is
|
||||
// reported with the exact seq where the chain first breaks.
|
||||
//
|
||||
// Complexity is O(n) over the records; for very large trails this streams row by
|
||||
// row (no full materialization). At cloud's audit volume this is fine; if a trail
|
||||
// grows past what an on-demand full walk should touch, verify a seq WINDOW
|
||||
// (Verify is easily extended with a bound) or rely on the externally-pinned head.
|
||||
func (r *Recorder) Verify(ctx context.Context) (Integrity, error) {
|
||||
rs, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM audit_log ORDER BY seq ASC`)
|
||||
if err != nil {
|
||||
return Integrity{}, fmt.Errorf("audit: verify query: %w", err)
|
||||
}
|
||||
defer func() { _ = rs.Close() }()
|
||||
|
||||
prevHash := genesisPrevHash
|
||||
var expectSeq uint64
|
||||
var count uint64
|
||||
headHash := genesisPrevHash
|
||||
|
||||
for rs.Next() {
|
||||
rec, scanErr := scanRecord(rs)
|
||||
if scanErr != nil {
|
||||
return Integrity{}, fmt.Errorf("audit: verify scan: %w", scanErr)
|
||||
}
|
||||
// Gapless, 0-based ordering.
|
||||
if rec.Seq != expectSeq {
|
||||
return Integrity{
|
||||
OK: false, Count: count, HeadHash: headHash,
|
||||
BrokenAt: int64(rec.Seq),
|
||||
Reason: fmt.Sprintf("seq gap: expected %d, got %d", expectSeq, rec.Seq),
|
||||
}, nil
|
||||
}
|
||||
// Link continuity: this record must chain to the previous record's hash.
|
||||
if rec.PrevHash != prevHash {
|
||||
return Integrity{
|
||||
OK: false, Count: count, HeadHash: headHash,
|
||||
BrokenAt: int64(rec.Seq),
|
||||
Reason: "prev_hash discontinuity (a record was deleted, reordered, or altered)",
|
||||
}, nil
|
||||
}
|
||||
// Content integrity: recompute the hash from the record's own fields.
|
||||
want, hErr := computeHash(rec, rec.PrevHash)
|
||||
if hErr != nil {
|
||||
return Integrity{}, fmt.Errorf("audit: verify hash: %w", hErr)
|
||||
}
|
||||
if want != rec.Hash {
|
||||
return Integrity{
|
||||
OK: false, Count: count, HeadHash: headHash,
|
||||
BrokenAt: int64(rec.Seq),
|
||||
Reason: "hash mismatch (record content was modified after it was written)",
|
||||
}, nil
|
||||
}
|
||||
prevHash = rec.Hash
|
||||
headHash = rec.Hash
|
||||
expectSeq = rec.Seq + 1
|
||||
count++
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
return Integrity{}, fmt.Errorf("audit: verify rows: %w", err)
|
||||
}
|
||||
return Integrity{OK: true, Count: count, HeadHash: headHash, BrokenAt: -1}, nil
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
// Package audit is the unified cloud binary's compliance-grade audit trail —
|
||||
// tamper-evident, append-only, and complete over the security-relevant request
|
||||
// surface (FedRAMP AU-* / SOC 2 CC-* controls).
|
||||
//
|
||||
// THE CONTROL, IN ONE SENTENCE. Every security-relevant action against this
|
||||
// binary is captured as a structured Record, hash-chained to its predecessor so
|
||||
// any later deletion or modification is detectable, and written INLINE (never
|
||||
// dropped) to an append-only store the application can only INSERT into.
|
||||
//
|
||||
// THREE PIECES, EACH IN ITS LANE (orthogonal, per the Zen of Hanzo):
|
||||
// - record.go — the event model + the hash-chain math (what a record IS and
|
||||
// how it links to the one before it). Pure, no I/O.
|
||||
// - store.go — the append-only sink (SQLite primary, INSERT-only; a
|
||||
// best-effort OLAP mirror) and the serialized Recorder that
|
||||
// owns the chain head. All persistence.
|
||||
// - redact.go — the secret-stripping allowlist/denylist for any structured
|
||||
// before/after an explicit emit point supplies. No secret ever
|
||||
// reaches a record.
|
||||
//
|
||||
// The HTTP middleware (Middleware, in the cloud package) and the query/verify
|
||||
// endpoints (in clients/admin) are thin callers of this package. This package
|
||||
// holds the security logic; it has zero knowledge of routes.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Actor identifies WHO performed the action. It is populated ONLY from a
|
||||
// validated principal (the sanitized X-User-* headers SanitizeIdentity mints
|
||||
// from a verified IAM JWT), never from a raw client header — so an actor can
|
||||
// never be forged by the request that is being audited. A service principal
|
||||
// (M2M / no user sub) records Org with an empty Sub.
|
||||
type Actor struct {
|
||||
// Org is the tenant (IAM `owner`). Empty for an unauthenticated request.
|
||||
Org string `json:"org"`
|
||||
// Sub is the user id (IAM `sub`/`preferred_username`). Empty for a service
|
||||
// principal or an anonymous request.
|
||||
Sub string `json:"sub"`
|
||||
// Email is the validated user email, when present.
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// Resource identifies WHAT was acted upon: a type (e.g. "org", "role",
|
||||
// "secret", "provider-config", "credit") and its id. For a plain HTTP mutation
|
||||
// with no finer resource semantics, Type is the route family and ID is empty —
|
||||
// the Action verb + path already pin the object.
|
||||
type Resource struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// AuthContext records HOW the actor authenticated and what authority they held
|
||||
// at decision time — the AC-* evidence (was this a global admin? by what
|
||||
// credential?). Method is "jwt" | "api-key" | "none". IsAdmin is the VALIDATED
|
||||
// global-admin bit (owner == AdminOrg), never a raw X-User-IsAdmin.
|
||||
type AuthContext struct {
|
||||
Method string `json:"method"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// Outcome is the result of the action: whether it was allowed and what
|
||||
// happened. Result is "success" | "deny" | "error". Status is the HTTP status.
|
||||
// Reason is a short, non-sensitive explanation for a deny/error (e.g.
|
||||
// "global admin required", "insufficient_balance") — never a secret, never a
|
||||
// raw upstream error body.
|
||||
type Outcome struct {
|
||||
Result string `json:"result"`
|
||||
Status int `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Record is one audit event. The JSON tags ARE the on-disk and on-wire contract.
|
||||
//
|
||||
// Field order in the struct is deliberate but IRRELEVANT to the hash: the chain
|
||||
// hashes the CANONICAL (sorted-key) JSON of the record with Hash/PrevHash zeroed
|
||||
// (see canonicalBytes), so re-ordering fields or adding an omitempty field can
|
||||
// never change an existing record's hash.
|
||||
type Record struct {
|
||||
// Seq is the strictly-increasing chain position (0-based). It is assigned by
|
||||
// the Recorder under its lock, so it is a true total order with no gaps.
|
||||
Seq uint64 `json:"seq"`
|
||||
|
||||
// Time is the UTC event timestamp (RFC3339Nano).
|
||||
Time time.Time `json:"time"`
|
||||
|
||||
// Actor / Action / Resource / Auth / Outcome — the AU-3 "content of audit
|
||||
// records" core: who, what, on what, how-authenticated, with what result.
|
||||
Actor Actor `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Resource Resource `json:"resource"`
|
||||
Auth AuthContext `json:"auth"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
|
||||
// SourceIP + UserAgent — the AU-3 "source of the event" fields.
|
||||
SourceIP string `json:"sourceIp,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
|
||||
// RequestID correlates the record to the request-line log and any downstream
|
||||
// trace (the X-Request-Id the pipeline mints).
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
|
||||
// Method + Path are the HTTP verb and route for a request-sourced event.
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// Before / After capture a mutation's prior and resulting state for the
|
||||
// AU-required "before/after" on config-affecting changes. They are populated
|
||||
// ONLY by explicit emit points and ONLY after Redact has stripped secrets —
|
||||
// the HTTP middleware never sets them (it never reads bodies), so a secret in
|
||||
// a request body can never leak here. Raw JSON so any shape round-trips.
|
||||
Before json.RawMessage `json:"before,omitempty"`
|
||||
After json.RawMessage `json:"after,omitempty"`
|
||||
|
||||
// PrevHash is the hash of record Seq-1 (hex). For the genesis record (Seq 0)
|
||||
// it is genesisPrevHash. Hash is this record's hash. Neither participates in
|
||||
// its own hash computation (both are zeroed in canonicalBytes).
|
||||
PrevHash string `json:"prevHash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// genesisPrevHash is the PrevHash of the first record in a fresh chain: 32 zero
|
||||
// bytes, hex-encoded. A non-empty, fixed anchor so the genesis record's hash is
|
||||
// still a function of a known constant (not the empty string, which would be
|
||||
// indistinguishable from "field omitted").
|
||||
const genesisPrevHash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// canonicalBytes returns the deterministic byte string a record hashes over: the
|
||||
// record with Hash AND PrevHash zeroed, marshaled by encoding/json (which sorts
|
||||
// struct fields in declaration order and, critically, is stable for a given
|
||||
// struct — the SAME bytes on every machine and every run). Zeroing PrevHash here
|
||||
// means the hash covers only the record's OWN content; the link to the previous
|
||||
// record is added explicitly in computeHash by appending prevHash. This keeps
|
||||
// the two concerns separable and the math obvious.
|
||||
//
|
||||
// We marshal a copy with the two hash fields cleared rather than a parallel
|
||||
// struct so there is exactly ONE definition of a record's fields (DRY): add a
|
||||
// field to Record and it is covered by the hash automatically.
|
||||
func canonicalBytes(r Record) ([]byte, error) {
|
||||
r.Hash = ""
|
||||
r.PrevHash = ""
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
// computeHash returns the hex SHA-256 of (canonical(record) || prevHash-bytes).
|
||||
// The prevHash is folded in as its RAW hex string bytes — the exact value stored
|
||||
// in the record's PrevHash field — so the verifier reproduces it byte-for-byte
|
||||
// from stored data alone. Any change to the record's content OR to which record
|
||||
// precedes it changes this output, which is the whole tamper-evidence property.
|
||||
func computeHash(r Record, prevHash string) (string, error) {
|
||||
body, err := canonicalBytes(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := sha256.New()
|
||||
h.Write(body)
|
||||
h.Write([]byte(prevHash))
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// seal finalizes a record into position seq linked to prevHash: it stamps Seq
|
||||
// and PrevHash, computes Hash, and returns the sealed record ready to append.
|
||||
// The Recorder calls this under its lock so seq/prevHash reflect the true head.
|
||||
func seal(r Record, seq uint64, prevHash string) (Record, error) {
|
||||
r.Seq = seq
|
||||
r.PrevHash = prevHash
|
||||
hash, err := computeHash(r, prevHash)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
r.Hash = hash
|
||||
return r, nil
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package audit
|
||||
|
||||
// Secret redaction for the before/after captured on a mutation.
|
||||
//
|
||||
// THE RULE. An audit record must NEVER contain a credential — no password,
|
||||
// token, API key, private key, card number, or session secret. Two layers
|
||||
// enforce this:
|
||||
//
|
||||
// 1. The HTTP middleware captures METADATA ONLY (actor/action/resource/outcome).
|
||||
// It NEVER reads a request or response body, so a secret in a POST body can
|
||||
// never reach a record through the automatic path. This is the primary
|
||||
// guarantee: the code that can't see a secret can't leak one.
|
||||
//
|
||||
// 2. An EXPLICIT emit point that supplies structured before/after (e.g. a config
|
||||
// change diff) runs it through Redact first. Redact walks the JSON and
|
||||
// replaces the VALUE of any key whose name matches the secret denylist with a
|
||||
// fixed marker, recursively. It is deny-by-key-name — the same allowlist
|
||||
// PATTERN cloud already uses for user-secret redaction — chosen because a
|
||||
// mutation diff has arbitrary shape and key-name matching is the robust,
|
||||
// well-understood control (vs. trying to detect "secret-looking" values).
|
||||
//
|
||||
// Redact is conservative: on any structural surprise it returns the redaction
|
||||
// marker rather than the input, so a parser edge case fails CLOSED (no raw
|
||||
// passthrough).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// redactedMarker replaces every redacted value. A constant so tests and the
|
||||
// query UI recognize it unambiguously.
|
||||
const redactedMarker = "[REDACTED]"
|
||||
|
||||
// secretKeyParts are substrings that, when contained (case-insensitively) in a
|
||||
// JSON object key, mark that key's value as secret. Kept as a small, auditable
|
||||
// denylist of the credential-bearing field names that actually occur across the
|
||||
// Hanzo surface (IAM, KMS, commerce, provider config). Matching is substring so
|
||||
// "clientSecret", "api_key", "PRIVATE_KEY", "accessToken" all match.
|
||||
var secretKeyParts = []string{
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"api-key",
|
||||
"authorization",
|
||||
"auth_token",
|
||||
"private_key",
|
||||
"privatekey",
|
||||
"privkey", // privkey, wgPrivKey
|
||||
"passphrase",
|
||||
"client_secret",
|
||||
"credential",
|
||||
"session",
|
||||
"cookie",
|
||||
"card", // card_number, cardNumber
|
||||
"cvv",
|
||||
"cvc",
|
||||
"pin",
|
||||
"ssn",
|
||||
"social_security", // socialSecurityNumber (lower-cased match covers camelCase)
|
||||
"socialsecurity",
|
||||
"otp",
|
||||
"mnemonic",
|
||||
"phrase", // seed_phrase, seedPhrase, recoveryPhrase
|
||||
"access_key",
|
||||
"secret_key",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"bearer",
|
||||
"signing_key",
|
||||
"encryption_key",
|
||||
}
|
||||
|
||||
// isSecretKey reports whether a JSON key names a credential-bearing field.
|
||||
func isSecretKey(key string) bool {
|
||||
k := strings.ToLower(key)
|
||||
for _, part := range secretKeyParts {
|
||||
if strings.Contains(k, part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Redact returns a copy of the JSON value with every secret-keyed value replaced
|
||||
// by the redaction marker, recursively through objects and arrays. Non-JSON or
|
||||
// empty input yields nil (nothing to record). On a JSON parse error the input is
|
||||
// dropped (returns the marker as a JSON string) rather than passed through —
|
||||
// fail closed.
|
||||
//
|
||||
// Use it at any explicit emit point that supplies before/after:
|
||||
//
|
||||
// audit.Emit(ctx, rec.WithChange(audit.Redact(before), audit.Redact(after)))
|
||||
func Redact(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
// Unparseable — never echo it back verbatim; record a marker instead.
|
||||
b, _ := json.Marshal(redactedMarker)
|
||||
return b
|
||||
}
|
||||
cleaned := redactValue("", v)
|
||||
out, err := json.Marshal(cleaned)
|
||||
if err != nil {
|
||||
b, _ := json.Marshal(redactedMarker)
|
||||
return b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redactValue walks a decoded JSON value. key is the object key under which v
|
||||
// sits (empty at the root and for array elements); when key is a secret key, the
|
||||
// ENTIRE value v is replaced (whether it is a scalar, object, or array — a secret
|
||||
// nested object is redacted whole). Otherwise objects/arrays are recursed.
|
||||
func redactValue(key string, v any) any {
|
||||
if key != "" && isSecretKey(key) {
|
||||
return redactedMarker
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = redactValue(k, val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, val := range t {
|
||||
out[i] = redactValue("", val) // array elements inherit no key
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package audit
|
||||
|
||||
// The append-only sink + the serialized Recorder that owns the hash-chain head.
|
||||
//
|
||||
// WHY SQLITE IS THE PRIMARY, DURABLE STORE (not ClickHouse). The chain is only
|
||||
// tamper-EVIDENT if records are appended in a strict, gapless total order and
|
||||
// each record's PrevHash is the immediately-preceding record's Hash. That demands
|
||||
// a single serializing writer with a synchronous, read-your-write head. cloud's
|
||||
// canonical store is embedded SQLite (one store per the storagelock lockdown;
|
||||
// pricing/provisioning already persist to {DataDir}/*.db). A local SQLite table
|
||||
// the application can only INSERT into gives us: (a) a real total order under one
|
||||
// connection, (b) synchronous durability so NO record is ever lost on the request
|
||||
// path (unlike a fire-and-forget mirror), and (c) an append-only surface — the
|
||||
// app issues no UPDATE/DELETE, and the hash-chain detects any out-of-band edit to
|
||||
// the file. That is the compliance-grade primary control.
|
||||
//
|
||||
// THE CLICKHOUSE MIRROR IS A PROJECTION, NOT THE SOURCE OF TRUTH. The datastore
|
||||
// (ClickHouse MergeTree — insert-only, mutation-rejected at parse time) is the
|
||||
// fleet-wide OLAP mirror for long-retention, cross-deployment query. It is
|
||||
// best-effort and asynchronous: a mirror outage must never block or fail an
|
||||
// audited request, and the local chain remains the authority the verifier walks.
|
||||
// Losing a mirror row is a query-completeness issue, not an integrity one.
|
||||
//
|
||||
// FAIL MODE. Append is INLINE and its error is RETURNED to the middleware, which
|
||||
// fails the request CLOSED (a security-relevant action that cannot be recorded is
|
||||
// not permitted to silently succeed). This is the AU-5 "response to audit logging
|
||||
// process failure": deny rather than act-unlogged.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
// modernc.org/sqlite is the pure-Go SQLite driver already in the cloud dep
|
||||
// graph (see clients/pricing, clients/provisioning). Blank import registers
|
||||
// the "sqlite" driver name.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Mirror is the optional OLAP projection sink (the datastore/ClickHouse). It is
|
||||
// deliberately a tiny interface, not a concrete client, so the Recorder has no
|
||||
// compile-time dependency on ClickHouse and tests can supply a fake. Append is
|
||||
// called best-effort, asynchronously, off the request path.
|
||||
type Mirror interface {
|
||||
// Append writes one sealed record to the projection. A returned error is
|
||||
// logged and dropped by the Recorder — the mirror never gates a request.
|
||||
Append(ctx context.Context, r Record) error
|
||||
}
|
||||
|
||||
// Checkpoint is a periodic, tamper-EVIDENCE digest of the chain head: the record
|
||||
// count and the head hash at a moment in time. It is the AU-9 anchor for
|
||||
// TAIL-TRUNCATION detection — an internal chain walk cannot notice that the last
|
||||
// K records were deleted (the surviving prefix still verifies), but a durable,
|
||||
// INDEPENDENT series of head checkpoints can: Count is monotonic, so any decrease
|
||||
// between two consecutive checkpoints is deletion, and an attacker cannot forge a
|
||||
// higher count without appending records whose hashes the chain walk would reject.
|
||||
type Checkpoint struct {
|
||||
Time time.Time `json:"time"`
|
||||
Count uint64 `json:"count"`
|
||||
Head string `json:"head"`
|
||||
}
|
||||
|
||||
// CheckpointSink is an optional capability a Mirror may implement to persist the
|
||||
// head digest series to an INDEPENDENT store (so truncating the local SQLite
|
||||
// cannot also rewrite the anchor history). A Mirror that does not implement it
|
||||
// still gets its records; checkpoints then flow only to the structured log.
|
||||
type CheckpointSink interface {
|
||||
Checkpoint(ctx context.Context, cp Checkpoint) error
|
||||
}
|
||||
|
||||
// Recorder is the single serialized writer that owns the audit chain head and
|
||||
// the append-only store. Every Record flows through Append, which under one lock
|
||||
// assigns the next Seq, links PrevHash to the current head, seals (hashes), and
|
||||
// synchronously persists to SQLite before returning. Concurrency is serialized
|
||||
// by mu AND by the single-connection SQLite pool, so the on-disk order equals
|
||||
// the chain order with no gaps.
|
||||
type Recorder struct {
|
||||
db *sql.DB
|
||||
mirror Mirror // nil when no OLAP mirror is configured.
|
||||
|
||||
mu sync.Mutex // guards nextSeq/headHash and serializes appends.
|
||||
nextSeq uint64 // Seq to assign to the next record.
|
||||
headHash string // Hash of the last-appended record (PrevHash for the next).
|
||||
|
||||
// Checkpoint emission (AU-9 tail-truncation anchor). logCheckpoint, when set,
|
||||
// receives each head digest so it lands in the append-only observability log;
|
||||
// stopCh/wg manage the periodic emitter goroutine's lifecycle; started guards
|
||||
// against a second StartCheckpoints call (the field write + WaitGroup use are
|
||||
// not safe to race). All three are set once, before any concurrent Append.
|
||||
logCheckpoint func(cp Checkpoint)
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
started bool
|
||||
}
|
||||
|
||||
// CheckpointFunc receives a head digest for the structured (o11y) log. It is a
|
||||
// plain func so the pure audit package stays free of any concrete logger type;
|
||||
// the cloud wiring adapts luxlog to it.
|
||||
type CheckpointFunc func(cp Checkpoint)
|
||||
|
||||
// Open opens (creating if needed) the append-only audit DB at path and recovers
|
||||
// the chain head from it, so a restart continues the SAME chain rather than
|
||||
// forking a new one. path may be ":memory:" for tests. mirror may be nil.
|
||||
//
|
||||
// modernc's "sqlite" driver; MaxOpenConns(1) serializes every statement against
|
||||
// the file lock — the same single-writer discipline pricing/provisioning use,
|
||||
// here doubling as the chain's serialization guarantee.
|
||||
func Open(path string, mirror Mirror) (*Recorder, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit: open sqlite %q: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("audit: pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
r := &Recorder{db: db, mirror: mirror}
|
||||
if err := r.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := r.recoverHead(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// migrate creates the append-only audit table. It is INSERT-only by application
|
||||
// discipline: this package issues no UPDATE or DELETE against it, and seq is the
|
||||
// PRIMARY KEY so a replayed/duplicated seq is rejected by the engine. The hash
|
||||
// columns make any out-of-band row edit detectable by Verify regardless of the
|
||||
// storage layer's own guarantees.
|
||||
func (r *Recorder) migrate() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
seq INTEGER PRIMARY KEY, -- chain position; gapless, assigned under lock
|
||||
ts TEXT NOT NULL, -- RFC3339Nano UTC event time
|
||||
actor_org TEXT NOT NULL DEFAULT '',
|
||||
actor_sub TEXT NOT NULL DEFAULT '',
|
||||
actor_email TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
res_type TEXT NOT NULL DEFAULT '',
|
||||
res_id TEXT NOT NULL DEFAULT '',
|
||||
auth_method TEXT NOT NULL DEFAULT '',
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
result TEXT NOT NULL, -- success|deny|error
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
method TEXT NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL DEFAULT '',
|
||||
before TEXT NOT NULL DEFAULT '', -- redacted JSON (explicit emit only)
|
||||
after TEXT NOT NULL DEFAULT '', -- redacted JSON (explicit emit only)
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
-- Query indexes for the /v1/admin/audit filters (actor/action/resource/time).
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_org_seq ON audit_log(actor_org, seq);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_action_seq ON audit_log(action, seq);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_result_seq ON audit_log(result, seq);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_ts ON audit_log(ts);
|
||||
`
|
||||
if _, err := r.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("audit: migrate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recoverHead loads the highest-seq record so a restarted process continues the
|
||||
// existing chain (nextSeq = maxSeq+1, headHash = its hash). An empty table starts
|
||||
// the genesis chain (nextSeq 0, headHash = genesisPrevHash).
|
||||
func (r *Recorder) recoverHead() error {
|
||||
var (
|
||||
maxSeq sql.NullInt64
|
||||
hash sql.NullString
|
||||
)
|
||||
row := r.db.QueryRow(`SELECT seq, hash FROM audit_log WHERE seq = (SELECT MAX(seq) FROM audit_log)`)
|
||||
if err := row.Scan(&maxSeq, &hash); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
r.nextSeq = 0
|
||||
r.headHash = genesisPrevHash
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("audit: recover head: %w", err)
|
||||
}
|
||||
if !maxSeq.Valid { // empty table (MAX over zero rows is NULL)
|
||||
r.nextSeq = 0
|
||||
r.headHash = genesisPrevHash
|
||||
return nil
|
||||
}
|
||||
r.nextSeq = uint64(maxSeq.Int64) + 1
|
||||
r.headHash = hash.String
|
||||
return nil
|
||||
}
|
||||
|
||||
// Append seals r into the next chain position and persists it. It fills Seq,
|
||||
// PrevHash, and Hash (the caller sets everything else), advances the in-memory
|
||||
// head only AFTER the durable INSERT succeeds, and mirrors best-effort. A
|
||||
// persistence error is returned so the caller can fail the request CLOSED — the
|
||||
// head is NOT advanced on failure, so the chain never gaps.
|
||||
//
|
||||
// The whole critical section (assign seq → seal → INSERT → advance head) holds
|
||||
// mu, so two concurrent requests can never claim the same seq or race the head.
|
||||
func (r *Recorder) Append(ctx context.Context, rec Record) (Record, error) {
|
||||
if rec.Time.IsZero() {
|
||||
rec.Time = time.Now().UTC()
|
||||
} else {
|
||||
rec.Time = rec.Time.UTC()
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
sealed, err := seal(rec, r.nextSeq, r.headHash)
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("audit: seal: %w", err)
|
||||
}
|
||||
if err := r.insert(ctx, sealed); err != nil {
|
||||
// Head not advanced; the next append reuses this seq. Fail closed upstream.
|
||||
return Record{}, fmt.Errorf("audit: persist: %w", err)
|
||||
}
|
||||
// Durable — advance the chain head.
|
||||
r.nextSeq = sealed.Seq + 1
|
||||
r.headHash = sealed.Hash
|
||||
|
||||
// Best-effort OLAP mirror, detached so a slow/failed mirror never blocks the
|
||||
// request or corrupts the reply. The local chain is already durable and is the
|
||||
// authority; a lost mirror row is a query-completeness gap, not an integrity
|
||||
// one. Copy by value: the record is immutable and safe to hand to a goroutine.
|
||||
if r.mirror != nil {
|
||||
m, out := r.mirror, sealed
|
||||
go func() { _ = m.Append(context.Background(), out) }()
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// insert writes one sealed record. INSERT-only — the sole write statement in this
|
||||
// package. A duplicate seq (PRIMARY KEY) fails here, which is the desired
|
||||
// invariant: the chain never overwrites a position.
|
||||
func (r *Recorder) insert(ctx context.Context, rec Record) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_log (
|
||||
seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
|
||||
auth_method, is_admin, result, status, reason, source_ip, user_agent,
|
||||
request_id, method, path, before, after, prev_hash, hash
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
rec.Seq, rec.Time.Format(time.RFC3339Nano),
|
||||
rec.Actor.Org, rec.Actor.Sub, rec.Actor.Email,
|
||||
rec.Action, rec.Resource.Type, rec.Resource.ID,
|
||||
rec.Auth.Method, boolToInt(rec.Auth.IsAdmin),
|
||||
rec.Outcome.Result, rec.Outcome.Status, rec.Outcome.Reason,
|
||||
rec.SourceIP, rec.UserAgent, rec.RequestID, rec.Method, rec.Path,
|
||||
string(rec.Before), string(rec.After), rec.PrevHash, rec.Hash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// checkpointCloseTimeout bounds the final (synchronous) checkpoint write to the
|
||||
// independent sink at shutdown, so Close cannot hang on an unreachable datastore.
|
||||
const checkpointCloseTimeout = 5 * time.Second
|
||||
|
||||
// Close stops the periodic checkpoint emitter, emits a FINAL checkpoint
|
||||
// SYNCHRONOUSLY (so the head at shutdown reaches both the o11y log and the
|
||||
// independent digest store before the process exits — the AU-9 anchor must be
|
||||
// current exactly when an attacker might trigger shutdown then truncate), and
|
||||
// closes the underlying database.
|
||||
func (r *Recorder) Close() error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
if r.stopCh != nil {
|
||||
close(r.stopCh)
|
||||
r.wg.Wait()
|
||||
r.stopCh = nil
|
||||
}
|
||||
// Anchor the final head before the DB closes — independent of whether the
|
||||
// periodic ticker was running (every<=0 still gets a shutdown checkpoint).
|
||||
// Synchronous to the sink (bounded), so the independent store's last count is
|
||||
// as fresh as the local chain at the moment of shutdown.
|
||||
r.emitCheckpoint(true)
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
// StartCheckpoints begins periodic head-digest emission every `every` (no ticker
|
||||
// if every<=0; the on-Close checkpoint still fires). logFn, when non-nil,
|
||||
// receives each digest for the append-only observability log (o11y), and a mirror
|
||||
// implementing CheckpointSink also gets it persisted to an INDEPENDENT store —
|
||||
// together the AU-9 anchor an external monitor compares to detect tail-truncation
|
||||
// (count regression). MUST be called at most once, before any concurrent Append
|
||||
// (a second call is ignored); the emitter stops on Close.
|
||||
func (r *Recorder) StartCheckpoints(every time.Duration, logFn CheckpointFunc) {
|
||||
// Guard the check-and-set under mu so a (mis)use that calls this concurrently
|
||||
// is race-free, not just the single-call production path.
|
||||
r.mu.Lock()
|
||||
if r.started {
|
||||
r.mu.Unlock()
|
||||
return // already started — do not re-arm (avoids a field/WaitGroup race).
|
||||
}
|
||||
r.started = true
|
||||
r.logCheckpoint = logFn
|
||||
r.mu.Unlock()
|
||||
if every <= 0 {
|
||||
return
|
||||
}
|
||||
r.stopCh = make(chan struct{})
|
||||
stop := r.stopCh
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
r.emitCheckpoint(false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// emitCheckpoint snapshots the head and emits it to the log and (if the mirror is
|
||||
// a CheckpointSink) the independent digest store. sync controls the sink write:
|
||||
// on the periodic path (sync=false) it is detached so a slow sink never delays
|
||||
// the ticker; on the Close path (sync=true) it BLOCKS on a bounded context so the
|
||||
// final anchor is durable before shutdown. The log emission is always synchronous
|
||||
// (it is the primary anchor and o11y ingests it append-only).
|
||||
func (r *Recorder) emitCheckpoint(sync bool) {
|
||||
count, head := r.Head()
|
||||
cp := Checkpoint{Time: time.Now().UTC(), Count: count, Head: head}
|
||||
if r.logCheckpoint != nil {
|
||||
r.logCheckpoint(cp)
|
||||
}
|
||||
cs, ok := r.mirror.(CheckpointSink)
|
||||
if !ok || cs == nil {
|
||||
return
|
||||
}
|
||||
if sync {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), checkpointCloseTimeout)
|
||||
defer cancel()
|
||||
_ = cs.Checkpoint(ctx, cp)
|
||||
return
|
||||
}
|
||||
go func() { _ = cs.Checkpoint(context.Background(), cp) }()
|
||||
}
|
||||
|
||||
// Head returns the current chain head (count of records, and the head hash). A
|
||||
// count of 0 means the genesis (empty) chain, headHash == genesisPrevHash. An
|
||||
// external monitor can pin (count, headHash) over time to detect tail-truncation
|
||||
// — which an internal chain walk alone cannot catch (a truncated prefix still
|
||||
// verifies). This is the anchor point for AU-9 protection against deletion of the
|
||||
// most-recent records.
|
||||
func (r *Recorder) Head() (count uint64, headHash string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.nextSeq, r.headHash
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
package cloud
|
||||
|
||||
// The audit middleware — the ONE place every security-relevant request is
|
||||
// recorded to the tamper-evident trail (decomplected: one function, every route).
|
||||
//
|
||||
// PLACEMENT (why it sits exactly where serve.go puts it). The pipeline is
|
||||
// Recover → RequestID → Logger → SanitizeIdentity → AuditTrail → BillingGate →
|
||||
// subsystems. AuditTrail runs:
|
||||
// - AFTER SanitizeIdentity, so the actor/isAdmin it records come from a
|
||||
// VALIDATED IAM principal (the sanitized X-User-* headers), never a raw
|
||||
// client header. The request being audited cannot forge its own actor.
|
||||
// - BEFORE BillingGate and every subsystem, so it WRAPS the whole handler
|
||||
// chain and observes the FINAL outcome — including a 402/503 billing denial
|
||||
// and a 403 admin-guard denial (both security-relevant) — via the response
|
||||
// status after Continue(), exactly like the Logger middleware reads it.
|
||||
//
|
||||
// WHAT IT CAPTURES: metadata only — actor, action (method+route family),
|
||||
// resource, source ip, user agent, request id, auth context, and the outcome
|
||||
// (result/status/reason). It NEVER reads the request or response BODY, so a
|
||||
// secret in a POST body can never reach a record through this path. before/after
|
||||
// diffs are the job of explicit emit points (audit.Recorder.Append with a
|
||||
// redacted diff), not this middleware.
|
||||
//
|
||||
// WHAT IT RECORDS (the coverage predicate, auditable in one place — see
|
||||
// isSecurityRelevant): every mutating request (POST/PUT/PATCH/DELETE), every
|
||||
// /v1/admin/* request (read or write — admin reads are AC-relevant), and every
|
||||
// auth-failure outcome (401/403) on ANY method (a denied GET is an access-control
|
||||
// event). Safe, unauthenticated reads (a 200 GET on a public route) are NOT
|
||||
// audited — that is request-log noise, not a security event, and auditing it
|
||||
// would bury the signal and balloon the trail.
|
||||
//
|
||||
// FAIL MODE (AU-5): if the trail write fails on a request we decided to audit,
|
||||
// the CLIENT gets a fail-closed 503 rather than a success it can rely on — the
|
||||
// AU-5 "response to an audit logging process failure" is to interrupt, not to
|
||||
// operate silently unlogged. A write to local SQLite is sub-millisecond, so this
|
||||
// is a real integrity stance, not a latency tax. When no Recorder is configured
|
||||
// the middleware is a no-op passthrough (an unconfigured deployment is never
|
||||
// blocked), exactly like BillingGate.
|
||||
//
|
||||
// PRECISE SEMANTIC (do not over-read the 503). This is POST-RESPONSE audit: the
|
||||
// handler has already run when the record is written, so a 503 here means "this
|
||||
// event could not be RECORDED", NOT "the action did not execute". PREVENTION is
|
||||
// the access-control layer's job and runs BEFORE the action — SanitizeIdentity
|
||||
// (identity can't be forged) + the per-route admin guard both execute inside
|
||||
// c.Next() ahead of any side effect. The audit trail's job is DETECTION and
|
||||
// ACCOUNTABILITY (tamper-evident record of what happened), which it does. On a
|
||||
// persistent audit-store outage every mutation returns 503 (loud, logged), so
|
||||
// the system degrades to read-only rather than mutating unaudited — the intended
|
||||
// compliance posture.
|
||||
//
|
||||
// PANIC BOUND. If a handler PANICS, the outermost middleware.Recover catches it
|
||||
// and renders 500 with a full stack trace (loud, never silent); the panic unwinds
|
||||
// PAST this middleware's post-c.Next() code, so a panicking request is not written
|
||||
// to the trail. This is an accepted bound, not an evasion: an attacker cannot turn
|
||||
// a panic into a SUCCESSFUL-but-unaudited mutation (a panic yields 500, not a
|
||||
// completed action), and every panic is already captured by Recover's logging.
|
||||
// Normal outcomes — including billing 402/503 and admin 403 denials, which return
|
||||
// through c.Next() rather than panicking — are always audited.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// AuditTrail returns the audit middleware bound to rec. A nil rec makes it a
|
||||
// no-op passthrough so callers always Use() it unconditionally.
|
||||
func AuditTrail(rec *audit.Recorder) zip.Handler {
|
||||
if rec == nil {
|
||||
return func(c *zip.Ctx) error { return c.Next() }
|
||||
}
|
||||
return func(c *zip.Ctx) error {
|
||||
// Capture the pre-decision inputs BEFORE running the chain: the request
|
||||
// context is recycled by Fiber after the handler returns, so identity and
|
||||
// request fields must be read now (mirrors BillingGate capturing usage by
|
||||
// value pre-Record).
|
||||
method := c.Method()
|
||||
path := c.Path()
|
||||
|
||||
err := c.Next()
|
||||
|
||||
// Resolve the EFFECTIVE status. A handler may set it on the response
|
||||
// directly (c.Status(...).JSON(...)) OR return a *zip.HTTPError that the
|
||||
// framework's error handler renders AFTER this middleware unwinds — in the
|
||||
// latter case the response still reads 200 here, so the returned error is
|
||||
// the authoritative source of a 401/403. Prefer the error's status when it
|
||||
// carries one; this is what makes admin-guard denials (which return
|
||||
// ErrForbidden) get audited as 403.
|
||||
status := effectiveStatus(c.Fiber().Response().StatusCode(), err)
|
||||
if !isSecurityRelevant(method, path, status) {
|
||||
return err // not an audited event; pass the handler result through.
|
||||
}
|
||||
|
||||
record := audit.Record{
|
||||
Actor: actorFromCtx(c),
|
||||
Action: method + " " + routeFamily(path),
|
||||
Resource: resourceFromPath(path),
|
||||
Auth: authFromCtx(c),
|
||||
Outcome: outcomeOf(status, err),
|
||||
SourceIP: ClientIP(c),
|
||||
// User-Agent is client-controlled free text; a misconfigured/malicious
|
||||
// client could embed a bearer token in it. Scrub credential-shaped runs
|
||||
// (and cap length) so the UA can never carry a secret into the record.
|
||||
UserAgent: scrubFreeText(c.Header("User-Agent")),
|
||||
RequestID: c.RequestID(),
|
||||
Method: method,
|
||||
// Path is scrubbed of any credential-shaped segment: Hanzo routes use
|
||||
// identifiers (:name/:slug/:id), not secrets, but a token that ever
|
||||
// rides in the path (an hk-/sk-/pk-/fw_/hz_ key) must never be recorded
|
||||
// verbatim. resourceFromPath applies the same scrub to the resource id.
|
||||
Path: scrubCredentialSegments(path),
|
||||
}
|
||||
|
||||
if _, aerr := rec.Append(c.Context(), record); aerr != nil {
|
||||
// AU-5: could not record a security-relevant event — fail the request
|
||||
// closed. Do NOT leak the audit error to the client; log it loud.
|
||||
c.Log().Error("audit append failed — failing request closed",
|
||||
"path", path, "method", method, "err", aerr)
|
||||
return c.JSON(503, map[string]any{
|
||||
"error": map[string]string{
|
||||
"code": "audit_unavailable",
|
||||
"message": "Request could not be securely recorded",
|
||||
},
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// isSecurityRelevant is the coverage predicate — the ONE place that decides which
|
||||
// requests enter the audit trail (AC/AU scope). Kept tiny and total so the
|
||||
// coverage matrix is reviewable at a glance. Order matters and is deliberate:
|
||||
// the security-relevant conditions are checked FIRST and are unconditional, so
|
||||
// the health-probe exemption can NEVER be used to evade audit of a mutation or a
|
||||
// denial (a POST/DELETE, or any 401/403, is always audited whatever the path).
|
||||
// - any auth-failure outcome (401/403) on any method (a denied access attempt),
|
||||
// - any /v1/admin/* request (admin reads are access-control-relevant),
|
||||
// - any mutating request (POST/PUT/PATCH/DELETE).
|
||||
//
|
||||
// Only then, a genuine liveness probe (a GET to an exact health route) is
|
||||
// exempted — it is neither a mutation, a denial, nor an admin call, so it is pure
|
||||
// request-log noise. The exemption matches EXACT probe paths, never an arbitrary
|
||||
// path that merely ends in "/health" (which a wildcard/attacker-named segment
|
||||
// like POST /v1/admin/orgs/x/health could otherwise abuse to slip past audit).
|
||||
func isSecurityRelevant(method, path string, status int) bool {
|
||||
// Unconditional security signals — never suppressed by any path shape. A
|
||||
// denial, an admin call, or a mutation is ALWAYS audited, whatever the path
|
||||
// (so a wildcard/attacker-named "/health" tail cannot evade it).
|
||||
if status == 401 || status == 403 {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/v1/admin/") {
|
||||
return true
|
||||
}
|
||||
if isMutation(method) {
|
||||
return true
|
||||
}
|
||||
// Everything left is a safe read (non-mutating, non-admin, non-denied). None
|
||||
// are audited — they are request-log noise, not security events. (Liveness
|
||||
// probes fall here too; there is no separate case because the answer is the
|
||||
// same: not recorded.)
|
||||
return false
|
||||
}
|
||||
|
||||
// isMutation reports whether a method changes state.
|
||||
func isMutation(method string) bool {
|
||||
switch method {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// actorFromCtx builds the Actor from the sanitized identity headers, gating
|
||||
// ANTI-FORGERY of the recorded actor on a VALIDATED principal.
|
||||
//
|
||||
// The authoritative "this request carried a validated principal" signal is a
|
||||
// non-empty X-User-Id (c.User()): SanitizeIdentity sets X-User-Id ONLY from a
|
||||
// JWT it verified, and strips any client-supplied copy on ingress. A request
|
||||
// with no principal — anonymous, OR one bearing an INVALID/garbage bearer that
|
||||
// failed validation — has an empty c.User().
|
||||
//
|
||||
// In that unvalidated case the org header is NOT trustworthy: SanitizeIdentity's
|
||||
// Phase-1 residual restores a client-supplied X-Org-Id for the data path, so an
|
||||
// anonymous attacker could send X-Org-Id: victim-org and, if we recorded it,
|
||||
// forge a FALSE ATTRIBUTION (an event stamped with a victim's org). So when there
|
||||
// is no validated sub, the actor is left EMPTY — the record stands as an honest
|
||||
// anonymous event identified by SourceIP, never mis-attributed to a claimed org.
|
||||
//
|
||||
// With a validated sub, org/sub/email all reflect the verified principal and are
|
||||
// recorded authoritatively.
|
||||
func actorFromCtx(c *zip.Ctx) audit.Actor {
|
||||
sub := strings.TrimSpace(c.User())
|
||||
if sub == "" {
|
||||
// No validated principal — do not trust the client-asserted org.
|
||||
return audit.Actor{}
|
||||
}
|
||||
return audit.Actor{
|
||||
Org: strings.TrimSpace(c.Org()),
|
||||
Sub: sub,
|
||||
Email: strings.TrimSpace(c.UserEmail()),
|
||||
}
|
||||
}
|
||||
|
||||
// authFromCtx records HOW the caller authenticated and the VALIDATED admin bit.
|
||||
// IsAdmin comes from c.IsAdmin() (the sanitized X-User-IsAdmin, true only for a
|
||||
// verified global admin), never a raw header. Method is inferred from the
|
||||
// presence/shape of a credential: an Authorization/X-Authorization bearer or a
|
||||
// session cookie ⇒ "jwt" (or "api-key" for an opaque hk-/sk- token); none ⇒
|
||||
// "none".
|
||||
func authFromCtx(c *zip.Ctx) audit.AuthContext {
|
||||
return audit.AuthContext{
|
||||
Method: authMethodOf(c),
|
||||
IsAdmin: c.IsAdmin(),
|
||||
}
|
||||
}
|
||||
|
||||
// authMethodOf classifies the credential kind WITHOUT capturing it — it inspects
|
||||
// only the token PREFIX (never stores the value). Order mirrors the sanitizer's
|
||||
// extraction (bearer, then cookie).
|
||||
func authMethodOf(c *zip.Ctx) string {
|
||||
auth := c.Header("Authorization")
|
||||
if auth == "" {
|
||||
auth = c.Header("X-Authorization")
|
||||
}
|
||||
if tok := bearerFromAuth(auth); tok != "" {
|
||||
if isAPIKey(tok) {
|
||||
return "api-key"
|
||||
}
|
||||
return "jwt"
|
||||
}
|
||||
if basicFromAuth(auth) != "" {
|
||||
return "basic"
|
||||
}
|
||||
for _, name := range cookieTokenNames {
|
||||
if c.Fiber().Cookies(name) != "" {
|
||||
return "jwt"
|
||||
}
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
// effectiveStatus reconciles the response status with a returned error. If the
|
||||
// handler returned a *zip.HTTPError (e.g. ErrForbidden), its Status is
|
||||
// authoritative — the framework renders it after this middleware unwinds, so the
|
||||
// live response status does not yet reflect it. A non-HTTPError returned error
|
||||
// with a still-2xx response means the framework will render a 500. Otherwise the
|
||||
// response status stands.
|
||||
func effectiveStatus(respStatus int, err error) int {
|
||||
if err != nil {
|
||||
var he *zip.HTTPError
|
||||
if errors.As(err, &he) && he.Status != 0 {
|
||||
return he.Status
|
||||
}
|
||||
// A non-HTTPError propagating up renders as 500, unless the handler already
|
||||
// set an explicit error status on the response.
|
||||
if respStatus < 400 {
|
||||
return 500
|
||||
}
|
||||
}
|
||||
return respStatus
|
||||
}
|
||||
|
||||
// outcomeOf maps the final HTTP status + handler error to an audit Outcome.
|
||||
// 2xx/3xx ⇒ success; 401/403 ⇒ deny; everything else (4xx/5xx) ⇒ error. The
|
||||
// reason is a short, non-sensitive label derived from the status class — never a
|
||||
// raw upstream error body (which could echo sensitive detail).
|
||||
func outcomeOf(status int, err error) audit.Outcome {
|
||||
switch {
|
||||
case status == 401:
|
||||
return audit.Outcome{Result: "deny", Status: status, Reason: "unauthenticated"}
|
||||
case status == 403:
|
||||
return audit.Outcome{Result: "deny", Status: status, Reason: "forbidden"}
|
||||
case status >= 500:
|
||||
return audit.Outcome{Result: "error", Status: status, Reason: "server_error"}
|
||||
case status >= 400:
|
||||
return audit.Outcome{Result: "error", Status: status, Reason: "client_error"}
|
||||
default:
|
||||
return audit.Outcome{Result: "success", Status: status}
|
||||
}
|
||||
}
|
||||
|
||||
// routeFamily reduces a concrete path to its stable route family for the action
|
||||
// verb, dropping trailing high-cardinality id segments so "DELETE /v1/admin/
|
||||
// orgs/acme" and "DELETE /v1/admin/orgs/globex" share the action "DELETE
|
||||
// /v1/admin/orgs". The full concrete path is preserved separately in Record.Path.
|
||||
func routeFamily(path string) string {
|
||||
segs := strings.Split(strings.Trim(path, "/"), "/")
|
||||
// Keep the leading "v1/<subsystem>/<noun>" and stop before an id-looking tail.
|
||||
out := make([]string, 0, len(segs))
|
||||
for _, s := range segs {
|
||||
if looksLikeID(s) {
|
||||
break
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "/" + strings.Join(segs, "/")
|
||||
}
|
||||
return "/" + strings.Join(out, "/")
|
||||
}
|
||||
|
||||
// resourceFromPath derives the {type,id} resource from a /v1/<subsystem>/<type>/
|
||||
// [<id>] path. Type is the noun after the subsystem; ID is the following segment
|
||||
// when it looks like an identifier. Best-effort — the Action verb + Path are the
|
||||
// authoritative locator; this is a convenience for filtering by resource type.
|
||||
func resourceFromPath(path string) audit.Resource {
|
||||
segs := strings.Split(strings.Trim(path, "/"), "/")
|
||||
// segs: [v1, <subsystem>, <type>, <id?>, ...]
|
||||
if len(segs) < 3 {
|
||||
return audit.Resource{}
|
||||
}
|
||||
res := audit.Resource{Type: segs[2]}
|
||||
if len(segs) >= 4 && looksLikeID(segs[3]) {
|
||||
res.ID = scrubToken(segs[3])
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// scrubToken replaces a path segment that is a credential-shaped token with a
|
||||
// fixed marker, so a secret that ever appears in a URL is never recorded
|
||||
// verbatim. It catches, in increasing generality:
|
||||
// - a known API-key prefix (hk-/sk-/pk-/fw_/hz_ — what isAPIKey recognizes),
|
||||
// ALSO after percent-decoding, so hk%2DKEY can't slip the prefix check,
|
||||
// - a JWT (three base64url parts split by '.', starting eyJ),
|
||||
// - a long, high-entropy base64url/hex run (>=24) — a raw API key / access
|
||||
// token / hex secret that carries no telltale prefix.
|
||||
//
|
||||
// A normal identifier passes through unchanged: a UUID (5 hyphen-split groups,
|
||||
// each short), a slug, a numeric id, a dotted model name — none is a long
|
||||
// unbroken high-entropy blob. See TestScrubToken_NoFalsePositives.
|
||||
//
|
||||
// RED-review hardening (finding: scrub bypass): the entropy test now (a) accepts
|
||||
// the FULL base64url alphabet incl. '-' and '_' (RFC 4648 §5), (b) does NOT
|
||||
// require a digit (an all-alpha opaque key is still a secret), (c) percent-
|
||||
// decodes first so %2D/%5F can't hide structure, and (d) drops the threshold to
|
||||
// 24 (short enough for a 128-bit base64 or a 24-hex key, long enough that no
|
||||
// human-readable slug reaches it).
|
||||
func scrubToken(seg string) string {
|
||||
dec := percentDecode(seg)
|
||||
if isAPIKey(seg) || isAPIKey(dec) ||
|
||||
looksLikeJWT(seg) || looksLikeJWT(dec) ||
|
||||
looksLikeHighEntropyToken(seg) || looksLikeHighEntropyToken(dec) {
|
||||
return "[REDACTED-TOKEN]"
|
||||
}
|
||||
return seg
|
||||
}
|
||||
|
||||
// percentDecode best-effort URL-decodes s so a percent-encoded credential
|
||||
// (hk%2DKEY, sk%5Flive%5F…) is normalized before the credential tests run. On a
|
||||
// malformed escape it returns s unchanged (the raw form is then tested as-is).
|
||||
func percentDecode(s string) string {
|
||||
// Decode repeatedly (bounded) so a NESTED encoding (%252D -> %2D -> -) is
|
||||
// fully normalized before the credential tests run. Stop when a pass makes no
|
||||
// change, on a malformed escape, or after a small cap (defeats a decode bomb).
|
||||
for i := 0; i < 3 && strings.Contains(s, "%"); i++ {
|
||||
dec, err := url.PathUnescape(s)
|
||||
if err != nil || dec == s {
|
||||
break
|
||||
}
|
||||
s = dec
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// looksLikeJWT reports whether s is a JSON Web Token OR a JWT header segment. A
|
||||
// full JWT is three base64url parts split by '.', header starting "eyJ". But when
|
||||
// free text (a UA) is tokenized on '.', a dotted JWT splits into parts; a real
|
||||
// header part is >=24 chars (caught by the high-entropy run), yet to be safe we
|
||||
// ALSO flag any lone segment starting with the canonical base64url header prefix
|
||||
// "eyJ" (which decodes to '{"') regardless of length — a JWT header can never be
|
||||
// a legitimate resource id, so redacting it has no false-positive cost.
|
||||
func looksLikeJWT(s string) bool {
|
||||
if !strings.HasPrefix(s, "eyJ") {
|
||||
return false
|
||||
}
|
||||
// A full token (two dots) or a bare header segment — either way, redact.
|
||||
return true
|
||||
}
|
||||
|
||||
// highEntropyMinLen is the length at/above which an UNBROKEN run of base64/hex
|
||||
// chars is treated as an opaque secret. 24 covers a 128-bit base64 token, a
|
||||
// 24-nibble hex key, and short API keys, while every human-readable path
|
||||
// segment/slug/model-name stays under it once split on its separators — no
|
||||
// single run of a name like "text-embedding-3-large" reaches 24.
|
||||
const highEntropyMinLen = 24
|
||||
|
||||
// looksLikeHighEntropyToken reports whether s CONTAINS an unbroken run of
|
||||
// >= highEntropyMinLen base64/hex chars — the shape of a raw API key / access
|
||||
// token / hex secret — UNLESS s is a structured human identifier.
|
||||
//
|
||||
// The run alphabet is [A-Za-z0-9_+/-] — the base64 alphabets (url-safe §5 '-”_'
|
||||
// and standard §4 '+”/') and hex. '-' is INCLUDED so a url-safe-base64 token
|
||||
// that embeds '-' is still caught by its run (excluding '-' left an ~11% bypass
|
||||
// for 32-byte url-safe tokens whose '-' happened to break every 24-run — measured).
|
||||
//
|
||||
// TWO-STAGE DETECTION:
|
||||
//
|
||||
// 1. UNCONDITIONAL run scan — a >= highEntropyMinLen UNBROKEN run over
|
||||
// [A-Za-z0-9_+/-] flags the value REGARDLESS of the structured-id exemption.
|
||||
// This catches every raw secret WITHOUT internal separators (hex, base64) —
|
||||
// the realistic "a client bug put a raw key in the URL" case — at 100%. A
|
||||
// structured identifier never has a 24-char unbroken run, so this stage never
|
||||
// over-scrubs one.
|
||||
//
|
||||
// 2. STRUCTURED-ID EXEMPTION for the rest (values with separators that DON'T have
|
||||
// a 24-run): exempt a clearly hyphen-joined human id, judged by LEXICAL
|
||||
// content — every group WORD-LIKE (single-case word or decimal number), which
|
||||
// a mixed-case base64 chunk or a long hex-with-letters chunk is NOT (those are
|
||||
// redacted). Shape alone is attacker-satisfiable (RED found a 3x12-chunked
|
||||
// secret slipped a shape-only check); the lexical test rejects the common
|
||||
// secret encodings.
|
||||
//
|
||||
// ACCEPTED RESIDUAL BOUND (documented, per RED review): the ONLY residual is a
|
||||
// secret deliberately chunked into >=3 SINGLE-CASE-ALPHABETIC groups of <=12 chars
|
||||
// with no hex-letter runs >= hexChunkMinLen (a lowercase- or uppercase-only base32
|
||||
// alphabet, e.g. abcdefghijkl-mnopqrstuvwx-…). Such a value is lexically
|
||||
// indistinguishable from a hyphenated model id (deepseek-r1-distill-qwen-32b
|
||||
// carries the SAME 24-char entropy budget), so it passes stage 2. This is NOT
|
||||
// closable by any length/case/count rule without a word dictionary
|
||||
// (over-engineering for a defense-in-depth URL/UA backstop). Mixed-case base64
|
||||
// chunks AND small-hex chunks (md5/sha display grouping) ARE now caught
|
||||
// (isWordLikeGroup). The residual does not widen exposure for any REAL credential:
|
||||
// Hanzo keys are hk-/sk-/pk-/fw_/hz_-prefixed (isAPIKey, caught at any
|
||||
// length/shape), JWTs are eyJ-prefixed (looksLikeJWT), and request/response BODIES
|
||||
// are never read. It is an adversary DELIBERATELY base32-chunking their OWN secret
|
||||
// into a URL path to seed an admin-only audit row — contrived, low-value. The
|
||||
// realistic accidental leak (an unbroken raw key) is caught by stage 1.
|
||||
//
|
||||
// A canonical UUID is exempt (its longest run is 12; the check documents intent).
|
||||
func looksLikeHighEntropyToken(s string) bool {
|
||||
if len(s) < highEntropyMinLen {
|
||||
return false
|
||||
}
|
||||
// Stage 1 — unconditional: a long unbroken run is always a secret.
|
||||
if hasHighEntropyRun(s) {
|
||||
return true
|
||||
}
|
||||
// Stage 2 — separated values: redact unless it's a lexical structured id.
|
||||
if isUUID(s) || isStructuredID(s) {
|
||||
return false
|
||||
}
|
||||
// A >=24-length value with separators, not a UUID, not a structured id — e.g.
|
||||
// "sk.live.LONGSECRET…" dotted, or a chunk pattern that is not word-like.
|
||||
return true
|
||||
}
|
||||
|
||||
// hasHighEntropyRun reports whether s contains an unbroken run of
|
||||
// >= highEntropyMinLen high-entropy chars where the run alphabet EXCLUDES '-'
|
||||
// (and '.'): a real separator breaks the run. This is stage 1 — it fires only on
|
||||
// a genuinely UNBROKEN opaque blob (a raw hex/base64 key with no separators), so
|
||||
// it never catches a hyphenated identifier (which stage 2 then classifies). A
|
||||
// url-safe-base64 secret that embeds '-' still trips because the run on ONE side
|
||||
// of the hyphen is >= 24 (verified: "AbCdEf-GhIjKl_MnOpQrStUvWxYz012345" -> 27).
|
||||
func hasHighEntropyRun(s string) bool {
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if isUnbrokenTokenChar(r) {
|
||||
run++
|
||||
if run >= highEntropyMinLen {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isUnbrokenTokenChar is the stage-1 run alphabet: base64/hex MINUS '-' (and the
|
||||
// implicit exclusion of '.', space, etc.). '_' '+' '/' are kept — they appear
|
||||
// inside opaque tokens and are not identifier separators.
|
||||
func isUnbrokenTokenChar(r rune) bool {
|
||||
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '+' || r == '/'
|
||||
}
|
||||
|
||||
// idPartMaxLen bounds a hyphen-group length in the structured-id exemption. 12
|
||||
// covers the longest word in real model ids ("embedding", "20241022", "preview")
|
||||
// while a raw secret's random hyphen groups routinely exceed it.
|
||||
const idPartMaxLen = 12
|
||||
|
||||
// isStructuredID reports whether s is a hyphen-joined human identifier (a model
|
||||
// name, slug): >= 3 hyphen groups where EVERY group is non-empty, <= idPartMaxLen
|
||||
// chars, and WORD-LIKE. The word-like test is the anti-bypass core — a raw secret
|
||||
// chunk cannot satisfy it — so the exemption is not attacker-satisfiable by shape.
|
||||
func isStructuredID(s string) bool {
|
||||
groups := strings.Split(s, "-")
|
||||
if len(groups) < 3 {
|
||||
return false
|
||||
}
|
||||
for _, g := range groups {
|
||||
if g == "" || len(g) > idPartMaxLen || !isWordLikeGroup(g) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isWordLikeGroup reports whether a hyphen group looks like a model-id token (a
|
||||
// dictionary word or a decimal number) rather than a random secret chunk. Two
|
||||
// lexical signals reject a secret chunk:
|
||||
// - MIXED CASE (both upper and lower letters) — base64 tokens are dense
|
||||
// mixed-case; real model tokens are single-case ("sonnet", "Instruct", "3").
|
||||
// - an all-hex-with-letters run (>= hexChunkMinLen chars, all [0-9a-fA-F], and
|
||||
// not all-digits) — a hex secret chunk ("dead", "beef", "cafebabe"); a version
|
||||
// date ("20241022", all digits) is NOT hex-with-letters, so it stays
|
||||
// word-like. The threshold is 4: RED re-review verified that NO real model-id
|
||||
// group is all-hex-with-letters of length >= 4, so 4 (vs the prior 8) closes
|
||||
// the small-hex-chunk leak (md5/sha shown in "xxxx-xxxx" display grouping)
|
||||
// with zero model-id over-scrub.
|
||||
func isWordLikeGroup(g string) bool {
|
||||
var hasUpper, hasLower, allHex, allDigit bool = false, false, true, true
|
||||
for _, r := range g {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
hasUpper = true
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLower = true
|
||||
}
|
||||
if r < '0' || r > '9' {
|
||||
allDigit = false
|
||||
}
|
||||
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
|
||||
if !isHex {
|
||||
allHex = false
|
||||
}
|
||||
}
|
||||
if hasUpper && hasLower {
|
||||
return false // dense mixed-case → base64 secret chunk, not a word.
|
||||
}
|
||||
if allHex && !allDigit && len(g) >= hexChunkMinLen {
|
||||
return false // hex-with-letters → hex secret chunk (not a version date).
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hexChunkMinLen is the length at/above which an all-hex-with-letters group is
|
||||
// treated as a secret chunk rather than a word. 4 is the tightest bound that does
|
||||
// not over-scrub any real model-id group (RED-verified across 19 model ids) while
|
||||
// catching hex secrets displayed in short groups (md5 "xxxx-xxxx", uuid-ish).
|
||||
const hexChunkMinLen = 4
|
||||
|
||||
// isUUID reports whether s is a canonical 8-4-4-4-12 hex UUID (case-insensitive).
|
||||
// Used to exempt uuids from the high-entropy secret test — a uuid is a legitimate
|
||||
// resource id, not a credential.
|
||||
func isUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if r != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// maxUserAgentLen caps the recorded User-Agent so an oversized UA can neither
|
||||
// bloat the trail nor smuggle a long payload. 512 chars covers every real UA.
|
||||
const maxUserAgentLen = 512
|
||||
|
||||
// scrubFreeText scrubs credential-shaped words out of client-controlled free
|
||||
// text (the User-Agent) and caps its length. It tokenizes on a broad delimiter
|
||||
// superset — whitespace and the punctuation that commonly glues a token into a
|
||||
// UA/header value (= ; , : / ( ) [ ] { } " ' < > | and backslash) — scrubs each
|
||||
// token, and rebuilds the string preserving the exact delimiters between tokens.
|
||||
//
|
||||
// RED-review hardening (finding: UA tokenizer split on too few delimiters, and
|
||||
// strings.ReplaceAll was substring-fragile): this walks the string in one pass
|
||||
// (token-run, delimiter-run, …) and replaces each credential token IN PLACE, so a
|
||||
// secret delimited by ':' '/' '(' etc. is caught and a token that is a substring
|
||||
// of another is never mis-replaced. A normal UA ("Mozilla/5.0 (Macintosh …)") is
|
||||
// unchanged because none of its words is credential-shaped.
|
||||
func scrubFreeText(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if len(s) > maxUserAgentLen {
|
||||
s = s[:maxUserAgentLen]
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
start := -1 // start index of the current token run, or -1 in a delimiter run.
|
||||
flush := func(end int) {
|
||||
if start >= 0 {
|
||||
b.WriteString(scrubToken(s[start:end]))
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
for i, r := range s {
|
||||
if isFreeTextDelimiter(r) {
|
||||
flush(i)
|
||||
b.WriteRune(r)
|
||||
} else if start < 0 {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
flush(len(s))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isFreeTextDelimiter reports whether r separates tokens in free text (a UA /
|
||||
// header value). Deliberately broad so a credential can't hide behind an unusual
|
||||
// separator.
|
||||
//
|
||||
// RED re-review (UA bypass persists): '.' '@' '#' '~' are INCLUDED — a prefixed
|
||||
// key glued by one of them (client@sk-live-KEY, app.sk-live-KEY, build#hk-KEY)
|
||||
// otherwise stayed one token whose PREFIX was no longer sk-/hk-, so isAPIKey
|
||||
// missed it. Splitting on them exposes the bare key to scrubToken. Real UA dots
|
||||
// are numeric version separators (<24, safe) and are split harmlessly. A JWT
|
||||
// (eyJ.h.p.s) is handled up-front by scrubToken via looksLikeJWT before any
|
||||
// tokenizer runs on a URL path segment; in free text a dotted JWT will split,
|
||||
// but each ~40-char base64 part is itself a >=24 high-entropy run, so every part
|
||||
// is still redacted.
|
||||
func isFreeTextDelimiter(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', '=', ';', ',', ':', '/', '\\',
|
||||
'(', ')', '[', ']', '{', '}', '"', '\'', '<', '>', '|', '&', '?',
|
||||
'.', '@', '#', '~':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scrubCredentialSegments applies scrubToken to every segment of a path, so the
|
||||
// recorded Path can never carry a credential even if a future route embeds one.
|
||||
// Route nouns/ids pass through unchanged (they are not isAPIKey-shaped).
|
||||
func scrubCredentialSegments(path string) string {
|
||||
if !strings.ContainsAny(path, "/") {
|
||||
return scrubToken(path)
|
||||
}
|
||||
segs := strings.Split(path, "/")
|
||||
changed := false
|
||||
for i, s := range segs {
|
||||
if scrubbed := scrubToken(s); scrubbed != s {
|
||||
segs[i] = scrubbed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return path
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
// looksLikeID heuristically flags a path segment as a high-cardinality id (a
|
||||
// uuid, a long hex/opaque token, or a numeric id) vs a fixed route noun. Used to
|
||||
// collapse route families; false positives only make the action slightly more
|
||||
// specific, never leak anything.
|
||||
func looksLikeID(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if len(s) >= 16 { // long opaque/uuid-ish segment
|
||||
return true
|
||||
}
|
||||
allDigits := true
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return allDigits && len(s) > 0
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
package cloud
|
||||
|
||||
// Integration tests for the audit middleware. They drive REAL requests through
|
||||
// the zip/fiber stack (app.Fiber().Test) with the audit middleware in front of a
|
||||
// handler, backed by a REAL on-disk audit store, then read the store back to
|
||||
// assert what was (and was not) recorded. No mocks — the whole capture path runs.
|
||||
//
|
||||
// The middleware trusts SanitizeIdentity to have already validated identity, so
|
||||
// these tests set the sanitized X-User-* headers directly (as SanitizeIdentity
|
||||
// would after verifying a JWT) — that is the contract boundary under test here.
|
||||
// The forgery/bypass properties of SanitizeIdentity itself are proven in
|
||||
// middleware_identity_test.go; here we prove the middleware records the VALIDATED
|
||||
// identity and the correct outcome for every security-relevant request.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// newAuditApp wires a zip app with the audit middleware in front of a small set
|
||||
// of routes covering the coverage matrix: a mutating POST, a safe GET, an
|
||||
// admin-gated route that 403s, and a route that echoes a (secret-bearing) body
|
||||
// so we can prove the body never reaches a record.
|
||||
func newAuditApp(t *testing.T) (*zip.App, *audit.Recorder) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("audit.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
|
||||
// Mutating route — must be audited.
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusCreated, map[string]string{"id": "sec_1"})
|
||||
})
|
||||
// Safe read — must NOT be audited (not a mutation, not admin, not a denial).
|
||||
app.Get("/v1/pricing/models", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
// Admin route that denies (as the real admin guard would) — the 403 is a
|
||||
// security event and MUST be audited even though it is a GET.
|
||||
app.Get("/v1/admin/orgs", func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
// A mutation whose request body carries secrets — used to prove the body is
|
||||
// never captured. The handler ignores the body; the point is what the
|
||||
// middleware records (metadata only).
|
||||
app.Post("/v1/iam/users", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
return app, rec
|
||||
}
|
||||
|
||||
// asAdmin sets the sanitized identity headers a VALIDATED global admin would
|
||||
// carry after SanitizeIdentity (X-User-IsAdmin=true, org=admin).
|
||||
func asAdmin(req *http.Request) {
|
||||
req.Header.Set("X-User-Id", "z@hanzo.ai")
|
||||
req.Header.Set("X-User-Email", "z@hanzo.ai")
|
||||
req.Header.Set("X-Org-Id", "admin")
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
req.Header.Set("Authorization", "Bearer eyJ.validated.jwt") // shape only; classifies as jwt
|
||||
}
|
||||
|
||||
// asUser sets sanitized headers for a normal (non-admin) validated principal.
|
||||
func asUser(req *http.Request) {
|
||||
req.Header.Set("X-User-Id", "alice")
|
||||
req.Header.Set("X-Org-Id", "acme")
|
||||
req.Header.Set("Authorization", "Bearer eyJ.validated.jwt")
|
||||
}
|
||||
|
||||
func mustTest(t *testing.T, app *zip.App, req *http.Request) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", req.Method, req.URL.Path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestAudit_RecordsMutation proves a mutating request is captured with the
|
||||
// correct validated actor, action, resource, outcome, and auth context — and the
|
||||
// record is hash-chained (has a hash) and verifies.
|
||||
func TestAudit_RecordsMutation(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
asUser(req)
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
|
||||
req.Header.Set("User-Agent", "test-agent/1.0")
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("recorded %d events, want exactly 1", total)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Actor.Org != "acme" || r.Actor.Sub != "alice" {
|
||||
t.Errorf("actor = %+v, want org=acme sub=alice (the VALIDATED identity)", r.Actor)
|
||||
}
|
||||
if r.Method != "POST" || r.Path != "/v1/kms/secrets" {
|
||||
t.Errorf("method/path = %s %s, want POST /v1/kms/secrets", r.Method, r.Path)
|
||||
}
|
||||
if r.Resource.Type != "secrets" {
|
||||
t.Errorf("resource type = %q, want secrets", r.Resource.Type)
|
||||
}
|
||||
if r.Outcome.Result != "success" || r.Outcome.Status != 201 {
|
||||
t.Errorf("outcome = %+v, want success/201", r.Outcome)
|
||||
}
|
||||
if r.Auth.Method != "jwt" {
|
||||
t.Errorf("auth method = %q, want jwt", r.Auth.Method)
|
||||
}
|
||||
if r.SourceIP != "203.0.113.9" {
|
||||
t.Errorf("source ip = %q, want the left-most XFF entry", r.SourceIP)
|
||||
}
|
||||
if r.UserAgent != "test-agent/1.0" {
|
||||
t.Errorf("user agent = %q, want test-agent/1.0", r.UserAgent)
|
||||
}
|
||||
if r.Hash == "" {
|
||||
t.Error("record has no hash — not chained")
|
||||
}
|
||||
if iv, _ := rec.Verify(t.Context()); !iv.OK {
|
||||
t.Errorf("chain broke after one record at %d (%s)", iv.BrokenAt, iv.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_RecordsDenial proves a 403 (access-control denial) is audited even on
|
||||
// a GET, with outcome result="deny", and records the actor who was denied.
|
||||
func TestAudit_RecordsDenial(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
// A NON-admin hits the admin route → 403.
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/admin/orgs", nil)
|
||||
asUser(req) // not admin
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{Result: "deny"})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("recorded %d denials, want 1", total)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Outcome.Result != "deny" || r.Outcome.Status != 403 {
|
||||
t.Errorf("outcome = %+v, want deny/403", r.Outcome)
|
||||
}
|
||||
if r.Actor.Sub != "alice" {
|
||||
t.Errorf("denied actor sub = %q, want alice", r.Actor.Sub)
|
||||
}
|
||||
if r.Auth.IsAdmin {
|
||||
t.Error("denied non-admin recorded as admin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_SkipsSafeReads proves an ordinary successful GET on a non-admin route
|
||||
// is NOT audited — the trail captures security events, not read-log noise.
|
||||
func TestAudit_SkipsSafeReads(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/pricing/models", nil)
|
||||
asUser(req)
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
_, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("a safe GET was audited (%d rows) — trail should skip non-security reads", total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_AdminReadIsAudited proves a SUCCESSFUL admin read is audited (admin
|
||||
// access itself is an AC-relevant event), distinguishing it from a normal read.
|
||||
func TestAudit_AdminReadIsAudited(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/admin/orgs", nil)
|
||||
asAdmin(req)
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("admin read recorded %d, want 1", total)
|
||||
}
|
||||
if !rows[0].Auth.IsAdmin || rows[0].Outcome.Result != "success" {
|
||||
t.Errorf("admin read record = auth.isAdmin=%v outcome=%+v, want admin+success", rows[0].Auth.IsAdmin, rows[0].Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_NeverCapturesRequestBody is the secret-safety proof: a mutation whose
|
||||
// body is FULL of credentials is audited, but the stored record contains NONE of
|
||||
// the body — the middleware captures metadata only, so a secret in a body can
|
||||
// never leak into the trail. This is the "reuse RedactUserSecrets" guarantee at
|
||||
// its strongest: the code that could leak a secret never reads it.
|
||||
func TestAudit_NeverCapturesRequestBody(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
secretBody := `{"username":"bob","password":"hunter2","apiKey":"sk-live-DEADBEEF","token":"ghp_SECRET"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/iam/users", strings.NewReader(secretBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
asAdmin(req)
|
||||
// Also stuff a secret into a header value that is NOT an identity header — it
|
||||
// must not be captured either (we only record User-Agent + XFF, never arbitrary
|
||||
// headers, and never Authorization's value).
|
||||
req.Header.Set("Authorization", "Bearer eyJsuper.secret.token.value")
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("recorded %d, want 1", total)
|
||||
}
|
||||
// Serialize the WHOLE record and scan for any secret substring.
|
||||
blob, _ := json.Marshal(rows[0])
|
||||
for _, secret := range []string{"hunter2", "sk-live-DEADBEEF", "ghp_SECRET", "super.secret.token.value"} {
|
||||
if strings.Contains(string(blob), secret) {
|
||||
t.Fatalf("SECRET LEAKED into audit record: %q found in %s", secret, blob)
|
||||
}
|
||||
}
|
||||
// But the metadata IS there: the auth method is classified without the token.
|
||||
if rows[0].Auth.Method != "jwt" {
|
||||
t.Errorf("auth method = %q, want jwt (classified from prefix, token not stored)", rows[0].Auth.Method)
|
||||
}
|
||||
if rows[0].Before != nil || rows[0].After != nil {
|
||||
t.Errorf("middleware set before/after (%s / %s) — it must never read bodies", rows[0].Before, rows[0].After)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_ScrubsCredentialInPath proves a credential-shaped token that rides in
|
||||
// the URL PATH (e.g. a KMS route where a caller wrongly puts an sk-/hk- key in the
|
||||
// path) is never recorded verbatim in either Path or resource.ID — defense in
|
||||
// depth beyond "bodies are never read". A normal identifier is untouched.
|
||||
func TestAudit_ScrubsCredentialInPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Delete("/v1/kms/secrets/*", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
|
||||
// Three credential shapes smuggled into the path across three requests: a
|
||||
// prefixed key, a raw high-entropy hex secret (NO telltale prefix), and a JWT.
|
||||
paths := []struct{ path, secret string }{
|
||||
{"/v1/kms/secrets/sk-live-SUPERSECRETKEY1234567890", "SUPERSECRETKEY"},
|
||||
{"/v1/kms/secrets/deadbeefcafe0123456789abcdef0123456789abcdef0123", "deadbeefcafe0123"},
|
||||
{"/v1/kms/secrets/eyJhbGciOiJIUzI1NiJ9.cGF5bG9hZA.c2ln", "eyJhbGciOiJIUzI1NiJ9"},
|
||||
}
|
||||
for _, tc := range paths {
|
||||
req := httptest.NewRequest(http.MethodDelete, tc.path, nil)
|
||||
asAdmin(req)
|
||||
if resp := mustTest(t, app, req); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status = %d, want 200", tc.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != len(paths) {
|
||||
t.Fatalf("recorded %d, want %d", total, len(paths))
|
||||
}
|
||||
blob, _ := json.Marshal(rows)
|
||||
for _, tc := range paths {
|
||||
if strings.Contains(string(blob), tc.secret) {
|
||||
t.Fatalf("credential in path leaked into record: %q present in %s", tc.secret, blob)
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
if !strings.Contains(r.Path, "[REDACTED-TOKEN]") {
|
||||
t.Errorf("path token not scrubbed: %q", r.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_ScrubsSecretInUserAgent proves a bearer/API-key embedded in the
|
||||
// client-controlled User-Agent is scrubbed, and a normal UA is untouched.
|
||||
func TestAudit_ScrubsSecretInUserAgent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
asAdmin(req)
|
||||
req.Header.Set("User-Agent", "myclient/1.0 Bearer eyJhbGciOiJI.pay.sig key=sk-live-LEAKME99999")
|
||||
mustTest(t, app, req)
|
||||
|
||||
rows, _, _ := rec.Query(t.Context(), audit.Filter{})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("got %d rows, want 1", len(rows))
|
||||
}
|
||||
for _, secret := range []string{"eyJhbGciOiJI", "sk-live-LEAKME99999"} {
|
||||
if strings.Contains(rows[0].UserAgent, secret) {
|
||||
t.Errorf("UA secret leaked: %q in %q", secret, rows[0].UserAgent)
|
||||
}
|
||||
}
|
||||
// The non-secret UA prefix survives (audit usefulness preserved).
|
||||
if !strings.Contains(rows[0].UserAgent, "myclient/1.0") {
|
||||
t.Errorf("UA over-scrubbed, lost the client name: %q", rows[0].UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrubToken_NoFalsePositives proves legitimate identifiers are NEVER
|
||||
// scrubbed — the guard fires only on genuinely secret-shaped segments, so the
|
||||
// audit trail keeps its query precision for normal resource ids.
|
||||
func TestScrubToken_NoFalsePositives(t *testing.T) {
|
||||
for _, id := range []string{
|
||||
"acme-corp", "gpt-4o-mini", "my_project_123", "user@example.com",
|
||||
"550e8400-e29b-41d4-a716-446655440000", // uuid (hyphens)
|
||||
"claude-opus-4-20250514", "text-embedding-3-large",
|
||||
"my-cool-project-name", "feature-branch-xyz",
|
||||
"deployment-2024-01-15", "report.pdf", "data.json",
|
||||
// Red re-review round 2 — hyphenated model ids MUST pass (AU-3: an auditor
|
||||
// must still see WHICH model a config change touched).
|
||||
"claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022",
|
||||
"claude-sonnet-4-20250514", "claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-sonnet-latest", "deepseek-r1-distill-qwen-32b",
|
||||
"claude-3-opus-20240229", "stable-diffusion-xl-base",
|
||||
"mixtral-8x7b-instruct", "llama-3-1-8b-instruct", "whisper-large-v3-turbo",
|
||||
"v1", "models", "sync", "12345", "a", "",
|
||||
} {
|
||||
if got := scrubToken(id); got != id {
|
||||
t.Errorf("false scrub: legit id %q → %q", id, got)
|
||||
}
|
||||
}
|
||||
// And genuine secrets ARE scrubbed.
|
||||
for _, sec := range []string{
|
||||
"sk-live-abcdef", "hk-1234567890abcdef", "eyJhbG.payload.signature",
|
||||
"deadbeefcafe0123456789abcdef0123456789abcdef0123", // 48-char raw hex
|
||||
} {
|
||||
if scrubToken(sec) == sec {
|
||||
t.Errorf("missed secret: %q not scrubbed", sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrubToken_RedReviewBypassClasses is the regression for the 4 scrub-bypass
|
||||
// classes Red found: base64url with -/_, all-alpha opaque >=len, percent-encoded
|
||||
// prefixes, and delimiter-glued UA tokens. Each MUST now be redacted.
|
||||
func TestScrubToken_RedReviewBypassClasses(t *testing.T) {
|
||||
for _, sec := range []string{
|
||||
"AbCdEf-GhIjKl_MnOpQrStUvWxYz012345", // base64url with - and _
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN", // all-alpha opaque, no digit
|
||||
"hk%2DROTATEKEY0001SECRETKEY", // percent-encoded hk-
|
||||
"sk%5Flive%5FBYPASS0001SECRETKEY", // percent-encoded sk_
|
||||
// Red re-review round 2 — dotted-exemption + encoding + standard-base64:
|
||||
"deadbeefcafe0123456789abcdef0123456789abcdef.x", // ".x" tail forces dotted exemption
|
||||
"AbCdEfGhIjKlMnOpQrStUvWxYz012345.json", // secret with a filename-ish suffix
|
||||
"hk%252DROTATEKEY0001SECRETKEY", // double percent-encoded hk-
|
||||
"AbCdEfGhIjKlMnOpQrStUvWx0123456789", // 34-char opaque run (standard/url b64)
|
||||
// Red re-review round 3 — interior-hyphen raw secret (NOT a structured id:
|
||||
// only 1 hyphen, long parts) must still redact despite '-' in the run.
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
// Red final re-review — a secret CHUNKED to satisfy the structured-id shape
|
||||
// (>=3 groups <=12 chars) must STILL redact: the lexical (word-like) test
|
||||
// rejects mixed-case base64 chunks and hex chunks (>= hexChunkMinLen=4).
|
||||
"AbCdEfGhIjKl-MnOpQrStUvWx-YzAbCdEfGhIj", // mixed-case base64 chunks
|
||||
"A1b2C3d4-E5f6G7h8-I9j0K1l2", // 128-bit mixed-case chunks
|
||||
"deadbeef-cafebabe-01234567-89abcdef", // all-hex chunks (8-char groups)
|
||||
"abcdef01-23456789-abcdef01", // hex chunks
|
||||
// Red final polish — SMALL hex chunks (md5/sha "xxxx-xxxx" display) now
|
||||
// caught by the len>=4 hex rule.
|
||||
"abcd-ef01-2345-6789-abcd-ef01", // 4-char hex groups
|
||||
"dead-beef-cafe-babe-0123-4567", // 4-char hex groups
|
||||
} {
|
||||
if got := scrubToken(sec); got != "[REDACTED-TOKEN]" {
|
||||
t.Errorf("Red bypass STILL OPEN: %q → %q (want redacted)", sec, got)
|
||||
}
|
||||
}
|
||||
// UA with a secret glued by :/()[]= — must be scrubbed, client name kept.
|
||||
ua := scrubFreeText("myapp/1.0 (token:sk-live-BYPASS0001) [key=hk-1234567890abcdef]")
|
||||
for _, leak := range []string{"sk-live-BYPASS0001", "hk-1234567890abcdef"} {
|
||||
if strings.Contains(ua, leak) {
|
||||
t.Errorf("UA bypass: %q leaked in %q", leak, ua)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(ua, "myapp/1.0") {
|
||||
t.Errorf("UA over-scrubbed, lost client name: %q", ua)
|
||||
}
|
||||
// Red re-review round 2 — a key glued by . @ # ~ must NOT survive.
|
||||
for _, glued := range []string{
|
||||
"client@sk-live-SECRETKEY00001", "app.sk-live-SECRETKEY00001",
|
||||
"build#hk-SECRETKEY000000001", "v1~sk-live-SECRETKEY00001",
|
||||
} {
|
||||
if got := scrubFreeText(glued); strings.Contains(got, "SECRETKEY") {
|
||||
t.Errorf("UA glue-char bypass: %q → %q (secret survives)", glued, got)
|
||||
}
|
||||
}
|
||||
// Normal UAs must be byte-identical (no false scrub).
|
||||
for _, ua := range []string{
|
||||
"console2", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
"curl/8.1.2", "Go-http-client/2.0",
|
||||
} {
|
||||
if got := scrubFreeText(ua); got != ua {
|
||||
t.Errorf("UA false positive: %q → %q", ua, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_HealthSuffixCannotEvadeAudit proves a mutating request whose path
|
||||
// ENDS in /health (an attacker-named wildcard segment) is STILL audited — the
|
||||
// liveness exemption is exact and never suppresses a mutation or a denial. This
|
||||
// closes an audit-evasion hole where POST /v1/admin/orgs/x/health would slip past.
|
||||
func TestAudit_HealthSuffixCannotEvadeAudit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Post("/v1/admin/orgs/*", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
|
||||
app.Get("/v1/kms/health", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) })
|
||||
|
||||
// (a) A mutating POST ending in /health MUST be audited.
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/admin/orgs/evil/health", nil)
|
||||
asAdmin(req)
|
||||
mustTest(t, app, req)
|
||||
_, total, _ := rec.Query(t.Context(), audit.Filter{})
|
||||
if total != 1 {
|
||||
t.Fatalf("EVASION: mutating POST ending /health audited %d times, want 1", total)
|
||||
}
|
||||
|
||||
// (b) A genuine liveness GET /v1/kms/health MUST still be skipped.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/v1/kms/health", nil)
|
||||
mustTest(t, app, req2)
|
||||
_, total2, _ := rec.Query(t.Context(), audit.Filter{})
|
||||
if total2 != 1 {
|
||||
t.Fatalf("liveness probe was audited (total went %d→%d) — should be exempt", total, total2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_AnonRequestNotAttributedToForgedOrg proves an UNAUTHENTICATED
|
||||
// attacker cannot forge a false attribution: sending X-Org-Id/X-User-Id/
|
||||
// X-User-IsAdmin with no validated principal records an ANONYMOUS actor (empty
|
||||
// org+sub, not admin), never the claimed victim org. Runs the REAL SanitizeIdentity
|
||||
// (nil validator ⇒ strips authority, restores client X-Org-Id for the data path)
|
||||
// ahead of AuditTrail, exactly as serve.go wires them.
|
||||
func TestAudit_AnonRequestNotAttributedToForgedOrg(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(nil, "admin")) // trust boundary
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
// Anonymous attacker forging every identity header.
|
||||
req.Header.Set("X-Org-Id", "victim-org")
|
||||
req.Header.Set("X-User-Id", "victim-user")
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
mustTest(t, app, req)
|
||||
|
||||
rows, _, _ := rec.Query(t.Context(), audit.Filter{})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("got %d rows, want 1", len(rows))
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Auth.IsAdmin {
|
||||
t.Error("forged X-User-IsAdmin survived into the record")
|
||||
}
|
||||
if r.Actor.Sub != "" {
|
||||
t.Errorf("forged X-User-Id recorded as actor.Sub = %q", r.Actor.Sub)
|
||||
}
|
||||
if r.Actor.Org == "victim-org" {
|
||||
t.Errorf("FALSE ATTRIBUTION: anonymous request stamped with claimed org %q", r.Actor.Org)
|
||||
}
|
||||
if r.Auth.Method != "none" {
|
||||
t.Errorf("auth method = %q, want none (no valid credential)", r.Auth.Method)
|
||||
}
|
||||
// The event is still recorded (a mutation), honestly anonymous.
|
||||
if r.Outcome.Result != "success" {
|
||||
t.Errorf("outcome = %+v, want the anonymous mutation recorded", r.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_NoopWhenUnconfigured proves a nil Recorder makes the middleware a
|
||||
// pass-through (an unconfigured deployment is never blocked), exactly like
|
||||
// BillingGate's nil-client behavior.
|
||||
func TestAudit_NoopWhenUnconfigured(t *testing.T) {
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(nil))
|
||||
var ran bool
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
|
||||
ran = true
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusOK || !ran {
|
||||
t.Fatalf("nil-recorder gate must pass through: status=%d ran=%v", resp.StatusCode, ran)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_FailsClosedOnWriteError proves that when the audit store cannot
|
||||
// record a security-relevant event, the request is failed CLOSED (503) rather
|
||||
// than allowed to succeed unlogged (AU-5). We force the failure by closing the
|
||||
// store's DB before the request, so Append errors.
|
||||
func TestAudit_FailsClosedOnWriteError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
// Close the underlying store so every subsequent Append fails.
|
||||
_ = rec.Close()
|
||||
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
var ran bool
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
|
||||
ran = true
|
||||
return c.JSON(http.StatusCreated, map[string]string{"id": "x"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
asAdmin(req)
|
||||
resp := mustTest(t, app, req)
|
||||
// The handler may have run (audit wraps AFTER the chain), but the response the
|
||||
// CLIENT sees must be the fail-closed 503, not the handler's 201 — a
|
||||
// security-relevant action that could not be recorded is not acknowledged as
|
||||
// success.
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 (fail-closed when audit write fails)", resp.StatusCode)
|
||||
}
|
||||
_ = ran
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package cloud
|
||||
|
||||
// The datastore (ClickHouse) OLAP mirror — a best-effort projection of the audit
|
||||
// trail for fleet-wide, long-retention, cross-deployment query. It implements
|
||||
// audit.Mirror.
|
||||
//
|
||||
// The datastore is the natural OLAP audit sink: the table is a MergeTree, which
|
||||
// is INSERT-ONLY by engine — ClickHouse rejects UPDATE/DELETE against it at parse
|
||||
// time ("MergeTree does not support mutations"), so the mirror is append-only at
|
||||
// the storage layer, matching the local chain's discipline. We create the table
|
||||
// idempotently on first connect (CREATE TABLE IF NOT EXISTS) and insert via the
|
||||
// canonical clickhouse-go PrepareBatch → Append → Send idiom (the same the
|
||||
// provisioning subsystem uses; the driver is already in cloud's module graph, so
|
||||
// this adds no dependency).
|
||||
//
|
||||
// This mirror is NEVER the integrity authority — the local SQLite hash-chain is.
|
||||
// Its rows carry the same seq + hash so an operator CAN cross-check the OLAP copy
|
||||
// against the chain, but a mirror gap is a query-completeness issue, not a
|
||||
// tamper-evidence one. Every mirror error is logged and dropped by the Recorder;
|
||||
// the request path never sees it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// clickhouseMirror writes audit records to a ClickHouse MergeTree table.
|
||||
type clickhouseMirror struct {
|
||||
conn clickhouse.Conn
|
||||
table string
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
// newAuditMirror builds the OLAP mirror from operator config, or returns nil when
|
||||
// no datastore is configured (mirroring is optional — the local chain is the
|
||||
// authority). It connects lazily-validated (a Ping) and ensures the table exists.
|
||||
//
|
||||
// Config (all from env / KMS-injected secrets, never hard-coded):
|
||||
//
|
||||
// CLOUD_AUDIT_CLICKHOUSE_ADDR host:9000 of the datastore native port
|
||||
// CLOUD_AUDIT_CLICKHOUSE_DB database (default "hanzo")
|
||||
// CLOUD_AUDIT_CLICKHOUSE_TABLE table (default "audit_log")
|
||||
// CLOUD_AUDIT_CLICKHOUSE_USER user
|
||||
// CLOUD_AUDIT_CLICKHOUSE_PASSWORD password (KMS-backed secret)
|
||||
func newAuditMirror(log luxlog.Logger) (audit.Mirror, error) {
|
||||
addr := strings.TrimSpace(os.Getenv("CLOUD_AUDIT_CLICKHOUSE_ADDR"))
|
||||
if addr == "" {
|
||||
return nil, nil // no datastore configured — local chain only.
|
||||
}
|
||||
db := getenv("CLOUD_AUDIT_CLICKHOUSE_DB", "hanzo")
|
||||
table := getenv("CLOUD_AUDIT_CLICKHOUSE_TABLE", "audit_log")
|
||||
|
||||
conn, err := clickhouse.Open(&clickhouse.Options{
|
||||
Addr: []string{addr},
|
||||
Auth: clickhouse.Auth{
|
||||
Database: db,
|
||||
Username: os.Getenv("CLOUD_AUDIT_CLICKHOUSE_USER"),
|
||||
Password: os.Getenv("CLOUD_AUDIT_CLICKHOUSE_PASSWORD"),
|
||||
},
|
||||
DialTimeout: 5 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit mirror: open: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := conn.Ping(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("audit mirror: ping %s: %w", addr, err)
|
||||
}
|
||||
|
||||
qualified := db + "." + table
|
||||
m := &clickhouseMirror{conn: conn, table: qualified, log: log}
|
||||
if err := m.ensureTable(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("audit OLAP mirror connected", "addr", addr, "table", qualified)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ensureTable creates the append-only audit table if it does not exist. MergeTree
|
||||
// = insert-only (mutations rejected at parse time). Partitioned by month and
|
||||
// ordered for the (org, time) query pattern; seq + hash are carried so the OLAP
|
||||
// copy is cross-checkable against the local chain.
|
||||
func (m *clickhouseMirror) ensureTable(ctx context.Context) error {
|
||||
ddl := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
seq UInt64,
|
||||
ts DateTime64(3, 'UTC'),
|
||||
actor_org LowCardinality(String),
|
||||
actor_sub String,
|
||||
actor_email String,
|
||||
action LowCardinality(String),
|
||||
res_type LowCardinality(String),
|
||||
res_id String,
|
||||
auth_method LowCardinality(String),
|
||||
is_admin UInt8,
|
||||
result LowCardinality(String),
|
||||
status UInt16,
|
||||
reason String,
|
||||
source_ip String,
|
||||
user_agent String,
|
||||
request_id String,
|
||||
method LowCardinality(String),
|
||||
path String,
|
||||
prev_hash String,
|
||||
hash String
|
||||
) ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(ts)
|
||||
ORDER BY (actor_org, ts, seq)`, m.table)
|
||||
if err := m.conn.Exec(ctx, ddl); err != nil {
|
||||
return fmt.Errorf("audit mirror: ensure table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Append writes one record to the OLAP mirror via the canonical batch idiom. The
|
||||
// before/after diffs are DELIBERATELY not mirrored — the OLAP copy is for
|
||||
// query/analytics over the event stream, and keeping the (already-redacted but
|
||||
// still payload-bearing) diffs out of the fleet warehouse minimizes the blast
|
||||
// radius of a warehouse compromise. The full record (with diffs) lives only in
|
||||
// the local, access-controlled chain.
|
||||
func (m *clickhouseMirror) Append(ctx context.Context, r audit.Record) error {
|
||||
batch, err := m.conn.PrepareBatch(ctx, "INSERT INTO "+m.table+` (
|
||||
seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
|
||||
auth_method, is_admin, result, status, reason, source_ip, user_agent,
|
||||
request_id, method, path, prev_hash, hash)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit mirror: prepare: %w", err)
|
||||
}
|
||||
if err := batch.Append(
|
||||
r.Seq, r.Time.UTC(), r.Actor.Org, r.Actor.Sub, r.Actor.Email,
|
||||
r.Action, r.Resource.Type, r.Resource.ID,
|
||||
r.Auth.Method, boolToUint8(r.Auth.IsAdmin),
|
||||
r.Outcome.Result, uint16(r.Outcome.Status), r.Outcome.Reason,
|
||||
r.SourceIP, r.UserAgent, r.RequestID, r.Method, r.Path,
|
||||
r.PrevHash, r.Hash,
|
||||
); err != nil {
|
||||
_ = batch.Abort()
|
||||
return fmt.Errorf("audit mirror: append: %w", err)
|
||||
}
|
||||
return batch.Send()
|
||||
}
|
||||
|
||||
// Checkpoint persists a head-digest checkpoint to an INDEPENDENT digest table in
|
||||
// the datastore — the AU-9 tail-truncation anchor. Because this lives in a store
|
||||
// SEPARATE from the local SQLite chain, truncating the chain cannot also rewrite
|
||||
// the checkpoint history: an external monitor querying this table sees the count
|
||||
// series and alerts on any regression. Best-effort; a failure is dropped by the
|
||||
// Recorder (the structured log carries the same digest). Implements
|
||||
// audit.CheckpointSink.
|
||||
func (m *clickhouseMirror) Checkpoint(ctx context.Context, cp audit.Checkpoint) error {
|
||||
if err := m.ensureCheckpointTable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
batch, err := m.conn.PrepareBatch(ctx, "INSERT INTO "+m.table+"_checkpoints (ts, count, head)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit mirror: checkpoint prepare: %w", err)
|
||||
}
|
||||
if err := batch.Append(cp.Time.UTC(), cp.Count, cp.Head); err != nil {
|
||||
_ = batch.Abort()
|
||||
return fmt.Errorf("audit mirror: checkpoint append: %w", err)
|
||||
}
|
||||
return batch.Send()
|
||||
}
|
||||
|
||||
// ensureCheckpointTable creates the append-only checkpoint digest table. A plain
|
||||
// MergeTree ordered by time — the monitor reads the latest rows and checks that
|
||||
// count never decreases.
|
||||
func (m *clickhouseMirror) ensureCheckpointTable(ctx context.Context) error {
|
||||
ddl := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s_checkpoints (
|
||||
ts DateTime64(3, 'UTC'),
|
||||
count UInt64,
|
||||
head String
|
||||
) ENGINE = MergeTree
|
||||
ORDER BY ts`, m.table)
|
||||
if err := m.conn.Exec(ctx, ddl); err != nil {
|
||||
return fmt.Errorf("audit mirror: ensure checkpoint table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToUint8(b bool) uint8 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cloud
|
||||
|
||||
// Audit trail construction — the wiring Serve calls to stand up the Recorder.
|
||||
//
|
||||
// The audit store is a COMPLIANCE CONTROL, so its persistence is treated like the
|
||||
// pricing catalog overlay's: a non-persistent (in-memory) audit trail would
|
||||
// silently lose the record of every prior action on each restart — a fail-OPEN
|
||||
// degradation of an integrity control. So an empty DataDir is a hard boot error
|
||||
// in a normal run (prod always sets CLOUD_DATA_DIR; provisioning + pricing already
|
||||
// require it, so the unified binary always has one). The trail can be turned OFF
|
||||
// deliberately (CLOUD_AUDIT_DISABLED=true) for a minimal single-service dev run —
|
||||
// an explicit opt-out, never a silent one.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// buildAuditRecorder constructs the audit Recorder from cfg: the append-only
|
||||
// SQLite chain at {DataDir}/audit.db plus a best-effort ClickHouse OLAP mirror
|
||||
// when a datastore is configured. Returns (nil, nil) only when the trail is
|
||||
// explicitly disabled — the caller then wires a no-op middleware.
|
||||
func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, error) {
|
||||
if getenvBool("CLOUD_AUDIT_DISABLED") {
|
||||
if logger != nil {
|
||||
logger.Warn("audit trail DISABLED by CLOUD_AUDIT_DISABLED — no tamper-evident record will be kept")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
return nil, fmt.Errorf("empty DataDir — the audit trail is a compliance control and requires a persistent data dir (set CLOUD_DATA_DIR); refusing to boot with a non-persistent trail that would lose all prior records on restart (or set CLOUD_AUDIT_DISABLED=true to opt out explicitly)")
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("data dir: %w", err)
|
||||
}
|
||||
|
||||
// OLAP mirror is optional and best-effort. A mirror that cannot be reached at
|
||||
// boot must NOT stop the binary — the local chain is the authority — so a
|
||||
// mirror construction error is logged and the trail runs local-only.
|
||||
var mirror audit.Mirror
|
||||
if m, err := newAuditMirror(logger); err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("audit OLAP mirror unavailable — running local-only (chain integrity unaffected)", "err", err)
|
||||
}
|
||||
} else {
|
||||
mirror = m
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(cfg.DataDir, "audit.db")
|
||||
rec, err := audit.Open(dbPath, mirror)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open audit store: %w", err)
|
||||
}
|
||||
|
||||
// AU-9 tail-truncation anchor: emit a periodic head-digest checkpoint to the
|
||||
// append-only observability log (and, when a mirror supports it, an
|
||||
// independent digest store). An external o11y monitor compares consecutive
|
||||
// checkpoints and alerts on a count regression — the only way to detect that
|
||||
// the most-recent records were deleted (an internal chain walk cannot). The
|
||||
// interval is CLOUD_AUDIT_CHECKPOINT_INTERVAL (default 5m; 0 disables).
|
||||
interval := auditCheckpointInterval()
|
||||
if logger != nil {
|
||||
rec.StartCheckpoints(interval, func(cp audit.Checkpoint) {
|
||||
logger.Info("audit_head_checkpoint",
|
||||
"count", cp.Count, "head", cp.Head, "ts", cp.Time.Format(time.RFC3339Nano))
|
||||
})
|
||||
} else {
|
||||
rec.StartCheckpoints(interval, nil)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
count, head := rec.Head()
|
||||
logger.Info("audit trail ready (tamper-evident, append-only)",
|
||||
"store", dbPath, "records", count, "head", head,
|
||||
"mirror", mirror != nil, "checkpoint_interval", interval.String())
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// auditCheckpointInterval resolves the head-digest checkpoint cadence.
|
||||
// CLOUD_AUDIT_CHECKPOINT_INTERVAL is a Go duration (e.g. "5m", "1h"); default 5m;
|
||||
// "0" disables periodic checkpoints (the on-close checkpoint still fires).
|
||||
func auditCheckpointInterval() time.Duration {
|
||||
if v := getenv("CLOUD_AUDIT_CHECKPOINT_INTERVAL", ""); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return 5 * time.Minute
|
||||
}
|
||||
+17
-10
@@ -36,6 +36,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
@@ -45,6 +46,10 @@ type svc struct {
|
||||
commerce *commerceClient
|
||||
health *healthClient
|
||||
adminOrg string
|
||||
// auditStore is cloud's OWN tamper-evident audit store (nil when unconfigured,
|
||||
// in which case /v1/admin/audit falls back to the IAM get-records proxy). Serve
|
||||
// builds it and hands it over via deps.Audit. See audit.go.
|
||||
auditStore *audit.Recorder
|
||||
}
|
||||
|
||||
// Mount registers the /v1/admin/* surface on app. Every handler gates on
|
||||
@@ -60,10 +65,11 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
logger = logger.New("subsystem", "admin")
|
||||
|
||||
s := &svc{
|
||||
iam: newIAMClient(iamBase(deps)),
|
||||
commerce: newCommerceClient(os.Getenv("CLOUD_COMMERCE_HTTP_URL"), os.Getenv("COMMERCE_SERVICE_TOKEN")),
|
||||
health: newHealthClient(o11yHealthURL()),
|
||||
adminOrg: adminOrgOf(deps),
|
||||
iam: newIAMClient(iamBase(deps)),
|
||||
commerce: newCommerceClient(os.Getenv("CLOUD_COMMERCE_HTTP_URL"), os.Getenv("COMMERCE_SERVICE_TOKEN")),
|
||||
health: newHealthClient(o11yHealthURL()),
|
||||
adminOrg: adminOrgOf(deps),
|
||||
auditStore: deps.Audit,
|
||||
}
|
||||
|
||||
app.Get("/v1/admin/me", s.guard(s.me))
|
||||
@@ -73,6 +79,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
app.Get("/v1/admin/roles", s.guard(s.roles))
|
||||
app.Get("/v1/admin/applications", s.guard(s.applications))
|
||||
app.Get("/v1/admin/audit", s.guard(s.audit))
|
||||
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
|
||||
app.Get("/v1/admin/usage", s.guard(s.usage))
|
||||
app.Get("/v1/admin/products", s.guard(s.products))
|
||||
app.Post("/v1/admin/sync", s.guard(s.sync))
|
||||
@@ -270,8 +277,12 @@ func (s *svc) iamPassthrough(c *zip.Ctx, path string) error {
|
||||
}
|
||||
|
||||
// ── /v1/admin/audit — records directory (AuditRow[]) ─────────────────────────
|
||||
//
|
||||
// The handler lives in audit.go (it reads cloud's OWN tamper-evident store).
|
||||
// iamAuditQuery builds the IAM get-records query for the federated fallback
|
||||
// auditFromIAM uses when no local store is configured.
|
||||
|
||||
func (s *svc) audit(c *zip.Ctx) error {
|
||||
func iamAuditQuery(c *zip.Ctx) url.Values {
|
||||
q := url.Values{}
|
||||
if org := strings.TrimSpace(c.Query("org")); org != "" {
|
||||
q.Set("organizationName", org)
|
||||
@@ -284,11 +295,7 @@ func (s *svc) audit(c *zip.Ctx) error {
|
||||
q.Set("pageSize", ps)
|
||||
q.Set("sortField", "createdTime")
|
||||
q.Set("sortOrder", "descend")
|
||||
res, err := s.iam.getList(c.Context(), callerCreds(c), "/v1/iam/get-records", q)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
return okRaw(c, res.rows, res.total)
|
||||
return q
|
||||
}
|
||||
|
||||
// ── /v1/admin/usage — fleet usage roll-up (UsageData) ────────────────────────
|
||||
|
||||
@@ -33,6 +33,7 @@ func mount(t *testing.T, iamURL, commerceURL, healthURL string) func(method, pat
|
||||
app.Get("/v1/admin/roles", s.guard(s.roles))
|
||||
app.Get("/v1/admin/applications", s.guard(s.applications))
|
||||
app.Get("/v1/admin/audit", s.guard(s.audit))
|
||||
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
|
||||
app.Get("/v1/admin/usage", s.guard(s.usage))
|
||||
app.Get("/v1/admin/products", s.guard(s.products))
|
||||
app.Post("/v1/admin/sync", s.guard(s.sync))
|
||||
@@ -63,6 +64,7 @@ var adminRoutes = []struct{ method, path string }{
|
||||
{"GET", "/v1/admin/roles"},
|
||||
{"GET", "/v1/admin/applications"},
|
||||
{"GET", "/v1/admin/audit"},
|
||||
{"GET", "/v1/admin/audit/verify"},
|
||||
{"GET", "/v1/admin/usage"},
|
||||
{"GET", "/v1/admin/products"},
|
||||
{"POST", "/v1/admin/sync"},
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package admin
|
||||
|
||||
// The /v1/admin/audit query surface, wired to cloud's REAL tamper-evident audit
|
||||
// store (the audit.Recorder Serve builds and hands over via deps.Audit).
|
||||
//
|
||||
// This REPLACES the previous behavior — proxying IAM get-records — as the primary
|
||||
// source: cloud now keeps its OWN append-only, hash-chained trail of every
|
||||
// security-relevant request against this binary, and that is what a compliance
|
||||
// auditor queries here. IAM's own login/session records remain available in IAM;
|
||||
// they are a DIFFERENT trail (IAM's request surface), and admin still federates
|
||||
// them as a fallback when cloud's local store is not configured, so no capability
|
||||
// is lost.
|
||||
//
|
||||
// SECURITY. Both handlers are registered behind the SAME s.guard as every other
|
||||
// /v1/admin/* route (global-admin only, fail-closed). They are READ-ONLY (Query
|
||||
// and Verify issue SELECT only), so exposing them cannot weaken the append-only
|
||||
// property. The verify endpoint returns integrity STATUS, never a way to mutate.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// auditRow is one record in the operator's audit table (AuditRow). The JSON tags
|
||||
// are the operator contract. It is cloud's OWN record shape — richer than the IAM
|
||||
// Record it supersedes: it carries the outcome, the validated auth context, and
|
||||
// the hash-chain linkage so the console can show integrity per row.
|
||||
type auditRow struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Time string `json:"time"`
|
||||
Org string `json:"org"`
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID string `json:"resourceId,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Result string `json:"result"`
|
||||
Status int `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
SourceIP string `json:"sourceIp,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Auth string `json:"authMethod,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
PrevHash string `json:"prevHash"`
|
||||
}
|
||||
|
||||
// audit answers GET /v1/admin/audit from cloud's local tamper-evident store when
|
||||
// configured, else falls back to the IAM get-records proxy (federated view).
|
||||
// Filters: org, sub, action, resource, result, since, until, pageSize, p (page).
|
||||
// The response is the casibase list envelope { data:[rows], data2:total } the
|
||||
// operator decodes, with the current chain integrity summary attached.
|
||||
func (s *svc) audit(c *zip.Ctx) error {
|
||||
// No local store configured → preserve the legacy federated IAM view so the
|
||||
// endpoint never regresses to empty.
|
||||
if s.auditStore == nil {
|
||||
return s.auditFromIAM(c)
|
||||
}
|
||||
|
||||
f := auditFilterFromQuery(c)
|
||||
rows, total, err := s.auditStore.Query(c.Context(), f)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
|
||||
out := make([]auditRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toAuditRow(r))
|
||||
}
|
||||
|
||||
// Attach the live integrity summary so the console can badge the trail as
|
||||
// verified. Best-effort: a verify error must not fail the listing.
|
||||
integrity, ivErr := s.auditStore.Verify(c.Context())
|
||||
var integrityPayload any
|
||||
if ivErr == nil {
|
||||
integrityPayload = integrity
|
||||
}
|
||||
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok",
|
||||
"msg": "",
|
||||
"data": out,
|
||||
"data2": total,
|
||||
"integrity": integrityPayload,
|
||||
})
|
||||
}
|
||||
|
||||
// auditVerify answers GET /v1/admin/audit/verify — the tamper-evidence check. It
|
||||
// walks the whole hash chain and returns the integrity result (ok, count, head,
|
||||
// and the seq where the chain first breaks if tampered). Global-admin gated like
|
||||
// every admin route.
|
||||
func (s *svc) auditVerify(c *zip.Ctx) error {
|
||||
if s.auditStore == nil {
|
||||
return fail(c, "audit store not configured")
|
||||
}
|
||||
integrity, err := s.auditStore.Verify(c.Context())
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
return ok(c, integrity)
|
||||
}
|
||||
|
||||
// auditFilterFromQuery builds an audit.Filter from the request query params. Time
|
||||
// bounds accept RFC3339. pageSize (default 100, cap 1000) and p (1-based page)
|
||||
// drive Limit/Offset. Unknown/blank params are simply not applied.
|
||||
func auditFilterFromQuery(c *zip.Ctx) audit.Filter {
|
||||
f := audit.Filter{
|
||||
Org: strings.TrimSpace(c.Query("org")),
|
||||
Sub: strings.TrimSpace(c.Query("sub")),
|
||||
Action: strings.TrimSpace(c.Query("action")),
|
||||
Resource: strings.TrimSpace(c.Query("resource")),
|
||||
Result: strings.TrimSpace(c.Query("result")),
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("since")); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Since = t
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("until")); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Until = t
|
||||
}
|
||||
}
|
||||
pageSize := 100
|
||||
if v := strings.TrimSpace(c.Query("pageSize")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
pageSize = n
|
||||
}
|
||||
}
|
||||
f.Limit = pageSize
|
||||
if v := strings.TrimSpace(c.Query("p")); v != "" {
|
||||
if page, err := strconv.Atoi(v); err == nil && page > 1 {
|
||||
f.Offset = (page - 1) * pageSize
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// toAuditRow maps a stored audit.Record to the operator wire row.
|
||||
func toAuditRow(r audit.Record) auditRow {
|
||||
return auditRow{
|
||||
Seq: r.Seq,
|
||||
Time: r.Time.UTC().Format(time.RFC3339Nano),
|
||||
Org: r.Actor.Org,
|
||||
Sub: r.Actor.Sub,
|
||||
Email: r.Actor.Email,
|
||||
Action: r.Action,
|
||||
Resource: r.Resource.Type,
|
||||
ResourceID: r.Resource.ID,
|
||||
Method: r.Method,
|
||||
Path: r.Path,
|
||||
Result: r.Outcome.Result,
|
||||
Status: r.Outcome.Status,
|
||||
Reason: r.Outcome.Reason,
|
||||
SourceIP: r.SourceIP,
|
||||
UserAgent: r.UserAgent,
|
||||
RequestID: r.RequestID,
|
||||
IsAdmin: r.Auth.IsAdmin,
|
||||
Auth: r.Auth.Method,
|
||||
Hash: r.Hash,
|
||||
PrevHash: r.PrevHash,
|
||||
}
|
||||
}
|
||||
|
||||
// auditFromIAM is the legacy federated view: when cloud has no local audit store,
|
||||
// forward the IAM get-records read verbatim (the prior behavior), so the endpoint
|
||||
// still surfaces IAM's own audit trail rather than an empty list.
|
||||
func (s *svc) auditFromIAM(c *zip.Ctx) error {
|
||||
q := iamAuditQuery(c)
|
||||
res, err := s.iam.getList(c.Context(), callerCreds(c), "/v1/iam/get-records", q)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
return okRaw(c, res.rows, res.total)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package admin
|
||||
|
||||
// Tests for the store-backed /v1/admin/audit + /v1/admin/audit/verify surface.
|
||||
// They wire admin against a REAL audit.Recorder (on-disk SQLite) seeded with
|
||||
// records, drive requests through the whole zip app, and assert the query
|
||||
// results, the integrity summary, and the global-admin gate.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v3"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// mountWithStore builds a zip app with admin's audit routes wired to a real audit
|
||||
// store, and returns the store + a request helper. Only the audit routes are
|
||||
// mounted here (the rest are covered by mount()); this keeps the store-backed
|
||||
// tests focused.
|
||||
func mountWithStore(t *testing.T) (*audit.Recorder, func(method, path string, hdr map[string]string) (*http.Response, []byte)) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("audit.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &svc{adminOrg: "admin", auditStore: rec}
|
||||
app.Get("/v1/admin/audit", s.guard(s.audit))
|
||||
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
|
||||
fa := app.Fiber()
|
||||
|
||||
do := func(method, p string, hdr map[string]string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, p, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, p, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
return rec, do
|
||||
}
|
||||
|
||||
func seedAudit(t *testing.T, rec *audit.Recorder, n int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < n; i++ {
|
||||
_, err := rec.Append(ctx, audit.Record{
|
||||
Time: time.Now().UTC(),
|
||||
Actor: audit.Actor{Org: "admin", Sub: "z@hanzo.ai"},
|
||||
Action: "DELETE /v1/admin/orgs",
|
||||
Resource: audit.Resource{Type: "org", ID: "acme"},
|
||||
Auth: audit.AuthContext{Method: "jwt", IsAdmin: true},
|
||||
Outcome: audit.Outcome{Result: "success", Status: 200},
|
||||
Method: "DELETE",
|
||||
Path: "/v1/admin/orgs/acme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var globalAdmin = map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "z@hanzo.ai"}
|
||||
|
||||
// TestAdminAudit_ReturnsRealRecords proves GET /v1/admin/audit returns the
|
||||
// store's records (newest-first) with an accurate total and an integrity summary.
|
||||
func TestAdminAudit_ReturnsRealRecords(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 5)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit", globalAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("audit: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data []struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Action string `json:"action"`
|
||||
Hash string `json:"hash"`
|
||||
Result string `json:"result"`
|
||||
} `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
Integrity struct {
|
||||
OK bool `json:"ok"`
|
||||
Count uint64 `json:"count"`
|
||||
} `json:"integrity"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||
}
|
||||
if env.Data2 != 5 || len(env.Data) != 5 {
|
||||
t.Fatalf("got %d rows / total %d, want 5/5", len(env.Data), env.Data2)
|
||||
}
|
||||
if env.Data[0].Seq < env.Data[len(env.Data)-1].Seq {
|
||||
t.Errorf("not newest-first: %d..%d", env.Data[0].Seq, env.Data[len(env.Data)-1].Seq)
|
||||
}
|
||||
if env.Data[0].Hash == "" {
|
||||
t.Error("row has no hash — chain linkage not surfaced")
|
||||
}
|
||||
if !env.Integrity.OK || env.Integrity.Count != 5 {
|
||||
t.Errorf("integrity summary = %+v, want ok/count=5", env.Integrity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_Filters proves the query filters (result) reach the store.
|
||||
func TestAdminAudit_Filters(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
ctx := context.Background()
|
||||
// One deny among successes.
|
||||
_, _ = rec.Append(ctx, audit.Record{Action: "POST /v1/admin/roles", Actor: audit.Actor{Org: "admin"}, Outcome: audit.Outcome{Result: "deny", Status: 403}})
|
||||
seedAudit(t, rec, 3)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit?result=deny", globalAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &env)
|
||||
if env.Data2 != 1 || len(env.Data) != 1 {
|
||||
t.Fatalf("result=deny returned %d/%d, want 1/1", len(env.Data), env.Data2)
|
||||
}
|
||||
if env.Data[0]["result"] != "deny" {
|
||||
t.Errorf("filtered row result = %v, want deny", env.Data[0]["result"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_VerifyEndpoint proves GET /v1/admin/audit/verify returns the
|
||||
// integrity result for the chain.
|
||||
func TestAdminAudit_VerifyEndpoint(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 8)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit/verify", globalAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
OK bool `json:"ok"`
|
||||
Count uint64 `json:"count"`
|
||||
BrokenAt int64 `json:"brokenAt"`
|
||||
HeadHash string `json:"headHash"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||
}
|
||||
if !env.Data.OK || env.Data.Count != 8 || env.Data.BrokenAt != -1 {
|
||||
t.Errorf("verify result = %+v, want ok/count=8/brokenAt=-1", env.Data)
|
||||
}
|
||||
if env.Data.HeadHash == "" {
|
||||
t.Error("verify returned no head hash")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_DeniedWithoutGlobalAdmin proves BOTH audit endpoints fail-closed
|
||||
// 403 for a non-global-admin, and — critically — the store is NEVER read on a
|
||||
// denied request (the gate runs before the handler, so no records leak to an
|
||||
// unauthorized caller). We assert non-leakage by seeding records and confirming
|
||||
// the denied response body contains none of them.
|
||||
func TestAdminAudit_DeniedWithoutGlobalAdmin(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 3)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
hdr map[string]string
|
||||
}{
|
||||
{"no identity", map[string]string{}},
|
||||
{"tenant admin (org != adminOrg, no minted IsAdmin)", map[string]string{"X-Org-Id": "acme", "X-User-Id": "mallory"}},
|
||||
{"forged-looking but non-admin", map[string]string{"X-Org-Id": "acme"}},
|
||||
}
|
||||
for _, ep := range []string{"/v1/admin/audit", "/v1/admin/audit/verify"} {
|
||||
for _, tc := range cases {
|
||||
resp, body := do("GET", ep, tc.hdr)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s [%s]: got %d, want 403 (body=%s)", ep, tc.name, resp.StatusCode, body)
|
||||
}
|
||||
// No record content must appear in a denied response.
|
||||
if len(body) > 0 && (contains(body, "DELETE /v1/admin/orgs") || contains(body, `"hash"`)) {
|
||||
t.Errorf("%s [%s]: denied response leaked audit data: %s", ep, tc.name, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_FallsBackToIAMWhenNoStore proves that when no local store is
|
||||
// configured (auditStore == nil), /v1/admin/audit still serves the federated IAM
|
||||
// view rather than erroring — preserving the prior capability. Covered by the
|
||||
// existing TestAudit_MapsRecords (IAM proxy path); here we assert the nil-store
|
||||
// verify endpoint reports "not configured" rather than panicking.
|
||||
func TestAdminAudit_VerifyWithoutStore(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &svc{adminOrg: "admin"} // no auditStore
|
||||
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
|
||||
req := httptest.NewRequest("GET", "/v1/admin/audit/verify", nil)
|
||||
for k, v := range globalAdmin {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// A well-formed error envelope, not a 500/panic.
|
||||
if resp.StatusCode != http.StatusOK || !contains(body, "not configured") {
|
||||
t.Errorf("nil-store verify = %d %s, want an ok-envelope 'not configured' error", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(b []byte, sub string) bool {
|
||||
s := string(b)
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/hanzoai/commerce/metering"
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
@@ -73,6 +74,14 @@ type Deps struct {
|
||||
// (separate from the ZAP Commerce client above, which is for typed
|
||||
// inter-subsystem calls). Nil or not-Enabled() makes the gate a no-op.
|
||||
Metering *metering.Client
|
||||
|
||||
// Audit is the tamper-evident, append-only audit trail Recorder (FedRAMP AU-*
|
||||
// / SOC 2 CC-*). Serve constructs it once, wires the AuditTrail middleware to
|
||||
// it, and hands it here so the /v1/admin/audit query + /v1/admin/audit/verify
|
||||
// endpoints read the SAME store the middleware writes. Nil makes the audit
|
||||
// middleware a no-op and the query endpoint fall back to the IAM proxy (an
|
||||
// unconfigured deployment is never blocked). See audit/ and audit_middleware.go.
|
||||
Audit *audit.Recorder
|
||||
}
|
||||
|
||||
// Per-subsystem client interfaces live in cloud/types so the
|
||||
|
||||
@@ -1471,7 +1471,7 @@ github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
|
||||
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
|
||||
github.com/luxfi/address v1.0.1 h1:Sc4keyuVzBIvHr7uVeYZf2/WY9YDGUgDi/iiWenj49g=
|
||||
github.com/luxfi/address v1.0.1/go.mod h1:5j3Eh66v9zvv1GbNdZwt+23krV8JlSDaRzmWZU8ZRM0=
|
||||
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
|
||||
github.com/luxfi/age v1.5.0 h1:zC/Fw/ptZwAXr9nqrxmrcf8752EIl1Lq9RECp9OmCO0=
|
||||
github.com/luxfi/age v1.5.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
|
||||
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
|
||||
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
|
||||
@@ -1507,7 +1507,7 @@ github.com/luxfi/go-bip39 v1.1.2 h1:p+wLMPGs6MLQh7q0YIsmy2EhHL7LHiELEGTJko6t/Jg=
|
||||
github.com/luxfi/go-bip39 v1.1.2/go.mod h1:96de9VkR2kY/ASAnhMtvt3TSh+PZkAFAngNj0GjRGDo=
|
||||
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
|
||||
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
|
||||
github.com/luxfi/keys v1.2.0 h1:+AriQNM7FOylAEls1XvFdlSOXDfoyc6X3ZfJRWQ2I9g=
|
||||
github.com/luxfi/keys v1.2.0 h1:3TAcr4twyMpwQp7J29ZRtIa5vzAoDrnXnLcPKVHJWmw=
|
||||
github.com/luxfi/keys v1.2.0/go.mod h1:SjsAaxo6sGmSp9OaHXUiVCqsknO8iPspN6jMOoEAMb8=
|
||||
github.com/luxfi/kms v1.11.6 h1:qLcPjurqr/GA2rC9PEDPfTNn6hEPsccuJy2gKLkqA/k=
|
||||
github.com/luxfi/kms v1.11.6/go.mod h1:XhLUVqN4RBv6j4Bj3MNgTZmHCnm74jH7RqqK0b9xbzw=
|
||||
@@ -1531,9 +1531,9 @@ github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
|
||||
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
|
||||
github.com/luxfi/p2p v1.21.1 h1:gmz1JMDhzHIL3dQlhwIDvR4OlFuhNVfnWUl/ipYhAIo=
|
||||
github.com/luxfi/p2p v1.21.1/go.mod h1:SsNPR5fPGWWNem9plGWhSmRqyDoysJ3kPAN0zG0g3iw=
|
||||
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
|
||||
github.com/luxfi/pq v1.0.3 h1:ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=
|
||||
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
|
||||
github.com/luxfi/precompile v0.5.37 h1:2v0zTZtU3cP/hlCA1602Bz//mK+9jjUkYCBsc3KU67w=
|
||||
github.com/luxfi/precompile v0.5.37 h1:Yh3dJ+dYuFuzsJgIBRmAJXNsmGEb0BUJN5fzZe3yYeE=
|
||||
github.com/luxfi/precompile v0.5.37/go.mod h1:z1ZLWPKPdZXqIQZOSdVObM8nTIxrblP6a+fqf3O7OHs=
|
||||
github.com/luxfi/proto v1.0.0 h1:nlxv4lt/i75XDB2Q3nTqC1o0RDPmJ9K9CJ8UyYvceak=
|
||||
github.com/luxfi/proto v1.0.0/go.mod h1:pZLKsCmhiPtmm3z7ezBbnsYT2m79q+cFSmzxvuBeANE=
|
||||
@@ -1557,7 +1557,7 @@ github.com/luxfi/vm v1.2.0 h1:jTwQRHdC9VmyRZTPSn+IqjMju7f6xlxLc6P9CEg+Y2M=
|
||||
github.com/luxfi/vm v1.2.0/go.mod h1:qasVIBRerVQuvy9vFGrX3H8X8pPMPG5un/KbZSyq5YY=
|
||||
github.com/luxfi/warp v1.19.3 h1:tU6aAniYrPiRGrht7oFSwsUmk5KBb9RDFSyZq3lJ4HY=
|
||||
github.com/luxfi/warp v1.19.3/go.mod h1:kqyk7Fa5mgw1zzMLqjrZWVLuTef35CCW0gOEv10UlZk=
|
||||
github.com/luxfi/zap v0.8.11 h1:NIiZp/YyS1TQHfU/PHnMXynzPOKCfZOQssn5ytCdYXg=
|
||||
github.com/luxfi/zap v0.8.11 h1:jT+ol9rj557MRdmnzxrVUCR3CDFaE+8OpzUsLIn92og=
|
||||
github.com/luxfi/zap v0.8.11/go.mod h1:JfqII8VtVQYLLTX6obU1DP9sjGqf9L24vfug5ifh0b8=
|
||||
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
|
||||
github.com/luxfi/zapdb v1.10.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
|
||||
|
||||
@@ -68,6 +68,22 @@ func Serve(enable []string) error {
|
||||
identity := newIdentityValidator(cfg.IAMIssuer, cfg.JWKSURL, cfg.JWTAudiences, 0)
|
||||
app.Use(SanitizeIdentity(identity, cfg.AdminOrg))
|
||||
|
||||
// Audit trail (FedRAMP AU-* / SOC 2 CC-*). Runs AFTER SanitizeIdentity so the
|
||||
// actor/isAdmin it records come from a VALIDATED principal (never a raw
|
||||
// header), and BEFORE BillingGate + every subsystem so it wraps the whole
|
||||
// chain and observes the final outcome — including a billing 402/503 and an
|
||||
// admin 403 denial. It is the ONE place every security-relevant request is
|
||||
// recorded to the tamper-evident, append-only store (see audit_middleware.go /
|
||||
// audit/). A write failure fails the request CLOSED (AU-5). Constructed here
|
||||
// so the Recorder lives for the process and the /v1/admin/audit query + verify
|
||||
// endpoints (clients/admin) read the SAME store via deps.Audit.
|
||||
auditRec, err := buildAuditRecorder(cfg, deps.Logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit: %w", err)
|
||||
}
|
||||
deps.Audit = auditRec
|
||||
app.Use(AuditTrail(auditRec))
|
||||
|
||||
// Billing gate. Sits at the (future) Auth position — after identity is
|
||||
// established by Recover/RequestID/Logger and before any subsystem mounts —
|
||||
// so every priced route is balance-gated once, at the edge, fail-closed.
|
||||
@@ -160,6 +176,11 @@ func Serve(enable []string) error {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = healthSrv.Shutdown(shutdownCtx)
|
||||
// Close the audit store last so any in-flight append has drained through the
|
||||
// serialized writer and the SQLite file is flushed cleanly.
|
||||
if auditRec != nil {
|
||||
_ = auditRec.Close()
|
||||
}
|
||||
return app.ShutdownWithContext(shutdownCtx)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user