createrolewg_ownernologin;-- owns the schema and tables
createrolewg_apploginpassword'app-secret';-- the application logs in as this
createrolewg_readonlynologin;-- a group for read access
role | member | inherit_option | set_option | admin_option
-------------+-------------+----------------+------------+--------------
wg_owner | wg_migrator | f | t | f
wg_readonly | wg_analyst | t | t | f
作为 wg_migrator 时,普通查询会失败,直到它执行 set role wg_owner:这正是模式变更所需要的“sudo”行为。(wg_analyst 是一个登录角色,通过普通的 grant wg_readonly to wg_analyst 成为成员。)其他值得了解的预定义角色还有 pg_read_all_data 和 pg_write_all_data(PostgreSQL 14+),以及用于取消查询的 pg_signal_backend。
动手验证 · lab 32-security
selectlab.prove('PostgreSQL 16+ grants carry inherit, set, and admin options: wg_migrator may set role but does not inherit',(selectnota.inherit_optionanda.set_optionandnota.admin_optionfrompg_auth_membersajoinpg_rolesronr.oid=a.roleidjoinpg_rolesmonm.oid=a.memberwherer.rolname='wg_owner'andm.rolname='wg_migrator'));
grant ... on all tables in schema app 只覆盖当前已经存在的数据表。下周新建的数据表会返回 permission denied,于是有人会用“给所有人授予一切权限”来修复它。默认权限可以填上这个缺口:它们会应用于某个角色之后创建的对象。
setrolewg_owner;alterdefaultprivilegesinschemaappgrantselect,insert,update,deleteontablestowg_app;alterdefaultprivilegesinschemaappgrantselectontablestowg_readonly;-- serial columns also need: grant usage on sequences to wg_app
-- (identity columns, like ours, don't)
alter default privileges 只作用于执行该命令的角色所创建的对象(或者 for role 指定的角色所创建的对象)。如果迁移由另一个角色执行,新建对象就不会自动带上这些授权。始终以所有者角色创建对象,并在 psql 中用 \ddp 检查。
较旧的数据库仍允许任何人在 public 中创建对象
search_path(默认值为 "$user", public)决定 Postgres 查找未限定名称的位置。多年来,每个角色都可以在 public 中创建对象,这带来了经典攻击:在其中放置一个与特权用户会调用的函数同名的函数,然后等待。PostgreSQL 15 对新数据库取消了这一默认权限;除了数据库所有者之外的所有人都只有 USAGE:
=> create table public.scratch (id int); -- as wg_app
ERROR: permission denied for schema public
升级或从旧版本恢复的数据库会保留旧授权:如果 \dn+ public 显示所有人都有 =UC,就执行 revoke create on schema public from public。对应用模式,在 SQL 中限定名称(app.events),或者为角色设置路径:alter role wg_app set search_path = app。
setrolewg_owner;altertableapp.eventsenablerowlevelsecurity;createpolicytenant_isolationonapp.eventsusing(account_id=current_setting('app.account_id',true)::bigint);-- and the same on app.projects
租户由自定义设置 app.account_id 表示,应用在每个请求中设置它;如果从未设置,current_setting(..., true) 会返回 null。没有单独的 with check 表达式时,using 同时控制读取和写入。
来自 MySQL 的读者
MySQL 没有行级安全;隔离由应用代码实现,或者由视图实现,而对基础表的查询可以绕过视图。这里策略附加在数据表上(Postgres 不是 MySQL)。
在 1,000 个账户共有 2,000,000 个事件的情况下,未设置租户的应用角色什么也看不到:
=> select count(*) from app.events; -- as wg_app, no tenant
count
-------
0
ERROR: new row violates row-level security policy for table "events"
动手验证 · lab 32-security
setrolewg_app;selectlab.prove('no tenant set: the app role sees 0 of 2,000,000 events (RLS fails closed)',(selectcount(*)fromapp.events)=0);selectset_config('app.account_id','42',false)\g/dev/nullselectlab.prove('tenant 42 sees its 2,084 events',(selectcount(*)fromapp.events)=2084);
begin;insertintoapp.events(account_id,project_id,kind)values(42,1,'deploy');selectlab.prove('yet it can attach an event to project 1: the foreign key check bypasses RLS',(selectcount(*)fromapp.eventswhereproject_id=1)=1);rollback;-- after adding the composite foreign key, the same insert fails with:
-- insert or update on table "events" violates foreign key constraint "events_project_fk"
setrolewg_owner;selectlab.prove('the owner, same tenant setting, sees all 2,000,000 rows: RLS does not apply to owners by default',current_setting('app.account_id',true)='42'and(selectcount(*)fromapp.events)=2000000);altertableapp.eventsforcerowlevelsecurity;selectlab.prove('after force row level security, the owner sees 2,084',(selectcount(*)fromapp.events)=2084);
有一个陷阱:提交之后,一个没有设置租户的请求得到的不是零行,而是 invalid input syntax for type bigint: ""。自定义设置一旦在会话中设置过,之后会恢复成空字符串,而不是“未设置”。访问仍会在失败时被拒绝,但忘记设置租户会变成一个 500 错误。要把空字符串当作缺失值处理:
selectlab.prove('after commit, a transaction-local custom setting reverts to an empty string, not to null',current_setting('app.account_id',true)='');-- the original policy then fails with: invalid input syntax for type bigint: ""
-- after alter policy ... nullif(current_setting('app.account_id', true), '')::bigint:
selectlab.prove('with nullif(..., ''''), an unset tenant is null again and the query returns zero rows',(selectcount(*)fromapp.projects)=0and(selectcount(*)fromapp.events)=0);
import{Pool,PoolClient}from"pg";constpool=newPool();// direct, or through PgBouncer in transaction mode
exportasyncfunctionwithTenant<T>(accountId: string,fn:(db: PoolClient)=>Promise<T>,):Promise<T>{constclient=awaitpool.connect();try{awaitclient.query("begin");// true = transaction-local: gone at commit, never seen by the next request
awaitclient.query("select set_config('app.account_id', $1, true)",[accountId],);constresult=awaitfn(client);awaitclient.query("commit");returnresult;}catch(err){awaitclient.query("rollback");throwerr;}finally{client.release();}}// every query for the request runs inside the tenant's transaction
const{rows}=awaitwithTenant(session.accountId,(db)=>db.query("select id, name from app.projects order by name"),);
租户是绑定参数,绝不能拼接进 SQL。每条查询都通过 db 执行;直接使用 pool 的查询没有租户,并且由于策略的存在,什么也看不到。应用代码使用 set_config(..., true) 比使用 SET LOCAL 更合适,因为 SET 不能接受绑定参数。
RLS 性能:策略就是谓词
策略就是 where 子句中的另一个条件(视图和策略只是文本替换),因此和其他条件一样需要索引。没有索引时,应用角色统计租户 42 的部署事件数量会读取 33,309 个页面:执行并行顺序扫描,按 kind 和策略进行过滤。每张租户表的主要索引都应以租户列开头:
selectlab.prove('with an index leading on the tenant column, the policy becomes an index condition: about 2,036 pages','Bitmap Index Scan'=any(lab.nodes($$selectcount(*)fromapp.eventswherekind='deploy'$$))andlab.buffers($$selectcount(*)fromapp.eventswherekind='deploy'$$)between1900and2200);
Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=2036
-> Bitmap Heap Scan on events (actual rows=1.00 loops=1)
Filter: (payload @> '{"duration_ms": 1234}'::jsonb)
Rows Removed by Filter: 2083
-> Bitmap Index Scan on events_account_id_created_at_idx
(actual rows=2084.00 ...)
动手验证 · lab 32-security
setrolewg_app;selectlab.prove('through the policy, the GIN index is not allowed: the planner fetches all of the tenant''s rows and filters',not(lab.plan($$selectcount(*)fromapp.eventswherepayload@>'{"duration_ms": 1234}'$$)::textlike'%events_payload_idx%')andlab.buffers($$selectcount(*)fromapp.eventswherepayload@>'{"duration_ms": 1234}'$$)between1900and2200);
-- the attack: as wg_app, define wg_app.count(*) whose state function records
-- current_user, then: set search_path = wg_app, pg_catalog;
selectapp.unpinned_event_count(42)\g/dev/nullselectlab.prove('an unpinned SECURITY DEFINER function ran the caller''s count(*), as wg_owner',current_setting('lab.ran_as')='wg_owner');selectset_config('lab.ran_as','',false)\g/dev/nullselectlab.prove('with search_path pinned to pg_catalog, pg_temp, the real count runs and the caller''s code does not',app.account_event_count(42)=2084andcurrent_setting('lab.ran_as')='');
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
hostssl app wg_app 10.0.0.0/16 scram-sha-256
hostssl app wg_migrator 10.0.5.10/32 scram-sha-256
hostssl replication replicator 10.0.6.0/24 scram-sha-256
host all all all reject
log_connections='authentication,authorization' # PostgreSQL 18 takes a listlog_disconnections=onlog_statement='ddl' # every create/alter/drop, with the userlog_line_prefix='%m [%p] %q%u@%d %a '