feat: expose row-level can_edit/can_delete on select *
Compute per-row editability and deletability from a table's row-level security policies and return them as synthetic columns so clients can hide edit/delete affordances for rows the user cannot change. - Introspect pg_policies and relrowsecurity at schema-cache load and combine the UPDATE/DELETE USING qualifiers per table (permissive OR, restrictive AND). - Store the combined qualifiers on Table and inject can_edit/can_delete as computed select fields when expanding `select *`, only for RLS-enabled tables with a matching policy (COALESCE'd to a boolean). - Keep the computed columns out of the OpenAPI spec so they are not rendered as regular fields. - Add a cfExpression field to CoercibleField to carry raw SQL expressions through the planner to SqlFragment.
This commit is contained in:
@@ -7,6 +7,42 @@
|
||||
tableIsView: false
|
||||
tableName: authors_only
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
|
||||
- - qiName: no_rls_items
|
||||
qiSchema: public
|
||||
- tableColumns:
|
||||
id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: id
|
||||
colNominalType: integer
|
||||
colNullable: false
|
||||
colType: integer
|
||||
name:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: name
|
||||
colNominalType: text
|
||||
colNullable: true
|
||||
colType: text
|
||||
tableDeletable: true
|
||||
tableDescription: null
|
||||
tableInsertable: true
|
||||
tableIsView: false
|
||||
tableName: no_rls_items
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -39,6 +75,53 @@
|
||||
tableName: cats
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
|
||||
- - qiName: rls_items
|
||||
qiSchema: public
|
||||
- tableColumns:
|
||||
account_id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: account_id
|
||||
colNominalType: bigint
|
||||
colNullable: true
|
||||
colType: bigint
|
||||
id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: id
|
||||
colNominalType: integer
|
||||
colNullable: false
|
||||
colType: integer
|
||||
name:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: name
|
||||
colNominalType: text
|
||||
colNullable: true
|
||||
colType: text
|
||||
tableDeletable: true
|
||||
tableDescription: null
|
||||
tableInsertable: true
|
||||
tableIsView: false
|
||||
tableName: rls_items
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: COALESCE(((((current_setting('request.jwt.claims'::text, true))::json
|
||||
->> 'account_id'::text))::bigint = account_id), false)
|
||||
tableRlsEditQual: COALESCE(((((current_setting('request.jwt.claims'::text, true))::json
|
||||
->> 'account_id'::text))::bigint = account_id), false)
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -70,6 +153,8 @@
|
||||
tableIsView: true
|
||||
tableName: items_w_isolation_level
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -102,6 +187,8 @@
|
||||
tableName: directors
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -115,6 +202,8 @@
|
||||
tableIsView: false
|
||||
tableName: projects
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -128,6 +217,8 @@
|
||||
tableIsView: true
|
||||
tableName: infinite_recursion
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: false
|
||||
@@ -187,6 +278,8 @@
|
||||
tableName: awards
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -228,6 +321,8 @@
|
||||
tableName: films
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -250,6 +345,8 @@
|
||||
tableIsView: false
|
||||
tableName: items
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
|
||||
@@ -7,3 +7,8 @@ GRANT SELECT ON directors, films, awards TO postgrest_test_anonymous, postgrest_
|
||||
|
||||
GRANT ALL ON cats TO postgrest_test_anonymous;
|
||||
GRANT ALL ON items_w_isolation_level TO postgrest_test_anonymous, postgrest_test_repeatable_read, postgrest_test_serializable;
|
||||
|
||||
GRANT SELECT ON rls_items TO postgrest_test_author;
|
||||
GRANT UPDATE(name) ON rls_items TO postgrest_test_author;
|
||||
GRANT DELETE ON rls_items TO postgrest_test_author;
|
||||
GRANT SELECT ON no_rls_items TO postgrest_test_author;
|
||||
|
||||
@@ -268,3 +268,34 @@ $$ language sql;
|
||||
create or replace function get_work_mem() returns text as $$
|
||||
select current_setting('work_mem', true);
|
||||
$$ language sql;
|
||||
|
||||
-- RLS fixtures for testing can_edit/can_delete computed fields
|
||||
create table rls_items(
|
||||
id int primary key,
|
||||
account_id bigint,
|
||||
name text
|
||||
);
|
||||
|
||||
alter table rls_items enable row level security;
|
||||
|
||||
create policy rls_items_select on rls_items for select
|
||||
using (
|
||||
account_id is null
|
||||
or (current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id
|
||||
);
|
||||
|
||||
create policy rls_items_update on rls_items for update
|
||||
using ((current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id);
|
||||
|
||||
create policy rls_items_delete on rls_items for delete
|
||||
using ((current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id);
|
||||
|
||||
insert into rls_items(id, account_id, name) values (1, 1, 'own'), (2, null, 'public'), (3, 2, 'other');
|
||||
|
||||
-- no RLS at all: can_edit/can_delete must be omitted
|
||||
create table no_rls_items(
|
||||
id int primary key,
|
||||
name text
|
||||
);
|
||||
|
||||
insert into no_rls_items(id, name) values (1, 'a'), (2, 'b');
|
||||
|
||||
+1
-1
@@ -456,7 +456,7 @@ def test_schema_cache_query_timings_log(level, defaultenv):
|
||||
"PGRST_LOG_LEVEL": level,
|
||||
}
|
||||
log_pattern = re.compile(
|
||||
r".+: tables: [\d.]+ ms, keydeps: [\d.]+ ms, rels: [\d.]+ ms, funcs: [\d.]+ ms, comprels: [\d.]+ ms, dreps: [\d.]+ ms, mhandlers: [\d.]+ ms"
|
||||
r".+: tables: [\d.]+ ms, keydeps: [\d.]+ ms, rels: [\d.]+ ms, funcs: [\d.]+ ms, comprels: [\d.]+ ms, rls: [\d.]+ ms, dreps: [\d.]+ ms, mhandlers: [\d.]+ ms"
|
||||
)
|
||||
|
||||
with run(env=env, no_startup_stdout=False) as postgrest:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from config import SECRET
|
||||
from postgrest import run
|
||||
from util import jwtauthheader
|
||||
|
||||
|
||||
def author_headers(account_id):
|
||||
"Authorization header for postgrest_test_author with the given account id."
|
||||
return jwtauthheader(
|
||||
{"role": "postgrest_test_author", "account_id": account_id}, SECRET
|
||||
)
|
||||
|
||||
|
||||
def test_rls_can_edit_can_delete(defaultenv):
|
||||
"select * on an RLS table exposes can_edit/can_delete computed from the policies"
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/rls_items", headers=author_headers(1))
|
||||
assert response.status_code == 200
|
||||
rows = {r["id"]: r for r in response.json()}
|
||||
|
||||
# rows visible to account_id=1: own row and the public row
|
||||
assert set(rows) == {1, 2}
|
||||
|
||||
# the own row can be edited and deleted
|
||||
assert rows[1]["can_edit"] is True
|
||||
assert rows[1]["can_delete"] is True
|
||||
|
||||
# the public row is visible but not editable or deletable
|
||||
assert rows[2]["can_edit"] is False
|
||||
assert rows[2]["can_delete"] is False
|
||||
|
||||
|
||||
def test_no_rls_omits_can_edit_can_delete(defaultenv):
|
||||
"select * on a table without RLS omits the computed columns"
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/no_rls_items", headers=author_headers(1))
|
||||
assert response.status_code == 200
|
||||
rows = response.json()
|
||||
|
||||
assert len(rows) == 2
|
||||
for row in rows:
|
||||
assert "can_edit" not in row
|
||||
assert "can_delete" not in row
|
||||
|
||||
|
||||
def test_rls_columns_not_in_openapi(defaultenv):
|
||||
"The OpenAPI spec must not advertise the computed columns"
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/", headers=author_headers(1))
|
||||
assert response.status_code == 200
|
||||
spec = response.json()
|
||||
|
||||
properties = spec["definitions"]["rls_items"]["properties"]
|
||||
assert "can_edit" not in properties
|
||||
assert "can_delete" not in properties
|
||||
Reference in New Issue
Block a user