I have a row-level security policy that denies a user every row in a table. The
user queries the table and gets nothing, which is right. The user queries a view
over that same table and gets real salary data back.
This is documented PostgreSQL behaviour, not a bug, and it is six years older
than the option that fixes it. I went looking for it because I write a library
that decides which tables to show a language model, and I wanted to know whether
"you don't have the grant" was the same as "you can't read it". It isn't.
Here is the whole thing, on PostgreSQL 16.13. Paste it into a scratch database.
Note that roles are cluster-wide, not per-database, so if you run this twice you
will need the teardown at the bottom first.
CREATE TABLE employee_salary (id int primary key, name text, dept text, salary numeric);
INSERT INTO employee_salary VALUES (1,'Ann','SALES',91000),(2,'Bob','SALES',87000),
(3,'Cy','ENG',120000),(4,'Di','HR',70000);
ALTER TABLE employee_salary ENABLE ROW LEVEL SECURITY;
CREATE ROLE sgrls_r1 LOGIN;
CREATE ROLE sgrls_r2 LOGIN;
CREATE POLICY p_r1 ON employee_salary FOR SELECT TO sgrls_r1 USING (dept = 'SALES');
CREATE POLICY p_r2 ON employee_salary FOR SELECT TO sgrls_r2 USING (false);
CREATE VIEW v_sales_salary AS
SELECT * FROM employee_salary WHERE dept = 'SALES';
CREATE VIEW v_sales_salary_si WITH (security_invoker = true) AS
SELECT * FROM employee_salary WHERE dept = 'SALES';
GRANT USAGE ON SCHEMA public TO sgrls_r1, sgrls_r2;
GRANT SELECT ON employee_salary, v_sales_salary, v_sales_salary_si TO sgrls_r1, sgrls_r2;
sgrls_r2 has the policy USING (false). Not a narrow policy. False. No row
can satisfy it.
postgres=> \c - sgrls_r2
postgres=> SELECT * FROM employee_salary;
id | name | dept | salary
----+------+------+--------
(0 rows)
Correct. Now the view:
postgres=> SELECT * FROM v_sales_salary;
id | name | dept | salary
----+------+-------+--------
1 | Ann | SALES | 91000
2 | Bob | SALES | 87000
(2 rows)
Two rows, with the salaries in them, to a role whose policy admits nothing.
Why
A view executes with the privileges of its owner, not its caller. The owner
here is postgres, who is exempt from the policy. The RLS check runs as the
owner, passes, and the rows come back. The caller's own policy is never
consulted, because the caller never touched the table — the view did.
security_invoker = true flips that: the view reads the base table as the
caller, so the caller's policy applies. The second view in the setup has it, and
the same role gets zero rows from it.
as sgrls_r2, policy USING (false)
|
rows |
|---|---|
SELECT * FROM employee_salary |
0 |
SELECT * FROM v_sales_salary |
2 |
SELECT * FROM v_sales_salary_si |
0 |
None of this is undocumented. CREATE POLICY says it plainly:
permission checks and policies for the tables which are referenced by a view
will use the view owner's rights and any policies which apply to the view
owner, except if the view is defined using thesecurity_invokeroption
And the PostgreSQL 15 release note that introduced the option is blunter still:
Previously, view accesses were always treated as being done by the view's
owner. That's still the default.
The problem is the timeline. Row-level security arrived in PostgreSQL 9.5, released
7 January 2016. security_invoker arrived in PostgreSQL 15, in 2022. For six years
there was no way to make a view respect the caller's policy, so every view written
in that window runs as its owner — and, per the release note above, that is still
what you get unless you opt in.
If your schema has RLS on the tables and views on top of them, and nobody went
back and added security_invoker, the views are a way around the policy for
anyone holding SELECT on them.
Find out whether you have this
This lists views that read an RLS-enabled table and do not have
security_invoker. Run it as a superuser or table owner.
SELECT n.nspname AS schema,
c.relname AS view,
pg_get_userbyid(c.relowner) AS runs_as,
t.relname AS rls_table
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_rewrite r ON r.ev_class = c.oid
JOIN pg_depend d ON d.objid = r.oid
AND d.classid = 'pg_rewrite'::regclass
AND d.refclassid = 'pg_class'::regclass
JOIN pg_class t ON t.oid = d.refobjid
AND t.relrowsecurity
AND t.oid <> c.oid
WHERE c.relkind = 'v'
AND n.nspname NOT IN ('pg_catalog','information_schema')
AND NOT coalesce(c.reloptions::text[] @> ARRAY['security_invoker=true'], false)
GROUP BY 1,2,3,4
ORDER BY 1,2;
On the schema above it returns exactly v_sales_salary and nothing else. I
checked it against decoys: a view over a table with no RLS is not flagged, and
neither is a security_invoker view.
One limitation, so you don't trust it further than it goes. It follows a
single dependency hop. A view over a view over an RLS table also leaks, and
this query will not report it. If you nest views, walk the chain. I would rather
tell you that than have you run it, get an empty result, and conclude you are
fine.
The part that caught me out
I expected the catalogue to tell me something. It does not. Under both roles —
the one that reads two rows and the one that reads none — employee_salary is
present in information_schema.tables, and all four columns are in
information_schema.columns. Both roles hold SELECT in the catalogue. The
grant says yes, the policy says no, and the catalogue believes the grant.
I ran the same shape of test on Oracle AI Database 26ai (23.26.3.3.0) with a
DBMS_RLS policy of 1 = 0. Same result: zero readable rows, still in
ALL_TABLES, SELECT still in ALL_TAB_PRIVS, four columns in
ALL_TAB_COLUMNS. Oracle has no security_invoker equivalent for this; there I
had to use proxy authentication (ADMIN[user]) to see the per-caller row counts
at all.
So if you are building anything that decides what a caller may read by querying
the data dictionary — an access audit, a data catalogue, a prompt builder for a
SQL agent — the dictionary will overstate it. RLS is a row filter, not a
privilege, and privileges are the only thing the dictionary records. That is
defensible behaviour on the database's part and completely wrong as an input to
"what is this user allowed to see".
MySQL has the same default and cannot have the same bug
I tested MariaDB 10.11.14 too, because MySQL-family views have the identical
owner-vs-caller split: SQL SECURITY DEFINER against SQL SECURITY INVOKER.
A view created with no SQL SECURITY clause comes out DEFINER:
CREATE VIEW v_default AS SELECT * FROM employee_salary WHERE dept='SALES';
SELECT table_name, security_type
FROM information_schema.views
WHERE table_schema = DATABASE();
-- v_default DEFINER
So the default is owner-rights there as well, and a user with no privilege at all
on employee_salary reads real salaries straight through it:
MariaDB [sgtest]> SELECT * FROM v_default; -- as a user with no grant on the table
+----+------+-------+----------+
| id | name | dept | salary |
+----+------+-------+----------+
| 1 | Ann | SALES | 91000.00 |
| 2 | Bob | SALES | 87000.00 |
+----+------+-------+----------+
The INVOKER version of the same view refuses, as it should:
ERROR 1142 (42000): SELECT command denied to user 'sgrls_r2'@'localhost'
for table `sgtest`.`employee_salary`
But the dictionary behaves differently from PostgreSQL, and this is the part worth
knowing. For that same user, employee_salary is simply not there:
| as a user with no grant on the table | MariaDB | PostgreSQL |
|---|---|---|
| rows from the DEFINER / owner-rights view | 2 | 2 |
information_schema.tables for the base table |
0 | 1 |
information_schema.columns for the base table |
0 | 4 |
MariaDB's information_schema is privilege-filtered, so it never claims you can read
something you cannot. PostgreSQL showed the table because the caller genuinely held
SELECT — the row policy denied it separately, and the dictionary has no column for
that.
Which gives the honest answer to my own question: MySQL's dictionary cannot be wrong
about row-level policies, because MySQL has none. Hold SELECT and you read every
row — I confirmed it, 4 of 4, for a user who was "supposed" to see only SALES. The only
way to filter rows per caller is a DEFINER view, and then the dictionary is correct
about the view and silent about the filter. Accurate, but not informative.
Teardown
Roles outlive the database, so dropping the database is not enough:
DROP VIEW IF EXISTS v_sales_salary, v_sales_salary_si;
DROP TABLE IF EXISTS employee_salary;
DROP OWNED BY sgrls_r1; DROP ROLE sgrls_r1;
DROP OWNED BY sgrls_r2; DROP ROLE sgrls_r2;
Two things I would like to know
- If you run the audit query on a real schema, what does it return? I am most interested in people who get a non-empty result and did not expect one.
- Is there an engine where the data dictionary does reflect row-level policies, rather than just grants? I have now tested PostgreSQL, Oracle and MariaDB; none of them do, for the different reasons above. SQL Server I have not tested — it has real RLS via security policies, so it is the interesting case, and I would rather be told than guess.
The reason I went digging: I maintain schemagate,
which picks the tables a SQL agent is allowed to see. It had a mode that derived
that list from grants. The test above is why that mode is no longer the secure
one — the fail-closed path is explicit per-caller rules, not the dictionary.
Writeup of the full test, both engines:
row-level security.
Top comments (0)