Documentation
¶
Overview ¶
Package ledger provides a typed Protocol primitive and Store interface for append-only, HMAC-linked audit chains.
Protocol Paradigm ¶
The package follows the typed-Go-heavy paradigm introduced in runtime/auth/session (S1+S2): protocol decisions are captured in a strongly- typed *Protocol value assembled at composition root. Cells consume an injected *Protocol; they never construct one. The AUDIT-LEDGER-PROTOCOL- COMPOSITION-ROOT-01 archtest in tools/archtest/ enforces this boundary.
Sealed Interface Markers ¶
Two sealed interfaces prevent external packages from declaring new protocol shapes without modifying this package:
- RestartRecoveryMode — implemented only by RestartRecoveryStrictTailVerify.
- IdempotencyMode — implemented only by IdempotencyContentFingerprint.
The marker methods (restartRecoveryModeOK, idempotencyModeOK) are unexported; external types that attempt to implement these interfaces fail to compile.
Hash Chain Algorithm ¶
Each entry's Hash is computed as:
HMAC-SHA256(key, json.Marshal(auditHashInput{
prev_hash, event_id, event_type, actor_id,
subject_id, tenant_id, session_id, correlation_id,
occurred_at_unix_nano, timestamp_unix_nano, payload,
}))
encoded as lowercase hex. The auditHashInput struct is unexported and JSON fields are emitted in source-declaration order — deterministic bytes across all Go versions / platforms.
The 12-field canonical-JSON format supersedes the pre-043 pipe-separated fmt.Sprintf format (`prevHash|eventID|eventType|actorID|UnixNano|payload`) in a single canonical rewrite (PR #1218 W0-transition path retracted in favor of the DROP+CREATE rebuild in 043_audit_entries_v2.sql, issue #1228). JSON quote/escape handling eliminates the field-boundary collision risk of the pipe-separator format (PR #1218 F3+F6).
INVARIANT: AUDIT-HASH-INPUT-FROZEN-01 (see protocol.go and tools/archtest/audit_hash_input_frozen_test.go) — the field set + JSON tag set + field order are reflect-locked; the hmac.New callsite is AST-locked to ComputeHash so no other code path can construct an HMAC over audit data.
Restart Recovery ¶
RestartRecoveryStrictTailVerify requires the store to verify the tail of the existing chain before accepting new entries after a restart. For MemStore this is a no-op (ephemeral state). For the PG store (S8+) it translates to a tail-integrity SELECT + verify before the first Append.
ref: google/trillian log/sequencer.go — IntegrateBatch verifies tree integrity before accepting new leaves.
Idempotency ¶
IdempotencyContentFingerprint uses the entry's EventID as the sole idempotency key. EventID (the outbox.Entry UUID) is stable across at-least-once redeliveries while Timestamp/Payload may vary per attempt — including them would defeat dedup. Duplicate appends return ErrAuditLedgerAlreadyExists.
The DB-level UNIQUE INDEX on (namespace, event_id) (migration 021) is the second-line guard against concurrent bypass of this application-level check.
ref: ADR 202605101800-adr-audit-ledger-protocol §D3 F-CR-2 ref: google/trillian types/logroot.go — LeafIdentityHash content-addressed deduplication.
Strict Payload Validation ¶
All Append calls validate that the payload is valid JSON (or nil). This strict mode is always on — there is no toggle Option. Producers must ensure their payloads are well-formed before calling Append.
ADR Reference ¶
docs/architecture/202605101800-adr-audit-ledger-protocol.md
Index ¶
- func QuerySort() []query.SortColumn
- func RowScopeAllUnsupportedError() error
- type AuditFilters
- type Entry
- type IdempotencyContentFingerprint
- type IdempotencyMode
- type MemStore
- func (m *MemStore) Append(_ context.Context, e *Entry) error
- func (m *MemStore) GetBySeq(_ context.Context, vis tenant.RowVisibility, seq int64) (*Entry, error)
- func (m *MemStore) Protocol() *Protocol
- func (m *MemStore) Query(_ context.Context, vis tenant.RowVisibility, filters AuditFilters, ...) ([]*Entry, error)
- func (m *MemStore) RepoReady(_ context.Context) error
- func (m *MemStore) Tail(_ context.Context) (TailSnapshot, error)
- func (m *MemStore) Verify(_ context.Context, fromSeq, toSeq int64) (valid bool, firstInvalidSeq int64, err error)
- type MultiStore
- type NamespaceID
- type Option
- type Protocol
- type QueryStore
- type RestartRecoveryMode
- type RestartRecoveryStrictTailVerify
- type Store
- type TailSnapshot
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func QuerySort ¶
func QuerySort() []query.SortColumn
QuerySort returns the canonical ordering for audit ledger listings: newest first (timestamp DESC) with the store-assigned id as a stable ASC tie-breaker. It is the single source of truth shared by every Store.Query caller and matches the idx_audit_namespace_ts_id composite index, so PG keyset pagination is an index scan. Store.Query requires a non-empty Sort — callers pass this.
A fresh slice is returned on each call so callers cannot mutate shared package state (the exported value would otherwise be an aliasable mutable global).
func RowScopeAllUnsupportedError ¶
func RowScopeAllUnsupportedError() error
RowScopeAllUnsupportedError reports that a RowVisibility carrying tenant.RowScopeAll reached a ledger read path (Query / GetBySeq). RowScopeAll is cross-tenant super-admin visibility whose audited BYPASSRLS path is not wired until epic #1337 PR-5; until then EVERY ledger backend fail-closes it (no silent degrade to tenant scope). It is shared by MemStore and the PG LedgerStore so the rejection is byte-identical across backends and exercised uniformly by the conformance suite. The classification is KindInternal: in PR-4 no caller constructs RowScopeAll for these reads, so its arrival is a wiring/programmer error, not user input.
Types ¶
type AuditFilters ¶
type AuditFilters struct {
// EventType filters by exact event type label. Empty means no filter.
EventType string
// ActorID filters by exact actor identifier. Empty means no filter.
ActorID string
// SubjectID filters by exact subject identifier — the principal the audited
// action targeted. Empty means no filter. Unlike ActorID (who performed the
// action), SubjectID lets an admin investigate impersonation where actor !=
// subject (#1290). For non-admin callers it composes with the actor-self
// scoping the auditquery policy enforces, so it only ever narrows within the
// caller's own actions.
SubjectID string
// TenantID scopes the query to a tenant boundary. A non-empty value matches
// that tenant's rows PLUS tenant-less system/framework rows (tenant_id == "" —
// e.g. bootstrap.auth.fail and other pre-auth events that have no principal
// tenant), and NEVER another tenant's rows. Empty means no filter (generic
// store consumers). The auditquery handler always sets this from
// principal.TenantID (epic #1337 PR-2a) — that handler is the isolation
// boundary. Standard multi-tenant audit semantics: a tenant admin sees its own
// tenant + global/system events. DB-layer RLS (PR-3) is the backstop.
TenantID string
// TraceID filters by exact trace_id. Empty means no filter. This field
// allows correlating audit entries with distributed traces for operational
// investigation. trace_id is an observability field and is NOT part of the
// HMAC hash chain.
TraceID string
// From filters entries with Timestamp >= From. Zero means no lower bound.
From time.Time
// To filters entries with Timestamp <= To. Zero means no upper bound.
To time.Time
}
AuditFilters holds optional filter predicates for Store.Query. Zero-value fields are treated as "no filter" (match all), including TenantID.
Tenant isolation is NOT enforced at this generic store layer (so non-HTTP callers — conformance suites, namespace-isolation tests, ops tooling — can query tenant-agnostically). The isolation boundary lives in the auditquery HTTP HANDLER, which always sets TenantID from the authenticated principal so a tenant-bearing caller only ever reads its own tenant's rows (epic #1337 PR-2a, replacing the PR-1 #1339 403 gate). DB-layer RLS (PR-3) is the backstop for the residual tenant-less case.
type Entry ¶
type Entry struct {
// SeqNo is the monotonically increasing sequence number assigned by the
// store on Append. Starts at 1. Zero value indicates the entry has not
// yet been persisted.
SeqNo int64
// ID is an optional opaque store-assigned identifier (UUID/ULID). The
// store may populate this on Append; callers must not rely on it for
// chain ordering — SeqNo is authoritative.
ID string
// EventID is the business-layer event identifier (UUID). Used as part of
// the HMAC input and as the idempotency fingerprint key.
EventID string
// EventType is the event type label (e.g. "user.login", "config.updated").
EventType string
// ActorID identifies the principal that triggered the event (the
// impersonator in OAuth `act.sub` semantics; equals SubjectID for normal
// non-impersonated flows).
ActorID string
// SubjectID is the OAuth subject-of-record (the end-user's stable
// identity, OAuth `sub`). Empty string when not available — the column is
// NOT NULL with no DEFAULT in the DB schema (043_audit_entries_v2.sql);
// callers (PG Store INSERT) supply the zero value explicitly so the chain
// reflects the producer's lack of injection rather than a sentinel DEFAULT.
//
// Output policy at the auditquery API boundary (DTO exposure / redaction /
// admin-gated filtering) is decided by issue #1229 §4 (PR-A2 sealed
// construction); issue #1219 tracks the contract.yaml DTO extension.
SubjectID string
// TenantID is the tenant boundary identifier for multi-tenant deployments.
// Empty string when not available. NOT NULL no-DEFAULT in DB. See SubjectID
// for the auditquery output policy reference.
TenantID string
// SessionID is the session identifier of the triggering request (server-
// side session binding). Empty string when not available. NOT NULL
// no-DEFAULT in DB. SessionID matches `pkg/redaction` sensitive-key set;
// issue #1229 §4 may strip it from the auditquery DTO entirely.
SessionID string
// CorrelationID carries the cross-cell correlation identifier from the
// outbox observability envelope. Empty string when not available. NOT NULL
// no-DEFAULT in DB.
CorrelationID string
// TraceID carries the OpenTelemetry trace id from the outbox observability
// envelope. Observability metadata, NOT an audited fact — it is deliberately
// EXCLUDED from the HMAC hash chain (Protocol.ComputeHash) so audit
// tamper-evidence is unchanged. Empty string when absent. NOT NULL no-DEFAULT
// in DB (app supplies explicit value).
// Writes are restricted to the audit appender by AUDIT-TRACE-ID-WRITE-CALLER-01 (tools/archtest).
TraceID string
// OccurredAt is the producer-clock event time (distinct from Timestamp
// which is the ledger persistence / HMAC time). The audit chain pins both
// times so consumers can distinguish "when the business event happened"
// from "when the audit row was sealed". NOT NULL no-DEFAULT in DB; the
// Go zero (`time.Time{}`) marshals as epoch when no value is supplied.
OccurredAt time.Time
// Timestamp is the event wall-clock time in UTC. Used in the HMAC input
// as UnixNano so the hash is timestamp-sensitive.
Timestamp time.Time
// Payload is the arbitrary JSON payload associated with the event. Strict
// validation (valid JSON) is enforced by the store on Append.
Payload []byte
// PrevHash is the Hash of the immediately preceding entry in the chain.
// Empty for the first entry (SeqNo == 1). Computed by the store.
PrevHash string
// Hash is the HMAC-SHA256 hex digest of this entry computed by
// Protocol.ComputeHash. Computed by the store on Append.
Hash string
}
Entry is the canonical audit ledger record persisted by Store implementations. Field layout is fixed by ADR-AuditLedger D1 (hash chain) and the equivalence requirement with cells/auditcore/internal/domain/audit_entry.go.
SeqNo is added by the store on Append — callers constructing Entry for Append leave SeqNo as 0; the store fills it in and returns the updated Entry via GetBySeq. Hash and PrevHash are computed by Protocol.ComputeHash and written by the store; callers must not pre-fill them for new entries.
type IdempotencyContentFingerprint ¶
type IdempotencyContentFingerprint struct{}
IdempotencyContentFingerprint uses a HMAC-SHA256 fingerprint of the entry content (eventID + eventType + actorID + timestamp + payload) as the idempotency key. Duplicate entries with identical content are rejected with ErrAuditLedgerAlreadyExists.
ref: google/trillian types/logroot.go — LeafIdentityHash pattern for content-addressed deduplication.
type IdempotencyMode ¶
type IdempotencyMode interface {
// contains filtered or unexported methods
}
IdempotencyMode is sealed: only types declared in this package may implement it (the marker method idempotencyModeOK is unexported). Callers select a concrete idempotency shape at composition root via WithIdempotency.
type MemStore ¶
type MemStore struct {
// contains filtered or unexported fields
}
MemStore is an in-memory Store implementation for dev and tests. It is not a production substrate — the PG-backed Store landing in S8+ owns the production path. Three properties follow from the dev/test scope and are documented design choices, not gaps:
- Single sync.Mutex over the entry slice. Append serializes under the write lock to guarantee monotonic SeqNo assignment and correct chain linkage. Acceptable at dev/test scale; PG handles concurrency via pg_advisory_xact_lock + SELECT FOR UPDATE.
- No capacity ceiling. Memory is bounded by the test's entry count.
- No instrumentation. Observability is a cell-layer concern.
Restart simulation: MemStore is ephemeral — entries are lost when the instance is discarded. The storetest Restart_Recovery case simulates restart by replaying entries from storeA into storeB via GetBySeq/Append; the PG store restores state from the DB on construction.
func NewMemStore ¶
NewMemStore constructs a MemStore. Both protocol and clk are strong- dependency wiring (they are not replaceable defaults); typed-nil and bare nil are rejected at construction so misconfiguration surfaces at startup rather than at the first request.
runtime-api.md §Option 范式分层 — one or two unconditional dependencies are passed positionally; Option pattern only becomes warranted at ≥ 3 deps or when an accumulator appears.
func (*MemStore) Append ¶
Append appends a new entry to the chain. It:
- Validates the entry payload is valid JSON (strict mode).
- Checks the content fingerprint for idempotency (ErrAuditLedgerAlreadyExists).
- Computes PrevHash from the current tail.
- Assigns the next SeqNo.
- Computes Hash via Protocol.ComputeHash.
- Persists the entry.
Thread-safe: all state mutations are serialized under the write lock.
func (*MemStore) GetBySeq ¶
GetBySeq returns a defensive copy of the entry at the given sequence number. Returns ErrAuditLedgerNotFound for missing sequence numbers or when the entry exists but vis.Allows(entry.ActorID) is false (IDOR-safe collapse).
func (*MemStore) Query ¶
func (m *MemStore) Query(_ context.Context, vis tenant.RowVisibility, filters AuditFilters, params query.ListParams) ([]*Entry, error)
Query returns entries matching the supplied filters using keyset cursor pagination: candidates are filtered, sorted by params.Sort, then ApplyCursor skips past params.CursorValues and returns up to params.FetchLimit() (Limit+1) rows for N+1 hasMore detection. Zero-value filter fields are treated as "no filter".
params.Sort must be non-empty (callers pass QuerySort). An empty Sort is a programmer error and yields ErrValidationFailed — the same rejection the PG keyset builder produces, so both backends reject it identically.
func (*MemStore) RepoReady ¶
RepoReady implements healthz.RepoProber. The in-memory store has no differentiated failure domain (it holds state entirely in process memory), so this always returns nil — matching the MemStore convention documented in kernel/healthz.RepoProber.
func (*MemStore) Tail ¶
func (m *MemStore) Tail(_ context.Context) (TailSnapshot, error)
Tail returns the current chain tail snapshot. Returns a zero TailSnapshot for an empty store (SeqNo=0, PrevHash="", EntryCount=0).
func (*MemStore) Verify ¶
func (m *MemStore) Verify(_ context.Context, fromSeq, toSeq int64) (valid bool, firstInvalidSeq int64, err error)
Verify re-computes the HMAC-SHA256 hash for each entry in [fromSeq, toSeq] and checks chain linkage (PrevHash). Returns valid=true and firstInvalidSeq=-1 when all entries are intact.
type MultiStore ¶
type MultiStore struct {
// contains filtered or unexported fields
}
MultiStore is a read-only QueryStore aggregator across multiple Stores. Each backing Store has its own NamespaceID and independent HMAC chain; MultiStore.Query fans out the same filters+params to every backing store and merges the results by params.Sort (which auditquery wires to QuerySort — timestamp DESC, id ASC).
MultiStore deliberately implements ONLY QueryStore, not the full Store interface. This is the Hard upstream defense for issue #1121: misconfiguring auditcore.WithLedgerStore(multiStore) — which would route writes through the aggregator and break the per-namespace chain — fails at compile time, not at the first event Append.
Cursor semantics are namespace-independent: every Entry carries a Timestamp (UTC) and an ID, and the canonical (timestamp DESC, id ASC) ordering applies uniformly across all chains. Passing the same cursor to every backing store is therefore safe — each store's keyset Query correctly filters its own entries past the cursor boundary, and tie-breaks by id work across stores because IDs are UUID strings everywhere (lexical comparison is consistent).
ref: k8s.io/apiserver/pkg/audit/union.go — Union(backends ...Backend) Backend is the canonical multi-backend read-side fan-out pattern; the write-side stays per-backend independent.
func NewMultiStore ¶
func NewMultiStore(stores ...Store) (*MultiStore, error)
NewMultiStore wraps two or more Stores into a read-only fan-out aggregator. Returns an error when:
- fewer than two stores are supplied (a single-store MultiStore is just an indirection — callers should use the store directly)
- any store is nil or typed-nil (validation.IsNilInterface)
func (*MultiStore) Query ¶
func (m *MultiStore) Query(ctx context.Context, vis tenant.RowVisibility, filters AuditFilters, params query.ListParams) ([]*Entry, error)
Query fans out to each backing store with the same filters+params, merges the results by params.Sort, and trims to params.FetchLimit() so the caller's pagination machinery (query.ExecutePagedQuery) detects hasMore via the N+1 sentinel.
Each backing store returns up to FetchLimit() rows; MultiStore collects up to len(stores)*FetchLimit() rows, sorts once, and trims to FetchLimit(). For a 2-store deployment with Limit=20 the worst case is 42 rows merged per page — a constant-factor cost that does not change pagination semantics.
type NamespaceID ¶
type NamespaceID string
NamespaceID is a typed string that identifies the owner of a ledger store (e.g. a cell ID). The legal set is restricted to [a-z_] with length ≤ 48: every byte must be a lowercase ASCII letter or underscore. This is stricter than adapters/redis.KeyNamespace ([a-z0-9_-]) on purpose — the namespace is the first signed field of the HMAC chain (cross-namespace domain separation, ADR-1042 §A), so its character set is frozen narrow to avoid any ambiguity in the canonical digest input.
func ParseNamespaceID ¶
func ParseNamespaceID(s string) (NamespaceID, error)
ParseNamespaceID parses and validates a NamespaceID from a string.
func (NamespaceID) Validate ¶
func (ns NamespaceID) Validate() error
Validate reports whether the NamespaceID satisfies all format constraints: non-empty, length ≤ 48, and every byte in [a-z_]. Any digit, dash, dot, uppercase letter, ':' / '{' / '}', or non-ASCII byte is rejected.
type Option ¶
Option mutates a Protocol during NewProtocol. Options are applied in order; each Option may return an error to short-circuit construction.
The mandatory namespace + HMAC key pair is passed positionally to NewProtocol — no Option can supply them, and no Option can override them. This forces the namespace ↔ key binding to be expressed at the type-system layer, so a caller cannot accidentally pair the wrong namespace with a stale or shared key (which would let HMAC chains from different cells be substituted across the wire). The compile error a caller gets when trying to construct a Protocol without supplying both arguments is the funnel's upstream Hard gate; archtest backs that up at the callsite layer.
func WithIdempotency ¶
func WithIdempotency(im IdempotencyMode) Option
WithIdempotency declares the idempotency mode.
Both bare-nil and typed-nil IdempotencyMode values are rejected immediately. NewProtocol short-circuits on the first error. Pattern mirrors runtime/http/router.WithRateLimiter (strong-dependency wiring option).
func WithRestartRecovery ¶
func WithRestartRecovery(rr RestartRecoveryMode) Option
WithRestartRecovery declares the restart recovery mode.
Both bare-nil and typed-nil RestartRecoveryMode values are rejected immediately. NewProtocol short-circuits on the first error. Pattern mirrors runtime/http/router.WithRateLimiter (strong-dependency wiring option).
type Protocol ¶
type Protocol struct {
// contains filtered or unexported fields
}
Protocol bundles the protocol decisions that govern an audit ledger.
Fields are required (NewProtocol fail-fasts on missing values) and are immutable after construction.
func NewProtocol ¶
func NewProtocol(namespace NamespaceID, key []byte, opts ...Option) (*Protocol, error)
NewProtocol assembles a Protocol from the namespace + HMAC key (mandatory positional arguments) and the supplied options. The positional form binds `namespace` and `key` at the type-system layer — a caller physically cannot pass just one, nor swap them, nor reuse a stale key against a fresh namespace silently. After the defensive HMAC key copy is made, the caller's `key` slice is zeroed (clear) so sensitive material is not retained in caller memory (F7).
Options are applied in order; the first error short-circuits and no subsequent options are applied. The returned *Protocol is safe for concurrent read-only use.
INVARIANT: AUDIT-HASH-INPUT-FROZEN-01 (positional binding closes the upstream "wrong namespace ↔ wrong key" attack vector at compile time; downstream Hard is provided by Protocol.ComputeHash hmac.New callsite uniqueness).
func (*Protocol) ComputeHash ¶
ComputeHash produces the HMAC-SHA256 hex digest for an entry using the configured HMAC key.
The HMAC message is the canonical JSON encoding of an auditHashInput struct (json.Marshal in source-declaration order). The 12-field canonical-JSON format supersedes the prior pipe-separated fmt.Sprintf format introduced in 020_audit_ledger.sql; both the field-boundary collision risk (any bytes in a field could shift `|` semantics) and the lack of OAuth Principal / CorrelationID / OccurredAt coverage are closed in one rewrite.
There is no protocol version byte and no legacy path: per CLAUDE.md "Review 和重构时不考虑向后兼容——当前只有 gocell 自身", existing audit rows in the pre-043 schema are discarded (DROP TABLE in 043_audit_entries_v2.sql) and all hash fixture expectations are regenerated in the same PR.
Payload is encoded as a base64 JSON string by encoding/json's []byte handling; no manual hex-encoding is needed.
INVARIANT: AUDIT-HASH-INPUT-FROZEN-01 — the auditHashInput struct shape + hmac.New callsite uniqueness are double-locked by archtest. ComputeHash is the only place in the audit ledger package that may construct an HMAC over audit data.
ref: google/trillian storage/leafdata.go (canonical input struct). ref: RFC 8785 JCS. ref: tools/archtest/audit_hash_input_frozen_test.go.
func (*Protocol) Idempotency ¶
func (p *Protocol) Idempotency() IdempotencyMode
Idempotency returns the configured idempotency mode.
func (*Protocol) Namespace ¶
func (p *Protocol) Namespace() NamespaceID
Namespace returns the configured namespace identifier.
func (*Protocol) RestartRecovery ¶
func (p *Protocol) RestartRecovery() RestartRecoveryMode
RestartRecovery returns the configured restart recovery mode.
type QueryStore ¶
type QueryStore interface {
// Query has identical semantics to Store.Query — see that method's godoc for
// the full contract (keyset pagination + params.Sort requirement, the vis
// row-visibility obligation enforced on the actor_id owner column, and the
// RowScopeAll fail-closed rule). This narrow read-only subset exists so
// read-side aggregators (MultiStore) implement only Query, not the write path.
Query(ctx context.Context, vis tenant.RowVisibility, filters AuditFilters, params query.ListParams) ([]*Entry, error)
}
QueryStore is the narrow read-only subset of Store used by the auditquery HTTP slice. Splitting Query out lets read-side aggregators (MultiStore) implement only this interface, so a misconfiguration that injects an aggregator as a write store fails at compile time rather than at the first Append call.
Every Store automatically satisfies QueryStore by structural method-set inclusion (Query has the same signature); existing callers continue to wire concrete stores through ledger.Store without change.
ref: k8s.io/apiserver/pkg/audit/union.go — read-side fan-out aggregator pattern (Union(backends ...Backend) Backend); the write side stays per- backend independent.
type RestartRecoveryMode ¶
type RestartRecoveryMode interface {
// contains filtered or unexported methods
}
RestartRecoveryMode is sealed: only types declared in this package may implement it (the marker method restartRecoveryModeOK is unexported). Callers select a concrete restart recovery shape at composition root via WithRestartRecovery.
type RestartRecoveryStrictTailVerify ¶
type RestartRecoveryStrictTailVerify struct{}
RestartRecoveryStrictTailVerify configures strict tail verification on startup: the store must verify the tail of the hash chain before accepting new entries. This prevents a restarted process from appending to a corrupted or tampered chain.
ref: google/trillian log/sequencer.go — IntegrateBatch verifies tree integrity before accepting new leaves.
type Store ¶
type Store interface {
// Protocol returns the immutable protocol decisions backing this store.
// Callers use it for composition-time invariant checks such as namespace
// disjointness; implementations must not return nil.
Protocol() *Protocol
// Append persists a new entry into the namespace's hash chain. Computes
// PrevHash from Tail, assigns SeqNo, and computes Hash via Protocol.ComputeHash.
// Rejects invalid JSON payload (ErrValidationFailed) and duplicate content
// fingerprints (ErrAuditLedgerAlreadyExists). Thread-safe.
Append(ctx context.Context, e *Entry) error
// Tail returns the current chain tail snapshot (SeqNo, PrevHash, EntryCount).
// Returns zero TailSnapshot when the store is empty (not an error).
Tail(ctx context.Context) (TailSnapshot, error)
// GetBySeq fetches a single entry by sequence number. The vis obligation
// is enforced on the actor_id OWNER column: if the entry exists but
// vis.Allows(entry.ActorID) is false, the implementation returns
// ErrAuditLedgerNotFound (IDOR-safe collapse — existence is not leaked).
// vis must be valid (NewRowVisibility must succeed). A vis carrying
// RowScopeAll is fail-closed on every backend (RowScopeAllUnsupportedError)
// until the audited super-admin path lands (epic #1337 PR-5).
//
// Tenant axis NOT enforced here (deliberate, tracked #1342 / #1618): GetBySeq
// enforces ONLY the owner dimension (vis on actor_id). Unlike Query it takes no
// AuditFilters, so it carries no tenant predicate — by seq_no it reads the
// namespace-global hash chain (the same chain-primitive surface as Tail/Verify),
// where seq_no is unique per namespace, not per (namespace, tenant). It has NO
// production caller today (chain replay / conformance only). A future
// tenant-FACING by-seq read endpoint MUST add a tenant filter (an AuditFilters /
// tenant.TenantID parameter that returns ErrAuditLedgerNotFound on tenant
// mismatch) rather than rely on this owner-only check; that is deferred until
// such a consumer exists (adding it now would be dead plumbing). The deeper fix
// — a per-(namespace, tenant) chain with RLS so seq reads are tenant-scoped at
// the DB — is #1618.
GetBySeq(ctx context.Context, vis tenant.RowVisibility, seq int64) (*Entry, error)
// Query lists entries matching AuditFilters using keyset cursor pagination
// defined by params (Limit + decoded CursorValues + Sort). It returns up to
// params.FetchLimit() (Limit+1) rows for N+1 hasMore detection, ordered by
// params.Sort. params.Sort must be non-empty (callers pass QuerySort);
// an empty Sort is a programmer error and yields ErrValidationFailed.
// Returns an empty (non-nil) slice when no entries match.
//
// vis is the row-visibility obligation enforced on the actor_id owner column.
// Self/device scopes restrict results to entries whose actor_id matches the
// obligation subject. Tenant scope returns all matching rows in the tenant.
// vis must be valid (NewRowVisibility must succeed). A vis carrying
// RowScopeAll is fail-closed on every backend (RowScopeAllUnsupportedError)
// until the audited super-admin path lands (epic #1337 PR-5).
Query(ctx context.Context, vis tenant.RowVisibility, filters AuditFilters, params query.ListParams) ([]*Entry, error)
// Verify re-computes the HMAC for each entry in [fromSeq, toSeq] and checks
// chain linkage (PrevHash). Returns valid=true and firstInvalidSeq=-1 when
// all entries are intact. Returns valid=false and the first invalid seq_no
// when tampering is detected.
Verify(ctx context.Context, fromSeq, toSeq int64) (valid bool, firstInvalidSeq int64, err error)
// RepoReady is a differentiated readiness check that exercises the
// audit_entries relation directly. SQL-backed implementations issue a
// representative query (e.g. Tail) so that schema/migration drift or
// table-level permission loss surfaces independently from the pool-level
// postgres_ready ping. In-memory implementations return nil (always ready).
RepoReady(ctx context.Context) error
}
Store persists audit entries in a tamper-evident hash chain. Implementations must obey the protocol decisions encoded in *Protocol — Append rejects entries with invalid JSON payload (strict mode), computes and stores the HMAC-SHA256 hash chain link, and enforces idempotency via content fingerprint.
Store also satisfies healthz.RepoProber: RepoReady exercises the audit_entries relation directly (differentiated check) so that schema/migration drift or table-level permission loss is detected independently from the pool-level postgres_ready ping. In-memory implementations return nil (always ready). See kernel/healthz.RepoProber godoc for the full contract.
Method semantics (ADR-AuditLedger §4.2):
- Protocol: returns the immutable protocol decisions used by this store. Must not return nil.
- Append: persist a new entry. Computes PrevHash from Tail, computes Hash via Protocol.ComputeHash, assigns SeqNo. Rejects invalid JSON payload (ErrValidationFailed). Rejects duplicate content fingerprint (ErrAuditLedgerAlreadyExists). Thread-safe (PG uses advisory lock; MemStore uses sync.Mutex).
- Tail: returns the current chain tail snapshot. Returns zero TailSnapshot for an empty store (not an error).
- GetBySeq: fetch entry by sequence number. Missing → ErrAuditLedgerNotFound.
- Query: list entries matching AuditFilters with keyset cursor pagination. Returns empty slice (not error) when no entries match.
- Verify: re-compute HMAC for each entry in [fromSeq, toSeq] and check chain linkage. Returns valid=true and firstInvalidSeq=-1 when all entries are intact.
- RepoReady: differentiated readiness check against the audit_entries relation. Distinct failure domain from pool-level postgres_ready probe. In-memory implementations return nil (always ready).
type TailSnapshot ¶
type TailSnapshot struct {
// SeqNo is the sequence number of the last committed entry.
// Zero if the store is empty.
SeqNo int64
// PrevHash is the Hash of the last committed entry.
// Empty if the store is empty.
PrevHash string
// EntryCount is the total number of entries in the store.
EntryCount int64
}
TailSnapshot holds a point-in-time snapshot of the ledger chain tail. Returned by Store.Tail to allow restart recovery and chain verification without reading all entries.