Bảo mật & Access ControlSecurity & Access Control
Thuộc bộ kiến thức PostgreSQL DBA Roadmap.
Tổng quan
Bảo mật một PostgreSQL server thực chất là ba câu hỏi tách biệt chồng lên nhau. Thứ nhất: ai được phép mở connection, và từ đâu — đây là authentication, được kiểm soát bởi pg_hba.conf và lớp transport (TCP thường hay TLS). Thứ hai: sau khi đã kết nối, role này được phép làm gì — đây là authorization, được kiểm soát bởi role, privilege, và GRANT/REVOKE. Thứ ba, một lớp mà nhiều team hay quên: trong một table mà role này đã được phép query, nó được thấy những row cụ thể nào — đây là Row-Level Security. Chỉ cần sai một trong ba lớp này, hai lớp còn lại cũng không cứu được: một hệ thống privilege được cấu hình hoàn hảo trở nên vô nghĩa nếu pg_hba.conf cho phép bất kỳ ai kết nối với trust, và authentication chặt chẽ cũng chẳng giúp gì nếu mọi application role đều được cấp SUPERUSER vì lười.
Bài viết này giả định bạn đã biết cách cài đặt PostgreSQL và kết nối bằng psql (đã nói ở ./02-installation-setup-and-connecting.md) và sẽ đi qua toàn bộ lớp phòng thủ: role và các attribute của nó, các phương thức authentication mà PostgreSQL hỗ trợ, cách đánh giá rule trong pg_hba.conf, mã hóa transport bằng SSL/TLS, mô hình privilege GRANT/REVOKE cùng default privileges, Row-Level Security cho multi-tenant và kiểm soát truy cập ở mức chi tiết, một ghi chú ngắn về anonymization dữ liệu cho các bản copy non-production, và hardening ở tầng OS với SELinux. Mọi thứ ở đây đều xoay quanh nguyên tắc least privilege — mỗi cơ chế bên dưới tồn tại để thu hẹp những gì một connection, role, hoặc query cụ thể được phép làm, không bao giờ để mở rộng nó.
Kiến thức nền tảng
Role: mô hình user/group hợp nhất của PostgreSQL
Nhiều hệ quản trị database tách biệt “user” (thứ đăng nhập được) khỏi “group” (tập hợp các quyền). PostgreSQL thì không — nó chỉ có một khái niệm duy nhất gọi là role. Một role có thể đăng nhập như một user truyền thống, đóng vai trò một group để các role khác inherit, hoặc cả hai cùng lúc. Việc một role hoạt động như “user” hay “group” hoàn toàn phụ thuộc vào các attribute của nó và việc các role khác có được grant membership vào nó hay không.
CREATE USER không phải một object khác — nó chỉ là một alias mỏng của CREATE ROLE kèm theo attribute LOGIN ngầm định:
-- hai câu lệnh này gần như tương đương về chức năng
CREATE USER alice WITH PASSWORD 'a-strong-password';
CREATE ROLE alice WITH LOGIN PASSWORD 'a-strong-password';
Các role attribute kiểm soát việc role được phép làm gì ở mức instance, độc lập với privilege trên object:
| Attribute | Ý nghĩa | Mặc định |
|---|---|---|
LOGIN | Role có thể authenticate và mở connection (nếu không có, role chỉ thuần túy là một “group”) | NOLOGIN với CREATE ROLE, LOGIN với CREATE USER |
SUPERUSER | Bỏ qua mọi kiểm tra quyền trong database — truy cập toàn quyền, không giới hạn | NOSUPERUSER |
CREATEDB | Role có thể tạo database mới | NOCREATEDB |
CREATEROLE | Role có thể tạo, sửa, xóa các role khác (không phải superuser) | NOCREATEROLE |
REPLICATION | Role có thể khởi tạo connection replication (physical/streaming) | NOREPLICATION |
PASSWORD | Đặt secret dùng để authenticate với các phương thức dựa trên password | không có |
CONNECTION LIMIT n | Giới hạn số connection đồng thời cho role này | -1 (không giới hạn) |
VALID UNTIL 'timestamp' | Password/role tự động hết hạn sau thời điểm này | không có (không bao giờ hết hạn) |
Một application role tuân thủ least-privilege gần như không bao giờ nên có SUPERUSER, CREATEDB, CREATEROLE, hay REPLICATION — đó là các attribute chỉ dành cho DBA. Một application role thực tế nên trông như thế này:
-- một service account cho application: đăng nhập được, không tạo được database,
-- không tạo được role khác, không replicate được, giới hạn connection,
-- và có ngày bắt buộc rotate password
CREATE ROLE app_orders WITH
LOGIN
PASSWORD 'use-a-secrets-manager-in-real-life'
NOSUPERUSER
NOCREATEDB
NOCREATEROLE
NOREPLICATION
CONNECTION LIMIT 50
VALID UNTIL '2027-01-01';
Role này có thể kết nối, và một khi được grant đúng privilege trên object (xem bên dưới), nó làm đúng việc của nó — không hơn không kém. Nếu credential bị lộ, phạm vi thiệt hại bị giới hạn bởi những gì đã được grant tường minh cho role đó, chứ không phải toàn bộ cluster.
Các mô hình authentication
Authentication trả lời câu hỏi “connection này thực sự là ai?” trước khi bất kỳ kiểm tra privilege nào chạy. PostgreSQL hỗ trợ nhiều phương thức có thể cắm vào (pluggable), được cấu hình theo từng rule trong pg_hba.conf.
| Phương thức | Cách xác minh danh tính | Phù hợp ở đâu |
|---|---|---|
trust | Không kiểm tra gì cả — chấp nhận bất kỳ role nào được claim mà không cần credential | Không bao giờ dùng qua network; nhiều lắm chỉ dùng cho máy dev local, single-user, không chứa dữ liệu nhạy cảm |
password / md5 | Client gửi password; server so khớp với MD5 hash đã lưu | Chỉ còn tồn tại vì lý do legacy — đang bị loại bỏ dần để thay bằng scram-sha-256 |
scram-sha-256 | Client và server thực hiện giao thức challenge-response SCRAM-SHA-256; password không bao giờ được gửi qua đường truyền, và verifier được lưu đã salt và iterate | Mặc định hiện đại cho password authentication — đây là giá trị password_encryption nên được đặt |
peer | Server đọc danh tính ở mức OS của process ở đầu bên kia một Unix socket local và ánh xạ nó vào tên role PostgreSQL (hoặc một tên đã map) | Các connection admin/bảo trì local trên cùng host — ví dụ sudo -u postgres psql |
ident | Ý tưởng giống peer nhưng qua TCP, hỏi một ident server từ xa để xác nhận OS user | Hiếm dùng ngày nay; giao thức ident không có authentication và dễ bị giả mạo trên mạng không tin cậy, nên chủ yếu chỉ còn mang tính lịch sử |
cert | Client trình một chứng chỉ X.509 TLS được ký bởi CA mà server tin tưởng; CN của chứng chỉ (hoặc một attribute đã map) trở thành danh tính role | Môi trường mutual-auth chặt chẽ — connection service-to-service, mạng zero-trust |
ldap | Server chuyển password sang một LDAP directory (simple bind hoặc search+bind) để xác minh | Tập trung hóa authentication vào một corporate directory sẵn có |
gss / sspi | Dùng GSSAPI/Kerberos (hoặc SSPI trên Windows) để authenticate, cho phép single sign-on trong một Kerberos realm | Môi trường enterprise đã chạy sẵn Kerberos |
Nâng cấp quan trọng nhất mà hầu hết các cài đặt PostgreSQL cần là ngừng dùng md5 để chuyển sang scram-sha-256. Xác thực password dựa trên MD5 dễ bị tấn công dictionary offline nếu file password bị đánh cắp, và cũng không bảo vệ password khi truyền đi theo cách mà challenge-response của SCRAM làm được. Kể từ PostgreSQL 14, scram-sha-256 đã là giá trị mặc định của password_encryption, nhưng các cluster cũ được upgrade tại chỗ thường vẫn mang MD5 hash và các rule pg_hba.conf từ trước khi chuyển đổi.
-- kiểm tra cấu hình hiện tại
SHOW password_encryption;
-- đặt mặc định cho password mới về sau
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SELECT pg_reload_conf();
-- re-hash password của một role hiện có bằng phương thức mới
-- (đặt lại password sẽ mã hóa lại theo password_encryption hiện tại)
ALTER ROLE app_orders WITH PASSWORD 'the-same-or-a-new-password';
Migrate các role hiện có đòi hỏi reset password của từng role (không có cách nào convert MD5 hash sang SCRAM verifier mà không cần plaintext), và mỗi dòng pg_hba.conf tương ứng cần đổi auth-method từ md5 sang scram-sha-256 để thực sự áp dụng giao thức mới.
pg_hba.conf: kiểm soát client nào được kết nối, bằng cách nào
pg_hba.conf (“host-based authentication”) là người gác cổng chạy trước khi bất kỳ SQL nào được thực thi. Mỗi dòng là một rule khớp với một tổ hợp loại connection, database, user, và địa chỉ nguồn, đi kèm phương thức authentication áp dụng nếu connection khớp.
# TYPE DATABASE USER ADDRESS METHOD
# 1. Connection local qua Unix socket từ OS user postgres: dùng peer auth
local all postgres peer
# 2. Connection local qua Unix socket từ role bất kỳ khác: SCRAM password
local all all scram-sha-256
# 3. Connection TCP loopback (ví dụ app chạy trên cùng host): SCRAM
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
# 4. Connection replication từ một standby đã biết, chỉ qua TLS
hostssl replication replicator 10.0.1.20/32 scram-sha-256
# 5. Traffic từ application, từ subnet nội bộ, bắt buộc TLS
hostssl orders app_orders 10.0.2.0/24 scram-sha-256
# 6. Mọi thứ khác: từ chối
host all all 0.0.0.0/0 reject
Một vài chi tiết quyết định pg_hba.conf có hiệu quả hay không trong thực tế:
- Loại connection (cột đầu tiên) phân biệt
local(Unix-domain socket, không qua network),host(TCP/IP thường hoặc TLS, chấp nhận cả hai),hostssl(TCP/IP, bắt buộc TLS — connection bị từ chối nếu không mã hóa), vàhostnossl(TCP/IP, cấm TLS). Ưu tiênhostsslthay vìhostcho bất cứ thứ gì đi qua network là một bước hardening có ý nghĩa ngay từ đó. - Các rule được đánh giá từ trên xuống dưới, và rule khớp đầu tiên sẽ thắng — PostgreSQL không tiếp tục quét để tìm một match “tốt hơn” sau đó. Đây là lỗi cấu hình
pg_hba.confphổ biến nhất: một rule rộng, dễ dãi được đặt trước một rule hẹp, chặt chẽ hơn sẽ âm thầm khiến rule chặt chẽ đó không bao giờ được chạy tới. Ví dụ, nếu dòng 3 ở trên (host all all 127.0.0.1/32 scram-sha-256) được viết lại thànhhost all all 0.0.0.0/0 trustvà đặt trước các rule replication và application, mọi rule phía sau — dù được viết cẩn thận đến đâu — cũng sẽ không bao giờ được đánh giá, vì ruletrustvới wildcard khớp trước tiên. Nguyên tắc chung: sắp xếp rule từ cụ thể nhất đến chung chung nhất, và đặt rule “từ chối tất cả còn lại” cuối cùng, không phải đầu tiên. - Dải địa chỉ nên hẹp nhất có thể về mặt vận hành — một
/32cho một host cụ thể đã biết tốt hơn/24cho “cả subnet app”, và tốt hơn0.0.0.0/0cho “bất cứ đâu”, mỗi bước thu hẹp đều làm giảm attack surface. - Sau khi sửa
pg_hba.conf, chỉ cần reload (không cần restart) để thay đổi có hiệu lực:SELECT pg_reload_conf();hoặcpg_ctl reload.
SSL/TLS: mã hóa connection client-server
Authentication chứng minh danh tính; TLS bảo vệ dữ liệu khi truyền đi khỏi bị đọc hoặc can thiệp bởi bất kỳ ai đứng trên đường truyền mạng. Hai thứ này độc lập với nhau — bạn có thể có authentication mạnh trên một socket không mã hóa, hoặc mã hóa với authentication yếu — đó là lý do cả hai đều cần được quan tâm.
Trên server, bật TLS cần một cặp certificate/key và một cờ cấu hình:
# postgresql.conf
ssl = on
ssl_cert_file = 'server.crt'
ssl_key_file = 'server.key'
ssl_ca_file = 'root.crt' # cần nếu muốn xác minh certificate của client
Ở phía client, tham số connection sslmode kiểm soát mức độ chặt chẽ khi enforce TLS, và các mode này tạo thành một bậc thang tăng dần về mức đảm bảo:
sslmode | Có mã hóa connection không? | Có xác minh certificate của server không? | Có chống được MITM không? |
|---|---|---|---|
disable | Không | Không | Không |
allow | Chỉ khi server yêu cầu | Không | Không |
prefer (mặc định của hầu hết client) | Thử TLS trước, âm thầm fallback về plaintext nếu TLS thất bại | Không | Không |
require | Có | Không — kết nối với bất kỳ server nào nói TLS, kể cả một server có certificate tự ký hoặc sai | Không |
verify-ca | Có | Kiểm tra certificate của server được ký bởi CA đáng tin | Một phần — không kiểm tra certificate có khớp hostname bạn yêu cầu hay không |
verify-full | Có | Kiểm tra cả chuỗi CA lẫn việc hostname của certificate khớp với hostname bạn đã kết nối tới | Có |
Khoảng cách giữa require và verify-full là nơi phần lớn mọi người bị “dính bẫy”: require đảm bảo đường truyền được mã hóa, nhưng không nói gì về ai ở đầu bên kia — một kẻ tấn công ở mức mạng có thể redirect connection của bạn sang server TLS-terminating của chính hắn sẽ đánh bại require một cách dễ dàng, vì bất kỳ certificate nào trông “hợp lệ” cũng được chấp nhận. Chỉ verify-full mới thực sự xác thực server đúng là cái bạn muốn kết nối tới, lấp khoảng trống đó. Bất kỳ connection string nào xử lý dữ liệu nhạy cảm — traffic application production, đường replication, bất cứ thứ gì đi qua mạng không tin cậy — nên dùng verify-full, không chỉ require.
# một connection string thực sự chống được MITM
postgresql://app_orders@db.internal:5432/orders?sslmode=verify-full&sslrootcert=root.crt
Khái niệm chính
GRANT/REVOKE: hệ thống privilege trên object
Sau khi role đã được authenticate, PostgreSQL kiểm tra privilege ở mức object cho mỗi thao tác. Privilege được grant cho role trên các object database cụ thể — table, sequence, function, schema, và nhiều hơn nữa — và mỗi loại object có tập privilege riêng phù hợp với nó (SELECT, INSERT, UPDATE, DELETE trên table; EXECUTE trên function; USAGE trên schema và sequence; CONNECT trên database; và các loại khác).
-- cho phép app_orders đọc và ghi table orders, nhưng không xóa row
GRANT SELECT, INSERT, UPDATE ON orders TO app_orders;
-- schema cần USAGE tường minh trước khi bất cứ thứ gì bên trong có thể truy cập được
GRANT USAGE ON SCHEMA sales TO app_orders;
-- function cần EXECUTE
GRANT EXECUTE ON FUNCTION sales.recalculate_totals(int) TO app_orders;
-- sequence cần USAGE cho nextval()/currval() (liên quan tới cột serial/identity)
GRANT USAGE ON SEQUENCE orders_id_seq TO app_orders;
-- lấy lại một privilege thì đối xứng
REVOKE DELETE ON orders FROM app_orders;
Một chi tiết quan trọng, hay bị bỏ sót: USAGE trên schema chứa object là điều kiện tiên quyết để dùng bất cứ thứ gì bên trong nó. Grant SELECT trên một table nằm trong schema sales sẽ không có tác dụng gì nếu role chưa bao giờ được grant USAGE ON SCHEMA sales — role thậm chí không thể “thấy” schema để chạm tới table.
Role inheritance: quản lý permission theo group
Thay vì grant cùng một tập privilege cho mười role riêng lẻ từng cái một, PostgreSQL cho phép grant một role cho role khác, biến role được grant thành một group mà thành viên tự động inherit privilege của nó.
-- tạo một role "group" không có khả năng login, chỉ là một tập hợp privilege
CREATE ROLE reporting_readonly NOLOGIN;
GRANT USAGE ON SCHEMA sales TO reporting_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA sales TO reporting_readonly;
-- các login riêng lẻ của analyst chỉ đơn giản trở thành member của group
CREATE ROLE analyst_bob LOGIN PASSWORD '...';
CREATE ROLE analyst_carol LOGIN PASSWORD '...';
GRANT reporting_readonly TO analyst_bob;
GRANT reporting_readonly TO analyst_carol;
Cả analyst_bob và analyst_carol giờ đều có quyền đọc mọi table trong sales, mà không ai trong hai người được grant trực tiếp bất cứ gì. Khi có analyst mới gia nhập, bạn chỉ cần grant membership vào reporting_readonly một lần; khi policy đọc của schema reporting cần thay đổi, bạn thay đổi nó một lần trên role group và mọi thành viên đều nhận ngay lập tức. Đây là cùng mô hình tư duy với group của OS/directory-service, và là pattern nên dùng bất cứ khi nào có nhiều hơn một vài role cần cùng một tập permission — xem thêm các pattern IAM group/role rộng hơn ở ../devsecops/en/05-identity-and-access-management.md.
Default privileges: giải quyết vấn đề “table của ngày mai”
GRANT SELECT ON ALL TABLES IN SCHEMA sales TO reporting_readonly chỉ ảnh hưởng tới các table đã tồn tại ngay bây giờ. Một table ai đó tạo trong schema đó vào ngày mai sẽ bắt đầu mà không có grant đó — nguồn gốc thường xuyên của các ticket kiểu “sao role reporting không thấy table mới”. ALTER DEFAULT PRIVILEGES giải quyết vấn đề này bằng cách đăng ký một rule đứng vững: “từ bây giờ trở đi, mỗi khi role X tạo một object thuộc loại này trong schema này trong tương lai, tự động áp dụng grant này.”
-- từ bây giờ, mỗi khi role `svc_migrations` tạo table mới trong schema `sales`,
-- tự động grant SELECT trên table đó cho reporting_readonly
ALTER DEFAULT PRIVILEGES FOR ROLE svc_migrations IN SCHEMA sales
GRANT SELECT ON TABLES TO reporting_readonly;
-- tương tự với sequence (lỗ hổng phổ biến: quên sequence của cột serial/identity)
ALTER DEFAULT PRIVILEGES FOR ROLE svc_migrations IN SCHEMA sales
GRANT USAGE ON SEQUENCES TO reporting_readonly;
Mệnh đề FOR ROLE svc_migrations rất quan trọng: rule default privilege được gắn với role tạo ra object, không chỉ với schema. Nếu migration của bạn chạy bằng một role khác với role bạn đã đăng ký rule cho, các table mới tạo sẽ vẫn không nhận được grant — một cạm bẫy thường gặp khi job migration CI/CD chạy bằng một service account chưa được đưa vào câu lệnh ALTER DEFAULT PRIVILEGES.
Mô hình tư duy gắn kết tất cả lại
Việc phân biệt rõ ba lớp sau rất hữu ích, vì chúng dễ bị nhầm lẫn với nhau:
| Lớp | Câu hỏi nó trả lời | Cơ chế |
|---|---|---|
| Role membership | ”Role này thuộc những group nào, và nó inherit gì?” | GRANT role_a TO role_b |
| Object privilege | ”Role này có thể làm gì với các object đã tồn tại?” | GRANT SELECT/INSERT/... ON object TO role |
| Default privilege | ”Role này sẽ tự động được làm gì với các object tạo ra trong tương lai?” | ALTER DEFAULT PRIVILEGES ... GRANT ... |
| Row-Level Security | ”Trong các row của một table mà role đã có thể query, nó được phép thấy hoặc sửa những row cụ thể nào?” | CREATE POLICY ... ON table USING (...) |
Object privilege và default privilege cùng nhau trả lời “table/column/function nào” một role được chạm tới; role membership là cách bạn tránh lặp lại các grant đó cho từng người; RLS là lớp nằm dưới tất cả những thứ đó, thu hẹp truy cập xuống tận từng row riêng lẻ trong một table mà role vẫn được phép query đầy đủ.
Row-Level Security (RLS): giới hạn những row nào một role được thấy
Mọi thứ ở trên đều hoạt động ở mức toàn bộ object — một role hoặc được phép hoặc không được phép SELECT từ một table. Row-Level Security thêm một nút điều chỉnh chi tiết hơn: các policy lọc row nào trong table đó được nhìn thấy hoặc sửa được, được đánh giá theo từng query, một cách trong suốt, mà application không cần nhớ thêm mệnh đề WHERE ở mọi nơi.
Use case kinh điển là SaaS multi-tenant: dữ liệu của nhiều tenant nằm trong một table vật lý dùng chung (để đơn giản hóa vận hành — một schema, một bộ index, một thứ để backup), nhưng query của mỗi tenant chỉ nên bao giờ thấy row của chính họ, không bao giờ thấy row của tenant khác, kể cả khi một query có bug hoặc ORM quên thêm filter.
CREATE TABLE invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL,
customer text NOT NULL,
amount numeric(12,2) NOT NULL
);
-- RLS không có tác dụng gì cho tới khi được bật cho table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- một policy: một row chỉ hiển thị/sửa được nếu tenant_id của nó khớp
-- với tenant_id mà application đã đặt cho session hiện tại
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::int);
Application đặt app.tenant_id một lần mỗi connection hoặc transaction (thường ngay sau khi authenticate end user), và mọi query tiếp theo trên invoices được lọc một cách trong suốt:
-- code application, mỗi request, trước khi chạy query của tenant
SET app.tenant_id = '42';
-- trông như một query không giới hạn, nhưng RLS âm thầm viết lại
-- để chỉ bao giờ trả về row có tenant_id = 42
SELECT * FROM invoices;
Một vài chi tiết vận hành quyết định RLS có thực sự bảo vệ được gì hay không:
ENABLE ROW LEVEL SECURITYlà bắt buộc — tạo một policy trên table chưa bật RLS hoàn toàn không có tác dụng gì; table vẫn hiển thị đầy đủ cho bất kỳ role nào có grantSELECTthông thường.- Owner của table và superuser mặc định bỏ qua RLS. Điều này là chủ đích (để DBA không bị khóa khỏi chính table của mình) nhưng có nghĩa là RLS không phải một thứ thay thế cho việc grant privilege trên object đúng cách — nó là một lớp bổ sung cho các application role thông thường không phải owner của table. Nếu một policy nhất thiết phải áp dụng kể cả với owner, thêm
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;. - Một mệnh đề
USINGduy nhất chi phối khả năng hiển thị choSELECT/UPDATE/DELETE; một mệnh đềWITH CHECKriêng (mặc định giống biểu thứcUSINGnếu không nêu) chi phối row mới/đã sửa được phép trông như thế nào khiINSERT/UPDATE, ngăn một tenant chèn một row tự nhận thuộc về tenant khác. - Bạn có thể xếp chồng nhiều policy (ví dụ: một cho user tenant thông thường, một cho role admin nội bộ với
USING (true)), và mặc định PostgreSQL kết hợp nhiều permissive policy bằngOR— nên chủ động thiết kế cách các policy tương tác thay vì giả định chúng kết hợp theo một cách cụ thể nào đó.
RLS là một lớp phòng thủ theo chiều sâu mạnh mẽ dành riêng cho bài toán dạng “table chung, nhiều tenant” hoặc “nhiều loại user với khả năng nhìn thấy row khác nhau”; nó không thay thế GRANT/REVOKE (role vẫn cần privilege cơ bản trên table trước khi policy RLS được xem xét đến), và nó cũng không phải một abstraction miễn phí về hiệu năng — biểu thức của policy được gấp vào query plan và cần được quan tâm về indexing như bất kỳ filter nào khác (một index trên tenant_id gần như là bắt buộc cho ví dụ trên).
Anonymization dữ liệu cho các bản copy non-production
Database production thường xuyên được copy sang staging, QA, hoặc sandbox của developer — và các bản copy đó thường mang cùng dữ liệu PII, dữ liệu tài chính, hoặc các trường nhạy cảm khác như production, chỉ với kiểm soát truy cập yếu hơn xung quanh chúng. Extension postgresql_anonymizer giải quyết trực tiếp vấn đề này bên trong PostgreSQL: nó cho phép bạn khai báo rule masking theo từng cột (thay thế tĩnh, ngẫu nhiên hóa, masking một phần, tổng quát hóa, hoặc thêm noise) và sau đó tạo ra hoặc một bản dump được anonymize hoàn toàn hoặc một view được mask động cho các role cụ thể, mà không cần tự viết một pipeline ETL/scrubbing riêng.
CREATE EXTENSION IF NOT EXISTS anon CASCADE;
SELECT anon.start_dynamic_masking();
SECURITY LABEL FOR anon ON COLUMN customers.email
IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN customers.national_id
IS 'MASKED WITH FUNCTION anon.partial(national_id, 2, ''XXXXXXX'', 2)';
Đây là một công cụ cụ thể, native của PostgreSQL, cho một kỷ luật rộng hơn nhiều — phân loại dữ liệu nhạy cảm, chọn giữa masking, tokenization, hay encryption, và quyết định điều gì được phép rời khỏi production — được nói sâu hơn ở ../data-engineer/en/15-data-security-and-compliance.md.
SELinux: mandatory access control ở mức OS như một lớp bổ sung
Mọi thứ đã nói ở trên đều được enforce bởi chính PostgreSQL — nghĩa là một bug trong PostgreSQL, một lỗi cấu hình, hoặc việc process OS postgres bị compromise đều có thể tiềm ẩn khả năng bypass toàn bộ những thứ đó. SELinux (hoặc một Linux Security Module tương đương) thêm một lớp mà PostgreSQL không kiểm soát được: mandatory access control được enforce bởi kernel, giới hạn những gì process postgres được phép làm bất kể các kiểm tra permission nội bộ của chính nó nói gì.
Trên các hệ thống dòng RHEL, tích hợp postgresql-server-selinux (hay sepgsql) cho phép bạn giới hạn process PostgreSQL vào đúng các đường dẫn file, network socket, và system call mà nó cần bằng một SELinux policy, và — thông qua extension sepgsql — tùy chọn mở rộng mandatory access control xuống tận từng object database, được ánh xạ vào SELinux security context. Trong thực tế, phần lớn team nhận được đa số lợi ích chỉ bằng cách chạy PostgreSQL dưới policy enforcing mặc định postgresql_t của SELinux (thay vì tắt SELinux “cho nó chạy được”, một thói quen đáng buồn là khá phổ biến nhưng lại tiêu cực về mặt bảo mật): nghĩa là ngay cả khi kẻ tấn công đạt được khả năng thực thi code bên trong process PostgreSQL, chúng vẫn bị giới hạn trong các file, port, và capability mà policy cho phép, thay vì được toàn quyền trên host. Đây là một lớp chắn thô ở mức OS, cố ý độc lập với — và không thay thế cho — toàn bộ hệ thống role/privilege/RLS mà chính PostgreSQL enforce ở trên.
Best Practices
| Nguyên tắc | Cơ chế PostgreSQL cụ thể |
|---|---|
| Không bao giờ để application role authenticate như superuser | Tạo role riêng cho từng application/service với NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION |
| Không bao giờ tin rằng network là riêng tư | Rule hostssl trong pg_hba.conf, ssl = on, sslmode=verify-full trên mọi client |
| Từ chối mặc định, chỉ cho phép tường minh | Rule deny/reject cuối cùng ở cuối pg_hba.conf; chỉ grant đúng privilege mà role thực sự cần |
Thứ tự quan trọng trong pg_hba.conf | Rule cụ thể nhất đặt trước, rule rộng/bắt tất cả đặt sau cùng — rule khớp đầu tiên thắng |
| Loại bỏ password hashing yếu | password_encryption = scram-sha-256, cấp lại password cho các role còn dùng md5 |
| Quản lý permission theo group, không theo từng người | Role group NOLOGIN + GRANT group TO user thay vì lặp lại grant |
| Đừng quên object của ngày mai | ALTER DEFAULT PRIVILEGES ... FOR ROLE <creator> IN SCHEMA ... |
| Cách ly các tenant dùng chung một table | ENABLE ROW LEVEL SECURITY + CREATE POLICY ... USING (tenant_id = ...) |
| Giữ các bản copy non-prod không rò rỉ PII thật | Masking bằng postgresql_anonymizer hoặc một pipeline export-anonymize đúng chuẩn |
| Thêm một lớp chắn được kernel enforce | Chạy dưới policy enforcing postgresql_t của SELinux thay vì tắt nó |
| Rotate và cho hết hạn credential | VALID UNTIL trên role, rotate password định kỳ qua secrets manager |
| Audit những gì thực sự đã grant | Định kỳ rà soát information_schema.role_table_grants và pg_roles để phát hiện lệch khỏi mục tiêu least-privilege |
Tài liệu tham khảo
- PostgreSQL Documentation — Database Roles
- PostgreSQL Documentation — Client Authentication (pg_hba.conf)
- PostgreSQL Documentation — Authentication Methods
- PostgreSQL Documentation — Secure TCP/IP Connections with SSL
- PostgreSQL Documentation — Privileges (GRANT/REVOKE)
- PostgreSQL Documentation — Row Security Policies
- postgresql_anonymizer Documentation
- PostgreSQL Documentation — SE-PostgreSQL (sepgsql)
Part of the PostgreSQL DBA Roadmap knowledge base.
Overview
Securing a PostgreSQL server is really three separate questions stacked on top of each other. First: who is allowed to even open a connection, and from where — that’s authentication, controlled by pg_hba.conf and the transport (plain TCP vs TLS). Second: once connected, what is this role allowed to do — that’s authorization, controlled by roles, privileges, and GRANT/REVOKE. Third, a layer many teams forget: within a table this role can already query, which specific rows can it see — that’s Row-Level Security. Get any one of the three wrong and the other two don’t save you: a perfectly configured privilege system is worthless if pg_hba.conf lets anyone connect as trust, and airtight authentication doesn’t help if every application role is granted SUPERUSER out of laziness.
This note assumes you can already install PostgreSQL and connect to it with psql (covered in ./02-installation-setup-and-connecting.md) and walks through the full defensive stack: roles and their attributes, the authentication methods PostgreSQL supports, pg_hba.conf rule evaluation, transport encryption with SSL/TLS, the GRANT/REVOKE privilege model and default privileges, Row-Level Security for multi-tenant and fine-grained access control, a brief note on data anonymization for non-production copies, and OS-level hardening with SELinux. Everything here is framed around the principle of least privilege — every mechanism below exists to narrow what a given connection, role, or query is allowed to do, never to widen it.
Fundamentals
Roles: PostgreSQL’s unified user/group model
Many database systems separate “users” (things that log in) from “groups” (collections of permissions). PostgreSQL doesn’t — it has a single concept called a role. A role can log in like a traditional user, act as a group that other roles inherit from, or both at once. Whether a role behaves like a “user” or a “group” is entirely determined by its attributes and whether other roles are granted membership in it.
CREATE USER is not a different object — it’s a thin alias for CREATE ROLE that implies the LOGIN attribute:
-- these two are functionally almost identical
CREATE USER alice WITH PASSWORD 'a-strong-password';
CREATE ROLE alice WITH LOGIN PASSWORD 'a-strong-password';
Role attributes control what a role is allowed to do at the instance level, independent of object privileges:
| Attribute | Meaning | Default |
|---|---|---|
LOGIN | Role can authenticate and open a connection (without it, the role is purely a “group”) | NOLOGIN for CREATE ROLE, LOGIN for CREATE USER |
SUPERUSER | Bypasses every permission check in the database — full, unrestricted access | NOSUPERUSER |
CREATEDB | Role can create new databases | NOCREATEDB |
CREATEROLE | Role can create, alter, and drop other (non-superuser) roles | NOCREATEROLE |
REPLICATION | Role can initiate physical/streaming replication connections | NOREPLICATION |
PASSWORD | Sets the authentication secret used by password-based auth methods | none |
CONNECTION LIMIT n | Caps concurrent connections for this role | -1 (unlimited) |
VALID UNTIL 'timestamp' | Password/role expires automatically after this time | none (never expires) |
A least-privilege application role should almost never have SUPERUSER, CREATEDB, CREATEROLE, or REPLICATION — those are DBA-only attributes. A realistic application role looks like this instead:
-- an application service account: can log in, cannot create databases,
-- cannot create other roles, cannot replicate, capped connections,
-- and a forced password rotation date
CREATE ROLE app_orders WITH
LOGIN
PASSWORD 'use-a-secrets-manager-in-real-life'
NOSUPERUSER
NOCREATEDB
NOCREATEROLE
NOREPLICATION
CONNECTION LIMIT 50
VALID UNTIL '2027-01-01';
This role can connect and, once granted the right object privileges (see below), do exactly its job — and nothing else. If its credentials leak, the blast radius is bounded by whatever privileges were explicitly granted to it, not the entire cluster.
Authentication models
Authentication answers “who is this connection, really?” before any privilege check ever runs. PostgreSQL supports several pluggable methods, configured per-connection-rule in pg_hba.conf.
| Method | How it verifies identity | Where it’s appropriate |
|---|---|---|
trust | Doesn’t verify anything — accepts any claimed role name with no credential | Never over the network; at most for local, single-user development boxes with no sensitive data |
password / md5 | Client sends a password; server checks it against a stored MD5 hash | Legacy only — being phased out in favor of scram-sha-256 |
scram-sha-256 | Client and server run the SCRAM-SHA-256 challenge-response protocol; the password itself is never sent over the wire, and the stored verifier is salted and iterated | The modern default for password authentication — this is what password_encryption should be set to |
peer | Server reads the OS-level identity of the process on the other end of a local Unix socket and matches it to the PostgreSQL role name (or a mapped name) | Local admin/maintenance connections on the same host — e.g. sudo -u postgres psql |
ident | Same idea as peer but over TCP, asking a remote ident server to vouch for the OS user | Rare today; the ident protocol is unauthenticated and easy to spoof on untrusted networks, so it’s mostly historical |
cert | Client presents an X.509 TLS certificate signed by a CA the server trusts; the certificate’s CN (or a mapped attribute) becomes the role identity | Strong mutual-auth environments — service-to-service connections, zero-trust networks |
ldap | Server forwards the password to an LDAP directory (simple bind or search+bind) for verification | Centralizing auth against an existing corporate directory |
gss / sspi | Uses GSSAPI/Kerberos (or SSPI on Windows) for authentication, enabling single sign-on within a Kerberos realm | Enterprise environments already running Kerberos |
The single most important upgrade most PostgreSQL installs need is retiring md5 in favor of scram-sha-256. MD5-based password verification is vulnerable to offline dictionary attacks against a stolen password file and doesn’t protect the password in transit the way SCRAM’s challenge-response does. Since PostgreSQL 14, scram-sha-256 has been the default value of password_encryption, but older clusters upgraded in place often still carry md5 hashes and pg_hba.conf rules from before the switch.
-- check what's currently configured
SHOW password_encryption;
-- set the default for new passwords going forward
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SELECT pg_reload_conf();
-- re-hash an existing role's password using the new method
-- (re-setting the password re-encodes it with the current password_encryption setting)
ALTER ROLE app_orders WITH PASSWORD 'the-same-or-a-new-password';
Migrating existing roles requires resetting each password (there’s no way to convert an md5 hash to a SCRAM verifier without the plaintext), and every corresponding pg_hba.conf line needs its auth-method changed from md5 to scram-sha-256 to actually enforce the new protocol.
pg_hba.conf: controlling which clients can connect, how
pg_hba.conf (“host-based authentication”) is the gatekeeper that runs before any SQL is executed. Every line is a rule matching some combination of connection type, database, user, and source address, paired with the authentication method to apply if the connection matches.
# TYPE DATABASE USER ADDRESS METHOD
# 1. Local Unix-socket connections from the postgres OS user: peer auth
local all postgres peer
# 2. Local Unix-socket connections from any other role: SCRAM password
local all all scram-sha-256
# 3. Loopback TCP connections (e.g. an app on the same host): SCRAM
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
# 4. Replication connections from a known standby, over TLS only
hostssl replication replicator 10.0.1.20/32 scram-sha-256
# 5. Application traffic from the internal app subnet, TLS required
hostssl orders app_orders 10.0.2.0/24 scram-sha-256
# 6. Everything else: reject
host all all 0.0.0.0/0 reject
A few details make or break a pg_hba.conf in practice:
- Connection type (first column) distinguishes
local(Unix-domain socket, no network involved),host(plain or TLS TCP/IP, accepts either),hostssl(TCP/IP, TLS required — the connection is rejected if it isn’t encrypted), andhostnossl(TCP/IP, TLS forbidden). Preferringhostssloverhostfor anything crossing the network is a meaningful hardening step on its own. - Rules are evaluated top-to-bottom, and the first matching rule wins — PostgreSQL does not keep scanning for a “better” match afterward. This is the single most common
pg_hba.confmisconfiguration: a broad, permissive rule placed before a narrower, stricter one silently makes the stricter rule unreachable. For example, if line 3 above (host all all 127.0.0.1/32 scram-sha-256) were instead written ashost all all 0.0.0.0/0 trustand placed before the replication and application rules, every subsequent rule — no matter how carefully written — would never be evaluated, because the wildcardtrustrule matches first. As a rule of thumb: order rules from most specific to least specific, and put your final “deny everything else” rule last, not first. - Address ranges should be as narrow as operationally possible — a
/32for a single known host beats a/24for “the whole app subnet” beats0.0.0.0/0for “anywhere,” and every step in that direction shrinks the attack surface. - After editing
pg_hba.conf, reload (not restart) is enough for the changes to take effect:SELECT pg_reload_conf();orpg_ctl reload.
SSL/TLS: encrypting the client-server connection
Authentication proves identity; TLS protects the data in transit from being read or tampered with by anyone sitting on the network path. The two are independent — you can have strong authentication over an unencrypted socket, or encryption with weak authentication — which is why both need attention.
On the server, enabling TLS requires a certificate/key pair and a config flag:
# postgresql.conf
ssl = on
ssl_cert_file = 'server.crt'
ssl_key_file = 'server.key'
ssl_ca_file = 'root.crt' # needed if verifying client certificates
On the client side, the sslmode connection parameter controls how strictly TLS is enforced, and the modes form an escalating ladder of guarantees:
sslmode | Encrypts the connection? | Verifies server certificate? | Protects against MITM? |
|---|---|---|---|
disable | No | No | No |
allow | Only if the server insists | No | No |
prefer (default for most clients) | Tries TLS first, silently falls back to plaintext if TLS fails | No | No |
require | Yes | No — connects to any server that speaks TLS, even one with a self-signed or wrong certificate | No |
verify-ca | Yes | Checks the server certificate is signed by a trusted CA | Partial — doesn’t check the certificate matches the hostname you asked for |
verify-full | Yes | Checks the CA chain and that the certificate’s hostname matches the one you connected to | Yes |
The gap between require and verify-full is where most people get burned: require guarantees the wire is encrypted, but says nothing about who is on the other end — a network-level attacker who can redirect your connection to their own TLS-terminating server defeats require trivially, because any valid-looking certificate is accepted. Only verify-full actually authenticates the server as the one you intended to reach, closing that gap. Any connection string handling sensitive data — production application traffic, replication links, anything crossing an untrusted network — should use verify-full, not just require.
# a client connection string that actually resists MITM
postgresql://app_orders@db.internal:5432/orders?sslmode=verify-full&sslrootcert=root.crt
Key Concepts
GRANT/REVOKE: the object privilege system
Once a role is authenticated, PostgreSQL checks object-level privileges for every operation. Privileges are granted to roles on specific database objects — tables, sequences, functions, schemas, and more — and each object type has its own relevant privilege set (SELECT, INSERT, UPDATE, DELETE on tables; EXECUTE on functions; USAGE on schemas and sequences; CONNECT on databases; and others).
-- allow app_orders to read and write the orders table, but not delete rows
GRANT SELECT, INSERT, UPDATE ON orders TO app_orders;
-- schemas need explicit USAGE before anything inside them is reachable at all
GRANT USAGE ON SCHEMA sales TO app_orders;
-- functions need EXECUTE
GRANT EXECUTE ON FUNCTION sales.recalculate_totals(int) TO app_orders;
-- sequences need USAGE for nextval()/currval() (relevant for serial/identity columns)
GRANT USAGE ON SEQUENCE orders_id_seq TO app_orders;
-- taking a privilege back is symmetric
REVOKE DELETE ON orders FROM app_orders;
An important, often-missed detail: USAGE on the containing schema is a prerequisite for using anything inside it. Granting SELECT on a table sitting in schema sales does nothing useful if the role was never granted USAGE ON SCHEMA sales — the role can’t even “see” the schema to reach the table.
Role inheritance: group-based permission management
Instead of granting the same set of privileges to ten individual roles one at a time, PostgreSQL lets you grant one role to another, turning the granted role into a group whose privileges the member automatically inherits.
-- create a "group" role that holds no login capability, just a bundle of privileges
CREATE ROLE reporting_readonly NOLOGIN;
GRANT USAGE ON SCHEMA sales TO reporting_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA sales TO reporting_readonly;
-- individual analyst logins simply become members of the group
CREATE ROLE analyst_bob LOGIN PASSWORD '...';
CREATE ROLE analyst_carol LOGIN PASSWORD '...';
GRANT reporting_readonly TO analyst_bob;
GRANT reporting_readonly TO analyst_carol;
Both analyst_bob and analyst_carol now have read access to every table in sales, without either of them being granted anything directly. When a new analyst joins, you grant membership in reporting_readonly once; when the reporting schema’s read policy needs to change, you change it once on the group role and every member picks it up immediately. This is the same mental model as OS/directory-service groups, and it’s the pattern to reach for whenever more than a couple of roles need the same permission set — see also the broader IAM group/role patterns in ../devsecops/en/05-identity-and-access-management.md.
Default privileges: solving “tomorrow’s table”
GRANT SELECT ON ALL TABLES IN SCHEMA sales TO reporting_readonly only affects tables that exist right now. A table someone creates in that schema tomorrow starts with no such grant — a constant source of “why can’t the reporting role see the new table” tickets. ALTER DEFAULT PRIVILEGES fixes this by registering a standing rule: “whenever role X creates an object of this type in this schema in the future, automatically apply this grant.”
-- from now on, whenever role `svc_migrations` creates a new table in schema `sales`,
-- automatically grant SELECT on it to reporting_readonly
ALTER DEFAULT PRIVILEGES FOR ROLE svc_migrations IN SCHEMA sales
GRANT SELECT ON TABLES TO reporting_readonly;
-- same idea for sequences (common gap: forgetting serial/identity columns' sequences)
ALTER DEFAULT PRIVILEGES FOR ROLE svc_migrations IN SCHEMA sales
GRANT USAGE ON SEQUENCES TO reporting_readonly;
The FOR ROLE svc_migrations clause matters: default privilege rules are keyed to the role that creates the object, not to the schema alone. If your migrations run as a different role than the one you registered the rule for, newly created tables still won’t pick up the grant — a frequent gotcha when CI/CD migration jobs run as a service account that wasn’t included in the ALTER DEFAULT PRIVILEGES statement.
A mental model tying it together
It helps to keep three distinct layers straight, because they’re easy to conflate:
| Layer | Question it answers | Mechanism |
|---|---|---|
| Role membership | ”What groups is this role part of, and what does it inherit?” | GRANT role_a TO role_b |
| Object privileges | ”What can this role do to objects that already exist?” | GRANT SELECT/INSERT/... ON object TO role |
| Default privileges | ”What will this role automatically be able to do to objects created in the future?” | ALTER DEFAULT PRIVILEGES ... GRANT ... |
| Row-Level Security | ”Of the rows in a table this role can already query, which specific ones is it allowed to see or touch?” | CREATE POLICY ... ON table USING (...) |
Object privileges and default privileges together answer “which tables/columns/functions” a role can touch; role membership is how you avoid repeating those grants per person; RLS is the layer underneath all of that, narrowing access down to individual rows within a table the role is otherwise fully allowed to query.
Row-Level Security (RLS): restricting which rows a role can see
Everything above operates at the level of whole objects — a role either can or cannot SELECT from a table. Row-Level Security adds a finer dial: policies that filter which rows within that table are visible or modifiable, evaluated per-query, transparently, without the application needing to remember to add a WHERE clause everywhere.
The textbook use case is multi-tenant SaaS: many tenants’ data lives in one shared physical table (for operational simplicity — one schema, one set of indexes, one thing to back up), but each tenant’s queries should only ever see their own rows, never another tenant’s, even if a query has a bug or an ORM forgets a filter.
CREATE TABLE invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL,
customer text NOT NULL,
amount numeric(12,2) NOT NULL
);
-- RLS does nothing until it's turned on for the table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- a policy: a row is only visible/modifiable if its tenant_id matches
-- the tenant_id the application set for the current session
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::int);
The application sets app.tenant_id once per connection or transaction (typically right after authenticating the end user), and every subsequent query against invoices is transparently filtered:
-- application code, per request, before running tenant queries
SET app.tenant_id = '42';
-- this looks like an unrestricted query, but RLS silently rewrites it
-- to only ever return rows where tenant_id = 42
SELECT * FROM invoices;
A few operational details matter for RLS to actually protect anything:
ENABLE ROW LEVEL SECURITYis mandatory — creating a policy on a table that hasn’t had RLS enabled has no effect at all; the table is still fully visible to any role with a plainSELECTgrant.- Table owners and superusers bypass RLS by default. This is intentional (so DBAs aren’t locked out of their own tables) but means RLS is not a substitute for correct object-privilege grants — it’s an additional layer for the ordinary application roles that are not the table owner. If a policy absolutely must apply even to the owner, add
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;. - A single
USINGclause governsSELECT/UPDATE/DELETEvisibility; a separateWITH CHECKclause (defaulting to the same expression asUSINGif omitted) governs what new/modified rows are allowed to look like onINSERT/UPDATE, preventing a tenant from inserting a row that claims to belong to a different tenant. - You can layer multiple policies (e.g., one for regular tenant users, another with
USING (true)for an internal admin role), and by default PostgreSQL combines multiple permissive policies withOR— plan policy interactions deliberately rather than assuming they combine one particular way.
RLS is a strong defense-in-depth layer specifically for the “shared table, many tenants” or “many user classes with different row visibility” shape of problem; it doesn’t replace GRANT/REVOKE (a role still needs base table privileges before RLS policies are even consulted), and it isn’t a performance-free abstraction — the policy’s expression is folded into the query plan and needs the same indexing attention as any other filter (an index on tenant_id is essentially mandatory for the example above).
Data anonymization for non-production copies
Production databases routinely get copied into staging, QA, or developer sandboxes — and those copies frequently carry the same PII, financial data, or other sensitive fields as production, just with weaker access controls around them. The postgresql_anonymizer extension addresses this directly inside PostgreSQL: it lets you declare per-column masking rules (static substitution, randomization, partial masking, generalization, or noise) and then produce either a fully anonymized dump or a dynamically masked view for specific roles, without hand-writing a separate ETL/scrubbing pipeline.
CREATE EXTENSION IF NOT EXISTS anon CASCADE;
SELECT anon.start_dynamic_masking();
SECURITY LABEL FOR anon ON COLUMN customers.email
IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN customers.national_id
IS 'MASKED WITH FUNCTION anon.partial(national_id, 2, ''XXXXXXX'', 2)';
This is one specific, PostgreSQL-native tool for a much broader discipline — classifying sensitive data, choosing masking versus tokenization versus encryption, and deciding what’s allowed to leave production at all — covered in depth in ../data-engineer/en/15-data-security-and-compliance.md.
SELinux: OS-level mandatory access control as an additional layer
Everything covered so far is enforced by PostgreSQL itself — which means a bug in PostgreSQL, a misconfiguration, or a compromise of the postgres OS process can potentially bypass all of it. SELinux (or an equivalent Linux Security Module) adds a layer PostgreSQL has no control over: mandatory access control enforced by the kernel, confining what the postgres process is allowed to do regardless of what its own internal permission checks say.
On RHEL-family systems, the postgresql-server-selinux (or sepgsql) integration lets you confine the PostgreSQL process to its expected file paths, network sockets, and system calls using an SELinux policy, and — via the sepgsql extension — optionally extend mandatory access control down to individual database objects, mapped to SELinux security contexts. In practice, most teams get the bulk of the benefit from just running PostgreSQL under SELinux’s default postgresql_t enforcing policy (as opposed to disabling SELinux “to make it work,” which is a distressingly common but security-negative habit): it means that even if an attacker achieves code execution inside the PostgreSQL process, they’re still constrained to the files, ports, and capabilities the policy allows, rather than having the full run of the host. This is a coarse-grained, OS-level backstop, deliberately independent from — and not a replacement for — everything PostgreSQL’s own role/privilege/RLS system enforces above.
Best Practices
| Principle | Concrete PostgreSQL mechanism |
|---|---|
| Never let application roles authenticate as superuser | Create dedicated roles per application/service with NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION |
| Never trust the network to be private | hostssl rules in pg_hba.conf, ssl = on, sslmode=verify-full on every client |
| Deny by default, allow explicitly | Terminal reject/deny rule at the bottom of pg_hba.conf; grant only the privileges a role actually needs |
Order matters in pg_hba.conf | Most specific rules first, broad/catch-all rules last — first match wins |
| Retire weak password hashing | password_encryption = scram-sha-256, re-issue passwords for roles still on md5 |
| Manage permissions by group, not per-person | NOLOGIN group roles + GRANT group TO user instead of duplicating grants |
| Don’t forget tomorrow’s objects | ALTER DEFAULT PRIVILEGES ... FOR ROLE <creator> IN SCHEMA ... |
| Isolate tenants sharing one table | ENABLE ROW LEVEL SECURITY + CREATE POLICY ... USING (tenant_id = ...) |
| Keep non-prod copies from leaking real PII | postgresql_anonymizer masking or a proper anonymized-export pipeline |
| Add a kernel-enforced backstop | Run under SELinux’s postgresql_t enforcing policy rather than disabling it |
| Rotate and expire credentials | VALID UNTIL on roles, periodic password rotation via a secrets manager |
| Audit what’s actually granted | Regularly review information_schema.role_table_grants and pg_roles for drift from intended least-privilege |
References
- PostgreSQL Documentation — Database Roles
- PostgreSQL Documentation — Client Authentication (pg_hba.conf)
- PostgreSQL Documentation — Authentication Methods
- PostgreSQL Documentation — Secure TCP/IP Connections with SSL
- PostgreSQL Documentation — Privileges (GRANT/REVOKE)
- PostgreSQL Documentation — Row Security Policies
- postgresql_anonymizer Documentation
- PostgreSQL Documentation — SE-PostgreSQL (sepgsql)