postgres

package module
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 22, 2026 License: MIT Imports: 18 Imported by: 0

README

audit/postgres

Documentation map · Repository README Next: Documentation map

audit/postgres is the separately releasable PostgreSQL persistence adapter for the core audit contracts. It owns append-only storage, bounded queries and exports, caller-owned transaction staging, and legal-hold-aware retention. It does not own PostgreSQL pools, commit caller transactions, manage deployment role lifecycles, replace the core record and delivery policy, or establish regulatory compliance.

Status and compatibility

The module is stable and released independently with postgres/v* repository tags. Its canonical module path is github.com/faustbrian/go-audit/postgres.

The minimum supported Go version is Go 1.27.0, matching this module's go.mod and the repository support policy. Repository verification declares Go 1.27.2 as its producer; the minimum is unchanged. The adapter supports PostgreSQL 14 through 18. See compatibility and assurance for the exact evidence and deployment-owned boundaries.

Add the latest compatible release to an application:

go get github.com/faustbrian/go-audit/postgres@v1.0.1

Applications import the package using its conventional identifier:

import auditpostgres "github.com/faustbrian/go-audit/postgres"

Identity transaction contributor (unreleased)

NewIdentityContributor supplies the identity coordinator's audit and recovery phases using the existing root-v1 record contract. It owns no pool or transaction and cannot commit a protected command. Apply all embedded migrations, including 5 and 6, before applying PrivilegeSQL with deployment-owned role names. Only the writer receives execution on the two identity routines; direct binding-table reads or mutations are not granted.

The immutable tenant/command/index binding preserves the original event ID, canonical digest and retention-policy version across retries and payload pruning. Conflicts fail closed; failed prefixes require coordinator rollback or reconciliation. Recovery is participant evidence, never a command receipt. Its positive-evidence profile is single-audit: extra or conflicting bindings remain unknown; multi-slot recovery can prove unanimous absence, not fabricate completion. These additions are not included in the published postgres/v1.0.1.

Five-minute quick start

The example assumes the embedded migrations and deployment-specific grants described in PostgreSQL operations have already been applied and DATABASE_URL contains writer credentials.

package main

import (
	"context"
	"errors"
	"os"
	"time"

	"github.com/faustbrian/go-audit"
	auditpostgres "github.com/faustbrian/go-audit/postgres"
	"github.com/jackc/pgx/v5/pgxpool"
)

func main() {
	if err := run(context.Background()); err != nil {
		// Report through an application-owned, credential-safe diagnostic path.
		os.Exit(1)
	}
}

func run(ctx context.Context) error {
	dsn, ok := os.LookupEnv("DATABASE_URL")
	if !ok {
		return errors.New("DATABASE_URL is required")
	}

	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		return err
	}
	defer pool.Close()

	sink, err := auditpostgres.New(pool, auditpostgres.Config{})
	if err != nil {
		return err
	}
	redactor, err := audit.NewRedactor(audit.RedactionRules{})
	if err != nil {
		return err
	}
	recorder, err := audit.NewRecorder(audit.RecorderConfig{
		Sink: sink, Redactor: redactor, Mode: audit.DeliveryFailClosed,
	})
	if err != nil {
		return err
	}

	builder, err := audit.NewBuilder(audit.BuilderConfig{})
	if err != nil {
		return err
	}
	record, err := builder.Build(audit.RecordInput{
		OccurredAt: time.Now(),
		Action:     "invoice.approved",
		Outcome:    audit.OutcomeSucceeded,
		Actor:      audit.ActorInput{Kind: audit.ActorService, ID: "billing"},
		Subject:    audit.SubjectInput{Type: "invoice", ID: "invoice-42"},
		Changes:    audit.ChangeSetInput{NoChange: true},
	})
	if err != nil {
		return err
	}

	_, err = recorder.Submit(ctx, record)
	return err
}

Empty Config selects the bounded core defaults. Construction validates the pool and all non-zero overrides before use. The recorder applies explicit caller-owned redaction before the adapter receives a record; empty redaction allowlists remove all extensible attributes and change values.

Package map and selection

This module contains one public postgres package:

  • Store implements durable append, bounded stable query, and streaming export over a caller-owned pgxpool.Pool.
  • TxWriter stages an atomic bounded batch in a caller-owned pgx.Tx. Only the caller commits; stage failures may require or attempt outer rollback.
  • RetentionAdmin provides separately privileged legal-hold and archive-before-delete operations.
  • Migrations, FreshInstallPreflightSQL, and PrivilegeSQL expose the deployment inputs without performing hidden I/O or role discovery.

Use this module when audit records require durable PostgreSQL persistence and the deployment can own migrations, credentials, backup, recovery, and role separation. Use the core audit module without this adapter for storage-neutral contracts, or its memory package for bounded process-local tests. Do not use this adapter as a general event store, log database, migration runner, pool manager, authorization layer, or partition-management framework.

Construction, ownership, and lifecycle

New, NewTx, and NewRetentionAdmin validate configuration and retain no caller-mutable collection aliases. They start no goroutines. Pools and transactions remain caller-owned, so the adapter has no Close or Shutdown method: callers close pools and finish transactions after all adapter calls return. Values may be used concurrently where their caller-owned pgx resource permits it; a TxWriter remains bound to the ownership and concurrency rules of its single transaction.

Application-facing database work takes context.Context first and observes caller cancellation and deadlines. Rollback cleanup is the deliberate exception: once a transaction or savepoint must be aborted, the adapter uses context.WithoutCancel and a fresh five-second timeout so an already-canceled operation cannot strand database resources. That detached context performs cleanup only; it does not retry or continue the canceled operation. The adapter otherwise performs no hidden retry. An unknown append outcome must be reconciled by record ID and canonical bytes before a caller decides whether to retry. Stable categories support errors.Is, while audit.AppendOutcomeOf distinguishes rejected, committed, and unknown durability outcomes. errors.Is(err, auditpostgres.ErrRetryableTransaction) identifies a transaction that must be retried in full. Database diagnostics are deliberately sanitized.

NewTx stages each bounded batch behind a savepoint. Ordinary failures attempt rollback of that batch; deadlock, serialization or failed savepoint cleanup attempts rollback of the whole caller transaction. Cleanup success is not guaranteed; callers must finish or discard an unusable transaction. Query results and export callbacks are bounded, ordered, and closed before return. See the package API and adapter documentation index for the detailed contracts.

PostgreSQL setup and operations

For a fresh database, execute FreshInstallPreflightSQL() and every embedded migration in one outer transaction with the deployment's migration owner. The preflight reserves the three fixed roles as NOLOGIN; a collision fails instead of granting temporary access. For an existing installation, commit migration 3's role neutralization before migration 4 so poisoned legacy history cannot roll the role safety change back.

Create distinct deployment-specific writer, reader, and retention roles, then generate and review their grants:

sql, err := auditpostgres.PrivilegeSQL(auditpostgres.RoleNames{
	Writer:    "billing_audit_writer",
	Reader:    "security_audit_reader",
	Retention: "privacy_audit_retention",
})

The writer can execute the idempotent append function but cannot directly select, update, or delete audit rows or retained identity tombstones. Reserve RetentionAdmin for an independently controlled retention pool. The database encoding must be UTF-8, and migration 4 requires an exclusive enough window to validate legacy rows and install the durable acceptance boundary within its 30-second lock-acquisition bound.

Security, performance, and operational boundaries

Redact records before persistence; never store credentials, unrestricted request or response bodies, or secret-bearing fields. Deployers own transport security, database credentials, role membership, authorization, tenancy, backup and restore, legal holds, archive verification, alerting, and incident response. Report vulnerabilities through the repository's private security process.

Queries, exports, batches, decoded records, callbacks, and retention plans are bounded. The supported schema is intentionally unpartitioned so PostgreSQL can enforce global record-ID uniqueness; partition rollover is not a supported tuning option. Benchmark results are comparative evidence, not deployment capacity promises. Review the operations guide, assurance evidence and caveats, and FAQ and troubleshooting before production adoption.

Documentation and ecosystem navigation

The adapter exposes no separate testing-helper package. Use the core memory package for process-local unit tests and a deployment-owned PostgreSQL instance for integration evidence.

The versioned Golib ecosystem index explains how independently released modules compose. The persistence and durability family guidance places this adapter alongside its storage, migration, idempotency, and outbox companions without introducing a framework or umbrella dependency.

Documentation

Overview

Package postgres provides the separately releasable PostgreSQL adapter for audit without adding pgx or database dependencies to the core module. This independently versioned module consumes github.com/faustbrian/go-audit v1 records, not root-v2 records. Applications own pools, authorization, migrations, deployment-specific roles and archive/retention policy.

Store commits atomic batches; TxWriter only stages in a caller transaction. RetentionAdmin is a separately privileged boundary, not an archive verifier. Calls start no background workers. Cancellation is cooperative; rollback attempts deliberately retain context values while detaching cancellation under a five-second timeout. Driver diagnostics are hidden behind safe text and errors.Is classifications, rather than exposed through unwrapping.

Index

Constants

View Source
const (
	// DefaultMaxBatchBytes is the 16 MiB canonical payload budget selected by a zero
	// override.
	DefaultMaxBatchBytes = 16 << 20
	// MaxBatchBytes is the absolute 64 MiB canonical payload ceiling for one append or
	// stage.
	MaxBatchBytes = 64 << 20
)

Variables

View Source
var (
	// ErrPoolRequired reports construction without a PostgreSQL pool.
	ErrPoolRequired = errors.New("audit/postgres: pool is required")
	// ErrTransactionRequired reports transaction-writer construction without a
	// caller-owned transaction.
	ErrTransactionRequired = errors.New("audit/postgres: transaction is required")
	// ErrRetryableTransaction reports a PostgreSQL deadlock or serialization
	// failure. The complete transaction must be retried with identical IDs.
	ErrRetryableTransaction = errors.New("audit/postgres: transaction must be retried")
)

Functions

func FreshInstallPreflightSQL

func FreshInstallPreflightSQL() string

FreshInstallPreflightSQL atomically reserves the fixed NOLOGIN roles before an empty database applies migration 1. Existing role-name collisions fail with PostgreSQL duplicate-object error 42710; concurrent collisions wait for the reservation and then fail under PostgreSQL role-name uniqueness. Execute it in the same outer transaction as every migration returned by Migrations so a failed installation cannot retain the reserved roles. Existing installations must apply the forward migrations instead.

func Migrations

func Migrations() fs.FS

Migrations returns the embedded PostgreSQL SQL files rooted at the migrations directory. It neither applies them nor owns a migration runner. Preserve published file checksums and apply forward migrations with deployment-owned credentials; the files are not portable SQL for arbitrary database engines.

func PrivilegeSQL

func PrivilegeSQL(roles RoleNames) (string, error)

PrivilegeSQL returns least-privilege grants for deployment-owned roles. Apply it with the migration owner after creating and auditing the roles. Names must be distinct valid UTF-8 without NUL, at most 63 bytes, and not the reserved fixed names. Identifiers are quoted, but SQL is only returned, never executed; existing grants, ownership and role memberships are not audited or revoked.

Types

type Config

type Config struct {
	// Limits bounds canonical decoding; an entirely zero value selects root-v1 defaults.
	// Root-v1 callers must also retain the pre-construction work bounds in SECURITY.md.
	Limits audit.Limits
	// MaxBatchRecords selects a positive count ceiling up to audit.MaxAppendBatchRecords;
	// zero uses that ceiling.
	MaxBatchRecords int
	// MaxBatchBytes caps total canonical payload bytes; zero uses DefaultMaxBatchBytes.
	// It does not measure driver, SQL protocol or heap overhead.
	MaxBatchBytes int
}

Config bounds decoded records and append batches. Zero values select core defaults and the absolute core batch ceiling.

type IdentityContributor added in v1.1.0

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

IdentityContributor supplies the identity coordinator's audit phase without owning a pool, transaction, Work, registry or goroutine. Configuration is immutable after construction, so one instance may serve independent Work values concurrently. Each Work itself remains serial-use and generation-bound. Existing standalone Store and TxWriter contracts remain separate and unchanged.

func NewIdentityContributor added in v1.1.0

func NewIdentityContributor(config Config) (*IdentityContributor, error)

NewIdentityContributor validates and copies record/batch limits without I/O or transaction ownership. Zero Config selects the existing audit defaults. Apply all Migrations, including the command-binding migration, before use; missing schema or incompatible storage fails closed at persistence.

func (*IdentityContributor) Descriptor added in v1.1.0

Descriptor is the exact stable open contract admitted at composition time. It is pure and independent of configuration or database state.

func (*IdentityContributor) PersistAudits added in v1.1.0

func (c *IdentityContributor) PersistAudits(ctx context.Context, w identitypostgres.Work) error

PersistAudits writes every complete staged event and its immutable command/ index binding through this same Work. Exact replay is idempotent; changed canonical bytes, event-ID/index reuse or binding/policy mismatch fails closed. Event IDs are retained, never derived. A successful return is not a commit receipt: only the Coordinator closes the transaction. On any error it must abort/reconcile, never ignore a partial prefix. No sink, network, savepoint, independent transaction or caller callback is used, and Work is not retained. Errors are redacted. Nil receiver/context, cancelled context and invalid, incomplete, expired or closed Work fail without staging persistence.

func (*IdentityContributor) RecoverParticipant added in v1.1.0

RecoverParticipant inspects retained command bindings through the sole coordinator's frozen, invocation-bound primary evidence capability. Absent bindings prove no audit effect; one exact binding with retained record identity proves only that participant's effect, never a command receipt. Conflicting, additional or incomplete bindings remain unknown. Legal payload pruning does not erase evidence. No writes, callbacks, network, private transaction or retained handles are used. Errors are redacted; invalid or cancelled authority returns RecoveryUnknown. Independent invocations may run concurrently.

type RetentionAdmin

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

RetentionAdmin is a separately constructed privileged boundary. Applications can give Store only deployment-specific writer credentials and reserve this value for an archive-and-retention process with separately generated retention privileges.

func NewRetentionAdmin

func NewRetentionAdmin(pool *pgxpool.Pool, config Config) (*RetentionAdmin, error)

NewRetentionAdmin constructs the separately privileged retention adapter. The supplied pool remains caller-owned. Construction does not inspect or grant privileges and does not prove that a deployment has separated credentials.

func (*RetentionAdmin) AppendRetentionEvent

func (admin *RetentionAdmin) AppendRetentionEvent(ctx context.Context, event audit.RetentionEvent) (audit.AppendResult, error)

AppendRetentionEvent atomically appends one idempotent hold or release event. A commit failure is classified as unknown. The result RecordID identifies the retention event. Hold precedence uses database acceptance order, not the event occurrence time; event ID reuse with different content is rejected.

func (*RetentionAdmin) ApplyRetention

func (admin *RetentionAdmin) ApplyRetention(ctx context.Context, plan audit.RetentionPlan) (audit.RetentionApplyResult, error)

ApplyRetention reconciles and prunes an unchanged plan under per-record advisory locking in one transaction. It does not check archive custody, the original cutoff or tenant authorization: that handoff is caller-owned. Pruning removes content but preserves identity tombstones and retention history. Deleted/Held/Changed counts are confirmed only after commit; commit failure returns an unknown outcome and requires reconciliation. Empty plans do no I/O.

func (*RetentionAdmin) PlanRetention

func (admin *RetentionAdmin) PlanRetention(ctx context.Context, request audit.RetentionRequest) (audit.RetentionPlan, error)

PlanRetention returns bounded, stable, digest-bound candidates that are not held at query time, selected by recording-time/ID order and returned sorted by record ID for later lock ordering. Callers must archive and independently verify the complete plan before application; this method never writes an archive.

type RoleNames

type RoleNames struct {
	// Writer names the role granted append-function execution but no table reads.
	Writer string
	// Reader names the role granted audit-record and retention-history reads.
	Reader string
	// Retention names the separately privileged planning, hold-event and prune role.
	Retention string
}

RoleNames identifies deployment-owned PostgreSQL roles. Names must be unique to the deployment or database; the adapter never creates cluster-wide roles or chooses memberships. The historical fresh-install preflight and embedded migration 1 separately reserve fixed roles that must remain inert.

type Store

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

Store owns no pool and starts no goroutines. Append batches are atomic; query and export ordering is always recorded_at then record_id.

func New

func New(pool *pgxpool.Pool, config Config) (*Store, error)

New constructs a durable adapter over an existing pool. The pool remains caller-owned; Store starts no goroutines and Close is unnecessary.

func (*Store) Append

func (store *Store) Append(ctx context.Context, record audit.Record) (audit.AppendResult, error)

Append inserts one record atomically. An identical existing ID is a successful duplicate; a commit error is classified as unknown.

func (*Store) AppendBatch

func (store *Store) AppendBatch(ctx context.Context, records []audit.Record) (audit.BatchResult, error)

AppendBatch atomically inserts a bounded input-order batch. A commit error is unknown; every earlier validation, begin, or statement error is a confirmed rejection. Retrying identical IDs and canonical bytes is idempotent, including after pruning because identity tombstones remain. Every input must have crossed an explicit redaction boundary. Preparation revalidates canonical bytes against configured decode limits; it does not verify a chain or authorize the supplied actor/tenant. The input slice must remain unchanged during use.

func (*Store) Export

func (store *Store) Export(ctx context.Context, query audit.Query, consume func(audit.Record) error) error

Export streams at most Query.Limit records without collecting the full result set. The callback runs in recording-time/ID order while rows and their database connection remain open, but no adapter transaction lock is held. Persisted digests/canonical bytes are checked. Consumer side effects before a failure are not undone; ordinary callback errors/panics become audit.ErrExportConsumerFailed, while cancellation/deadline classifications remain public. Callbacks are synchronous.

func (*Store) Query

func (store *Store) Query(ctx context.Context, query audit.Query) (audit.Page, error)

Query returns one bounded page ordered by recording time then record ID. It conveys no read authorization and closes database rows on every path. Fresh queries capture an acceptance watermark; continuations exclude later accepts. Persisted canonical SHA-256 and schema-v1 bytes are checked before exposure, but this unkeyed digest is not chain authentication. Page slices are caller-owned.

type TxWriter

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

TxWriter stages records in one caller-owned pgx transaction. It deliberately does not implement audit.Sink because a successful Stage is not durable until the caller commits. A retryable failure or failed savepoint cleanup attempts rollback of the complete caller transaction. The caller must finish or discard an unusable transaction if cleanup fails; rollback success is not guaranteed.

func NewTx

func NewTx(tx pgx.Tx, config Config) (*TxWriter, error)

NewTx constructs a staging writer over a caller-owned transaction. The caller commits the business transaction and owns its concurrency/lifecycle. Stage may roll back the whole transaction after retryable errors or failed savepoint cleanup; it never commits the outer transaction.

func (*TxWriter) Stage

func (writer *TxWriter) Stage(ctx context.Context, records []audit.Record) (audit.BatchResult, error)

Stage inserts an atomic bounded batch without committing the caller-owned transaction. Ordinary stage failures roll back only the batch savepoint; a deadlock or serialization failure rolls back the complete transaction, which must be retried with identical record IDs. Failed savepoint rollback also triggers an outer rollback attempt. All rollback attempts use a fresh detached five-second timeout; errors still require the caller to finish/discard an unusable transaction. A successful result acknowledges staging, not durability.

Jump to

Keyboard shortcuts

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