db

package
v0.8.4 Latest Latest
Warning

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

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

Documentation

Overview

Package db provides Postgres connection bootstrapping and schema migration for the Wardyn control plane. Postgres is the ONLY required dependency.

Index

Constants

View Source
const AuditChainLockKey int64 = 0x5741524459_434841 // ASCII "WARDYCHA"

AuditChainLockKey serializes appends to the audit_events hash chain. Transaction-scoped, never session-scoped: released by the commit that makes the new row visible, so the next writer's head read can't miss it. Since 0056_audit_chain_serialize.sql the trigger takes it too and allocates seq inside the trigger under this lock, so position and chain link are decided together (0047 alone let two racing writers invert chain order against seq order, since seq was assigned before the trigger ran). In-tree insert paths still take it before their INSERT; re-entrant within a transaction, so the trigger's own acquisition is free for them. ponytail: ONE lock for the whole chain — a chain has exactly one head, and write volume is nowhere near contention. Upgrade only if that changes: per-partition chains with a key per partition.

View Source
const BootKeyLockKey int64 = 0x5741524459_424B59 // ASCII "WARDYBKY"

BootKeyLockKey serializes the CREATE path of cmd/wardynd's boot keys across -allow-multi-instance replicas: without it, two booting against an empty store each generate a key and the loser serves one nobody else holds.

View Source
const GroundTruthRotatorLockKey int64 = 0x5741524459_475452 // ASCII "WARDYGTR"

GroundTruthRotatorLockKey elects the ground-truth token rotator's leader. Unlike ReaperAdvisoryLockKey, acquired ONCE and held for the process lifetime; other replicas back off and retry.

HONEST CEILING: at most one steady-state leader, not mutual exclusion — an advisory lock dies with its SESSION, not the process, so a Postgres restart/failover/idle-timeout can release it under a still-running leader and a standby takes over unaware. Harmless only because every write is an atomic rename of a stateless token; work needing genuine fencing must not reuse this key.

View Source
const LoginSupersedeLockClass int32 = 0x574C474E // ASCII "WLGN"

LoginSupersedeLockClass is the classid half of a TWO-argument advisory lock key (objid = one person's actor string folded to int32, store.LoginLocker). Serializes ONE person's concurrent sign-in launches: without it, two launches' independent supersede/insert/supersede statements can each read the other as not-yet-existing, leaving two live sandboxes each holding a captured AWS SSO session. Taken BLOCKING: the loser must run AFTER the winner, not skip the supersede. A separate key space from db.go's int64 keys — the one- and two-argument lock forms carry different objsubid, so they cannot collide.

View Source
const PushPathListLockClass int32 = 0x57505054 // ASCII "WPPT"

PushPathListLockClass is the classid of the TRANSACTION-scoped two-argument lock store.PG.RecordPushPathList takes, keyed to one run: that run's list inserts serialize, so the per-run cap it counts is the cap it enforces.

View Source
const ReaperAdvisoryLockKey int64 = 0x5741524459_524541 // ASCII "WARDYREA"

ReaperAdvisoryLockKey makes the lifecycle reap tick single-flight. Unlike migrateAdvisoryLockKey, only ever taken with TryAdvisoryLock: a losing replica SKIPS the tick, since a queued second reap is duplicate work.

View Source
const SecretConvertLockKey int64 = 0x5741524459_454E56 // ASCII "WARDYENV"

SecretConvertLockKey makes the boot conversion of legacy (v0) secrets rows to envelope v1 single-writer (secretstore/pg's ConvertV0): a second replica booting at the same moment waits, then finds nothing left to convert.

View Source
const SecretRekeyLockKey int64 = 0x5741524459_524B59 // ASCII "WARDYRKY"

SecretRekeyLockKey serializes `wardynd -rotate-age-key` maintenance mode: two concurrent rekeys would each re-encrypt from an old key the other already replaced, so the second is refused.

HONEST CEILING: does NOT detect a running wardynd (that's SingleInstanceLockKey). "Stop the daemon first" is an operator procedure, not enforced here — a live daemon would write ciphertext under an already-retired key.

View Source
const SecretRowLockClass int32 = 0x57534543 // ASCII "WSEC"

SecretRowLockClass is the classid of the TRANSACTION-scoped two-argument lock secretstore/pg takes around a store-mode Put, keyed to one (owner, name) row: one writer per row at a time across replicas. Taken with pg_advisory_xact_lock inside the Put's own transaction, released by the commit that writes the row — not through AdvisoryLockKeyed.

View Source
const SingleInstanceLockKey int64 = 0x5741524459_494E53 // ASCII "WARDYINS"

SingleInstanceLockKey is the RUNTIME half of the one-replica safety control (the Helm chart's `replicas > 1` render refusal is the other half). Taken once at boot, held for the process lifetime: a second instance refuses to start.

SECURITY: not modesty. wardynd keeps per-process state a second instance can't see — sharpest in the secret-masking registry (internal/secretmask), an in-memory map that FAILS OPEN: a recording uploaded to the wrong instance is persisted VERBATIM with live credentials in cleartext under a `success` audit event. The chart's refusal is render-time only; `kubectl scale` or an HPA defeats it silently.

HONEST CEILING, same mechanism as GroundTruthRotatorLockKey: at most one steady-state instance, not a fence.

View Source
const TerminalSandboxSweepLockKey int64 = 0x5741524459_545353 // ASCII "WARDYTSS"

TerminalSandboxSweepLockKey makes the terminal-sandbox sweep tick single-flight across control planes (#710), the same shape as ReaperAdvisoryLockKey and for the same reason SingleInstanceLockKey alone is not enough: that lock is at most one steady-state instance, not mutual exclusion (its own HONEST CEILING) — a deployment booted with -allow-multi-instance skips the claim entirely, and a Postgres restart/failover can release its session under a still-running daemon while a second one boots and claims it. Either way, two tickers running at once would both re-run teardown for the same aged KILLED run, doubling its run.kill rows and calling the runner twice. Always taken with TryAdvisoryLock: a control plane that loses simply skips the tick, since a queued second sweep of the same page is pure duplicate work.

Variables

View Source
var AuditChainLockTimeout = 5 * time.Second

AuditChainLockTimeout bounds how long ANY writer waits for AuditChainLockKey. Since 0056 the trigger takes it on every audit_events insert, including from outside this repo, so a stray open transaction (an operator's psql session) can hold up every audit write with no Wardyn bug involved. Previously an unbounded request-path wait pinned a goroutine and a pool connection per stuck write, exhausting the pool at pool_max_conns=3.

5s is measured: a legitimate wait is other writers queueing for one nextval+head-read+sha256+insert each (low single-digit ms), so 5s absorbs a queue in the thousands, and stays under AuditSpool.Drain's 15s bound so the request path yields before the background drain does.

A write that loses the race is not dropped: on the request path it's spooled locally for the drain to replay; on the broker's mint transaction the audit insert shares the tx with the credential, so a timeout fail-closed refuses the MINT rather than issue an unaudited credential.

lock_timeout, not a context deadline: fires only on a lock wait (never a slow-but-progressing statement), server-side, SET LOCAL-scoped, reports SQLSTATE 55P03, and (measured on Postgres 17) also bounds the trigger's own acquisition, covering a writer that never takes the lock explicitly.

View Source
var ErrAdvisoryLockNoCapacity = errors.New("db: pool cannot spare a connection for an advisory lock")

ErrAdvisoryLockNoCapacity is AdvisoryLockKeyed's one STRUCTURAL refusal: the pool can't spare advisoryLockFreeConnsNeeded connections (every call, on a pool at the documented floor of 2). The caller proceeds unlocked and audits it; every other error is a wait or a database fault, and refuses.

View Source
var LoginSupersedeLockWait = 5 * time.Second

LoginSupersedeLockWait is the TOTAL budget one caller spends trying to take a keyed lock before being REFUSED (retry) rather than let through unlocked; only ErrAdvisoryLockNoCapacity proceeds unlocked. Matches AuditChainLockTimeout: enough to absorb a deep queue of a rival launch's handful of indexed statements.

Functions

func AdvisoryLock added in v0.8.0

func AdvisoryLock(ctx context.Context, pool *pgxpool.Pool, key int64) (release func(), err error)

AdvisoryLock is TryAdvisoryLock's BLOCKING sibling: waits for session-level advisory lock key until ctx ends, erroring rather than proceeding unlocked. Same release contract and pool_max_conns >= 2 need.

func AdvisoryLockKeyed added in v0.8.0

func AdvisoryLockKeyed(ctx context.Context, pool *pgxpool.Pool, class, obj int32, wait time.Duration) (release func(), err error)

AdvisoryLockKeyed takes the SESSION-level TWO-argument advisory lock (class, obj) on a connection borrowed from pool, waiting up to wait in TOTAL. TryAdvisoryLock's blocking sibling, for work that must be SERIALIZED rather than skipped. ErrAdvisoryLockNoCapacity is the one error a caller may treat as "proceed unlocked"; every other one means the work was not serialized and must not run.

SET LOCAL lock_timeout scopes server-side to a short transaction, but the LOCK itself is session-level and outlives that commit — the caller's guarded work is several independent statements after this returns, which a transaction-scoped lock could not cover.

Call the returned release (deferred, every path) to unlock and free the in-process slot — skipping it strands both for the life of the process.

func AppClockAgeMicros added in v0.7.0

func AppClockAgeMicros(t, now time.Time) int64

AppClockAgeMicros returns how long ago the app-clock instant t was, as of the app-clock instant now, in microseconds, clamped to [0, maxAppClockAge]. Both arguments must come from the SAME clock: a duration between two readings of one clock carries no skew, so it's safe to hand to a different clock — passing a value read back from the DATABASE as t would reintroduce the skew.

Clamped at zero so a value stamped in the future reads as "now" rather than a negative interval. The ZERO TIME is NOT special-cased here: it means different things to different callers, so each states its own answer.

func AppClockAgeSQL added in v0.7.0

func AppClockAgeSQL(placeholder string) string

AppClockAgeSQL renders an app-measured age, in MICROSECONDS, as an interval to subtract from the database's own clock. Microseconds as a bigint (not a float or pgx interval) matches timestamptz's own resolution exactly.

func AuditChainCanary added in v0.7.0

func AuditChainCanary(ctx context.Context, pool *pgxpool.Pool) error

auditChainCanary appends ONE synthetic audit row inside a transaction it always rolls back, and asserts it came out chained: row_hash set, prev_hash equal to the head read under the same lock. It is the FUNCTIONAL half of the boot check — catalog shape (trigger present, enabled, shipped function) is not enough: 0057 shipped a trigger that was all three and did not work (a SECURITY DEFINER pinned to the wrong search_path), so Migrate reported success while every audit insert then failed or silently linked to the wrong table's head. Never committed, so there is no synthetic row in anybody's audit log — cost is one burned seq per boot, which the sweep tolerates.

A chain that demonstrably does not chain refuses the boot, since serving over it writes a log the verify sweep will report broken for as long as it runs. A canary that could not be RUN (chain lock busy, statement cancelled) reports at ERROR and lets the boot continue — those are bounded, transient and self-clearing.

AuditChainCanary runs the same check against an arbitrary pool, for the caller that needs it on a pool Migrate never touched: with a split migrate/app DSN, real audit writes go through a different role with different search_path/privileges, so a canary that only ever ran as the migrator would prove nothing about the pool that actually writes audit rows. Same function, not a second copy, so the two boot paths can't disagree about what a working chain is.

func AuditChainLockTimeoutSQL added in v0.7.0

func AuditChainLockTimeoutSQL() string

AuditChainLockTimeoutSQL applies AuditChainLockTimeout to the current transaction. SET takes no bind parameters — the value is a formatted-in integer, never caller input. SET LOCAL reverts at commit/rollback.

func AuditDDLBypassRoutes added in v0.7.0

func AuditDDLBypassRoutes(ctx context.Context, pool *pgxpool.Pool) ([]string, error)

AuditDDLBypassRoutes names EVERY route by which pool's role can reach past audit_events' append-only guard. An empty slice means protected (what AuditDDLProtected checks for). Named routes let the boot log tell an operator which one to close, since the fix differs per route and a bare bool would mislead. Fails safe: a missing table, or any error, is a bypass.

func AuditDDLProtected

func AuditDDLProtected(ctx context.Context, pool *pgxpool.Pool) (bool, error)

AuditDDLProtected reports whether pool's role is UNABLE to bypass the audit_events append-only triggers: not a superuser or MEMBER (via the grant chain, not just ownership) of the owner role, holds no TRIGGER privilege, and cannot SET session_replication_role (silences every simply-enabled trigger, no DDL needed). Fails safe on any ambiguity (missing table, error). Delegates to AuditDDLBypassRoutes so no second query can disagree.

SECURITY: TRIGGER privilege alone — without owning or superusing the table — lets a role add its own BEFORE INSERT trigger and rewrite any field (prev_hash/row_hash included) before the chain trigger hashes the forgery; ensureAuditTriggers guards the trigger SHAPE, this only checks whether the privilege to create one is held.

All four legs test MEMBERSHIP, not a role attribute: reading rolsuper off current_user directly misses the managed-Postgres shape (GRANT admin role TO app role, NOINHERIT) that can SET ROLE to superuser while every direct-attribute check reports protected.

Deliberately NOT modeled: pg_write_all_data (write rights, but append-only triggers still fire) and host-level escalation (a compromised host survives no claim here anyway).

func Connect

func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error)

Connect opens a pgxpool to dsn and performs a lightweight liveness check. Returns the pool; caller owns Close().

func Migrate

func Migrate(ctx context.Context, pool *pgxpool.Pool) error

Migrate applies all migrations in internal/db/migrations/*.sql in lexical order. Each migration runs inside its own transaction; already-applied filenames are skipped. Idempotent. It REFUSES a database that records an applied migration this binary does not ship: see unknownAppliedMigrations.

func MigrateAllowingUnknown added in v0.7.13

func MigrateAllowingUnknown(ctx context.Context, pool *pgxpool.Pool) error

MigrateAllowingUnknown is Migrate with the unknown-migration refusal turned into a WARN — the break-glass behind WARDYN_ALLOW_UNKNOWN_MIGRATIONS.

func TryAdvisoryLock added in v0.5.0

func TryAdvisoryLock(ctx context.Context, pool *pgxpool.Pool, key int64) (release func(), ok bool, err error)

TryAdvisoryLock takes session-level advisory lock key on a connection borrowed from pool WITHOUT waiting, reporting ok=false if held elsewhere. Call the returned release (deferred) or strand a pooled conn permanently. Only for BOUNDED work: the conn is unavailable to everyone else until release, so the caller's own queries need a second conn — requires pool_max_conns >= 2 (a 1-conn pool self-deadlocks).

Types

This section is empty.

Jump to

Keyboard shortcuts

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