Skip to content

PostgreSQL Row-Level Security — Policies and Rewrite-Time Quals

Contents:

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”:

  1. 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 forgotten WHERE clause leaks the whole table, and ad-hoc tools (psql, a reporting connection) bypass it entirely.

  2. 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’s WHERE clause 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.

  3. 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 USING and WITH 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.

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.

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.

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 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.h
CATALOG(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.h
typedef 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.c
if (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.c
Oid *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.

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

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 the PolicyPolrelidPolnameIndexId scan and stringToNode deserialization), RemovePolicyById (drop, with the CacheInvalidateRelcache note that relrowsecurity is not merely “policies exist”).

  • DDL execution. CreatePolicy, AlterPolicy, parse_policy_command (clause-string → polcmd char), policy_role_list_to_array (PUBLIC collapsing), RangeVarCallbackForPolicy (ownership + relkind check under AccessExclusiveLock), RemoveRoleFromObjectPolicy (DROP ROLE cleanup).

  • Enablement. check_enable_rls (the three-valued oracle: relrowsecurityhas_bypassrls_privilegeobject_ownercheck + relforcerowsecurity + InNoForceRLSOperationrow_security GUC), row_security_active (the SQL-callable wrapper with noError = true), and the CheckEnableRlsResult enum (RLS_NONE / RLS_NONE_ENV / RLS_ENABLED).

  • Rewrite injection. get_row_security_policies (the per-RTE driver, including the FOR UPDATE, RETURNING, ON CONFLICT DO UPDATE, and MERGE sub-cases), get_policies_for_relation (command/role routing + hooks), add_security_quals (USING → securityQuals, OR-of-permissive / AND-of-restrictive / default-deny FALSE), add_with_check_options (WITH CHECK → WithCheckOption, the QUAL_FOR_WCO macro and force_using), check_role_for_policy (ACL_ID_PUBLIC fast 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):

SymbolFileLine
get_row_security_policiessrc/backend/rewrite/rowsecurity.c97
get_policies_for_relationsrc/backend/rewrite/rowsecurity.c540
sort_policies_by_namesrc/backend/rewrite/rowsecurity.c664
row_security_policy_cmpsrc/backend/rewrite/rowsecurity.c673
add_security_qualssrc/backend/rewrite/rowsecurity.c699
add_with_check_optionssrc/backend/rewrite/rowsecurity.c795
QUAL_FOR_WCO (macro)src/backend/rewrite/rowsecurity.c808
check_role_for_policysrc/backend/rewrite/rowsecurity.c915
row_security_policy_hook_permissivesrc/backend/rewrite/rowsecurity.c86
check_enable_rlssrc/backend/utils/misc/rls.c52
row_security_activesrc/backend/utils/misc/rls.c136
CheckEnableRlsResult (enum)src/include/utils/rls.h41
row_security (GUC bool)src/backend/utils/misc/guc_tables.c1696
parse_policy_commandsrc/backend/commands/policy.c107
policy_role_list_to_arraysrc/backend/commands/policy.c136
RelationBuildRowSecuritysrc/backend/commands/policy.c192
RemovePolicyByIdsrc/backend/commands/policy.c331
RemoveRoleFromObjectPolicysrc/backend/commands/policy.c415
CreatePolicysrc/backend/commands/policy.c568
AlterPolicysrc/backend/commands/policy.c767
RangeVarCallbackForPolicysrc/backend/commands/policy.c(static, after CreatePolicy)
FormData_pg_policysrc/include/catalog/pg_policy.h(CATALOG block)
RowSecurityPolicysrc/include/rewrite/rowsecurity.h(struct)
  • A policy is one pg_policy tuple carrying polrelid, a single-char polcmd, polpermissive, a polroles OID array, and the two pg_node_tree columns polqual (USING) and polwithcheck (WITH CHECK). Read from pg_policy.h (the CATALOG(pg_policy,...) block) on 2026-06-05. polcmd reuses the ACL_*_CHR privilege characters (r/a/w/d) plus '*' for ALL; confirmed against parse_policy_command in policy.c.

  • check_enable_rls returns a three-valued result and resolves bypass in a fixed order: BYPASSRLS/superuser, then owner (unless FORCE and not an RI check), then the row_security GUC. Verified in rls.c (has_bypassrls_privilegeobject_ownercheck + !relforcerowsecurity || InNoForceRLSOperation()!row_security && !noError raises ERRCODE_INSUFFICIENT_PRIVILEGE). Built-in relations (OID < FirstNormalObjectId) short-circuit to RLS_NONE.

  • RLS_NONE vs RLS_NONE_ENV is the plan-cache hinge. Verified in get_row_security_policies: RLS_NONE returns with hasRowSecurity left false; RLS_NONE_ENV sets *hasRowSecurity = true and returns without adding quals, forcing a replan when role or GUC changes. The rls.h enum 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 in add_security_quals: restrictive quals are appended one at a time (implicit AND), permissive quals are folded with makeBoolExpr(OR_EXPR, ...), and the else branch emits makeConst(BOOLOID, ..., BoolGetDatum(false), ...). add_with_check_options mirrors this with WithCheckOption nodes.

  • WITH CHECK defaults to USING unless force_using overrides. Verified in the QUAL_FOR_WCO macro: !force_using && with_check_qual != NULL ? with_check_qual : qual. SELECT/ALL policies are folded into the write path with force_using = true for RETURNING / ON CONFLICT DO UPDATE / MERGE so a non-visible written row errors instead of vanishing.

  • Restrictive WCOs carry polname; the OR’d permissive WCO does not. Verified in add_with_check_options: the permissive wco->polname = NULL, while each restrictive wco->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 an ACL_ID_PUBLIC fast path. Verified in check_role_for_policy. The DDL side (policy_role_list_to_array) collapses empty/PUBLIC role lists to the single {ACL_ID_PUBLIC} element and warns on PUBLIC mixed with named roles.

  • MERGE has no policy of its own; its behavior is derived. Verified in get_policies_for_relation (empty CMD_MERGE switch case) and the CMD_MERGE block of get_row_security_policies, which assembles UPDATE/DELETE/INSERT/SELECT policies into WCO_RLS_MERGE_*_CHECK checks.

  • The relcache descriptor is built by scanning pg_policy in (polrelid, polname) index order and stringToNode-deserializing each qual. Verified in RelationBuildRowSecurity (uses PolicyPolrelidPolnameIndexId, builds the list via lcons, reparents the context under CacheMemoryContext, precomputes hassublinks).

  1. 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 from rowsecurity.c alone. Investigation path: trace a multi-policy table through securityQuals into expand_security_quals.

  2. Exact replan frequency under RLS_NONE_ENV. hasRowSecurity = true forces plan-cache revalidation when role/GUC change, but how the plancache distinguishes an RLS-driven invalidation from other invalidations (and whether a SET ROLE round-trip always discards the plan) lives in the plancache, not here. Investigation path: cross-read plancache.c’s CheckCachedPlan against the hasRowSecurity flag.

  3. 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 in rls.c. Investigation path: enumerate InNoForceRLSOperation call sites in ri_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_POLICY registers a PL/SQL policy function per (table, statement-type) that returns a predicate string the server appends to the statement, plus optional sec_relevant_cols for column-sensitive masking. PostgreSQL stores declarative expressions (not functions) in pg_policy and 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_tree model 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 FILTER predicates (read path) or BLOCK predicates (write path) — structurally the same USING/WITH-CHECK split PostgreSQL names, but expressed as functions rather than expressions. Mapping FILTERsecurityQuals and BLOCKwithCheckOptions makes 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_quals would connect theory to rowsecurity.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 sepgsql SELinux-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.proleakproof marking, error-message and EXPLAIN-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 and GRANT. 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.)

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 the QUAL_FOR_WCO macro.
  • 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 — the CheckEnableRlsResult enum (RLS_NONE / RLS_NONE_ENV / RLS_ENABLED) and the row_security GUC extern.
  • src/include/catalog/pg_policy.hFormData_pg_policy, the catalog schema and polcmd encoding.
  • src/include/rewrite/rowsecurity.hRowSecurityPolicy, RowSecurityDesc, the row_security_policy_hook_type typedef.
  • src/backend/utils/misc/guc_tables.c — the row_security boolean GUC (PGC_USERSET, default on).
  • 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.mdfireRIRrules (the caller of get_row_security_policies) and how rte->securityQuals becomes a security-barrier subquery via expand_security_quals / preprocess_rowmarks. The leakproofness / qual-pushdown rules live there.
  • postgres-ddl-execution.md — how ALTER TABLE ... ENABLE / FORCE ROW LEVEL SECURITY sets pg_class.relrowsecurity / relforcerowsecurity, and the ProcessUtility dispatch to CreatePolicy / AlterPolicy.
  • postgres-executor.mdExecWithCheckOptions, which evaluates the WithCheckOption nodes this module builds against each written row.
  • postgres-relcache.md — the relcache lifecycle and invalidation that drive RelationBuildRowSecurity and the rd_rsdesc descriptor.
  • postgres-system-catalogs.mdpg_policy among the access-control catalogs (pg_authid, pg_class flags, pg_default_acl).