start-vibing-stacks 2.7.3 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,521 @@
1
+ ---
2
+ name: postgres-patterns
3
+ version: 1.0.0
4
+ description: PostgreSQL design, security, and tuning at the SQL/operations layer — role separation, Row-Level Security (RLS), connection security, JSONB, partitioning, indexing strategy (BTREE/GIN/BRIN/partial/expression), EXPLAIN ANALYZE, isolation levels, locking, advisory locks, connection pool sizing. Stack-agnostic. Invoke when designing Postgres schemas, hardening database access, or debugging Postgres query performance. Complements per-stack ORM skills (Drizzle, SQLAlchemy, Eloquent, Mongoose) and the database-migrations skill.
5
+ ---
6
+
7
+ # PostgreSQL — Design, Security, and Tuning
8
+
9
+ **Invoke when designing a Postgres schema, granting database access, picking an index, debugging a slow query, or hardening prod connection settings.**
10
+
11
+ > Postgres is the same on every stack. The application code differs. This skill covers the database side — what to ask of it, how to lock it down, and how to read its performance signals. For schema **deployment** (concurrent indexes, lock timeouts, backfills, parallel change), see `database-migrations`.
12
+
13
+ This skill assumes Postgres ≥ 15 (14 still supported but missing some features called out below). PG 16/17 add more — flagged where relevant.
14
+
15
+ ---
16
+
17
+ ## 1. Connection Security (do this on day 1)
18
+
19
+ ### TLS — non-negotiable in production
20
+
21
+ ```
22
+ # Connection string
23
+ postgresql://app_user:****@db.example.com:5432/app_db?sslmode=verify-full&sslrootcert=/etc/ssl/certs/postgres-ca.pem
24
+ ```
25
+
26
+ | `sslmode` | Use |
27
+ |---|---|
28
+ | `disable` | Local dev only |
29
+ | `require` | Encryption only — does NOT verify cert (vulnerable to MITM) |
30
+ | `verify-ca` | Verifies cert is signed by trusted CA |
31
+ | `verify-full` | + verifies hostname matches cert. **Production minimum.** |
32
+
33
+ Server: set `ssl = on` and require TLS in `pg_hba.conf` with `hostssl` lines (not `host`). Reject plaintext.
34
+
35
+ ### Credentials in env, never in code
36
+
37
+ Connection strings live in environment variables (`DATABASE_URL`), never in source. See `secrets-management`.
38
+
39
+ Connection strings should not appear in:
40
+ - application logs (mask before logging)
41
+ - error messages returned to clients
42
+ - crash dumps / Sentry breadcrumbs
43
+
44
+ ---
45
+
46
+ ## 2. Role Separation (least privilege)
47
+
48
+ **Never** connect the application as a `SUPERUSER` or as the database owner. Three roles minimum:
49
+
50
+ ```sql
51
+ -- Owner: created tables. Used only by migrations.
52
+ CREATE ROLE app_owner LOGIN PASSWORD '...';
53
+
54
+ -- Application: runtime. SELECT/INSERT/UPDATE/DELETE on data tables, no DDL.
55
+ CREATE ROLE app_runtime LOGIN PASSWORD '...';
56
+
57
+ -- Read-only: analytics, BI, on-call investigation.
58
+ CREATE ROLE app_readonly LOGIN PASSWORD '...';
59
+
60
+ -- Schema, owned by the migration role
61
+ CREATE SCHEMA app AUTHORIZATION app_owner;
62
+ ALTER ROLE app_owner SET search_path = app, public;
63
+
64
+ -- Grants
65
+ GRANT USAGE ON SCHEMA app TO app_runtime, app_readonly;
66
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_runtime;
67
+ GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_readonly;
68
+
69
+ -- Apply to future tables (created by migrations)
70
+ ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
71
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_runtime;
72
+ ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
73
+ GRANT SELECT ON TABLES TO app_readonly;
74
+ ```
75
+
76
+ Outcomes:
77
+ - An RCE in the app cannot `DROP TABLE` (no DDL grant)
78
+ - A leaked read-only credential cannot mutate
79
+ - Migrations get their own audit trail
80
+
81
+ **Lock down `public`:** remove the default `CREATE` privilege on the `public` schema (PG 15 already revokes it for non-owners — verify with `\dn+`). Untrusted users with table-creation rights in `public` enable function-search-path attacks.
82
+
83
+ ---
84
+
85
+ ## 3. Row-Level Security (RLS) — multi-tenant default
86
+
87
+ For any multi-tenant or per-user data, enable RLS so even a buggy `WHERE` clause can't leak across tenants.
88
+
89
+ ```sql
90
+ -- Schema
91
+ CREATE TABLE app.documents (
92
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
93
+ tenant_id uuid NOT NULL,
94
+ owner_id uuid NOT NULL,
95
+ title text NOT NULL,
96
+ body text,
97
+ created_at timestamptz NOT NULL DEFAULT now()
98
+ );
99
+ CREATE INDEX ON app.documents (tenant_id, created_at DESC);
100
+
101
+ -- Enable RLS
102
+ ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY;
103
+ ALTER TABLE app.documents FORCE ROW LEVEL SECURITY; -- applies to table owner too
104
+
105
+ -- App-level policy: tenant set per session
106
+ CREATE POLICY tenant_isolation ON app.documents
107
+ USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
108
+
109
+ -- App connects, then SETs the tenant from session-trusted context (JWT, cookie):
110
+ SET LOCAL app.tenant_id = '01HZ...';
111
+ SELECT * FROM app.documents; -- automatically filtered
112
+ ```
113
+
114
+ **Critical:** the `SET LOCAL` value comes from your **server-side authn**, never from a request header you didn't validate. RLS is enforced by the database; the trust boundary is whoever owns the connection setting.
115
+
116
+ With PgBouncer transaction pooling, use `SET LOCAL` (transaction-scoped), never `SET` (session-scoped — leaks to next pooled user).
117
+
118
+ ---
119
+
120
+ ## 4. Schema Design
121
+
122
+ ### IDs
123
+
124
+ | Choice | When |
125
+ |---|---|
126
+ | `bigserial` / `bigint generated always as identity` | Internal, single-region; fastest |
127
+ | `uuid` (v4 random) | Distributed, public IDs; trades index locality for non-guessability |
128
+ | **`uuid` v7** (RFC 9562) | **2025 best practice** — time-ordered UUIDs; preserve insert locality + non-guessability |
129
+
130
+ Postgres 18 ships native `uuidv7()`. On older versions, use the `pg_uuidv7` extension or generate in app code (Drizzle's `uuid().defaultRandom()`, Python's `uuid_utils`).
131
+
132
+ Avoid `serial`/`integer` IDs for anything in URLs (4-byte counter exhausts at 2.1B; no privacy).
133
+
134
+ ### Timestamps
135
+
136
+ ```sql
137
+ created_at timestamptz NOT NULL DEFAULT now(),
138
+ updated_at timestamptz NOT NULL DEFAULT now()
139
+ ```
140
+
141
+ `timestamptz` (= `timestamp with time zone`) — always. It stores UTC; the "with time zone" is misleading. `timestamp` (without) is a foot-gun: no offset info, time-zone-conversion bugs at every boundary.
142
+
143
+ ### Constraints — push correctness into the DB
144
+
145
+ ```sql
146
+ CREATE TABLE app.orders (
147
+ id uuid PRIMARY KEY,
148
+ user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
149
+ status text NOT NULL,
150
+ total_cents bigint NOT NULL,
151
+ currency text NOT NULL DEFAULT 'USD',
152
+ created_at timestamptz NOT NULL DEFAULT now(),
153
+
154
+ CONSTRAINT chk_status CHECK (status IN ('pending','paid','shipped','cancelled')),
155
+ CONSTRAINT chk_total_positive CHECK (total_cents > 0),
156
+ CONSTRAINT chk_currency CHECK (currency ~ '^[A-Z]{3}$')
157
+ );
158
+ ```
159
+
160
+ CHECK constraints catch bad data the application failed to. They cost almost nothing on insert.
161
+
162
+ ### Money
163
+
164
+ **Always** integer cents (`bigint`) or `numeric(precision, scale)`. **Never** `float`/`double` for money — IEEE 754 rounding errors compound.
165
+
166
+ ### Enums vs lookup tables
167
+
168
+ - **Postgres `ENUM`**: cheaper, rigid (adding values is `ALTER TYPE`, ordering matters)
169
+ - **Lookup table** (`status_codes (code text PK, label text)` + FK): flexible, joins required
170
+
171
+ Lookup tables age better for any value users might reorder/rename/translate.
172
+
173
+ ### JSONB — when to use it
174
+
175
+ Use `jsonb` (not `json`) when:
176
+ - Truly heterogeneous attributes per row (settings blobs, audit details, integration payloads)
177
+ - Sparse fields where >80% of rows wouldn't have them
178
+ - External payloads stored as-is (Stripe events, webhook bodies)
179
+
180
+ **Don't** use JSONB for:
181
+ - Fields you'll filter or order on regularly (worse than indexed columns)
182
+ - Anything with a stable shape (use real columns + CHECK)
183
+ - Multi-row data (use a child table)
184
+
185
+ ```sql
186
+ -- Indexing JSONB
187
+ CREATE INDEX ON app.events USING GIN (payload jsonb_path_ops); -- good for @>, ?
188
+ CREATE INDEX ON app.events ((payload->>'user_id')); -- expression index for one path
189
+ ```
190
+
191
+ ### Generated columns
192
+
193
+ ```sql
194
+ CREATE TABLE app.users (
195
+ id uuid PRIMARY KEY,
196
+ email citext NOT NULL UNIQUE,
197
+ search_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', email)) STORED
198
+ );
199
+ CREATE INDEX ON app.users USING GIN (search_tsv);
200
+ ```
201
+
202
+ The DB keeps it consistent. Saves an UPDATE trigger or app-side maintenance.
203
+
204
+ ---
205
+
206
+ ## 5. Indexing Strategy
207
+
208
+ ### Index types — choose by workload
209
+
210
+ | Type | Use For |
211
+ |---|---|
212
+ | **BTREE** (default) | Equality, range, ORDER BY |
213
+ | **GIN** | `@>`, `?`, `?|`, `?&` on JSONB; full-text search; arrays |
214
+ | **GIST** | Geometry (PostGIS), exclusion constraints, range types |
215
+ | **BRIN** | Huge append-only tables (events, logs) ordered by insertion time. Tiny index, range-scan focus |
216
+ | **HASH** | Equality only on large keys; rare — BTREE usually wins |
217
+
218
+ ### Partial indexes — index only what you query
219
+
220
+ ```sql
221
+ -- 90% of rows are status='inactive' — don't index them
222
+ CREATE INDEX idx_active_users_email ON app.users (email) WHERE status = 'active';
223
+
224
+ -- Soft-delete pattern
225
+ CREATE INDEX idx_orders_user ON app.orders (user_id) WHERE deleted_at IS NULL;
226
+ ```
227
+
228
+ Partial indexes are smaller, faster to maintain, and the planner uses them when the query's `WHERE` matches.
229
+
230
+ ### Expression indexes — index computed values
231
+
232
+ ```sql
233
+ -- Case-insensitive email lookup without citext
234
+ CREATE UNIQUE INDEX idx_users_email_lower ON app.users (lower(email));
235
+ SELECT * FROM app.users WHERE lower(email) = lower($1);
236
+ ```
237
+
238
+ ### Composite ordering matters
239
+
240
+ ```sql
241
+ -- Used by: WHERE tenant_id=? ORDER BY created_at DESC
242
+ CREATE INDEX ON app.documents (tenant_id, created_at DESC);
243
+
244
+ -- DOES NOT serve: WHERE created_at >= ? (leftmost prefix rule)
245
+ ```
246
+
247
+ Leftmost prefix rule: a composite index `(a, b, c)` serves queries on `(a)`, `(a, b)`, `(a, b, c)` — never on `(b)` alone.
248
+
249
+ ### Unique constraints
250
+
251
+ ```sql
252
+ -- One active subscription per user (partial UNIQUE)
253
+ CREATE UNIQUE INDEX one_active_sub_per_user
254
+ ON app.subscriptions (user_id) WHERE status = 'active';
255
+ ```
256
+
257
+ ---
258
+
259
+ ## 6. Reading Performance — `EXPLAIN ANALYZE`
260
+
261
+ ```sql
262
+ EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
263
+ SELECT u.id, count(o.id)
264
+ FROM app.users u
265
+ LEFT JOIN app.orders o ON o.user_id = u.id
266
+ WHERE u.created_at > now() - interval '7 days'
267
+ GROUP BY u.id;
268
+ ```
269
+
270
+ What to look for:
271
+
272
+ | Signal | Meaning |
273
+ |---|---|
274
+ | `Seq Scan` on a big table | Missing or unusable index; or planner thinks scan is cheaper |
275
+ | `rows=10000 vs actual rows=10` | Planner stats are stale → `ANALYZE table_name` |
276
+ | `Buffers: shared read=...` huge | Cold cache or genuinely large scan |
277
+ | `Sort Method: external merge Disk: ...kB` | `work_mem` too small; bumps to disk |
278
+ | `Rows Removed by Filter: 99000` | Index is wrong shape; fetching too much |
279
+ | `Heap Fetches: 100000` | Index-only scan turned into heap fetch — visibility map stale, run `VACUUM` |
280
+
281
+ Always test with `ANALYZE` (real execution); plain `EXPLAIN` shows estimated plan, not actuals.
282
+
283
+ `pg_stat_statements` extension: enable in production. Sort by `total_exec_time DESC` to find the queries actually consuming the database.
284
+
285
+ ---
286
+
287
+ ## 7. Transactions and Locking
288
+
289
+ ### Isolation levels
290
+
291
+ | Level | Postgres default | Phenomena prevented |
292
+ |---|---|---|
293
+ | `READ COMMITTED` | yes (default) | Dirty reads |
294
+ | `REPEATABLE READ` | | + non-repeatable reads, phantom reads |
295
+ | `SERIALIZABLE` | | + write skew (Serializable Snapshot Isolation) |
296
+
297
+ `SERIALIZABLE` is the safest and well-implemented in Postgres (SSI). The cost is occasional `40001` serialization-failure errors — your application **must** retry them. Worth it for financial/balance logic.
298
+
299
+ ### Locking selects
300
+
301
+ ```sql
302
+ -- Block other transactions from updating this row
303
+ SELECT * FROM app.invoices WHERE id = $1 FOR UPDATE;
304
+
305
+ -- Wait at most 5 seconds for the lock
306
+ SET LOCAL lock_timeout = '5s';
307
+ SELECT * FROM app.invoices WHERE id = $1 FOR UPDATE;
308
+
309
+ -- Job queue pattern: pick + lock + skip already-locked
310
+ SELECT * FROM app.jobs
311
+ WHERE status = 'pending'
312
+ ORDER BY priority, created_at
313
+ LIMIT 1
314
+ FOR UPDATE SKIP LOCKED;
315
+ ```
316
+
317
+ `FOR UPDATE SKIP LOCKED` is the classic correct way to build a worker queue on top of Postgres. No external broker needed for many use cases.
318
+
319
+ ### Advisory locks — coordinate without rows
320
+
321
+ ```sql
322
+ -- App-level mutex, e.g. "only one cron at a time"
323
+ SELECT pg_try_advisory_lock(42); -- returns true if got it
324
+ -- ... do work ...
325
+ SELECT pg_advisory_unlock(42);
326
+ ```
327
+
328
+ Survives across rows; cheap. Useful for cross-instance leadership without Redis.
329
+
330
+ ### Idempotent upsert
331
+
332
+ ```sql
333
+ INSERT INTO app.users (id, email, name)
334
+ VALUES ($1, $2, $3)
335
+ ON CONFLICT (email) DO UPDATE
336
+ SET name = EXCLUDED.name,
337
+ updated_at = now()
338
+ RETURNING *;
339
+ ```
340
+
341
+ Atomic. Use this everywhere you'd otherwise SELECT-then-INSERT.
342
+
343
+ ---
344
+
345
+ ## 8. Connection Pooling
346
+
347
+ Postgres connections are heavyweight (each fork = ~10MB + planner state). Pool, but understand the modes.
348
+
349
+ | Tool | Modes |
350
+ |---|---|
351
+ | **PgBouncer** | session, transaction, statement |
352
+ | **Pgpool-II** | similar; also load-balancing |
353
+ | In-app pool (drizzle/pg-pool, SQLAlchemy, etc.) | session |
354
+
355
+ ### Sizing
356
+
357
+ ```
358
+ total_pool_size ≈ (cores * 2) + spindle_count
359
+ ```
360
+
361
+ Real-world starting point per Postgres instance: 50–200. **Never** size your app pool * replica count > server `max_connections` — you'll get `too many clients already` outages.
362
+
363
+ Run multiple app instances? Use PgBouncer. Each app's local pool = ~5–10 connections to PgBouncer; PgBouncer multiplexes onto fewer real connections.
364
+
365
+ ### PgBouncer transaction-mode caveats
366
+
367
+ In transaction-pooling mode, a single backend connection serves many clients across transactions. Things that break:
368
+
369
+ | Feature | Why it breaks | Workaround |
370
+ |---|---|---|
371
+ | `SET` (session-scope) | Leaks to next user | Use `SET LOCAL` inside a tx |
372
+ | Prepared statements (server-side) | Cached on the wrong backend | `pgbouncer.ini`: `max_prepared_statements`; PG ≥ 16 + PgBouncer ≥ 1.21 supports protocol-level prepared in transaction mode |
373
+ | `LISTEN`/`NOTIFY` | Notification listeners survive across pooling | Use a dedicated connection outside PgBouncer |
374
+ | Temporary tables | Live for the connection, not your transaction | Don't, or use `ON COMMIT DROP` |
375
+ | Advisory **session** locks | Leak | Use `pg_advisory_xact_lock` (transaction-scoped) |
376
+
377
+ If your stack uses a lot of session state, run PgBouncer in **session** mode and accept fewer connection-multiplexing wins, or rely on the in-app pool only.
378
+
379
+ ---
380
+
381
+ ## 9. Timeouts (set everywhere)
382
+
383
+ In every connection or session:
384
+
385
+ ```sql
386
+ SET statement_timeout = '15s'; -- kill any single statement past 15s
387
+ SET lock_timeout = '5s'; -- kill any wait-on-lock past 5s
388
+ SET idle_in_transaction_session_timeout = '60s'; -- kill stuck transactions
389
+ ```
390
+
391
+ Without these, one slow query holding a lock cascades into a database-wide stall. Set per-role defaults so nobody forgets:
392
+
393
+ ```sql
394
+ ALTER ROLE app_runtime SET statement_timeout = '15s';
395
+ ALTER ROLE app_runtime SET lock_timeout = '5s';
396
+ ALTER ROLE app_runtime SET idle_in_transaction_session_timeout = '60s';
397
+ ```
398
+
399
+ Migrations need different timeouts (longer; less concurrent traffic). Set them in the migration role only.
400
+
401
+ ---
402
+
403
+ ## 10. Maintenance
404
+
405
+ ### Autovacuum — leave it on, tune for hot tables
406
+
407
+ Autovacuum is enabled by default and good. But hot-write tables (`>10k upserts/min`) often need:
408
+
409
+ ```sql
410
+ ALTER TABLE app.events SET (
411
+ autovacuum_vacuum_scale_factor = 0.05, -- vacuum at 5% dead tuples (default 20%)
412
+ autovacuum_analyze_scale_factor = 0.02,
413
+ autovacuum_vacuum_cost_limit = 1000 -- vacuum more aggressively
414
+ );
415
+ ```
416
+
417
+ Without this, dead tuples accumulate, indexes bloat, planner stats stale, queries slow.
418
+
419
+ ### `VACUUM ANALYZE` after large data change
420
+
421
+ After a big migration, backfill, or import, run `VACUUM ANALYZE table_name;` so the planner re-stats the table.
422
+
423
+ ### Bloat monitoring
424
+
425
+ `pgstattuple` extension or `pg_repack` to repack tables online without taking write locks. See `database-migrations` §8.
426
+
427
+ ---
428
+
429
+ ## 11. Encryption at Rest (column-level)
430
+
431
+ For specific PII fields, use `pgcrypto`:
432
+
433
+ ```sql
434
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
435
+
436
+ -- Symmetric: stores ciphertext in column; key stays in app
437
+ INSERT INTO app.kyc_documents (user_id, ssn_enc)
438
+ VALUES ($1, pgp_sym_encrypt($2, current_setting('app.encryption_key')));
439
+
440
+ SELECT pgp_sym_decrypt(ssn_enc, current_setting('app.encryption_key'))
441
+ FROM app.kyc_documents WHERE user_id = $1;
442
+ ```
443
+
444
+ Disk-level encryption (LUKS, EBS, RDS-default) protects against backup theft and disposal. Column encryption protects against DB-admin access and SQL injection. They are **not** substitutes — use both for sensitive PII.
445
+
446
+ For audit trails, prefer storage in a separate, write-only-by-app schema with `app_runtime` having only `INSERT` (not `UPDATE`/`DELETE`).
447
+
448
+ ---
449
+
450
+ ## 12. SQL Injection — Postgres specifics
451
+
452
+ Generic SQL injection defense lives in `security-baseline` §A03. Postgres-specific gotchas:
453
+
454
+ | Gotcha | Defense |
455
+ |---|---|
456
+ | String concat in dynamic SQL (`EXECUTE 'SELECT ' || user_input`) | Use `EXECUTE ... USING ($1, $2)` with bindings — even in `plpgsql` |
457
+ | `quote_ident()` / `quote_literal()` | Use **only** when truly building dynamic identifiers; never for user data |
458
+ | `search_path` hijacking — function calls resolve via search_path | Schema-qualify everywhere in functions: `SELECT app.users.id`; set `SET search_path = pg_catalog, app` at function definition |
459
+ | `LIKE` patterns | Escape `%` and `_` in user input: `replace(replace(input, '%', '\\%'), '_', '\\_')` then bind |
460
+ | ORM "raw" escape hatches | Audit every call site; treat as guilty until proven safe |
461
+
462
+ ---
463
+
464
+ ## 13. Backups
465
+
466
+ | Tool | Use |
467
+ |---|---|
468
+ | `pg_basebackup` + WAL archiving | Point-in-time recovery (PITR), production standard |
469
+ | `pg_dump --format=custom` | Logical, portable across versions; use for schema migration / small DBs |
470
+ | Cloud provider (RDS, Cloud SQL, Neon, Supabase) snapshots | Convenient, verify retention/region |
471
+
472
+ Three rules:
473
+ 1. **Test restores monthly.** A backup nobody has restored is not a backup.
474
+ 2. **Store off-region.** A region failure that takes the DB also takes co-located backups.
475
+ 3. **Encrypt and rotate** the backup encryption keys; treat them as production secrets.
476
+
477
+ ---
478
+
479
+ ## 14. Pre-Deploy Checklist
480
+
481
+ - [ ] App connects as a non-superuser role with only the privileges it needs
482
+ - [ ] `sslmode=verify-full` against a known CA
483
+ - [ ] `statement_timeout`, `lock_timeout`, `idle_in_transaction_session_timeout` set per role
484
+ - [ ] RLS enabled on every multi-tenant table; `FORCE ROW LEVEL SECURITY` on
485
+ - [ ] Multi-tenant policies tested with a non-bypass role
486
+ - [ ] PgBouncer mode matches what the ORM/driver expects (transaction vs session)
487
+ - [ ] Autovacuum tuned for known hot tables
488
+ - [ ] `pg_stat_statements` enabled with retention configured
489
+ - [ ] Backup + restore drill within last 30 days
490
+ - [ ] Slow-query log threshold set (`log_min_duration_statement = '500ms'`)
491
+
492
+ ---
493
+
494
+ ## FORBIDDEN
495
+
496
+ | Pattern | Reason |
497
+ |---|---|
498
+ | App connects as `postgres` superuser | RCE = `DROP DATABASE`; no audit boundary |
499
+ | `sslmode=require` (no `verify-full`) | MITM-vulnerable |
500
+ | Multi-tenant table without RLS | One bad WHERE → cross-tenant leak |
501
+ | Tenant ID derived from request header app didn't authenticate | RLS becomes attacker-controlled |
502
+ | `float`/`double precision` for money | Rounding errors |
503
+ | `timestamp` (no time zone) for events | DST/UTC bugs |
504
+ | Sequential `serial` IDs in public URLs | Enumeration; existence leaks |
505
+ | Connection pool sized > server `max_connections` | Outage on traffic spike |
506
+ | `SET` (session) on PgBouncer transaction-mode | Leaks to next pooled user |
507
+ | No `statement_timeout` | One runaway query can stall the cluster |
508
+ | String concat for dynamic SQL in `plpgsql` functions | SQL injection inside the database |
509
+ | Column encryption keys in the same DB / same secret | Defeats the purpose |
510
+ | Untested backups | Not backups |
511
+
512
+ ---
513
+
514
+ ## See Also
515
+
516
+ - `database-migrations` — safe DDL: concurrent indexes, parallel-change pattern, lock-aware migrations
517
+ - `security-baseline` — A01 (authz), A03 (SQL injection generic), A09 (audit logging)
518
+ - `secrets-management` — DATABASE_URL hygiene, key rotation
519
+ - `observability` — slow-query log shipping, `pg_stat_statements` to APM
520
+ - `performance-patterns` — generic perf: caching, N+1, response compression
521
+ - per-stack ORM skill — Drizzle (Node), SQLAlchemy/asyncpg (Python), Eloquent (PHP), Mongoose (only if you accidentally added Postgres to a Mongo project — don't)