Skip to content

Commit d6d65a8

Browse files
committed
fix(security): reject privileged outbox views
1 parent da5b109 commit d6d65a8

1 file changed

Lines changed: 47 additions & 9 deletions

File tree

pg_llm_batch/context_lifecycle_outbox.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,49 @@ def _maintain_privilege_sql(role_expression: str) -> str:
103103
)
104104

105105

106+
def _privileged_outbox_view_sql(role_expression: str) -> str:
107+
"""Build a probe for caller-selectable views that can bypass outbox RLS."""
108+
return (
109+
"EXISTS ("
110+
"SELECT 1 FROM pg_catalog.pg_class AS exposed_view "
111+
"JOIN pg_catalog.pg_namespace AS exposed_view_schema "
112+
"ON exposed_view_schema.oid OPERATOR(pg_catalog.=) exposed_view.relnamespace "
113+
"JOIN pg_catalog.pg_roles AS exposed_view_owner "
114+
"ON exposed_view_owner.oid OPERATOR(pg_catalog.=) exposed_view.relowner "
115+
"JOIN pg_catalog.pg_rewrite AS exposed_view_rule "
116+
"ON exposed_view_rule.ev_class OPERATOR(pg_catalog.=) exposed_view.oid "
117+
"JOIN pg_catalog.pg_depend AS exposed_view_dependency "
118+
"ON exposed_view_dependency.classid OPERATOR(pg_catalog.=) "
119+
"'pg_catalog.pg_rewrite'::pg_catalog.regclass "
120+
"AND exposed_view_dependency.objid OPERATOR(pg_catalog.=) exposed_view_rule.oid "
121+
"AND exposed_view_dependency.refclassid OPERATOR(pg_catalog.=) "
122+
"'pg_catalog.pg_class'::pg_catalog.regclass "
123+
"AND exposed_view_dependency.refobjid OPERATOR(pg_catalog.=) admitted_relation.oid "
124+
"WHERE exposed_view.relkind::pg_catalog.text OPERATOR(pg_catalog.=) 'v' "
125+
"AND exposed_view_schema.nspname NOT LIKE 'pg\\_%' ESCAPE '\\' "
126+
"AND exposed_view_schema.nspname OPERATOR(pg_catalog.<>) 'information_schema' "
127+
"AND NOT COALESCE("
128+
"exposed_view.reloptions OPERATOR(pg_catalog.@>) "
129+
"ARRAY['security_invoker=true']::pg_catalog.text[], false) "
130+
"AND pg_catalog.has_schema_privilege("
131+
f"{role_expression}, exposed_view_schema.oid, 'USAGE') "
132+
"AND pg_catalog.has_table_privilege("
133+
f"{role_expression}, exposed_view.oid, 'SELECT') "
134+
"AND pg_catalog.has_any_column_privilege("
135+
"exposed_view_owner.oid, admitted_relation.oid, 'SELECT') "
136+
"AND (exposed_view_owner.rolsuper OR exposed_view_owner.rolbypassrls)"
137+
")"
138+
)
139+
140+
106141
def _require_rls_application_role(cursor: Any) -> None:
107142
"""Reject unsafe runtime roles or drifted canonical RLS policy authority."""
108143
maintain_selectable = _maintain_privilege_sql("selectable_role.oid")
109144
maintain_delegated_dml = _maintain_privilege_sql("delegated_dml_role.oid")
110145
maintain_definer = _maintain_privilege_sql("definer_role.oid")
111146
maintain_definer_admin = _maintain_privilege_sql("definer_admin_role.oid")
112147
maintain_definer_admin_set = _maintain_privilege_sql("definer_admin_set_role.oid")
148+
privileged_outbox_view = _privileged_outbox_view_sql("selectable_role.oid")
113149
cursor.execute(
114150
"SELECT admitted_role.rolsuper "
115151
"OR NOT admitted_relation.relrowsecurity "
@@ -310,6 +346,8 @@ def _require_rls_application_role(cursor: Any) -> None:
310346
"definer_admin_set_role.oid, admitted_relation.oid, 'TRIGGER')))"
311347
"))"
312348
")) "
349+
"OR "
350+
f"{privileged_outbox_view} "
313351
"OR pg_catalog.has_any_column_privilege("
314352
"selectable_role.oid, admitted_relation.oid, 'SELECT WITH GRANT OPTION') "
315353
"OR pg_catalog.has_any_column_privilege("
@@ -657,15 +695,15 @@ def load_in_transaction(
657695
can return to application code. Both the effective ``CURRENT_USER`` and the
658696
authenticated ``SESSION_USER`` role-selection closure must remain ordinary RLS
659697
subjects without outbox-owner, destructive, replication, database/role
660-
administration, delegable DML, relation-programming, or executable privileged
661-
user-schema ``SECURITY DEFINER`` authority, while the canonical relation still
662-
has RLS enabled and forced with the sole reviewed tenant policy semantics. The
663-
live admission is checked before tenant state is bound or durable rows are
664-
touched. Security-critical function, relation, and policy authority is
665-
explicitly schema-qualified, and ``ONLY`` prevents inherited relations from
666-
widening the canonical durable row source if an inheritance edge appears after
667-
migration admission. The outbox does not mutate or inherit the caller
668-
transaction's ``search_path``.
698+
administration, delegable DML, relation-programming, caller-selectable
699+
privileged-view, or executable privileged user-schema ``SECURITY DEFINER``
700+
authority, while the canonical relation still has RLS enabled and forced with
701+
the sole reviewed tenant policy semantics. The live admission is checked before
702+
tenant state is bound or durable rows are touched. Security-critical function,
703+
relation, and policy authority is explicitly schema-qualified, and ``ONLY``
704+
prevents inherited relations from widening the canonical durable row source if
705+
an inheritance edge appears after migration admission. The outbox does not
706+
mutate or inherit the caller transaction's ``search_path``.
669707
"""
670708
if type(for_update) is not bool:
671709
raise ValidationError(

0 commit comments

Comments
 (0)