Skip to content

Commit 226867d

Browse files
authored
feat(backend): add PostgreSQL Row-Level Security POLICY DDL expressions (#52)
* feat(backend): add PostgreSQL Row-Level Security POLICY DDL expressions Add PostgresCreatePolicyExpression, PostgresAlterPolicyExpression, and PostgresDropPolicyExpression (plus PolicyType/PolicyCommand/AlterPolicyMode enums) so RLS policies can be administered through the Expression-Dialect pipeline. The new PostgresPolicyMixin provides version-gated formatters (PG 9.5+ for basic CREATE/ALTER/DROP, 10+ for AS RESTRICTIVE and explicit AS PERMISSIVE, 15+ for the CURRENT_ROLE role keyword) and capability switches; PostgresPolicySupport is the matching @runtime_checkable Protocol. POLICY is a PostgreSQL extension, not SQL-standard, so it is implemented in the postgres backend package only; the core library is untouched. Note: changelog.d/0.added.md is a placeholder fragment — rename it to the actual PR number once assigned before merging. * ci: bump postgres 19 job from 19beta1 to 19beta2 (PGQ) * chore: add changelog fragment for PR #52
1 parent 5b83d05 commit 226867d

13 files changed

Lines changed: 1330 additions & 3 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,13 @@ jobs:
455455
fail_ci_if_error: false
456456

457457
test-postgres-19:
458-
name: Python 3.14 / postgres 19beta1 (PGQ)
458+
name: Python 3.14 / postgres 19beta2 (PGQ)
459459
runs-on: ubuntu-latest
460460
continue-on-error: true
461461

462462
services:
463463
postgres:
464-
image: postgres:19beta1
464+
image: postgres:19beta2
465465
env:
466466
POSTGRES_DB: test_db
467467
POSTGRES_USER: root

‎changelog.d/52.added.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added PostgreSQL Row-Level Security POLICY DDL expressions and dialect support. New `PostgresCreatePolicyExpression`, `PostgresAlterPolicyExpression`, `PostgresDropPolicyExpression` (plus `PolicyType`, `PolicyCommand`, `AlterPolicyMode` enums) in `expression/ddl/policy.py` allow RLS policy administration via the Expression-Dialect pipeline; the new `PostgresPolicyMixin` provides version-gated `format_*` methods (PG 9.5+ for basic CREATE/ALTER/DROP, 10+ for `AS RESTRICTIVE` and explicit `AS PERMISSIVE`, 15+ for the `CURRENT_ROLE` keyword in `TO`) and capability switches; `PostgresPolicySupport` is the corresponding `@runtime_checkable` Protocol. Per the layering decision POLICY is a PostgreSQL extension (not SQL standard) and is therefore implemented in the postgres backend package only — the core library `backend/expression/statements/` is untouched.

‎src/rhosocial/activerecord/backend/impl/postgres/dialect.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@
162162
PostgresCommentMixin,
163163
PostgresTypeMixin,
164164
PostgresConstraintMixin,
165+
PostgresPolicyMixin,
165166
# Type mixins
166167
EnumTypeMixin,
167168
TypesDataTypeMixin,
@@ -252,6 +253,7 @@
252253
PostgresCommentSupport,
253254
PostgresTypeSupport,
254255
PostgresConstraintSupport,
256+
PostgresPolicySupport,
255257
# Type feature protocols
256258
PostgresMultirangeSupport,
257259
PostgresEnumTypeSupport,
@@ -397,6 +399,7 @@ class PostgresDialect(
397399
PostgresCommentMixin,
398400
PostgresTypeMixin,
399401
PostgresConstraintMixin,
402+
PostgresPolicyMixin,
400403
# Type mixins
401404
EnumTypeMixin,
402405
TypesDataTypeMixin,
@@ -522,6 +525,7 @@ class PostgresDialect(
522525
PostgresCommentSupport,
523526
PostgresTypeSupport,
524527
PostgresConstraintSupport,
528+
PostgresPolicySupport,
525529
# Type feature protocols
526530
PostgresMultirangeSupport,
527531
PostgresEnumTypeSupport,

‎src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- mv.py - Materialized view expressions
1212
- type.py - Enum/Range type expressions
1313
- extension.py - Extension DDL expressions
14+
- policy.py - Row-Level Security POLICY expressions (CREATE/ALTER/DROP)
1415
1516
Missing expressions — why?
1617
============================
@@ -84,6 +85,14 @@
8485
PostgresPgPartmanUpdateConfigExpression,
8586
PostgresPgPartmanDeleteConfigExpression,
8687
)
88+
from .policy import (
89+
AlterPolicyMode,
90+
PolicyCommand,
91+
PolicyType,
92+
PostgresCreatePolicyExpression,
93+
PostgresAlterPolicyExpression,
94+
PostgresDropPolicyExpression,
95+
)
8796

8897
__all__ = [
8998
# vacuum
@@ -120,4 +129,11 @@
120129
"PostgresPgPartmanRunMaintenanceExpression",
121130
"PostgresPgPartmanUpdateConfigExpression",
122131
"PostgresPgPartmanDeleteConfigExpression",
132+
# policy
133+
"PolicyType",
134+
"PolicyCommand",
135+
"AlterPolicyMode",
136+
"PostgresCreatePolicyExpression",
137+
"PostgresAlterPolicyExpression",
138+
"PostgresDropPolicyExpression",
123139
]
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py
2+
"""
3+
PostgreSQL DDL expressions: Row-Level Security POLICY operations.
4+
5+
PostgreSQL Documentation:
6+
- CREATE POLICY: https://www.postgresql.org/docs/current/sql-createpolicy.html
7+
- ALTER POLICY: https://www.postgresql.org/docs/current/sql-alterpolicy.html
8+
- DROP POLICY: https://www.postgresql.org/docs/current/sql-droppolicy.html
9+
10+
Version Requirements:
11+
- CREATE/ALTER/DROP POLICY (basic): PostgreSQL 9.5+
12+
- AS RESTRICTIVE / explicit AS PERMISSIVE: PostgreSQL 10+
13+
- CURRENT_ROLE keyword in TO role list: PostgreSQL 15+
14+
15+
Note: These statements are PostgreSQL extensions (non-SQL-standard). They live in
16+
the postgres backend package, not in the core backend/expression/statements/.
17+
"""
18+
19+
from enum import Enum
20+
from typing import List, Optional, TYPE_CHECKING
21+
22+
from rhosocial.activerecord.backend.expression.bases import (
23+
BaseExpression,
24+
SQLPredicate,
25+
SQLQueryAndParams,
26+
)
27+
28+
if TYPE_CHECKING:
29+
from rhosocial.activerecord.backend.dialect import SQLDialectBase
30+
31+
32+
__all__ = [
33+
"PolicyType",
34+
"PolicyCommand",
35+
"AlterPolicyMode",
36+
"PostgresCreatePolicyExpression",
37+
"PostgresAlterPolicyExpression",
38+
"PostgresDropPolicyExpression",
39+
]
40+
41+
42+
class PolicyType(Enum):
43+
"""Row-Level Security policy type."""
44+
45+
PERMISSIVE = "PERMISSIVE"
46+
RESTRICTIVE = "RESTRICTIVE"
47+
48+
49+
class PolicyCommand(Enum):
50+
"""SQL command a policy applies to."""
51+
52+
ALL = "ALL"
53+
SELECT = "SELECT"
54+
INSERT = "INSERT"
55+
UPDATE = "UPDATE"
56+
DELETE = "DELETE"
57+
58+
59+
class AlterPolicyMode(Enum):
60+
"""The two syntactic forms of ALTER POLICY.
61+
62+
REPLACE: alter roles / USING / WITH CHECK (several may be omitted).
63+
RENAME: ALTER POLICY name ON table RENAME TO new_name.
64+
"""
65+
66+
REPLACE = "REPLACE"
67+
RENAME = "RENAME"
68+
69+
70+
class PostgresCreatePolicyExpression(BaseExpression):
71+
"""PostgreSQL CREATE POLICY statement expression.
72+
73+
Defines a new row-level security policy for a table.
74+
75+
Attributes:
76+
name: Name of the policy (distinct per table).
77+
table_name: Name of the table the policy applies to.
78+
schema: Optional schema for the table.
79+
policy_type: Optional ``AS PERMISSIVE``/``AS RESTRICTIVE`` clause.
80+
RESTRICTIVE requires PostgreSQL 10+; PERMISSIVE keyword is only
81+
emitted on PostgreSQL 10+ (silently omitted on 9.5).
82+
command: Optional ``FOR {ALL|SELECT|INSERT|UPDATE|DELETE}`` clause.
83+
If None, the clause is omitted (PostgreSQL default = ALL).
84+
roles: Optional ``TO role [, ...]`` clause list. Special literals
85+
``PUBLIC`` / ``CURRENT_ROLE`` / ``CURRENT_USER`` / ``SESSION_USER``
86+
are emitted verbatim (no quoting); any other value is treated as
87+
an identifier. CURRENT_ROLE requires PostgreSQL 15+.
88+
using: Optional ``USING (predicate)`` expression. Forbidden when
89+
``command == INSERT``.
90+
with_check: Optional ``WITH CHECK (predicate)`` expression. Forbidden
91+
when ``command`` is SELECT or DELETE.
92+
93+
Example:
94+
>>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect
95+
>>> dialect = PostgresDialect((14, 0, 0))
96+
>>> expr = PostgresCreatePolicyExpression(
97+
... dialect=dialect,
98+
... name="user_select_own",
99+
... table_name="orders",
100+
... command=PolicyCommand.SELECT,
101+
... using=Column(dialect, "user_id") == 1,
102+
... )
103+
>>> sql, params = expr.to_sql() # doctest: +SKIP
104+
105+
"""
106+
107+
def __init__(
108+
self,
109+
dialect: "SQLDialectBase",
110+
name: str,
111+
table_name: str,
112+
schema: Optional[str] = None,
113+
policy_type: Optional[PolicyType] = None,
114+
command: Optional[PolicyCommand] = None,
115+
roles: Optional[List[str]] = None,
116+
using: Optional[SQLPredicate] = None,
117+
with_check: Optional[SQLPredicate] = None,
118+
):
119+
super().__init__(dialect)
120+
self.name = name
121+
self.table_name = table_name
122+
self.schema = schema
123+
self.policy_type = policy_type
124+
self.command = command
125+
self.roles = roles
126+
self.using = using
127+
self.with_check = with_check
128+
129+
def to_sql(self) -> "SQLQueryAndParams":
130+
"""Generate CREATE POLICY SQL statement.
131+
132+
Returns:
133+
Tuple of (SQL string, params tuple).
134+
135+
"""
136+
return self.dialect.format_create_policy_statement(self)
137+
138+
139+
class PostgresAlterPolicyExpression(BaseExpression):
140+
"""PostgreSQL ALTER POLICY statement expression.
141+
142+
PostgreSQL supports two syntactic forms:
143+
144+
1. ``ALTER POLICY name ON table RENAME TO new_name``
145+
2. ``ALTER POLICY name ON table [TO roles] [USING (...)] [WITH CHECK (...)]``
146+
147+
Each clause of form 2 may be independently specified or omitted; clauses not
148+
given are unchanged on the existing policy.
149+
150+
Attributes:
151+
name: Name of the existing policy to alter.
152+
table_name: Name of the table the policy is on.
153+
schema: Optional schema for the table.
154+
new_name: If given, switches the expression to form 1 (RENAME TO).
155+
Mutually exclusive with roles/using/with_check.
156+
roles: Optional new ``TO role [, ...]`` clause (form 2 only).
157+
using: Optional new ``USING (...)`` expression (form 2 only).
158+
with_check: Optional new ``WITH CHECK (...)`` expression (form 2 only).
159+
160+
Raises:
161+
ValueError: if ``new_name`` is set together with any of roles/using/
162+
with_check (the two forms are mutually exclusive).
163+
UnsupportedFeatureError: when the dialect version predates
164+
PostgreSQL 9.5.
165+
166+
"""
167+
168+
def __init__(
169+
self,
170+
dialect: "SQLDialectBase",
171+
name: str,
172+
table_name: str,
173+
schema: Optional[str] = None,
174+
new_name: Optional[str] = None,
175+
roles: Optional[List[str]] = None,
176+
using: Optional[SQLPredicate] = None,
177+
with_check: Optional[SQLPredicate] = None,
178+
):
179+
super().__init__(dialect)
180+
self.name = name
181+
self.table_name = table_name
182+
self.schema = schema
183+
self.new_name = new_name
184+
self.roles = roles
185+
self.using = using
186+
self.with_check = with_check
187+
188+
@property
189+
def mode(self) -> AlterPolicyMode:
190+
"""Determine the ALTER POLICY form implied by the parameters."""
191+
if self.new_name is not None:
192+
return AlterPolicyMode.RENAME
193+
return AlterPolicyMode.REPLACE
194+
195+
def to_sql(self) -> "SQLQueryAndParams":
196+
"""Generate ALTER POLICY SQL statement.
197+
198+
Returns:
199+
Tuple of (SQL string, params tuple).
200+
201+
"""
202+
return self.dialect.format_alter_policy_statement(self)
203+
204+
205+
class PostgresDropPolicyExpression(BaseExpression):
206+
"""PostgreSQL DROP POLICY statement expression.
207+
208+
Removes a row-level security policy from a table.
209+
210+
Attributes:
211+
name: Name of the policy to drop.
212+
table_name: Name of the table the policy is on.
213+
schema: Optional schema for the table.
214+
if_exists: When True, add ``IF EXISTS`` (PostgreSQL 9.5+, always
215+
supported). A notice — not an error — is issued if missing.
216+
cascade: When True, append ``CASCADE``. Note: per PostgreSQL
217+
documentation, CASCADE/RESTRICT have no effect for DROP POLICY
218+
(no dependencies) but are accepted syntactically.
219+
restrict: When True, append ``RESTRICT``. Mutually exclusive with
220+
``cascade``.
221+
222+
"""
223+
224+
def __init__(
225+
self,
226+
dialect: "SQLDialectBase",
227+
name: str,
228+
table_name: str,
229+
schema: Optional[str] = None,
230+
if_exists: bool = False,
231+
cascade: bool = False,
232+
restrict: bool = False,
233+
):
234+
super().__init__(dialect)
235+
self.name = name
236+
self.table_name = table_name
237+
self.schema = schema
238+
self.if_exists = if_exists
239+
self.cascade = cascade
240+
self.restrict = restrict
241+
242+
def to_sql(self) -> "SQLQueryAndParams":
243+
"""Generate DROP POLICY SQL statement.
244+
245+
Returns:
246+
Tuple of (SQL string, empty params tuple).
247+
248+
"""
249+
return self.dialect.format_drop_policy_statement(self)

‎src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .ddl.type import PostgresTypeMixin
2424
from .ddl.constraint import PostgresConstraintMixin
2525
from .ddl.column import PostgresAlterColumnModifierMixin
26+
from .ddl.policy import PostgresPolicyMixin
2627

2728
# DML mixins
2829
from .dml.vacuum import PostgresVacuumMixin
@@ -118,6 +119,7 @@
118119
"PostgresTypeMixin",
119120
"PostgresConstraintMixin",
120121
"PostgresAlterColumnModifierMixin",
122+
"PostgresPolicyMixin",
121123
# DML mixins
122124
"PostgresVacuumMixin",
123125
"PostgresStoredProcedureMixin",

‎src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .type import PostgresTypeMixin
99
from .constraint import PostgresConstraintMixin
1010
from .column import PostgresAlterColumnModifierMixin
11+
from .policy import PostgresPolicyMixin
1112

1213
__all__ = [
1314
"PostgresPartitionMixin",
@@ -17,4 +18,5 @@
1718
"PostgresTypeMixin",
1819
"PostgresConstraintMixin",
1920
"PostgresAlterColumnModifierMixin",
21+
"PostgresPolicyMixin",
2022
]

0 commit comments

Comments
 (0)