Documentation
¶
Overview ¶
Package graudit provides an append-only, tamper-evident audit trail for the gourdian ecosystem.
Overview:
graudit answers "what changed, who did it, and can we prove the record hasn't been altered" — a different question from what grlog answers ("what happened during this request"). It is a library component that could contribute to SOC2/HIPAA/PCI readiness, not a compliance certification product itself.
The Precise Claim (read this before trusting Verify()):
Hash-chaining proves internal consistency: nothing in the recorded range was altered or removed without Verify() detecting it. It does NOT prove who wrote a given entry beyond whatever ActorID the caller supplied, and it does NOT protect against a privileged attacker with direct database access regenerating the entire chain from scratch — a hash chain only detects partial tampering, not wholesale regeneration by someone who controls the storage. Do not let "tamper-evident" read as "cryptographically un-forgeable" anywhere this package is described.
Key Features:
- A single AuditLog interface: Record, RecordChange, Verify, Query, Close — implemented identically by all three backends
- Hash-chained entries: each entry's Hash covers its own fields plus the previous entry's Hash (see ComputeHash)
- Verify(from, to) recomputes the chain across a range and reports the first broken link, if any, via VerifyResult
- RecordChange diffs a before/after pair and stores the diff as the entry's Payload (see ChangeDiff)
- Publishes one grevents event (TopicAuditRecorded) per successful write — graudit only ever publishes, never subscribes
- Sentinel errors (ErrClosed, ErrInvalidEvent, ErrBackendUnavailable, ...) usable with errors.Is
- Optional diagnostic logging via any logger satisfying the small Logger interface — *grlog.Logger satisfies it with no adapter (proven at compile+run time by logger_test.go's TestGrlogSatisfiesLoggerInterface, mirroring grcache's own logger_test.go)
Getting Started:
import (
"context"
"log"
"github.com/gourdian25/graudit/memory"
)
func main() {
log_, err := memory.NewMemoryAuditLog()
if err != nil {
log.Fatal(err)
}
defer log_.Close()
ctx := context.Background()
id, err := log_.Record(ctx, graudit.AuditEvent{
ActorID: "user:42",
EntityType: "invoice",
EntityID: "inv_123",
Action: "create",
})
if err != nil {
log.Fatal(err)
}
log.Println("recorded entry", id)
}
Backends:
Each backend lives in its own importable subpackage so that consumers who only need one backend don't pull in the other backends' client libraries.
The first entry in every chain (EntryID 1) has PrevHash set to the exported GenesisPrevHash constant (64 zero characters, matching SHA-256's hex output length) rather than an empty string — Verify treats this as the documented base case, not a special "entry #0."
In-memory (graudit/memory) — test/dev only, never for anything you need to keep. Single-process; a sync.Mutex is both the storage guard and the chain's serialization point.
log_, err := memory.NewMemoryAuditLog()
PostgreSQL (graudit/postgres) — production-eligible, durable. Uses GORM. Chain serialization is a pg_advisory_xact_lock held for the duration of the transaction that reads the tail and inserts the new entry; EntryID is explicitly assigned inside that transaction, never a SERIAL/BIGSERIAL column (a sequence advances even on rollback, which would silently create an EntryID gap — see docs/architecture.md).
log_, err := postgres.NewPostgresAuditLog(postgres.PostgresConfig{DSN: dsn})
MongoDB (graudit/mongo) — production-eligible, durable. Uses go.mongodb.org/mongo-driver v1. Chain serialization is a multi-document ACID transaction (session.WithTransaction) covering a singleton chain-state document and the new entry's insert. Requires the target deployment to be a replica set (even single-node) unconditionally — construction fails fast, wrapping ErrReplicaSetRequired, against a standalone instance, rather than silently degrading to non-transactional writes that could corrupt the chain.
log_, err := mongo.NewMongoAuditLog(mongo.MongoConfig{URI: uri, Database: "myapp"})
grevents Integration:
Every backend accepts an optional grevents.Bus (via its Config/Option), defaulting to nil — no bus configured means Record simply doesn't publish, which is not an error. When configured, every successful Record publishes one TopicAuditRecorded event carrying the full recorded AuditEvent as Payload. A publish failure is logged, never propagated as a Record error.
import "github.com/gourdian25/grevents"
bus, _ := grevents.NewBus()
log_, err := postgres.NewPostgresAuditLog(postgres.PostgresConfig{
DSN: dsn,
EventBus: bus,
})
Error Handling:
id, err := log_.Record(ctx, event)
if errors.Is(err, graudit.ErrInvalidEvent) {
// caller bug: missing required field or bad Payload
} else if err != nil {
// backend unavailable or another real failure
}
Verify() Semantics:
ok, detail, err := log_.Verify(ctx, 1, latestID)
if err != nil {
// operational failure — could not even attempt verification
}
if !ok {
log.Printf("tampering detected at entry %d: expected %s, got %s",
detail.BrokenAt, detail.Expected, detail.Actual)
}
Testing:
graudit's own tests run against real local Postgres/MongoDB instances — no mocks — mirroring the ecosystem's testing philosophy. The Mongo backend's tests additionally require the instance to be configured as a (single-node is sufficient) replica set. See CLAUDE.md/README.md for exact connection settings and docker commands. A shared conformance package runs one behavioral suite (hash-chain integrity, tamper detection, concurrent Record ordering) against all three backends through the AuditLog interface.
Out of Scope (v1):
Full SOC2/HIPAA/PCI/ISO certification tooling, real-time alerting (a consumer's job, reacting to TopicAuditRecorded), auto-instrumentation (every entry comes from an explicit Record/RecordChange call, never automatic interception), cryptographic per-entry signatures (true non-repudiation — see the precise claim above), and archival/retention sweeping of old entries.
License: MIT Repository: https://github.com/gourdian25/graudit
Index ¶
- Constants
- Variables
- func ComputeHash(entryID EntryID, actorID, entityType, entityID, action string, payload any, ...) (string, error)
- func DecodeStoredPayload(raw []byte) (any, error)
- func PublishRecorded(ctx context.Context, bus grevents.Bus, logger Logger, entry AuditEvent)
- type AuditEvent
- type AuditLog
- type ChangeDiff
- type EntryID
- type FieldDiff
- type Logger
- type QueryFilter
- type VerifyResult
Constants ¶
const TopicAuditRecorded = "audit.recorded"
TopicAuditRecorded is the grevents topic every backend publishes to after a successful Record/RecordChange. Two segments, resource.pastTenseVerb, matching the real convention already established by grevents' own examples (role.assigned, order.placed, payment.failed, user.signup).
Variables ¶
var ( // ErrClosed indicates a method was called after Close. ErrClosed = errors.New("graudit: audit log is closed") // ErrInvalidEvent indicates an AuditEvent passed to Record is missing a // required field or has a Payload that cannot be JSON-marshaled. ErrInvalidEvent = errors.New("graudit: invalid audit event") // ErrEntryNotFound indicates a query for a specific entry found none. ErrEntryNotFound = errors.New("graudit: entry not found") // ErrChainCorrupted indicates a backend-level invariant violation it // cannot even attempt to answer (e.g. a missing Mongo chain-state // document, or a Postgres row with an EntryID gap) — distinct from // Verify() returning VerifyResult.Valid == false, which is the designed // tamper-detection path, not an error. ErrChainCorrupted means the // backend itself is in a state Verify() cannot even evaluate. ErrChainCorrupted = errors.New("graudit: chain state corrupted") // (connection failure, timeout, transaction failure, etc.). ErrBackendUnavailable = errors.New("graudit: backend unavailable") // ErrReplicaSetRequired indicates the graudit/mongo backend was // constructed against a standalone (non-replica-set) MongoDB // deployment. Multi-document transactions — which the chain's // serialization strategy depends on for correctness, not just // performance — require a replica set, even a single-node one. ErrReplicaSetRequired = errors.New("graudit: mongo backend requires a replica-set deployment") )
Sentinel errors for use with errors.Is. Backend implementations translate their own native errors (gorm.ErrRecordNotFound, mongo.ErrNoDocuments, ...) into these sentinels before wrapping with fmt.Errorf("...: %w", ...) — a backend-native error must never leak through the AuditLog interface unwrapped.
There is deliberately no IsX(err error) bool helper: callers use errors.Is(err, graudit.ErrClosed) directly, consistent with every other gourdian repo's sentinel-error convention.
var GenesisPrevHash = strings.Repeat("0", 64)
GenesisPrevHash is the well-known PrevHash value for the first entry in a chain — 64 zero characters, matching SHA-256's hex output length. Exported so every backend and Verify() agree on one value instead of each hardcoding its own; computed via strings.Repeat rather than a hand-typed literal, since a miscounted zero would be exactly the kind of subtle determinism bug this package exists to prevent.
Functions ¶
func ComputeHash ¶
func ComputeHash(entryID EntryID, actorID, entityType, entityID, action string, payload any, timestamp time.Time, prevHash string) (string, error)
ComputeHash computes the SHA-256 hex-encoded hash for a single chain entry:
SHA256(entryID || actorID || entityType || entityID || action ||
canonicalJSON(payload) || timestamp || prevHash)
Exported so all three backends (memory, postgres, mongo) call the identical function — if a backend computed hashes even slightly differently, Verify() would disagree across backends for logically identical data, undermining the entire tamper-evidence claim.
Parameters:
- entryID: EntryID — this entry's chain position
- actorID, entityType, entityID, action: string — included verbatim
- payload: any — normalized via canonicalJSON before hashing (see its doc comment for the determinism guarantee); may be nil
- timestamp: time.Time — truncated to millisecond precision and encoded as time.RFC3339Nano in UTC, not Go's default String()/MarshalJSON (both timezone-sensitive) and not full nanosecond precision. The millisecond truncation is deliberate and load-bearing, not cosmetic: MongoDB's BSON datetime type only stores millisecond precision, so a hash computed from a Go time.Time's full nanosecond value would never match the hash recomputed from that same entry after a round trip through graudit/mongo's storage — Verify() would falsely report every entry as tampered. Truncating here, inside the one function every backend calls, makes hash determinism hold regardless of which backend's storage precision is coarser than Go's in-memory time.Time (Mongo: millisecond; Postgres: microsecond, still coarser than nanosecond; memory: no serialization loss at all) — every backend's storage preserves at least millisecond precision, so truncating to that floor here is always safe and always idempotent across a store/read-back round trip.
- prevHash: string — the previous entry's Hash, or GenesisPrevHash for entry #1
Returns:
- string: lowercase hex-encoded SHA-256 digest
- error: non-nil only if payload cannot be marshaled to JSON at all (e.g. contains a channel, func, or unsupported value)
func DecodeStoredPayload ¶
DecodeStoredPayload decodes raw (a JSON-encoded Payload as previously written by a durable backend) back into an `any` suitable for passing to ComputeHash when recomputing a stored entry's hash (Verify, Query).
Backends must use this — not a plain json.Unmarshal(raw, &v) — because plain unmarshaling into `any` decodes every number as float64, and a subsequent canonicalJSON re-marshal of that float64 can reformat a large integer (e.g. via exponential notation), producing a hash that no longer matches the one computed at write time from the original value. Decoding with UseNumber() preserves the exact original numeric text through the round trip, since encoding/json marshals a json.Number by writing its text back out verbatim rather than reparsing it as a float64.
Parameters:
- raw: []byte — nil or empty is treated as "no payload" (returns nil, nil)
Returns:
- any: map[string]any/[]any/json.Number/string/bool/nil, ready to pass as ComputeHash's payload argument
- error: non-nil if raw is not valid JSON
func PublishRecorded ¶
PublishRecorded builds and publishes a TopicAuditRecorded event for entry via bus. It is the single implementation every backend calls once after its own durable write commits — never called before the write is confirmed durable, and never allowed to fail the caller's Record: bus may be nil (no grevents.Bus configured, a silent no-op), and any error returned by bus.Publish is only logged via logger, never propagated. The durable write is graudit's guarantee; grevents delivery is a best-effort side channel on top of it.
Parameters:
- ctx: context.Context — passed through to bus.Publish unchanged
- bus: grevents.Bus — may be nil
- logger: Logger — must be non-nil (backends call graudit.OrNop first)
- entry: AuditEvent — the fully-populated entry as durably written (ID, Hash, PrevHash already set)
Types ¶
type AuditEvent ¶
type AuditEvent struct {
// ID is this entry's chain position. Set by Record; any input value is
// ignored.
ID EntryID
// ActorID identifies who performed the action. Required. graudit does
// not verify this beyond non-emptiness — it proves nothing about
// authorship beyond what the caller asserts (see docs.go's precise
// tamper-evidence claim).
ActorID string
// EntityType and EntityID together identify what was acted on (e.g.
// EntityType "user", EntityID "42"). Both required.
EntityType string
EntityID string
// Action describes what happened, e.g. "create"/"update"/"delete".
// Freeform but a small controlled vocabulary is recommended. Required.
Action string
// Payload is arbitrary JSON-serializable detail — typically a
// ChangeDiff produced by RecordChange, or caller-supplied structured
// data for a direct Record call. May be nil.
Payload any
// Timestamp is set by Record if left zero on input.
Timestamp time.Time
// Hash is set by Record; hex-encoded SHA-256.
Hash string
// PrevHash is set by Record; hex-encoded SHA-256 of the previous entry,
// or GenesisPrevHash for entry #1.
PrevHash string
}
AuditEvent is a single entry in the chain.
func BuildChangeEvent ¶
func BuildChangeEvent(actorID, entityType, entityID string, before, after any) (AuditEvent, error)
BuildChangeEvent computes the diff between before and after and returns an AuditEvent ready to pass to a backend's own Record — exported so every backend's RecordChange method shares one implementation instead of reimplementing diff-then-build three times. Action is inferred from which of before/after is nil ("create" if before is nil, "delete" if after is nil, "update" otherwise) so the returned event already passes AuditEvent.Validate without the caller needing to set it explicitly.
func (AuditEvent) Validate ¶
func (e AuditEvent) Validate() error
Validate checks that e has the minimum fields required to be recorded and that Payload can be JSON-marshaled at all. Every backend calls this once, at the top of Record, before doing anything durable — exported (not just an unexported helper) specifically because backends live in separate subpackages (graudit/postgres, graudit/memory, graudit/mongo) and must all apply identical validation.
type AuditLog ¶
type AuditLog interface {
// Record computes the entry's hash (its own fields plus the previous
// entry's hash), appends it durably, and publishes a
// TopicAuditRecorded event via the backend's configured grevents.Bus on
// success (a nil/unconfigured bus, or a publish failure, never causes
// Record itself to fail — the durable write is the source of truth,
// grevents is a best-effort side channel; a publish failure is only
// logged via the backend's configured Logger).
//
// Parameters:
// - ctx: context.Context
// - event: AuditEvent — ActorID, EntityType, EntityID, and Action are
// required; ID, Hash, PrevHash are set by Record and any input
// value is ignored; Timestamp defaults to time.Now() if zero
//
// Returns:
// - EntryID: the newly assigned, strictly-sequential chain position
// - error: wraps ErrInvalidEvent for a missing required field or a
// non-JSON-serializable Payload, ErrClosed if called after Close,
// ErrBackendUnavailable for a storage failure
Record(ctx context.Context, event AuditEvent) (EntryID, error)
// RecordChange is a convenience wrapper: computes a field-level diff
// between before and after (see ChangeDiff), and calls Record with that
// diff as the Payload and Action left to the caller's convention (a
// small controlled vocabulary — "create"/"update"/"delete" — is
// recommended but not enforced).
//
// Parameters:
// - ctx: context.Context
// - actorID, entityType, entityID: string — all required, same as AuditEvent
// - before, after: any — JSON-serializable snapshots of the entity's
// state; either may be nil (nil before means "creation", nil after
// means "deletion")
//
// Returns:
// - EntryID: see Record
// - error: see Record, plus any error from computing the diff itself
// (a before/after value that cannot be marshaled to a JSON object)
RecordChange(ctx context.Context, actorID, entityType, entityID string, before, after any) (EntryID, error)
// Verify recomputes the hash chain across [from, to] (inclusive) and
// confirms integrity via two checks per entry: that each entry's stored
// Hash matches one recomputed from its own stored fields, and that each
// entry's stored PrevHash matches the immediately preceding entry's
// stored Hash. ok=false (with a non-nil detail, no error) means
// tampering or corruption was detected; err is reserved for genuine
// operational failures (e.g. can't reach the backend).
//
// Parameters:
// - ctx: context.Context
// - from, to: EntryID — inclusive range; from must be >= 1
//
// Returns:
// - ok: bool — true if every entry in range passes both checks
// - detail: VerifyResult — see its own doc comment
// - err: error — non-nil only for an operational failure, never for
// detected tampering (that is reported via detail, with ok=false)
Verify(ctx context.Context, from, to EntryID) (ok bool, detail VerifyResult, err error)
// Query returns entries matching filter, ordered oldest-first.
//
// Parameters:
// - ctx: context.Context
// - filter: QueryFilter — zero value matches every entry, subject to
// Limit (0 means no limit)
//
// Returns:
// - []AuditEvent: entries matching filter, oldest-first
// - error: ErrClosed if called after Close, ErrBackendUnavailable for
// a storage failure
Query(ctx context.Context, filter QueryFilter) ([]AuditEvent, error)
// Close releases any underlying connections/resources. After Close,
// every other method returns ErrClosed. Close is idempotent.
Close() error
}
AuditLog is the primary interface all backends implement. Every backend subpackage (graudit/memory, graudit/postgres, graudit/mongo) provides a constructor returning an AuditLog, so application code can depend on this interface alone and swap backends without changing call sites.
type ChangeDiff ¶
ChangeDiff maps dot-joined field paths to their before/after values, as produced by computeDiff and stored as an AuditEvent's Payload by RecordChange.
Arrays are compared as a whole — an element added, removed, or reordered inside an array reports the entire array as changed under its own field path, rather than diffing individual elements. This is a deliberate v1 limitation: element-level array diffing (matching by identity/index) is ambiguous without a caller-supplied key, and out of scope for now.
type EntryID ¶
type EntryID uint64
EntryID is a chain position: strictly increasing starting at 1, never a UUID. Backends assign it explicitly (never via a database auto-increment sequence — see each backend's doc comment for why) so that "no gaps" is an invariant Verify() can rely on.
type FieldDiff ¶
FieldDiff describes one field's value before and after a change. Before and/or After may be nil — a field absent in "before" but present in "after" (or vice versa) is reported as a change with the missing side left as nil, not omitted from the diff.
type Logger ¶
type Logger interface {
Infof(format string, args ...interface{})
Warnf(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
Logger is the minimal logging interface graudit backends accept for optional diagnostic logging (constructor connectivity failures, grevents publish failures, shutdown). It is satisfied structurally by *grlog.Logger's printf-style methods (Infof/Warnf/Errorf) — graudit itself does not import grlog, so plugging in a logger is entirely opt-in and adds no dependency for consumers who don't want one.
A nil Logger passed to any backend's Config/Option is replaced with NopLogger() — logging is always optional, never required for a backend to function.
Example:
import "github.com/gourdian25/grlog"
logger := grlog.NewDefaultLogger()
log, err := postgres.NewPostgresAuditLog(postgres.PostgresConfig{
DSN: dsn,
Logger: logger, // *grlog.Logger satisfies graudit.Logger directly
})
type QueryFilter ¶
type QueryFilter struct {
ActorID string
EntityType string
EntityID string
From, To time.Time
Limit int // 0 means no limit
}
QueryFilter selects entries for Query. The zero value matches every entry. Any combination of fields may be set together.
type VerifyResult ¶
type VerifyResult struct {
// Valid is true if every entry in the requested range passed both
// integrity checks.
Valid bool
// BrokenAt is the lowest EntryID where a check failed; zero if Valid.
BrokenAt EntryID
// Expected and Actual report whichever hash values disagreed at
// BrokenAt: for a per-entry integrity failure, Expected is the
// recomputed hash and Actual is the entry's stored Hash; for a
// chain-linkage failure, Expected is the previous entry's stored Hash
// and Actual is this entry's stored PrevHash. Both empty if Valid.
Expected string
Actual string
}
VerifyResult is the detailed outcome of Verify.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package conformance is a shared behavioral test suite run against every graudit backend via the common graudit.AuditLog interface.
|
Package conformance is a shared behavioral test suite run against every graudit backend via the common graudit.AuditLog interface. |
|
Command example is a runnable demonstration of graudit against the memory backend (no live services required, so `go run` works with no setup — grlog is a lightweight in-process logger, not an external service).
|
Command example is a runnable demonstration of graudit against the memory backend (no live services required, so `go run` works with no setup — grlog is a lightweight in-process logger, not an external service). |
|
Package memory is graudit's test/dev-only backend.
|
Package memory is graudit's test/dev-only backend. |
|
Package mongostore is graudit's second production-eligible durable backend.
|
Package mongostore is graudit's second production-eligible durable backend. |
|
Package postgres is graudit's primary production-eligible durable backend.
|
Package postgres is graudit's primary production-eligible durable backend. |