Skip to content

Commit 10c7e4e

Browse files
DavidLiedleclaude
andcommitted
Add Chapter 16: Security
Covers the role system and least-privilege design, pg_hba.conf and authentication methods, SSL/TLS configuration, row-level security for multi-tenancy, column-level security and encryption with pgcrypto, pgaudit, trigger-based audit tables, and a security checklist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 69dd6e7 commit 10c7e4e

1 file changed

Lines changed: 358 additions & 0 deletions

File tree

src/ch16-security.md

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
# Security
2+
3+
PostgreSQL has one of the most sophisticated security models of any open-source database. Roles, row-level security, column-level permissions, encryption, audit logging — the tools are all there. The failure mode is not missing features; it's not using them.
4+
5+
This chapter covers how to use Postgres's security features to build a database that's properly defended: least-privilege access, data-level access control, encrypted connections and storage, and comprehensive audit trails.
6+
7+
## The Role System
8+
9+
Postgres security is organized around *roles*. A role is a named identity in the database that can hold privileges, own objects, and log in (or not). The distinction between "users" (roles that can log in) and "groups" (roles that can't log in but grant privileges) is a convention, not a hard distinction.
10+
11+
### Creating Roles
12+
13+
```sql
14+
-- A login role (user)
15+
CREATE ROLE app_user WITH LOGIN PASSWORD 'secure_password';
16+
17+
-- A login role with expiry
18+
CREATE ROLE temp_analyst WITH LOGIN PASSWORD 'password'
19+
VALID UNTIL '2025-01-01';
20+
21+
-- A non-login role (group)
22+
CREATE ROLE readonly;
23+
CREATE ROLE readwrite;
24+
CREATE ROLE admin;
25+
```
26+
27+
### Granting Privileges
28+
29+
```sql
30+
-- Grant schema usage (required to access objects within the schema)
31+
GRANT USAGE ON SCHEMA public TO readonly;
32+
GRANT USAGE ON SCHEMA public TO readwrite;
33+
34+
-- Grant SELECT on all existing tables
35+
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;
36+
37+
-- Grant SELECT/INSERT/UPDATE/DELETE on all existing tables
38+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO readwrite;
39+
40+
-- Grant sequence usage (for INSERT with serial/identity columns)
41+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO readwrite;
42+
43+
-- Default privileges for future objects
44+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
45+
GRANT SELECT ON TABLES TO readonly;
46+
47+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
48+
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO readwrite;
49+
50+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
51+
GRANT USAGE, SELECT ON SEQUENCES TO readwrite;
52+
53+
-- Grant role membership (app_user gets readonly privileges)
54+
GRANT readonly TO app_user;
55+
```
56+
57+
The `ALTER DEFAULT PRIVILEGES` command is critical — without it, new tables created in the future won't automatically grant the expected privileges to existing roles.
58+
59+
### The Principle of Least Privilege
60+
61+
Every application connection should use a role with only the privileges it needs:
62+
63+
```sql
64+
-- Application: reads and writes data, but never drops tables
65+
CREATE ROLE myapp_user WITH LOGIN PASSWORD 'app_password';
66+
GRANT readwrite TO myapp_user;
67+
68+
-- Read replica: SELECT only
69+
CREATE ROLE myapp_reader WITH LOGIN PASSWORD 'reader_password';
70+
GRANT readonly TO myapp_reader;
71+
72+
-- Migrations: need to create/alter tables
73+
CREATE ROLE myapp_migrations WITH LOGIN PASSWORD 'migration_password';
74+
GRANT readwrite TO myapp_migrations;
75+
-- Also grant CREATE privileges:
76+
GRANT CREATE ON SCHEMA public TO myapp_migrations;
77+
78+
-- Admin: SUPERUSER or carefully-scoped privileges
79+
-- Prefer specific privileges over SUPERUSER in production
80+
```
81+
82+
Separate credentials for migrations (run during deployments) vs. application connections (run all the time) is a security hygiene practice with real value: if the application credential is compromised, the attacker can't run DDL.
83+
84+
## `pg_hba.conf`: Client Authentication
85+
86+
`pg_hba.conf` (host-based authentication) controls which hosts can connect to which databases with which roles and which authentication methods.
87+
88+
```
89+
# TYPE DATABASE USER ADDRESS METHOD
90+
local all all peer # OS user = DB user
91+
host all all 127.0.0.1/32 scram-sha-256
92+
host mydb app_user 10.0.1.0/24 scram-sha-256
93+
hostssl all all 0.0.0.0/0 scram-sha-256 # Require SSL
94+
hostnossl all all 0.0.0.0/0 reject # Reject non-SSL
95+
```
96+
97+
Key authentication methods:
98+
- `peer`: Use OS username (local connections only). The OS user must match the database role name.
99+
- `scram-sha-256`: Password authentication using SCRAM. Modern and secure. Prefer over `md5`.
100+
- `md5`: Password authentication using MD5. Deprecated — use `scram-sha-256` instead.
101+
- `cert`: Client certificate authentication. Most secure for automated connections.
102+
- `reject`: Deny the connection outright.
103+
104+
**Always use `scram-sha-256` or `cert`, never `md5` or `trust` in production.**
105+
106+
`trust` means any connection from the matched host/user is allowed without a password. Appropriate only for local Unix socket connections on tightly controlled systems, and even then, risky.
107+
108+
## SSL/TLS
109+
110+
All external connections should use SSL/TLS to encrypt data in transit. Any credential or sensitive data query over an unencrypted connection can be sniffed.
111+
112+
In `postgresql.conf`:
113+
```ini
114+
ssl = on
115+
ssl_cert_file = 'server.crt'
116+
ssl_key_file = 'server.key'
117+
ssl_ca_file = 'ca.crt' # For client certificate verification
118+
ssl_min_protocol_version = 'TLSv1.2'
119+
ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL'
120+
```
121+
122+
Force SSL in `pg_hba.conf` by using `hostssl` entries (only allow SSL) and `hostnossl ... reject` (reject non-SSL):
123+
```
124+
hostssl all all 0.0.0.0/0 scram-sha-256
125+
hostnossl all all 0.0.0.0/0 reject
126+
```
127+
128+
In your application connection string, include `sslmode=verify-full` (or at minimum `sslmode=require`) to verify the server certificate.
129+
130+
## Row-Level Security (RLS)
131+
132+
Row-Level Security is Postgres's mechanism for restricting which rows a role can see or modify. It's declarative, transparent to the application, and enforced at the database layer regardless of how the query arrives.
133+
134+
Enable RLS on a table:
135+
```sql
136+
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
137+
```
138+
139+
Create a policy:
140+
```sql
141+
-- Users can only see their own orders
142+
CREATE POLICY orders_user_isolation ON orders
143+
FOR ALL
144+
TO app_user
145+
USING (user_id = current_setting('app.current_user_id')::bigint);
146+
```
147+
148+
The `USING` clause is checked for SELECT, UPDATE, and DELETE (which rows can you read/modify?). The `WITH CHECK` clause is checked for INSERT and UPDATE (which values can you write?):
149+
150+
```sql
151+
-- Users can only insert orders for themselves
152+
CREATE POLICY orders_insert_policy ON orders
153+
FOR INSERT
154+
TO app_user
155+
WITH CHECK (user_id = current_setting('app.current_user_id')::bigint);
156+
```
157+
158+
### Setting RLS Context
159+
160+
The application must set the context before executing queries:
161+
162+
```sql
163+
-- At the start of each request/transaction:
164+
SET LOCAL app.current_user_id = '42';
165+
-- Now all queries on `orders` only return rows for user_id = 42
166+
SELECT * FROM orders; -- Only returns user 42's orders
167+
```
168+
169+
`SET LOCAL` is scoped to the current transaction — it resets when the transaction ends. Safe for use in connection-pooled environments (transaction mode).
170+
171+
### Multi-tenant RLS
172+
173+
RLS is the foundation of multi-tenant data isolation in a shared database:
174+
175+
```sql
176+
-- Tenant isolation across all tables
177+
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
178+
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
179+
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
180+
181+
CREATE POLICY tenant_isolation ON users
182+
USING (tenant_id = current_setting('app.tenant_id')::bigint);
183+
184+
CREATE POLICY tenant_isolation ON orders
185+
USING (tenant_id = current_setting('app.tenant_id')::bigint);
186+
187+
-- Admin role bypasses RLS
188+
CREATE ROLE admin_user WITH LOGIN;
189+
ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- Applies RLS even to table owner
190+
GRANT BYPASS RLS ON orders TO admin_user; -- Explicitly grant bypass when needed
191+
```
192+
193+
Note: `FORCE ROW LEVEL SECURITY` applies RLS even to the table owner. Without it, the table owner bypasses RLS. In multi-tenant systems, you typically want `FORCE ROW LEVEL SECURITY` to prevent ownership-based bypass.
194+
195+
### RLS Performance
196+
197+
RLS policies add a predicate to every query. For policies on indexed columns (like `tenant_id`), the query planner can use the index, and performance impact is minimal. For policies on unindexed columns or complex expressions, every query pays the cost of evaluating the policy.
198+
199+
Index the columns used in RLS policies:
200+
```sql
201+
CREATE INDEX idx_orders_tenant_id ON orders(tenant_id);
202+
CREATE INDEX idx_orders_user_id ON orders(user_id);
203+
```
204+
205+
## Column-Level Security
206+
207+
Restrict access to specific columns using column-level privileges:
208+
209+
```sql
210+
-- Grant SELECT on all columns except ssn and salary
211+
GRANT SELECT (id, name, email, created_at) ON employees TO hr_viewer;
212+
213+
-- Or: grant all columns, then revoke the sensitive ones
214+
GRANT SELECT ON employees TO hr_viewer;
215+
REVOKE SELECT (ssn, salary) ON employees FROM hr_viewer;
216+
```
217+
218+
Column-level security is useful for compliance requirements where certain columns (PII, financial data) must be visible only to specific roles.
219+
220+
## Encryption at Rest
221+
222+
Postgres itself doesn't encrypt data files. Encryption at rest is typically handled at the storage level:
223+
224+
- **OS-level:** Linux LUKS (dm-crypt) encrypts the entire filesystem, including Postgres data files
225+
- **Cloud:** AWS RDS encrypts with AWS KMS, GCS with Cloud KMS — this is typically enabled by default
226+
- **Tablespace-level:** Some storage systems provide per-volume encryption
227+
- **pgcrypto:** Column-level encryption within the database (see below)
228+
229+
For cloud databases, ensure encryption at rest is enabled (it usually is by default).
230+
231+
## Column-Level Encryption with pgcrypto
232+
233+
For particularly sensitive data that should be encrypted even from database administrators:
234+
235+
```sql
236+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
237+
238+
-- Store an encrypted value
239+
INSERT INTO medical_records (patient_id, diagnosis_encrypted)
240+
VALUES (1, pgp_sym_encrypt('Type 2 Diabetes', 'encryption_key'));
241+
242+
-- Retrieve and decrypt
243+
SELECT patient_id, pgp_sym_decrypt(diagnosis_encrypted, 'encryption_key')
244+
FROM medical_records
245+
WHERE patient_id = 1;
246+
```
247+
248+
For public-key encryption (where different parties encrypt vs. decrypt):
249+
```sql
250+
-- Encrypt with public key
251+
UPDATE users
252+
SET ssn_encrypted = pgp_pub_encrypt(ssn, dearmor('-----BEGIN PGP PUBLIC KEY...'))
253+
WHERE id = 1;
254+
255+
-- Decrypt requires private key (held outside the DB)
256+
SELECT pgp_pub_decrypt(ssn_encrypted, dearmor('-----BEGIN PGP PRIVATE KEY...'), 'passphrase')
257+
FROM users WHERE id = 1;
258+
```
259+
260+
Column-level encryption has real costs: the data is not indexable, not queryable by value, and requires the application to manage keys. Use it only where the compliance requirement specifically demands it.
261+
262+
## Audit Logging
263+
264+
For compliance and security incident investigation, knowing who did what and when is essential.
265+
266+
### pgaudit
267+
268+
The `pgaudit` extension (Chapter 11) provides statement-level and object-level audit logging. Configure it in `postgresql.conf`:
269+
270+
```ini
271+
shared_preload_libraries = 'pgaudit'
272+
273+
# Log all write operations and DDL
274+
pgaudit.log = 'write, ddl'
275+
276+
# Include connection info in log
277+
pgaudit.log_client = on
278+
279+
# Log role that granted the privilege being used
280+
pgaudit.log_relation = on
281+
```
282+
283+
Per-role audit configuration:
284+
```sql
285+
-- Audit all operations by this user
286+
ALTER ROLE sensitive_user SET pgaudit.log = 'all';
287+
```
288+
289+
### Built-in Logging
290+
291+
Postgres's standard logging can also provide audit trails:
292+
293+
```ini
294+
log_connections = on
295+
log_disconnections = on
296+
log_duration = on
297+
log_statement = 'mod' # Log all DML; or 'all' for everything
298+
log_min_duration_statement = 0 # Log all statements (combine with log_statement)
299+
```
300+
301+
Log everything to a table via `log_destination = 'csvlog'` and import into a log analysis system.
302+
303+
### Trigger-Based Audit Tables
304+
305+
For high-fidelity row-level audit trails:
306+
307+
```sql
308+
CREATE TABLE audit_log (
309+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
310+
table_name TEXT NOT NULL,
311+
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
312+
row_id BIGINT,
313+
old_row JSONB,
314+
new_row JSONB,
315+
changed_by TEXT NOT NULL DEFAULT current_user,
316+
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
317+
);
318+
319+
CREATE OR REPLACE FUNCTION audit_trigger()
320+
RETURNS TRIGGER AS $$
321+
BEGIN
322+
INSERT INTO audit_log(table_name, operation, row_id, old_row, new_row)
323+
VALUES (
324+
TG_TABLE_NAME,
325+
TG_OP,
326+
CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END,
327+
CASE WHEN TG_OP != 'INSERT' THEN row_to_json(OLD)::jsonb END,
328+
CASE WHEN TG_OP != 'DELETE' THEN row_to_json(NEW)::jsonb END
329+
);
330+
RETURN COALESCE(NEW, OLD);
331+
END;
332+
$$ LANGUAGE plpgsql;
333+
334+
CREATE TRIGGER audit_orders
335+
AFTER INSERT OR UPDATE OR DELETE ON orders
336+
FOR EACH ROW EXECUTE FUNCTION audit_trigger();
337+
```
338+
339+
This captures a complete before/after picture for every row change. Partition the `audit_log` table by date for manageability.
340+
341+
## Security Checklist
342+
343+
A practical checklist for a production Postgres deployment:
344+
345+
- [ ] All external connections use SSL/TLS (`hostssl` in pg_hba.conf)
346+
- [ ] Password authentication uses `scram-sha-256`, not `md5` or `trust`
347+
- [ ] Application uses a role with only necessary privileges (not superuser)
348+
- [ ] Separate credentials for migration runner vs. application
349+
- [ ] `pg_hba.conf` limits which hosts can connect
350+
- [ ] Default passwords changed (especially `postgres` role)
351+
- [ ] RLS enabled for multi-tenant or user-scoped tables
352+
- [ ] Column-level grants for sensitive data (SSN, salary, health data)
353+
- [ ] `pgaudit` or trigger-based audit logging for compliance requirements
354+
- [ ] Encryption at rest enabled (storage-level or column-level for most-sensitive data)
355+
- [ ] Postgres data directory accessible only to the `postgres` OS user
356+
- [ ] `pg_hba.conf` and `postgresql.conf` not world-readable
357+
358+
PostgreSQL provides all the mechanisms needed for a properly secured database. The work is in applying them consistently. The most common security failures are not clever exploits — they're misconfigured authentication, overly privileged application accounts, and missing audit trails for compliance requirements. The checklist above addresses all of them.

0 commit comments

Comments
 (0)