Skip to content
← Free FDE training guides

Free learning / 45-minute role-policy lab + 30-minute session workshop

PostgreSQL row security: test the role that actually reads

Run real PostgreSQL role and row-policy checks. Observe default denial, rejected writes, privileged bypass and permissive policies, then trace request context across one reused connection.

No account needed. Use your own copy for writing and keep the original as a reference.

Engineering lab library / Run locally

Choose your next agent lab.

Start with the durable workflow reference, then choose the boundary you want to test. Open its walkthrough for setup, commands and expected observations.

Synthetic records. No provider key or model inference. Runs locally with the standard library.

  1. Start here / Python + Docker · PostgreSQL 17

    PostgreSQL roles and row policies

    Observe ordinary grants, RLS policies, write checks, bypass roles, permissive policy composition and a denied role switch in an isolated scratch database.

    Python 3.11+ · Local Docker · Cached PostgreSQL 17 image · No cloud or existing database

A query can be parameterized and still return another organization's records. Application scope and database enforcement are different layers. In this 45-minute lesson, run actual PostgreSQL 17 queries under four fictional roles. Observe ordinary grants, default denial, policy checks, owner and maintenance bypass, and an additional policy that unexpectedly broadens a read.

You need basic SELECT/INSERT knowledge, Python 3.11+ and an approved local Docker runtime. Start with Identity 101 if authentication, authorization or tenant scope is unfamiliar. The workshop uses invented procedure rows. It does not connect to an application, cloud project or existing database.

1. Separate the connection, grant and row policy

A role is a PostgreSQL identity with privileges. A schema groups database objects; a table holds rows. A table grant permits a kind of operation such as SELECT. Row-level security (RLS) adds row conditions for ordinary reads and writes. These layers answer different questions: which role is connected, which operation is granted, and which rows may participate?

Our scratch table has two independently defined records:

IDtenant_idTitle
101tenant_alphaAlpha procedure
202tenant_betaBeta procedure

The two tenant roles receive SELECT and INSERT grants. They are neither table owners, superusers nor BYPASSRLS roles. lab_owner owns the table. lab_bypass is a deliberately powerful maintenance example with BYPASSRLS. The setup records these actual role attributes in role_attributes.

These are separate database roles to make the mechanism inspectable. Many applications instead share a database role and bind trusted request context to a transaction. That requires its own context-setting, pool reuse and denial tests; this lab does not implement that design.

2. Follow the policy before assuming isolation

The scoped policy is deliberately small:

Code example
ALTER TABLE lab.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_scope ON lab.documents
USING (tenant_id = current_user)
WITH CHECK (tenant_id = current_user);

current_user is the effective database role, not a tenant string sent by an agent or browser. USING controls eligible existing rows. WITH CHECK controls proposed new rows for the relevant writes. A filtered SELECT can successfully return zero rows; that is different from a rejected INSERT. Neither establishes whether an application authenticated the right person.

Enabling RLS without an applicable policy denies ordinary row access even when the role has a table grant. A table owner ordinarily bypasses row policies. FORCE ROW LEVEL SECURITY subjects the owner to them, but does not constrain superusers or BYPASSRLS roles. An owner can also change the table's controls. Choosing an ordinary runtime role remains part of the boundary.

UNDERSTAND THE SYSTEM

A row policy depends on the role that reaches it

Follow the default synthetic PostgreSQL 17 experiment. These are authored explanations of the lab, not a browser execution. The downloadable runner records actual role attributes, SQL and rows in a disposable container.

FOLLOW THE WORK

Watch one example, step by step.

5 authored stepsYou control the pace
01 / EXPLORE THE PARTSControls loading · written explanations below
Direction of information or workReturn / feedback

WHAT THIS PART DOES

Connection role

The local trust fixture supplies tenant_alpha, tenant_beta, lab_owner or lab_bypass as the connection identity. No password or token is verified. A requested tenant value does not choose this role.

CONNECTED TO
  • Select the principal: Operation grant
Read the full worked example
  1. 01
    1 · Grant and default denial

    Alpha's ordinary SELECT initially reads 101 and 202. Enable row security without a policy and the same role reads no rows. Both reads succeed: their row populations change because the applicable controls change.

    DEFAULT LAB: Grant: [101, 202] · enabled, no policy: []

  2. 02
    2 · Scope and requested tenant

    The scoped policy returns 101 to Alpha and 202 to Beta. Alpha's request for tenant_beta returns nothing. The requested filter narrows a read; it does not change current_user or establish authority over another workspace.

    DEFAULT LAB: Alpha → [101] · Beta → [202] · Alpha requests Beta → []

  3. 03
    3 · Check the proposed write

    Alpha tries to insert Beta record 303. WITH CHECK rejects that statement with SQLSTATE 42501. An independent administrator read confirms the row is absent. A denied statement and a successful empty read are separate observations.

    DEFAULT LAB: INSERT rejected · 303 absent

  4. 04
    4 · FORCE and privileged roles

    Before FORCE, the table owner reads both records. After FORCE it sees none because neither row belongs to lab_owner. The maintenance BYPASSRLS role still reads both. The receiver must inspect the actual runtime attributes and owner.

    DEFAULT LAB: Forced owner → [] · BYPASSRLS → [101, 202]

  5. 05
    5 · Another policy can broaden access

    Add an applicable permissive SELECT policy USING (true). Alpha now sees both records even though tenant_scope still exists. Drop that extra policy and Alpha returns to 101. Its attempt to SET ROLE tenant_beta is independently refused; neither matching notes nor an existing policy name proves isolation.

    DEFAULT LAB: Extra policy → [101, 202] · restored → [101]

Read the connections in plain English
  1. 01
    Connection role → Operation grant

    Establish which database identity is executing the operation.

  2. 02
    Operation grant → Applicable policies

    A table privilege does not establish tenant isolation.

  3. 03
    Applicable policies → Observed rows

    Keep returned rows and SQLSTATE with the actual statement.

  4. 04
    Observed rows → Owner and bypass

    An apparently good policy does not constrain every privileged runtime.

  5. 05
    Owner and bypass → Receiving check

    Receive evidence about the actual runtime rather than a policy name.

Check your understanding: FORCE is enabled and a policy named tenant_scope exists. Has the runtime been shown to be isolated?

No. Inspect its actual role attributes, ownership and all applicable policies, then execute ordinary and denied operations. The lab's BYPASSRLS role ignores row security, and an extra permissive SELECT policy broadens an ordinary role's read.

3. Prepare the isolated local runtime

Download the original Python runner. Use your normal approved process to install Docker and cache a PostgreSQL 17 image. If allowed in your environment, the cache command is:

Code example
docker pull postgres:17
python3 --version
docker version

The runner never pulls an image. It resolves the cached tag once and starts that exact local image ID. Keep image_id, image_repo_digests and postgres_version with your results: postgres:17 is a moving major-version tag, not an immutable release identity or evidence of a verified signature.

One uniquely named scratch container uses a temporary memory-backed data volume, no published host port and Docker's none network. Readiness uses loopback inside that container; the actual SQL calls use its local socket. Local trust login supplies each fictional connection identity without checking a password. That fixture is confined to the disposable container and is not a production authentication example. The runner removes only its owned container on ordinary exit or failure and reports whether removal was confirmed. A forced process kill or unavailable Docker daemon can prevent cleanup.

4. Predict ten cases and run the SQL

Before opening the interpretation below, predict the returned IDs or SQLSTATE for each case. Keep your first answers separately from the report.

CaseQuestion to predict
ordinary-grantWhat does tenant_alpha see before RLS exists?
default-denyWhat changes after ENABLE, before a policy exists?
scoped-rolesWhich rows can Alpha and Beta independently read?
requested-tenantCan Alpha's requested Beta filter change its database role?
write-checkCan Alpha insert record 303 owned by Beta?
owner-bypassWhat does the table owner see before FORCE?
force-and-bypassWhat do the owner and BYPASSRLS role see after FORCE?
permissive-orWhat happens when Alpha gets an extra permissive SELECT policy?
policy-restoredWhat does Alpha see after that extra policy is removed?
role-switch-deniedCan Alpha SET ROLE to Beta without membership?
Code example
python3 fde-postgres-row-security.py > original.json
python3 -m json.tool original.json

The ten cases run in order in one fresh scratch database. changes records the DDL between observations. Every read retains its exact SQL, role, exit code and actual rows. Expected refusal attempts additionally retain SQLSTATE and the executed statement. A successful runner exit means it produced observations and confirmed cleanup; it does not grade your predictions or establish isolation for a real application.

5. Interpret the default run

Before RLS, Alpha reads 101 and 202 through its ordinary grant. After ENABLE without a policy it reads nothing. With the scoped policy, Alpha gets 101 and Beta gets 202. Alpha's request for tenant_beta returns an empty array; a WHERE condition narrows a query and does not select the connection identity.

Alpha's INSERT of Beta record 303 is rejected with SQLSTATE 42501. The separate administrator read confirms that 303 is absent. That read intentionally uses a superuser to inspect the complete synthetic table; it is not the application's permission check. Capture the role and statement with the error: the later forbidden SET ROLE also produces 42501, for a different operation.

The table owner initially reads both records. After FORCE, it sees none because neither row belongs to lab_owner. The maintenance BYPASSRLS role still reads both. FORCE is therefore not sufficient evidence that every configured runtime is subject to the intended boundary.

The extra SELECT policy is:

Code example
CREATE POLICY extra_read ON lab.documents
FOR SELECT TO tenant_alpha USING (true);

Permissive policies combine with OR. Alpha now reads both records despite the original tenant policy still existing; Beta still reads 202. After the extra policy is dropped, Alpha returns to 101. Do not infer safety from the presence or name of one policy. Review the applicable policy set and role memberships.

Alpha's attempt to become Beta is refused. The separately recorded fresh connection still reports session_user and current_user as tenant_alpha. The example checks a role boundary; it does not test a password, SSO token, connection pool or a security-definer function.

6. Change the request without changing identity

Keep original.json. Predict the changed requested-tenant case, then run:

Code example
python3 fde-postgres-row-security.py --requested-tenant tenant_alpha > changed.json

Only that case's requested filter changes: its Alpha connection now returns 101. Its role and scoped policy did not become more powerful. Compare the actual SQL, returned rows and the rest of the case sequence. Two matching whole-report files are not expected because runtime and image metadata may vary between machines.

For an advanced transfer, copy the runner and change only extra_read from TO tenant_alpha to TO tenant_beta. Predict both reads in permissive-or, run the changed copy and retain its exact code beside the output. Alpha should remain scoped while Beta's new applicable permissive policy broadens its read. The receiver must inspect those actual results rather than inherit your conclusion.

6a. Reuse a connection without reusing its authority

A connection pool lends an existing database connection to another request. A physical PostgreSQL session can therefore outlive the application request that first used it. The ordinary database role may stay app_runtime while successive requests belong to different workspaces. Request scope must not be inferred from whatever the connection happens to remember.

The optional 30-minute session workshop uses the same approved local runtime as the first exercise. It holds one actual PostgreSQL connection open through one psql process and labels six sequences of logical requests. It does not instantiate a driver pool or authenticate a person. Each read observes the real backend PID, session/effective role, current context and returned rows. The request labels and intended tenants are separately supplied teaching fixtures.

This fixture uses a different row policy, under an ordinary non-owner role:

Code example
CREATE POLICY request_scope ON lab.documents FOR SELECT
USING (tenant_id = nullif(current_setting('app.tenant', true), ''));

The custom setting app.tenant is just a string belonging to the database session. A missing or empty value matches no row in this fixture. It is not a verified tenant credential. An ordinary runtime that can execute the setting statement can change it. Trusted application code must first establish the caller and permitted workspace; binding an untrusted tenant as a SQL parameter does not make it authorized.

Predict these six sequences before reading their observations:

SequenceQuestion to predict
sticky-sessionAlpha sets a session value; a later Beta-labelled request omits setup. Which row is read?
commit-restorationA local Beta value ends with COMMIT. What session value comes back?
rollback-restorationDoes ROLLBACK restore a different baseline from COMMIT?
outside-transactionDoes SET LOCAL without BEGIN establish scope for the next query?
caller-chosen-contextThe supplied request fixture permits Alpha but the SQL chooses Beta. Does the policy verify the fixture?
clean-request-boundariesWith a known empty baseline and explicit local scope, what can each request and the after-end read see?

UNDERSTAND THE SYSTEM

A transaction-local value is not a caller identity

Follow the optional PostgreSQL session-reuse experiment. The diagram explains authored cases; the runner records fifteen actual reads on one physical backend. Logical request labels are supplied fixtures, not authenticated users or a working driver pool.

FOLLOW THE WORK

Watch one example, step by step.

6 authored stepsYou control the pace
01 / EXPLORE THE PARTSControls loading · written explanations below
Direction of information or workReturn / feedback

WHAT THIS PART DOES

Supplied request

The exercise supplies a logical request tenant separately from observed database state. A real application must establish the caller and authorized workspace before setting context. A submitted tenant field cannot establish that authority.

CONNECTED TO
  • Choose trusted scope: Physical connection
Read the full worked example
  1. 01
    1 · A sticky session

    SET app.tenant to Alpha. The next logical Beta request omits setup and still reads Alpha record 101 on the same backend. The database role never changed. Clearing the setting makes the next ordinary read return no rows.

    SESSION MODE: Alpha → [101] · reused without setup → [101]

  2. 02
    2 · A clean local override

    With a known empty baseline, BEGIN and SET LOCAL Beta allow record 202. COMMIT restores the empty baseline; a separate read returns no rows. A later Beta transaction followed by ROLLBACK also restores that baseline.

    CLEAN BASELINE: Inside → [202] · after either end → []

  3. 03
    3 · A dirty baseline returns

    Change only --baseline-tenant to tenant_alpha. The local Beta transaction still reads 202, but COMMIT and ROLLBACK each restore Alpha. The after-end read now returns 101. SET LOCAL limits its override; it does not scrub the session's earlier value.

    CHANGED INPUT: Local Beta → [202] · restored Alpha → [101]

  4. 04
    4 · Outside a transaction

    On an empty baseline, SET LOCAL Alpha is issued without BEGIN. PostgreSQL emits a warning and the following read has no context or rows. Returning without a SQL error does not establish the intended lifetime or request scope.

    ACTUAL DIAGNOSTIC: Warning · context null · rows []

  5. 05
    5 · A caller can choose the variable

    The fixture says the request is authorized for Alpha, but the ordinary runtime sets local context to Beta and reads 202. RLS and FORCE are enabled. The mutable custom value cannot authenticate who selected it; trusted application authorization must happen before this SQL path.

    DELIBERATE BOUNDARY FAILURE: Fixture Alpha · chosen Beta · actual [202]

  6. 06
    6 · Deliberate request boundaries

    The final sequence begins with an empty baseline, uses a separate local scope for each Alpha and Beta transaction, and observes no rows after each end. This proves those supplied reads on one backend. The receiver still needs real driver-pool, failure, release and trusted-context tests.

    BOUNDED OBSERVATION: Alpha [101] · end [] · Beta [202] · end []

Read the connections in plain English
  1. 01
    Supplied request → Physical connection

    The supplied request label is not itself verified authority.

  2. 02
    Physical connection → app.tenant setting

    The setting belongs to this physical session.

  3. 03
    app.tenant setting → Transaction lifetime

    A local override has transaction lifetime; the baseline may remain.

  4. 04
    Transaction lifetime → Observed row scope

    Keep actual context and rows before and after the transaction ends.

  5. 05
    Observed row scope → Receiving responsibility

    An earlier successful request cannot establish the next caller's scope.

Check your understanding: Does SET LOCAL prove a shared connection is safe for the next caller?

No. It restores the prior session baseline when its transaction ends. A dirty baseline can return, and the custom setting can be changed by the runtime. Check trusted context selection, explicit transaction/connection ownership, both end paths and the next use of the actual pool.

6b. Run and change the session baseline

Download the current runner again if you retained the earlier role-only version. Keep the original role reports, then run the two optional modes into separate files:

Code example
python3 fde-postgres-row-security.py --session-reuse > session-clean.json
python3 fde-postgres-row-security.py --session-reuse --baseline-tenant tenant_alpha > session-dirty.json

Both runs use a fresh isolated container. In each run, same_backend_connection is calculated from the fifteen observed backend_pid values. steps retains the supplied request tenant, actual setup commands and observation query; observations contains database results. sql_script is the complete sequence sent to that one connection. Keep diagnostic, actual runtime attributes and container cleanup with the reports. No real pool's checkout, cancellation, release or broken-connection handling has been tested.

A session-level Alpha setting survives into sticky-reused, whose supplied request label is Beta: the read still returns 101. Clearing the context makes sticky-cleared return no rows. A successful query is not evidence that the right request scope was established.

With the empty baseline in session-clean.json, the local Beta read returns 202; both after-commit and after-rollback have null context and empty rows. In session-dirty.json, those same transaction ends restore the earlier session-level Alpha value and return 101. SET LOCAL limits an override; it does not erase a dirty baseline. Compare these exact fields rather than whole files, since backend and container identities differ between runs.

outside-local-ignored follows SET LOCAL without an explicit transaction block. PostgreSQL warns and leaves no context for that subsequent read. Inspect the warning as well as the successful process exit. BEGIN, scope setup, queries and the transaction end must use the same leased connection in an actual driver; sending those operations independently through a pool is not that guarantee.

caller-chooses-beta returns 202 although its separately supplied request fixture permits Alpha. The runtime is neither owner nor BYPASSRLS, and FORCE is enabled. The policy follows the mutable custom value; it cannot authenticate who selected it. This is a deliberate boundary failure, not an example of secure caller verification. A real design must establish trusted scope before this SQL path and address arbitrary SQL access or a compromised runtime in its threat model. ROLLBACK afterward cannot recall content already read.

The final sequence starts from a known empty baseline, runs explicit local Alpha and Beta transactions, and observes empty rows after each end. It establishes those supplied operations on one backend. To receive an enterprise implementation, have a partner check an ordinary request, a failed request and the next caller through the actual driver and pooler. The application owner owns context authority; the pool owner owns connection lifecycle and handling of uncertain state. The pod lead coordinates evidence and the acceptance owner without inheriting database permissions. Record each participant's action and assistance in the worksheet below.

7. Give the receiving team a specific check

Work separately before comparing. The implementing engineer supplies the exact runner, image identity, role attributes and two reports. A receiving engineer chooses one role and attempts an ordinary and a denied operation. The database owner reviews actual runtime privileges, ownership and all applicable policies in the intended environment. The pod lead coordinates the missing checks and decision owner; the role does not grant database administration or acceptance.

Keep your record locally without credentials or real client data:

Text example
Case / exact runner revision / image ID:
My first predicted rows or SQLSTATE:
Actual statement, connection role and observed result:
Original versus changed request or policy:
Receiving action actually attempted / help received:
Unresolved access path (pool, view, function, export, cache):
Bounded receiving decision and supporting evidence:
Next action / authorized owner:
My individual contribution and remaining practice:

In an enterprise service, test the actual pooled identity, every access path, function privileges, source revocation and receiving contract. This exercise establishes its supplied PostgreSQL observations. It does not qualify a learner for unsupervised access or establish a healthcare or financial compliance posture.

8. Explain the mechanism without the report

Explain: Why can the same SELECT grant produce two, zero or one returned row as the policy state changes? Name the separate role, operation and row checks.

Debug: A tenant predicate exists but a worker reads both organizations. Which runtime attributes, table owner and additional policy would you inspect first? Use the owner-bypass, force-and-bypass and permissive-or cases as distinct leads.

Design: The application shares one database login across requests. What must establish the tenant context, how long may it remain attached, and how would the receiver test a connection reused by the next tenant? State the untested boundary instead of claiming this per-role example already proves it.

Sources

Primary PostgreSQL 17 documentation checked 27 October 2026: Row security policies, SET ROLE, SET and SET LOCAL, and configuration setting functions. The lesson and synthetic exercise are original instruction, not a reproduction of a vendor course.

Before you move on

Keep the evidence of your work.

  • Ten predictions beside actual PostgreSQL SQL, roles and returned rows.
  • Original and changed-input reports with the exact local image identity.
  • An individual receiving check assigning runtime and policy ownership.

Keep these in your own working copy. Reading this page does not save work or record completion.

Suggested next free session

Identity 101: a login is not permission

Connect the database role boundary to verified application identity, document grants and retrieval scope. This is a suggested sequence; choose the practice that addresses your current gap.

Open the next free guide

Free account

Give your practice a home.

Try a role exercise with account drafts and a downloadable worksheet. Keep the files you made in this guide in your own working copy.

Create a free learning account

Guided training · by application

Build toward a program.

Request structured lessons and a training seat. Scope, schedule, feedback arrangements and tuition are confirmed in writing before you commit.

Explore a training application
Have Academy access?

Use your existing training seat to open worked explanations and practice in the related lesson. Creating a free account does not grant a seat.

Open the related Academy lesson →
Back to top ↑