audit

package
v1.0.3 Latest Latest
Warning

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

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

Documentation

Overview

Package audit provides the durable write-side of forge's audit trail: an Entry record, a Store persistence interface, and a Postgres-backed DBAuditStore that writes and queries the audit_log table.

This is the mechanical, reusable half — the part that is the same in every project and get-it-wrong-and-you-lose-your-compliance-record. It used to ship as the audit-log PACK (copied into each project); it is now a versioned library you IMPORT, so a fix here reaches every project on the next bump.

Everything APP-SPECIFIC about audit is code you own, not library code:

  • the audit_log TABLE — add it with the normal entity flow (`forge scaffold entity`), owning the migration; the columns this store reads/writes are documented on Entry. If the migration puts it in a schema, tell the store which one with WithSchema.
  • the READ-SIDE ListAuditEvents RPC + its Connect handler — own the proto and the handler, backing it with this Store.

The observability/audit-log skill shows the full wiring; the short version is:

// write side — one line in the interceptor chain (see audit.Interceptor):
store := audit.NewDBAuditStore(db, audit.WithSchema("app"))
// observe.Chain(observe.Deps{Audit: audit.Interceptor(logger, claimsFrom, store)})

// read side — your owned handler delegates to the same store:
entries, err := store.Query(ctx, audit.Filter{UserID: "u_123", Limit: 50})

The interceptor that produces entries lives alongside this store in Interceptor / Sink; it fans each RPC out to a Store off the request path so audit writes never add latency to a response.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Interceptor

func Interceptor(logger *slog.Logger, claimsFrom middleware.ClaimsLookup, store Store) connect.Interceptor

Interceptor returns a Connect interceptor that logs every RPC to slog AND persists it to store. It is the DB-backed audit write-side: versioned library code you wire in one line.

It is a thin convenience over middleware.AuditInterceptorWithSink: the interceptor machinery (slog record, claim extraction, trace correlation, off-request-path dispatch) lives once in pkg/middleware; this constructor just bridges a Store to that interceptor's middleware.AuditSink via Sink. Reach for AuditInterceptorWithSink directly if you already hold a custom sink.

claimsFrom is your project's ClaimsFromContext — the claims context key stays project-owned, so a library cannot supply it (this mirrors every other versioned interceptor: middleware.AuditInterceptor, RateLimitInterceptor, HTTPStack). A nil claimsFrom logs every caller as "anonymous".

Pass a nil store for slog-only mode (identical to middleware.AuditInterceptor) — nothing is written to the database.

store := audit.NewDBAuditStore(db)
chain := observe.Chain(observe.Deps{
    Audit: audit.Interceptor(logger, middleware.ClaimsFromContext, store),
    // ...
})

func Sink

func Sink(store Store, logger *slog.Logger) middleware.AuditSink

Sink adapts a Store to middleware.AuditSink so any audit interceptor can fan events into the audit_log table. It maps a middleware.AuditEvent onto an Entry (folding the OTel TraceID into Metadata) and reports a failed write through logger — matching the interceptor's fire-and-forget contract: middleware.AuditSink.Record has no error return, so the sink owns failure handling.

Sink returns nil for a nil store, so middleware.AuditInterceptorWithSink(..., audit.Sink(nil, logger)) degrades cleanly to slog-only (no typed-nil sink). A nil logger falls back to slog.Default.

Types

type DBAuditStore

type DBAuditStore struct {
	// contains filtered or unexported fields
}

DBAuditStore is the *sql.DB-backed Store for the audit_log table. Construct it with NewDBAuditStore. It uses positional ($1, $2, …) placeholders and standard SQL, so it works against Postgres (the scaffold default) unchanged.

func NewDBAuditStore

func NewDBAuditStore(db *sql.DB, opts ...StoreOption) *DBAuditStore

NewDBAuditStore returns a DBAuditStore backed by db. Pass the same store to Interceptor (write side) and the ListAuditEvents handler (read side) so reads observe the writes. Without WithSchema the table name is unqualified and resolves through the connection's search_path.

func (*DBAuditStore) Log

func (s *DBAuditStore) Log(ctx context.Context, entry Entry) error

Log inserts entry into the audit_log table. ID and CreatedAt are left to the table's defaults (e.g. gen_random_uuid() / CURRENT_TIMESTAMP). A nil or unmarshalable Metadata is stored as an empty JSON object rather than failing the write — losing an audit row over a metadata quirk is the worse outcome.

func (*DBAuditStore) Query

func (s *DBAuditStore) Query(ctx context.Context, filter Filter) ([]Entry, error)

Query returns audit-log entries matching filter, ordered by timestamp descending. Filters are AND-combined; a nil filter (zero value) returns the most recent page. Pagination is offset-based with a default page size of 100.

type Entry

type Entry struct {
	ID           string            `json:"id" db:"id"`
	Timestamp    time.Time         `json:"timestamp" db:"timestamp"`
	UserID       string            `json:"user_id" db:"user_id"`
	Email        string            `json:"email" db:"email"`
	Procedure    string            `json:"procedure" db:"procedure"`
	PeerAddress  string            `json:"peer_address" db:"peer_address"`
	DurationMs   int               `json:"duration_ms" db:"duration_ms"`
	Status       string            `json:"status" db:"status"`
	ErrorCode    string            `json:"error_code,omitempty" db:"error_code"`
	ErrorMessage string            `json:"error_message,omitempty" db:"error_message"`
	Metadata     map[string]string `json:"metadata,omitempty" db:"metadata"`
	CreatedAt    time.Time         `json:"created_at" db:"created_at"`
}

Entry is a single audit-log record. Its fields mirror the columns of the audit_log table the read-side recipe creates: an audit write is one row per RPC capturing who (UserID/Email) did what (Procedure), from where (PeerAddress), how long it took (DurationMs), and how it ended (Status/ErrorCode/ErrorMessage). ID and CreatedAt are assigned by the database on insert and are only populated on rows read back via Store.Query.

type Filter

type Filter struct {
	UserID    string
	Procedure string
	Since     time.Time
	Until     time.Time
	Limit     int
	Offset    int
}

Filter is the query for Store.Query. All set fields are AND-combined; a zero-valued field is not constrained. Since/Until bound the Timestamp range (inclusive). A Limit <= 0 is treated as the default page size (100).

type Store

type Store interface {
	// Log persists a single audit entry.
	Log(ctx context.Context, entry Entry) error
	// Query returns audit entries matching filter, newest first.
	Query(ctx context.Context, filter Filter) ([]Entry, error)
}

Store is the audit-log persistence seam: Interceptor writes through it and the read-side ListAuditEvents handler reads through it. Implement it over any backend; DBAuditStore is the batteries-included *sql.DB implementation.

type StoreOption

type StoreOption func(*DBAuditStore)

StoreOption configures a DBAuditStore at construction.

func WithSchema

func WithSchema(schema string) StoreOption

WithSchema names the schema the audit_log table lives in, so every statement the store runs says "<schema>".audit_log instead of resolving a bare audit_log through the connection's search_path. The name is quoted, so it is taken literally (case included) and needs no validation. An empty schema leaves the name unqualified.

Use it whenever the table is not in the search_path's first schema, and ALWAYS behind a transaction-pooling proxy (PgBouncer pool_mode=transaction). There a statement runs on whichever server backend the pooler hands out, and a backend's search_path is whatever the last session to use it left behind: one client's session-level `SET search_path` (or the empty search_path pg_dump sets before anything else) outlives its transaction and makes a bare audit_log fail with SQLSTATE 42P01 for every later client that draws that backend. A qualified name does not depend on the connection's state.

Jump to

Keyboard shortcuts

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