ledger

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 17 Imported by: 0

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

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

func NewMemStore(protocol *Protocol, clk clock.Clock) (*MemStore, error)

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

func (m *MemStore) Append(_ context.Context, e *Entry) error

Append appends a new entry to the chain. It:

  1. Validates the entry payload is valid JSON (strict mode).
  2. Checks the content fingerprint for idempotency (ErrAuditLedgerAlreadyExists).
  3. Computes PrevHash from the current tail.
  4. Assigns the next SeqNo.
  5. Computes Hash via Protocol.ComputeHash.
  6. Persists the entry.

Thread-safe: all state mutations are serialized under the write lock.

func (*MemStore) GetBySeq

func (m *MemStore) GetBySeq(_ context.Context, vis tenant.RowVisibility, seq int64) (*Entry, error)

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) Protocol

func (m *MemStore) Protocol() *Protocol

Protocol returns the immutable protocol decisions backing this store.

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

func (m *MemStore) RepoReady(_ context.Context) error

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

type Option func(*Protocol) error

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

func (p *Protocol) ComputeHash(prevHash string, e *Entry) string

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.

Directories

Path Synopsis
Package storetest provides a reusable Protocol-driven contract test suite for ledger.Store implementations.
Package storetest provides a reusable Protocol-driven contract test suite for ledger.Store implementations.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL