graudit

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 10, 2026 License: MIT Imports: 13 Imported by: 0

README

graudit

Append-only, tamper-evident audit trail for the gourdian ecosystem.

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.

Part of the gourdian25 ecosystem

graudit is one of several small, independent Go libraries meant to be used together:

  • gourdiantoken — JWT access/refresh token issuance, verification, revocation, and rotation.
  • grlog — zero-dependency structured logging; graudit's optional Logger interface is satisfied by it directly.
  • grcache — backend-agnostic caching abstraction, mirroring graudit's own subpackage-per-backend layout.
  • grevents — an in-process event bus; graudit publishes an "audit.recorded" event through it on every successful write (see below).
  • grpolicy — attribute-based policy evaluation (RBAC/ABAC), independent of any notion of "user" or "role".

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. Don't let "tamper-evident" read as "cryptographically un-forgeable."

Install

go get github.com/gourdian25/graudit

Quickstart

import (
	"context"
	"log"

	"github.com/gourdian25/graudit"
	"github.com/gourdian25/graudit/memory"
)

func main() {
	auditLog, err := memory.NewMemoryAuditLog()
	if err != nil {
		log.Fatal(err)
	}
	defer auditLog.Close()

	ctx := context.Background()
	id, err := auditLog.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)
}

See example/example.go for a fuller runnable demo (Record, RecordChange, Verify, Query).

Backends

Each backend lives in its own importable subpackage so consumers who only need one backend don't pull in the others' client libraries.

Backend Package Use case Serialization
In-memory graudit/memory Test/dev only — never for anything you need to keep sync.Mutex
PostgreSQL graudit/postgres Production pg_advisory_xact_lock + explicitly-assigned EntryID
MongoDB graudit/mongostore Production (requires a replica set) Multi-document ACID transaction
// Postgres
import "github.com/gourdian25/graudit/postgres"
auditLog, err := postgres.NewPostgresAuditLog(postgres.PostgresConfig{
	DSN: "host=localhost user=myuser password=mypass dbname=mydb port=5432 sslmode=disable",
})

// MongoDB — must be a replica set (single-node is sufficient); construction
// fails fast otherwise, wrapping graudit.ErrReplicaSetRequired.
import "github.com/gourdian25/graudit/mongostore"
auditLog, err := mongostore.NewMongoAuditLog(mongostore.MongoConfig{
	URI:      "mongodb://localhost:27017/?replicaSet=rs0",
	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/RecordChange publishes one "audit.recorded" event carrying the full recorded AuditEvent as Payload. A publish failure is logged, never propagated as a Record error — the durable write is graudit's guarantee, grevents delivery is a best-effort side channel on top of it.

import "github.com/gourdian25/grevents"

bus, _ := grevents.NewBus()
auditLog, err := postgres.NewPostgresAuditLog(postgres.PostgresConfig{
	DSN:      dsn,
	EventBus: bus,
})

Logger

Every backend accepts an optional graudit.Logger (Infof/Warnf/Errorf) for diagnostic messages — connection failures, grevents publish failures, shutdown. A nil Logger (the default) means graudit logs nothing. *grlog.Logger satisfies this interface with no adapter, the same pattern grcache uses — see logger_test.go's TestGrlogSatisfiesLoggerInterface for the compile+run proof, and example/example.go for a runnable demo.

import "github.com/gourdian25/grlog"

logger := grlog.NewDefaultLogger()
auditLog, err := postgres.NewPostgresAuditLog(postgres.PostgresConfig{
	DSN:    dsn,
	Logger: logger, // *grlog.Logger satisfies graudit.Logger directly
})

// memory takes an Option instead of a Config field:
auditLog, err := memory.NewMemoryAuditLog(memory.WithLogger(logger))

Verify() semantics

ok, detail, err := auditLog.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)
}

Verify runs 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. See docs/architecture.md for why both are needed.

Testing

graudit's own tests run against real local Postgres/MongoDB instances — no mocks — mirroring the ecosystem's testing philosophy.

docker run -d --name graudit-postgres -p 5432:5432 \
  -e POSTGRES_USER=postgres_user -e POSTGRES_PASSWORD=postgres_password postgres:16
createdb -U postgres_user -h localhost graudit_test

# No auth env vars: MONGO_INITDB_ROOT_USERNAME/PASSWORD enables auth, which
# requires a keyFile once --replSet is also set ("security.keyFile is
# required when authorization is enabled with replica sets") — unnecessary
# complexity for a local test replica set, so this container runs without
# auth.
docker run -d --name graudit-mongo -p 27018:27017 mongo:7 --replSet rs0
docker exec graudit-mongo mongosh --eval 'rs.initiate()'

# A second, genuinely standalone (no --replSet) instance, required by
# TestNewMongoAuditLog_RequiresReplicaSet — the one test that actually
# proves construction fails fast against a non-replica-set deployment
# (see mongo/mongo_test.go's comment for why this test must never be
# skipped: an earlier version of this exact test caught a real bug where
# the fail-fast check silently passed against a standalone instance).
docker run -d --name graudit-mongo-standalone -p 27019:27017 mongo:7

The Mongo backend additionally requires the instance to be configured as a replica set (single-node is sufficient) — construction fails fast against a standalone instance.

make test             # go test -cover ./...
make race             # go test -race ./...  (mandatory before any commit touching the hash-chain or serialization code)
make coverage-check   # verify every package independently meets 80% coverage
make bench            # go test -bench=. -benchmem -benchtime=10s ./...
make lint             # golangci-lint run ./...

A shared conformance package runs one behavioral suite (hash-chain integrity, concurrent-write ordering, deliberate-tamper detection, hash determinism, grevents publish/publish-failure) 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 "audit.recorded"), 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. See docs/plan/graudit-plan.md for the full roadmap.

License

MIT — see LICENSE.

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."

  1. 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()

  2. 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})

  3. 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

View Source
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

View Source
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")

	// ErrBackendUnavailable indicates the backend could not be reached
	// (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.

View Source
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

func DecodeStoredPayload(raw []byte) (any, error)

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

func PublishRecorded(ctx context.Context, bus grevents.Bus, logger Logger, entry AuditEvent)

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

type ChangeDiff map[string]FieldDiff

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

type FieldDiff struct {
	Before any `json:"before,omitempty"`
	After  any `json:"after,omitempty"`
}

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

func NopLogger

func NopLogger() Logger

NopLogger returns a Logger that discards every message. It is the default used by every backend when no Logger is configured.

func OrNop

func OrNop(l Logger) Logger

OrNop returns l if it is non-nil, otherwise NopLogger(). Backends call this once at construction time so every subsequent log call site can assume a non-nil Logger.

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.

Jump to

Keyboard shortcuts

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