PostgreSQL Row-Level Security — Policies and Rewrite-Time Quals
Contents:
- Theoretical Background
- Common DBMS Design
- PostgreSQL’s Approach
- Source Walkthrough
- Source verification (as of 2026-06-05)
- Beyond PostgreSQL — Comparative Designs & Research Frontiers
- Sources
Theoretical Background
Section titled “Theoretical Background”Access control in a relational database has classically operated at the
granularity of the table and the column: GRANT SELECT ON accounts TO alice either gives Alice the whole table or none of it. This is
discretionary access control (DAC) at object granularity, and it is
the model SWEBOK and Database System Concepts (Silberschatz, ch. 4 on
“Intermediate SQL” / authorization, and ch. 9 on application design and
security) describe as the SQL standard’s GRANT/REVOKE core. It is
sufficient when the unit of confidentiality coincides with a table. It is
insufficient the moment two tenants share a table, or a manager may see
only her own department’s rows: the confidential boundary now runs
horizontally through the rows, not vertically along the columns.
Three classical answers exist to “restrict which rows a principal sees”:
-
Application-enforced filtering. Every query the application emits carries a
WHERE tenant_id = current_tenant()predicate. Simple, but the security guarantee is only as good as the developer’s discipline — one forgottenWHEREclause leaks the whole table, and ad-hoc tools (psql, a reporting connection) bypass it entirely. -
Updatable security views. Define
CREATE VIEW my_rows AS SELECT * FROM t WHERE owner = current_user, revoke access to the base table, and grant access only to the view. This moves the predicate into the schema where it cannot be forgotten. The classic difficulty, studied by Rizvi et al. in “Extending Query Rewriting Techniques for Fine-Grained Access Control” (SIGMOD 2004), is that a naive view does not stop a user-supplied, side-effecting function in the query from observing rows the view’sWHEREclause would have hidden — the optimizer may evaluate that function before the security predicate. The fix is the security-barrier concept: a qual that the planner is forbidden to reorder below user predicates unless the user’s function is provably leakproof. -
Row-level security (RLS) / label-based / fine-grained access control. The DBMS itself attaches a per-row predicate to the table, transparently, for every statement that touches it. This is the subject of this document. It is conceptually the security-view technique generalized: instead of forcing users through a named view, the engine injects the view’s predicate into the user’s own query at rewrite time. Bertino and Sandhu’s survey “Database Security — Concepts, Approaches, and Challenges” (IEEE TDSC 2005) classifies this as content-based, fine-grained access control, and notes the two independent decisions any such system must make: which existing rows a principal may read or lock (a read/visibility predicate), and which new rows a principal may introduce (a write/integrity predicate). PostgreSQL names these two decisions
USINGandWITH CHECK.
The theoretical heart of RLS, then, is query rewriting for access
control: the security predicate is not a runtime gate bolted onto the
executor but a transformation of the query tree, so that the optimizer and
executor see a single, fully-formed query whose answer already excludes
forbidden rows. This is why RLS lives in src/backend/rewrite/ rather than
in the executor: it is the same machinery that expands views and rules,
specialized to inject access-control quals. The two correctness
obligations the rewriter must honor are (a) completeness — every path
that returns rows (SELECT, RETURNING, FOR UPDATE locking) must be
filtered, and every path that writes rows (INSERT, UPDATE) must be checked;
and (b) non-circumvention — the injected predicate must be a
security barrier the optimizer cannot push a user function below.
Common DBMS Design
Section titled “Common DBMS Design”Commercial and open-source databases that offer row-level / fine-grained access control converge on a small set of design elements. Naming them makes PostgreSQL’s specific choices read as one point in a shared design space.
A catalog of policies keyed by (relation, command)
Section titled “A catalog of policies keyed by (relation, command)”Every implementation stores policy definitions in a system catalog, keyed
by the protected object and the operation. Oracle’s Virtual Private
Database (VPD) registers a policy function per (table, statement-type)
via DBMS_RLS.ADD_POLICY; the function returns a predicate string the
server appends to the statement. SQL Server’s security policies bind
predicate functions (inline table-valued functions) as either FILTER
predicates (read path) or BLOCK predicates (write path). PostgreSQL
stores declarative expressions (not functions) in pg_policy, one tuple
per named policy, carrying the command class, the role list, and the two
quals. The common denominator: the protected object plus the operation
class is the lookup key, and the value is a boolean predicate over the
row.
Two predicate kinds: read/visibility vs. write/integrity
Section titled “Two predicate kinds: read/visibility vs. write/integrity”The read predicate decides which existing rows are visible (and, for
locking reads, lockable); the write predicate decides which new row values
are admissible. SQL Server’s FILTER-vs-BLOCK split is exactly this.
Oracle VPD distinguishes statement_types and additionally offers
sec_relevant_cols for column-sensitive masking. PostgreSQL names the
read predicate USING and the write predicate WITH CHECK, and — a key
ergonomic choice — defaults WITH CHECK to USING when only the latter
is given, so a single predicate governs both “what you can see” and “what
you can write” unless the author deliberately separates them.
Combining multiple policies: OR-of-grants, AND-of-denies
Section titled “Combining multiple policies: OR-of-grants, AND-of-denies”When several policies apply, the system must define how they compose. The
near-universal convention is that permissive (grant) policies are
OR-combined — any one of them granting access is enough — while
restrictive (deny) policies are AND-combined — all of them must pass.
This mirrors the intuition that grants are additive and restrictions are
conjunctive. PostgreSQL makes the permissive/restrictive distinction
explicit per policy (AS PERMISSIVE is the default; AS RESTRICTIVE opts
into AND-semantics) and bakes the combination rule directly into the
rewriter.
Default-deny when the feature is armed but unconfigured
Section titled “Default-deny when the feature is armed but unconfigured”A subtle but critical convention: once RLS is enabled on a table, the absence of any matching policy must mean “no rows”, not “all rows”. Otherwise enabling the feature and forgetting to write a policy would silently expose everything. Every serious implementation defaults to deny. PostgreSQL realizes this by emitting a single always-false constant when no permissive policy grants access.
Owner/superuser bypass and a forced mode
Section titled “Owner/superuser bypass and a forced mode”Table owners and highly-privileged roles typically bypass RLS so that
maintenance, backup, and schema work are not themselves filtered.
PostgreSQL gives the owner an implicit bypass, a BYPASSRLS role
attribute (which superusers always have), and a FORCE ROW LEVEL SECURITY switch that revokes the owner’s own bypass — important when the
owner is also a normal data user, or for logical-replication and
pg_dump correctness.
The rewrite-vs-runtime placement decision
Section titled “The rewrite-vs-runtime placement decision”Finally, an implementation must decide where the predicate is enforced. Oracle VPD and PostgreSQL both inject at parse/rewrite time so the optimizer sees the predicate and can plan around it; this gives good plans but requires plan invalidation when the effective predicate changes (role, session setting). SQL Server’s predicate functions are likewise folded into the query plan. The rewrite-time approach is dominant because it lets the cost-based optimizer treat the security qual as just another predicate — subject to the leakproofness rules that keep it a barrier.
flowchart TB
subgraph Author["DDL — policy authoring"]
CP["CREATE POLICY p ON t<br/>FOR cmd TO roles<br/>USING (qual)<br/>WITH CHECK (wc)"]
AT["ALTER TABLE t<br/>ENABLE / FORCE<br/>ROW LEVEL SECURITY"]
end
subgraph Cat["System catalog"]
PGP["pg_policy row<br/>polrelid, polcmd, polpermissive,<br/>polroles, polqual, polwithcheck"]
PGC["pg_class flags<br/>relrowsecurity / relforcerowsecurity"]
end
subgraph RW["Query rewrite"]
GRSP["get_row_security_policies()<br/>per relation RTE"]
SQ["securityQuals<br/>USING -> visibility"]
WCO["withCheckOptions<br/>WITH CHECK -> write check"]
end
CP --> PGP
AT --> PGC
PGP --> GRSP
PGC --> GRSP
GRSP --> SQ
GRSP --> WCO
SQ --> PLAN["planner + executor<br/>(security-barrier scan)"]
WCO --> EXEC["executor<br/>ExecWithCheckOptions"]
PostgreSQL’s Approach
Section titled “PostgreSQL’s Approach”PostgreSQL’s RLS is a thin DDL surface over pg_policy plus a rewrite-time
qual-injection pass. The whole mechanism is three moving parts: the
catalog representation, the per-query enablement decision, and the
qual-construction logic. The adjacent machinery — how securityQuals
becomes a security-barrier subquery in the planner, and how the rest of
DDL execution sets relrowsecurity — is deferred to
postgres-rewriter.md and postgres-ddl-execution.md; this document
focuses on the policy catalog and the rowsecurity.c injection.
The catalog representation: pg_policy and the relcache descriptor
Section titled “The catalog representation: pg_policy and the relcache descriptor”A policy is one tuple in pg_policy. The catalog carries the relation OID,
the command class as a single char, the permissive flag, the role array,
and the two quals serialized as pg_node_tree:
// FormData_pg_policy — src/include/catalog/pg_policy.hCATALOG(pg_policy,3256,PolicyRelationId){ Oid oid; NameData polname; /* Policy name. */ Oid polrelid BKI_LOOKUP(pg_class); /* relation with policy */ char polcmd; /* One of ACL_*_CHR, or '*' for all */ bool polpermissive; /* restrictive or permissive policy */#ifdef CATALOG_VARLEN Oid polroles[1] BKI_LOOKUP_OPT(pg_authid) BKI_FORCE_NOT_NULL; pg_node_tree polqual; /* Policy quals. (USING) */ pg_node_tree polwithcheck; /* WITH CHECK quals. */#endif} FormData_pg_policy;polcmd is the same single-character encoding the privilege system uses
(ACL_SELECT_CHR = r, ACL_INSERT_CHR = a, ACL_UPDATE_CHR = w,
ACL_DELETE_CHR = d) plus '*' for an ALL policy. A unique index on
(polrelid, polname) enforces name uniqueness per table and — usefully —
gives the relcache loader a deterministic name order.
At first touch of a relation whose pg_class.relrowsecurity is set, the
relcache builds a RowSecurityDesc by scanning pg_policy and
deserializing each qual back into an expression tree. The descriptor is a
plain list of RowSecurityPolicy structs:
// RowSecurityPolicy — src/include/rewrite/rowsecurity.htypedef struct RowSecurityPolicy{ char *policy_name; char polcmd; /* command this policy is for */ ArrayType *roles; /* roles policy applies to */ bool permissive; /* permissive vs restrictive */ Expr *qual; /* USING expression (filter rows) */ Expr *with_check_qual; /* WITH CHECK expression (limit rows) */ bool hassublinks; /* either expression has sublinks */} RowSecurityPolicy;Caching the parsed expression in the relcache is what makes RLS cheap at
rewrite time: get_row_security_policies does not re-parse catalog text
per query, it copies already-deserialized trees.
The enablement decision: check_enable_rls and three states
Section titled “The enablement decision: check_enable_rls and three states”Whether RLS applies to this query against this relation by this role
is not a static property — it depends on the row_security GUC and the
current role. check_enable_rls collapses all of it into a three-valued
result (RLS_NONE, RLS_NONE_ENV, RLS_ENABLED). The crucial cases:
// check_enable_rls — src/backend/utils/misc/rls.c (condensed)relrowsecurity = classform->relrowsecurity;relforcerowsecurity = classform->relforcerowsecurity;/* Nothing to do if the relation does not have RLS */if (!relrowsecurity) return RLS_NONE;/* BYPASSRLS (and superuser) always bypass; depends on role -> _ENV */if (has_bypassrls_privilege(user_id)) return RLS_NONE_ENV;/* Owner bypasses, unless FORCE RLS and not an RI check */amowner = object_ownercheck(RelationRelationId, relid, user_id);if (amowner){ if (!relforcerowsecurity || InNoForceRLSOperation()) return RLS_NONE_ENV;}/* RLS applies; user may have turned off the GUC to get an error instead */if (!row_security && !noError) ereport(ERROR, (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), errmsg("query would be affected by row-level security policy ...")));return RLS_ENABLED;The distinction between RLS_NONE and RLS_NONE_ENV is the linchpin of
plan-cache correctness. RLS_NONE means RLS is structurally absent (the
table has no relrowsecurity), so a cached plan never needs revisiting on
RLS grounds. RLS_NONE_ENV means RLS is on the table but bypassed for the
moment because of who is asking — change the role (SET ROLE) or the
row_security GUC and the answer flips. So even when no qual is added,
the rewriter marks hasRowSecurity = true to force a replan if the
environment changes:
// get_row_security_policies — src/backend/rewrite/rowsecurity.cif (rls_status == RLS_NONE_ENV){ /* may involve RLS later; force replan if env changes, add nothing now */ *hasRowSecurity = true; return;}The row_security GUC has a third, deliberate behavior. It is a boolean
defaulting to on. Setting it off does not silently disable RLS for an
ordinary user — that would be a security hole — it instead makes
check_enable_rls raise an error if a query would have been filtered.
The only principals for whom row_security = off actually skips RLS are
those who bypass it anyway (owner without FORCE, BYPASSRLS, superuser),
because they reach RLS_NONE_ENV before the GUC check. This is what
pg_dump --enable-row-security=off relies on: a dump must either see all
rows or fail loudly, never a silent subset.
flowchart TD
S["check_enable_rls(relid, role)"] --> A{relrowsecurity<br/>set on table?}
A -- no --> N1["RLS_NONE<br/>structurally absent"]
A -- yes --> B{has BYPASSRLS<br/>or superuser?}
B -- yes --> NE1["RLS_NONE_ENV<br/>depends on role"]
B -- no --> C{is table owner?}
C -- yes --> D{FORCE RLS set<br/>and not RI check?}
D -- no --> NE2["RLS_NONE_ENV<br/>owner bypass"]
D -- yes --> E
C -- no --> E{row_security<br/>GUC = on?}
E -- on --> EN["RLS_ENABLED<br/>inject quals"]
E -- off --> ERR["ERROR:<br/>query would be affected<br/>by RLS policy"]
Building the quals: USING, WITH CHECK, permissive, restrictive
Section titled “Building the quals: USING, WITH CHECK, permissive, restrictive”get_row_security_policies is invoked once per relation range-table entry.
It picks the command type — the target relation gets the query’s command
type, every other relation is treated as a SELECT source — fetches the
matching permissive and restrictive policies via
get_policies_for_relation, and routes them into two output lists:
securityQuals (the visibility filter) and withCheckOptions (the write
check). The combination rule is the OR-of-permissive / AND-of-restrictive
convention made concrete:
// add_security_quals — src/backend/rewrite/rowsecurity.c (condensed)foreach(item, permissive_policies){ RowSecurityPolicy *policy = lfirst(item); if (policy->qual != NULL) permissive_quals = lappend(permissive_quals, copyObject(policy->qual));}if (permissive_quals != NIL){ /* restrictive USING quals: each appended -> implicit AND */ foreach(item, restrictive_policies) { ... list_append_unique(securityQuals, qual); } /* all permissive USING quals: OR'd into one expression */ if (list_length(permissive_quals) == 1) rowsec_expr = linitial(permissive_quals); else rowsec_expr = makeBoolExpr(OR_EXPR, permissive_quals, -1); *securityQuals = list_append_unique(*securityQuals, rowsec_expr);}else /* no permissive policy -> default deny: a single FALSE */ *securityQuals = lappend(*securityQuals, makeConst(BOOLOID, -1, InvalidOid, sizeof(bool), BoolGetDatum(false), false, true));Three properties fall out of this code. First, default-deny: with RLS
armed but no permissive policy matching the role and command, the only qual
is FALSE, so the scan returns nothing. Second, OR-of-permissive:
multiple PERMISSIVE policies widen access — a row is visible if any
permissive USING is true. Third, AND-of-restrictive: every
RESTRICTIVE policy is a separate conjunct that must also hold, so
restrictive policies can only narrow what the permissive set granted.
Each qual is run through ChangeVarNodes to rewrite its Var references
onto the target RTE’s range-table index before being attached.
The write path (add_with_check_options) is structurally parallel but
emits WithCheckOption nodes the executor evaluates against each
proposed row. Its one important wrinkle is which expression it uses:
// add_with_check_options — src/backend/rewrite/rowsecurity.c#define QUAL_FOR_WCO(policy) \ ( !force_using && \ (policy)->with_check_qual != NULL ? \ (policy)->with_check_qual : (policy)->qual )When a policy supplies an explicit WITH CHECK, that expression governs
writes; otherwise the USING expression is reused (the “WITH CHECK
defaults to USING” rule). The force_using flag overrides this — used when
SELECT/ALL policies are folded into the write path purely to guarantee the
written row remains visible (e.g. for RETURNING or ON CONFLICT DO UPDATE), so that RLS never silently drops a row instead of erroring.
Restrictive WCOs are emitted one-per-policy and carry polname, so a
violated restrictive write check names the offending policy in the error;
the OR’d permissive WCO carries no name (its failure means “no policy
permitted this write” rather than a specific violation).
The DDL side: CREATE POLICY parses quals into pg_node_tree
Section titled “The DDL side: CREATE POLICY parses quals into pg_node_tree”CreatePolicy (in policy.c) is the command-side counterpart to the
rewrite-time injection. It validates the command/clause combination,
resolves the role list, parses the USING and WITH CHECK expressions
against the target table’s range table, and serializes them into
pg_policy:
// CreatePolicy — src/backend/commands/policy.c (condensed)polcmd = parse_policy_command(stmt->cmd_name);
/* SELECT and DELETE forbid WITH CHECK; INSERT forbids USING */if ((polcmd == ACL_SELECT_CHR || polcmd == ACL_DELETE_CHR) && stmt->with_check != NULL) ereport(ERROR, ... "WITH CHECK cannot be applied to SELECT or DELETE");if (polcmd == ACL_INSERT_CHR && stmt->qual != NULL) ereport(ERROR, ... "only WITH CHECK expression allowed for INSERT");
/* parse each clause against the target relation's RTE */qual = transformWhereClause(qual_pstate, stmt->qual, EXPR_KIND_POLICY, "POLICY");with_check_qual = transformWhereClause(with_check_pstate, stmt->with_check, EXPR_KIND_POLICY, "POLICY");...if (qual) values[Anum_pg_policy_polqual - 1] = CStringGetTextDatum(nodeToString(qual));else isnull[Anum_pg_policy_polqual - 1] = true;Two clause-shape rules are enforced here, not in the grammar: a SELECT or
DELETE policy may not carry a WITH CHECK (there is no new row to check),
and an INSERT policy may carry only a WITH CHECK (there is no existing
row to filter). The expression is stored as the textual nodeToString
form of the post-analysis parse tree — the same pg_node_tree
representation that RelationBuildRowSecurity later feeds back through
stringToNode. CreatePolicy takes AccessExclusiveLock on the table
(via RangeVarGetRelidExtended + RangeVarCallbackForPolicy, which also
checks ownership), records an AUTO dependency on the table plus NORMAL
dependencies on every object the quals reference, and ends with
CacheInvalidateRelcache so the next touch rebuilds the RowSecurityDesc.
Loading the descriptor: RelationBuildRowSecurity
Section titled “Loading the descriptor: RelationBuildRowSecurity”The bridge from catalog tuples to the cached RowSecurityPolicy list is
RelationBuildRowSecurity, called lazily by the relcache the first time a
relation with relrowsecurity set is opened after an invalidation:
// RelationBuildRowSecurity — src/backend/commands/policy.c (condensed)/* scan pg_policy by the (polrelid, polname) index -> deterministic name order */sscan = systable_beginscan(catalog, PolicyPolrelidPolnameIndexId, true, NULL, 1, &skey);while (HeapTupleIsValid(tuple = systable_getnext(sscan))){ Form_pg_policy policy_form = (Form_pg_policy) GETSTRUCT(tuple); policy->polcmd = policy_form->polcmd; policy->permissive = policy_form->polpermissive; policy->policy_name = MemoryContextStrdup(rscxt, NameStr(policy_form->polname)); ... /* deserialize the stored pg_node_tree back into an Expr */ str_value = TextDatumGetCString(datum); policy->qual = (Expr *) stringToNode(str_value); ... policy->hassublinks = checkExprHasSubLink((Node *) policy->qual) || checkExprHasSubLink((Node *) policy->with_check_qual); /* list built in reverse via lcons; the index gives name order */ rsdesc->policies = lcons(policy, rsdesc->policies);}The whole descriptor lives in a dedicated memory context reparented under
CacheMemoryContext, so a relcache flush frees it in one shot. The
hassublinks flag is precomputed here so the rewriter can OR it into the
query’s hasSubLinks without re-walking the tree. Note that the scan uses
PolicyPolrelidPolnameIndexId, which is exactly why get_policies_for_relation
can rely on a stable per-relation name order without sorting permissive
policies (it only sorts the restrictive set, whose WCOs need a
deterministic error order).
Role matching: PUBLIC fast path and role inheritance
Section titled “Role matching: PUBLIC fast path and role inheritance”check_role_for_policy decides whether a policy’s polroles array applies
to the asking user. The two interesting cases are the PUBLIC shortcut and
the use of has_privs_of_role (membership, not identity):
// check_role_for_policy — src/backend/rewrite/rowsecurity.cOid *roles = (Oid *) ARR_DATA_PTR(policy_roles);
/* Quick fall-thru for policies applied to all roles */if (roles[0] == ACL_ID_PUBLIC) return true;
for (i = 0; i < ARR_DIMS(policy_roles)[0]; i++){ if (has_privs_of_role(user_id, roles[i])) return true;}return false;Because the test is has_privs_of_role rather than user_id == roles[i],
a policy granted TO managers applies to anyone who inherits the
managers role, mirroring how SQL privilege checks resolve role
membership. policy_role_list_to_array (the DDL side) collapses an empty
role list — or an explicit PUBLIC — into the single-element
{ACL_ID_PUBLIC} array that this fast path keys on, and warns if PUBLIC
is mixed with named roles (since PUBLIC already covers everyone).
Command-to-policy matching and extension hooks
Section titled “Command-to-policy matching and extension hooks”get_policies_for_relation is the routing core: for a given command type
it walks the cached descriptor, admits every ALL ('*') policy plus
those whose polcmd matches the command, filters by role, and splits the
survivors into permissive vs. restrictive lists — then appends any
policies contributed by the two extension hooks:
// get_policies_for_relation — src/backend/rewrite/rowsecurity.c (condensed)foreach(item, relation->rd_rsdesc->policies){ RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item); bool cmd_matches = false;
if (policy->polcmd == '*') /* ALL policy: always applies */ cmd_matches = true; else switch (cmd) { case CMD_SELECT: cmd_matches = (policy->polcmd == ACL_SELECT_CHR); break; case CMD_UPDATE: cmd_matches = (policy->polcmd == ACL_UPDATE_CHR); break; ... /* INSERT, DELETE; MERGE derives from the others, no own policy */ } if (cmd_matches && check_role_for_policy(policy->roles, user_id)) { if (policy->permissive) *permissive_policies = lappend(*permissive_policies, policy); else *restrictive_policies = lappend(*restrictive_policies, policy); }}sort_policies_by_name(*restrictive_policies); /* deterministic WCO order */CMD_MERGE has no polcmd of its own — the MERGE case in the switch is
deliberately empty, and get_row_security_policies instead synthesizes
MERGE behavior from the UPDATE/DELETE/INSERT/SELECT policies (the large
if (commandType == CMD_MERGE) block), enforcing each action’s USING quals
as WCO_RLS_MERGE_*_CHECK WithCheckOptions so a row the user may not
update/delete raises an error rather than being silently skipped. The two
hooks row_security_policy_hook_permissive / _restrictive let an
extension (e.g. sepgsql-style label security) inject additional policies;
hook-provided restrictive policies are themselves name-sorted and always
checked after the built-in restrictive set.
The full per-RTE flow
Section titled “The full per-RTE flow”flowchart TD
GR["get_row_security_policies(root, rte, rt_index)"] --> CE["check_enable_rls()"]
CE --> ST{rls_status?}
ST -- RLS_NONE --> R0["return: no quals"]
ST -- RLS_NONE_ENV --> R1["hasRowSecurity = true<br/>return: force replan"]
ST -- RLS_ENABLED --> CMD{rt_index ==<br/>resultRelation?}
CMD -- yes --> TC["commandType = query cmd"]
CMD -- no --> SC["commandType = CMD_SELECT"]
TC --> GP["get_policies_for_relation()<br/>split permissive / restrictive"]
SC --> GP
GP --> AQ["add_security_quals()<br/>USING -> rte->securityQuals"]
GP --> WC{INSERT / UPDATE /<br/>MERGE?}
WC -- yes --> AW["add_with_check_options()<br/>WITH CHECK -> withCheckOptions"]
WC -- no --> DONE
AQ --> DONE["setRuleCheckAsUser()<br/>hasRowSecurity = true"]
AW --> DONE
Source Walkthrough
Section titled “Source Walkthrough”The mechanism splits cleanly across three files. The command side
(policy.c) turns DDL into pg_policy tuples and rebuilds the relcache
descriptor; the enablement oracle (rls.c) answers “does RLS apply
here, now, for this role”; the rewrite side (rowsecurity.c) injects
the quals. Anchor on these symbols:
-
Catalog & descriptor.
FormData_pg_policy(the on-disk tuple),RowSecurityPolicy/RowSecurityDesc(the cached form),RelationBuildRowSecurity(catalog → cache, via thePolicyPolrelidPolnameIndexIdscan andstringToNodedeserialization),RemovePolicyById(drop, with theCacheInvalidateRelcachenote thatrelrowsecurityis not merely “policies exist”). -
DDL execution.
CreatePolicy,AlterPolicy,parse_policy_command(clause-string →polcmdchar),policy_role_list_to_array(PUBLICcollapsing),RangeVarCallbackForPolicy(ownership + relkind check underAccessExclusiveLock),RemoveRoleFromObjectPolicy(DROP ROLEcleanup). -
Enablement.
check_enable_rls(the three-valued oracle:relrowsecurity→has_bypassrls_privilege→object_ownercheck+relforcerowsecurity+InNoForceRLSOperation→row_securityGUC),row_security_active(the SQL-callable wrapper withnoError = true), and theCheckEnableRlsResultenum (RLS_NONE/RLS_NONE_ENV/RLS_ENABLED). -
Rewrite injection.
get_row_security_policies(the per-RTE driver, including theFOR UPDATE,RETURNING,ON CONFLICT DO UPDATE, andMERGEsub-cases),get_policies_for_relation(command/role routing + hooks),add_security_quals(USING →securityQuals, OR-of-permissive / AND-of-restrictive / default-denyFALSE),add_with_check_options(WITH CHECK →WithCheckOption, theQUAL_FOR_WCOmacro andforce_using),check_role_for_policy(ACL_ID_PUBLICfast path +has_privs_of_role),sort_policies_by_name/row_security_policy_cmp. -
Extension surface.
row_security_policy_hook_permissive/row_security_policy_hook_restrictive(function pointers an extension sets to contribute policies),WCOKind(WCO_RLS_INSERT_CHECK,WCO_RLS_UPDATE_CHECK,WCO_RLS_CONFLICT_CHECK,WCO_RLS_MERGE_UPDATE_CHECK,WCO_RLS_MERGE_DELETE_CHECK).
Position hints (as of 2026-06-05, REL_18 273fe94):
| Symbol | File | Line |
|---|---|---|
get_row_security_policies | src/backend/rewrite/rowsecurity.c | 97 |
get_policies_for_relation | src/backend/rewrite/rowsecurity.c | 540 |
sort_policies_by_name | src/backend/rewrite/rowsecurity.c | 664 |
row_security_policy_cmp | src/backend/rewrite/rowsecurity.c | 673 |
add_security_quals | src/backend/rewrite/rowsecurity.c | 699 |
add_with_check_options | src/backend/rewrite/rowsecurity.c | 795 |
QUAL_FOR_WCO (macro) | src/backend/rewrite/rowsecurity.c | 808 |
check_role_for_policy | src/backend/rewrite/rowsecurity.c | 915 |
row_security_policy_hook_permissive | src/backend/rewrite/rowsecurity.c | 86 |
check_enable_rls | src/backend/utils/misc/rls.c | 52 |
row_security_active | src/backend/utils/misc/rls.c | 136 |
CheckEnableRlsResult (enum) | src/include/utils/rls.h | 41 |
row_security (GUC bool) | src/backend/utils/misc/guc_tables.c | 1696 |
parse_policy_command | src/backend/commands/policy.c | 107 |
policy_role_list_to_array | src/backend/commands/policy.c | 136 |
RelationBuildRowSecurity | src/backend/commands/policy.c | 192 |
RemovePolicyById | src/backend/commands/policy.c | 331 |
RemoveRoleFromObjectPolicy | src/backend/commands/policy.c | 415 |
CreatePolicy | src/backend/commands/policy.c | 568 |
AlterPolicy | src/backend/commands/policy.c | 767 |
RangeVarCallbackForPolicy | src/backend/commands/policy.c | (static, after CreatePolicy) |
FormData_pg_policy | src/include/catalog/pg_policy.h | (CATALOG block) |
RowSecurityPolicy | src/include/rewrite/rowsecurity.h | (struct) |
Source verification (as of 2026-06-05)
Section titled “Source verification (as of 2026-06-05)”Verified facts
Section titled “Verified facts”-
A policy is one
pg_policytuple carryingpolrelid, a single-charpolcmd,polpermissive, apolrolesOID array, and the twopg_node_treecolumnspolqual(USING) andpolwithcheck(WITH CHECK). Read frompg_policy.h(theCATALOG(pg_policy,...)block) on 2026-06-05.polcmdreuses theACL_*_CHRprivilege characters (r/a/w/d) plus'*'forALL; confirmed againstparse_policy_commandinpolicy.c. -
check_enable_rlsreturns a three-valued result and resolves bypass in a fixed order: BYPASSRLS/superuser, then owner (unless FORCE and not an RI check), then therow_securityGUC. Verified inrls.c(has_bypassrls_privilege→object_ownercheck+!relforcerowsecurity || InNoForceRLSOperation()→!row_security && !noErrorraisesERRCODE_INSUFFICIENT_PRIVILEGE). Built-in relations (OID< FirstNormalObjectId) short-circuit toRLS_NONE. -
RLS_NONEvsRLS_NONE_ENVis the plan-cache hinge. Verified inget_row_security_policies:RLS_NONEreturns withhasRowSecurityleft false;RLS_NONE_ENVsets*hasRowSecurity = trueand returns without adding quals, forcing a replan when role or GUC changes. Therls.henum comment states the same contract. -
Permissive USING quals are OR-combined, restrictive ones AND-combined, and the absence of any permissive policy yields a single always-false
Const(default-deny). Verified inadd_security_quals: restrictive quals are appended one at a time (implicit AND), permissive quals are folded withmakeBoolExpr(OR_EXPR, ...), and theelsebranch emitsmakeConst(BOOLOID, ..., BoolGetDatum(false), ...).add_with_check_optionsmirrors this withWithCheckOptionnodes. -
WITH CHECK defaults to USING unless
force_usingoverrides. Verified in theQUAL_FOR_WCOmacro:!force_using && with_check_qual != NULL ? with_check_qual : qual. SELECT/ALL policies are folded into the write path withforce_using = trueforRETURNING/ON CONFLICT DO UPDATE/MERGEso a non-visible written row errors instead of vanishing. -
Restrictive WCOs carry
polname; the OR’d permissive WCO does not. Verified inadd_with_check_options: the permissivewco->polname = NULL, while each restrictivewco->polname = pstrdup(policy->policy_name), so a violated restrictive write check names the policy in the error. -
Role matching uses
has_privs_of_role(membership), with anACL_ID_PUBLICfast path. Verified incheck_role_for_policy. The DDL side (policy_role_list_to_array) collapses empty/PUBLICrole lists to the single{ACL_ID_PUBLIC}element and warns onPUBLICmixed with named roles. -
MERGEhas no policy of its own; its behavior is derived. Verified inget_policies_for_relation(emptyCMD_MERGEswitch case) and theCMD_MERGEblock ofget_row_security_policies, which assembles UPDATE/DELETE/INSERT/SELECT policies intoWCO_RLS_MERGE_*_CHECKchecks. -
The relcache descriptor is built by scanning
pg_policyin(polrelid, polname)index order andstringToNode-deserializing each qual. Verified inRelationBuildRowSecurity(usesPolicyPolrelidPolnameIndexId, builds the list vialcons, reparents the context underCacheMemoryContext, precomputeshassublinks).
Open questions
Section titled “Open questions”-
Cost of OR-folding many permissive policies on the planner. A row is visible if any permissive USING is true, so N permissive policies become an N-way
BoolExpr(OR)prepended as a security-barrier qual. How the planner’s selectivity estimation and the security-barrier subquery wrapping (postgres-rewriter.md) interact for large N — and whether leakproofness of the OR’d branches is evaluated branch-by-branch — is not obvious fromrowsecurity.calone. Investigation path: trace a multi-policy table throughsecurityQualsintoexpand_security_quals. -
Exact replan frequency under
RLS_NONE_ENV.hasRowSecurity = trueforces plan-cache revalidation when role/GUC change, but how the plancache distinguishes an RLS-driven invalidation from other invalidations (and whether aSET ROLEround-trip always discards the plan) lives in the plancache, not here. Investigation path: cross-readplancache.c’sCheckCachedPlanagainst thehasRowSecurityflag. -
Interaction of FORCE RLS with referential-integrity triggers on the owner.
InNoForceRLSOperation()suppresses FORCE RLS during RI checks for the owner, but the precise set of operations that enter that context (and whether all FK-maintenance paths reach it) is defined in the trigger machinery, not inrls.c. Investigation path: enumerateInNoForceRLSOperationcall sites inri_triggers.c.
Beyond PostgreSQL — Comparative Designs & Research Frontiers
Section titled “Beyond PostgreSQL — Comparative Designs & Research Frontiers”-
Oracle Virtual Private Database (VPD). Oracle’s
DBMS_RLS.ADD_POLICYregisters a PL/SQL policy function per (table, statement-type) that returns a predicate string the server appends to the statement, plus optionalsec_relevant_colsfor column-sensitive masking. PostgreSQL stores declarative expressions (not functions) inpg_policyand has no built-in column-masking analogue — column privileges and views cover that axis instead. A side-by-side of VPD’s function-returns-predicate model against PostgreSQL’s stored-pg_node_treemodel would sharpen the trade-off between flexibility (arbitrary PL/SQL) and plan-cacheability (a fixed parse tree the optimizer can reason about). -
SQL Server row-level security. SQL Server binds inline table-valued predicate functions as
FILTERpredicates (read path) orBLOCKpredicates (write path) — structurally the same USING/WITH-CHECK split PostgreSQL names, but expressed as functions rather than expressions. MappingFILTER→securityQualsandBLOCK→withCheckOptionsmakes the shared design space concrete. -
Query rewriting for fine-grained access control (Rizvi et al., SIGMOD 2004). “Extending Query Rewriting Techniques for Fine-Grained Access Control” is the academic lineage of the security-view approach PostgreSQL generalizes. Its central problem — a side-effecting user function observing rows the security predicate should hide — is exactly what PostgreSQL’s security-barrier + leakproofness machinery answers; the rewrite-vs-runtime placement is the same decision. A focused note relating the paper’s “authorization-transparent” rewriting to
expand_security_qualswould connect theory torowsecurity.c. -
Bertino & Sandhu, Database Security (IEEE TDSC 2005). The survey’s taxonomy of content-based, fine-grained access control — and its split between read (visibility) and write (integrity) predicates — is precisely the USING/WITH-CHECK dichotomy. It also frames label-based / mandatory access control (MAC), which PostgreSQL deliberately does not build in, delegating it to extensions via the two policy hooks (the
sepgsqlSELinux-label model being the canonical example). -
Leakproofness and the covert-channel frontier. RLS’s security guarantee depends on the optimizer not pushing a non-leakproof user function below the security qual. The boundary cases —
pg_proc.proleakproofmarking, error-message andEXPLAIN-cost side channels, timing channels — are an active hardening area. PostgreSQL’s choice to make the predicate a security barrier rather than a post-filter is the structural defense; quantifying residual channels is research-grade. -
CUBRID and row-level access control. CUBRID has no first-class RLS feature equivalent to
pg_policy; equivalent confidentiality is built with updatable views andGRANT. A comparison would highlight what the rewrite-time qual-injection design buys over the manual security-view pattern — chiefly non-circumvention (the user cannot bypass a view they are never forced through) and default-deny. (See the CUBRID analyses in the cubrid tree.)
Sources
Section titled “Sources”In-tree source files (REL_18_STABLE, commit 273fe94)
Section titled “In-tree source files (REL_18_STABLE, commit 273fe94)”src/backend/rewrite/rowsecurity.c— the rewrite-time injection:get_row_security_policies,get_policies_for_relation,add_security_quals,add_with_check_options,check_role_for_policy, the two extension hooks, and theQUAL_FOR_WCOmacro.src/backend/commands/policy.c— the DDL side:CreatePolicy,AlterPolicy,RemovePolicyById,RelationBuildRowSecurity(relcache loader),parse_policy_command,policy_role_list_to_array,RangeVarCallbackForPolicy,RemoveRoleFromObjectPolicy.src/backend/utils/misc/rls.c— the enablement oracle:check_enable_rls,row_security_active.src/include/utils/rls.h— theCheckEnableRlsResultenum (RLS_NONE/RLS_NONE_ENV/RLS_ENABLED) and therow_securityGUC extern.src/include/catalog/pg_policy.h—FormData_pg_policy, the catalog schema andpolcmdencoding.src/include/rewrite/rowsecurity.h—RowSecurityPolicy,RowSecurityDesc, therow_security_policy_hook_typetypedef.src/backend/utils/misc/guc_tables.c— therow_securityboolean GUC (PGC_USERSET, defaulton).
Papers and textbook chapters
Section titled “Papers and textbook chapters”- Rizvi, S., Mendelzon, A., Sudarshan, S. & Roy, P. (2004). “Extending Query Rewriting Techniques for Fine-Grained Access Control.” SIGMOD 2004. The query-rewriting-for-access-control lineage RLS generalizes.
- Bertino, E. & Sandhu, R. (2005). “Database Security — Concepts, Approaches, and Challenges.” IEEE TDSC 2(1):2-19. Taxonomy of fine-grained / content-based access control and the read/write predicate split.
- Database System Concepts (Silberschatz, Korth, Sudarshan, 7e), ch. 4
“Intermediate SQL” (authorization,
GRANT/REVOKE) and ch. 9 (application design and security) — the table/column-granularity DAC baseline RLS extends to the row. (knowledge/research/dbms-general/.)
Sibling docs (cross-references — mechanism owned there, not duplicated here)
Section titled “Sibling docs (cross-references — mechanism owned there, not duplicated here)”postgres-rewriter.md—fireRIRrules(the caller ofget_row_security_policies) and howrte->securityQualsbecomes a security-barrier subquery viaexpand_security_quals/preprocess_rowmarks. The leakproofness / qual-pushdown rules live there.postgres-ddl-execution.md— howALTER TABLE ... ENABLE / FORCE ROW LEVEL SECURITYsetspg_class.relrowsecurity/relforcerowsecurity, and theProcessUtilitydispatch toCreatePolicy/AlterPolicy.postgres-executor.md—ExecWithCheckOptions, which evaluates theWithCheckOptionnodes this module builds against each written row.postgres-relcache.md— the relcache lifecycle and invalidation that driveRelationBuildRowSecurityand therd_rsdescdescriptor.postgres-system-catalogs.md—pg_policyamong the access-control catalogs (pg_authid,pg_classflags,pg_default_acl).