Skip to content

Build1 publisher2 min readPublished

A Postgres role with no grants reads the FORCE flag on tables it cannot select from

PostgreSQL 18.3 handed a serving role with no grant at all on three tenant tables their exact owner and FORCE flags, in a dev.to author's test of three roles. The result gives an RLS setup check a third answer, UNDETERMINED, in cases where it would otherwise return a false nothing to report.

The Engineer · Build desk

Illustration accompanying A Postgres role with no grants reads the FORCE flag on tables it cannot select from

What happened

  • The test ran on PostgreSQL 18.3 with three tenant tables created by the database owner, RLS enabled and a tenant policy on each, and FORCE ROW LEVEL SECURITY deliberately left off.
  • Joining the application's table list to pg_class and pg_namespace by name returned three rows under every role, with exact owner and FORCE values even for the role with no grants.
  • The verdict query returned WARNING for det_owner over three tables without FORCE, NOTHING TO REPORT for both non-owning roles, and UNDETERMINED for a fresh database whose migrations had not run.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • capability The inspection can run as the serving role itself, with no grant on the tables it reads, so nobody has to provision a second role just to audit ownership and FORCE state.
  • decision Whoever writes this check chooses its listing source first, and information_schema folds an empty schema, a missing privilege and an un-migrated database into a single answer.
  • constraint The warning can only cover tables the application names, so a tenant entity missing from the Doctrine metadata stays outside it no matter which catalog the query reads.
  • cost Deployers who run CREATE DATABASE app_db OWNER app pay one query at setup for the warning, and keep the choice of adding FORCE or a separate role.

pg_class and pg_namespace are readable by PUBLIC in a default install [10]. det_bare owns nothing and holds no grant at all on the three tenant tables, and it read their owner, relrowsecurity and relforcerowsecurity exactly [6] [11]. Catalog visibility was the open question in the comment thread, and it turned out not to be the problem [10]. The author wrote that a check answering nothing to report because it cannot see the tables would be worse than no check at all [3].

The condition being checked is narrow. A role that owns a table bypasses that table's row-level security policies unless the table has FORCE ROW LEVEL SECURITY [1], and CREATE DATABASE app_db OWNER app followed by doctrine:migrations:migrate leaves the serving role owning everything the migrations created [22]. @to21as proposed in the thread that a check does not have to impose FORCE or a separate role to be useful, and can simply report that the serving role owns the tenant tables with FORCE off [2].

The query's FROM is a VALUES list of public.organization, public.invoice and archive.audit_log, LEFT JOINed to pg_namespace on nspname and to pg_class on relnamespace and relname [8]. The LEFT JOIN is what makes an invisible table reportable: it comes back as a row with seen_in_catalog = false instead of no row at all [9].

to_regclass() writes the same lookup in less SQL, and under det_bare it raised ERROR: permission denied for schema archive, the schema the test deliberately placed outside the default search_path [12] [5]. information_schema.tables failed quietly: zero rows for det_bare, no error, because its views only show what the current role has some privilege on [13]. Of the three listing methods tried, only the join to pg_class by name returned three rows under all three roles [19].

For det_bare the information_schema verdict comes out right anyway, since det_bare owns nothing [16]. Those views cannot separate "there are no tenant tables here" from "I am not allowed to see them" from "the migrations never ran", so a check built on them loses its third outcome [17].

The catalog does not carry the table list. The check takes it from the application, in a Symfony app from the Doctrine metadata of the entities scoped by tenant [7]. A table that never reaches the VALUES clause produces no row in either query [21]. The author counts two remaining routes to a false "nothing to report", and says one of them no query can fix [18].

What to watch

  • Whether the check ships in a Symfony bundle, and what it reports when the Doctrine metadata lists no tenant-scoped entities at all.
  • Whether a role holding USAGE on the archive schema but no table grants changes the to_regclass() result. For det_bare, to_regclass() errored.
  • Whether a future PostgreSQL release narrows the default PUBLIC read on pg_class. The third outcome depends on that read.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories