pgalchemy 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Matthew Beatty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.1
2
+ Name: pgalchemy
3
+ Version: 0.1.2
4
+ Summary:
5
+ Author: Matthew Beatty
6
+ Author-email: beattyml1@gmail.com
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Dist: alembic (>=1.13.2,<2.0.0)
12
+ Requires-Dist: alembic-utils (>=0.8.4,<0.9.0)
13
+ Requires-Dist: sqlalchemy (>=2.0.32,<3.0.0)
14
+ Description-Content-Type: text/markdown
15
+
16
+ # pg-rls-sqlalchemy
17
+
18
+ Work in progress.
19
+
20
+ SQLAlchemy and Alembic support for Postgres features like:
21
+ - Row Level Security (RLS)
22
+ - Policies
23
+
24
+ Built on top of alembic_utils but provides a more usable interface and a few missing features
25
+
26
+ ## Installation
27
+
28
+ ```shell
29
+ pip install pg-rls-sqlalchemy
30
+ ```
31
+
32
+ OR
33
+
34
+ ```shell
35
+ poetry add pg-rls-sqlalchemy
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### Using RLS BaseModel
41
+ Recommended most projects. This is for projects with majority of tables using RLS which will also be almost all new projects using this library.
42
+
43
+ ```python
44
+
45
+ from sqlalchemy.orm import declarative_base
46
+ from pg_rls import rls_base, policy, Policy, PolicyType, PolicyCommands
47
+
48
+ BaseModel = rls_base(declarative_base())
49
+
50
+
51
+ @policy(Policy("pol_my_models_select_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.SELECT, using="user_id == auth.uid()"))
52
+ @policy(Policy("pol_my_models_delete_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.DELETE, using="user_id == auth.uid()"))
53
+ @policy(Policy("pol_my_models_update_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.UPDATE, using="user_id == auth.uid()", with_check="user_id == auth.uid()"))
54
+ @policy(Policy("pol_my_models_update_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.INSERT, with_check="user_id == auth.uid()"))
55
+ # Equivalent to:
56
+ # @policy(Policy("pol_my_models_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.ALL, using="user_id == auth.uid()", with_check="user_id == auth.uid()"))
57
+ class MyModel(BaseModel):
58
+ ...
59
+ ```
60
+
61
+ ### Using RLS Decorator
62
+ Only intended for projects with majority of tables without RLS enabled. Usually only for existing projects with most tables not protected using RLS that are only using RLS for a niche use case
63
+
64
+ This is not recommended for other use cases as it makes it easy for a developer to forget to enable RLS and expose a security vulnerability.
65
+ ```python
66
+
67
+ from sqlalchemy.orm import declarative_base
68
+ from pg_rls import rls, policy, Policy, PolicyType, PolicyCommands
69
+
70
+ BaseModel = declarative_base()
71
+
72
+ @rls()
73
+ @policy(Policy("pol_my_models_select_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.SELECT, using="user_id == auth.uid()"))
74
+ @policy(Policy("pol_my_models_delete_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.DELETE, using="user_id == auth.uid()"))
75
+ @policy(Policy("pol_my_models_update_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.UPDATE, using="user_id == auth.uid()", with_check="user_id == auth.uid()"))
76
+ @policy(Policy("pol_my_models_update_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.INSERT, with_check="user_id == auth.uid()"))
77
+ # Equivalent to:
78
+ # @policy(Policy("pol_my_models_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.ALL, using="user_id == auth.uid()", with_check="user_id == auth.uid()"))
79
+ class MyModel(BaseModel):
80
+ ...
81
+ ```
82
+
83
+
@@ -0,0 +1,67 @@
1
+ # pg-rls-sqlalchemy
2
+
3
+ Work in progress.
4
+
5
+ SQLAlchemy and Alembic support for Postgres features like:
6
+ - Row Level Security (RLS)
7
+ - Policies
8
+
9
+ Built on top of alembic_utils but provides a more usable interface and a few missing features
10
+
11
+ ## Installation
12
+
13
+ ```shell
14
+ pip install pg-rls-sqlalchemy
15
+ ```
16
+
17
+ OR
18
+
19
+ ```shell
20
+ poetry add pg-rls-sqlalchemy
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Using RLS BaseModel
26
+ Recommended most projects. This is for projects with majority of tables using RLS which will also be almost all new projects using this library.
27
+
28
+ ```python
29
+
30
+ from sqlalchemy.orm import declarative_base
31
+ from pg_rls import rls_base, policy, Policy, PolicyType, PolicyCommands
32
+
33
+ BaseModel = rls_base(declarative_base())
34
+
35
+
36
+ @policy(Policy("pol_my_models_select_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.SELECT, using="user_id == auth.uid()"))
37
+ @policy(Policy("pol_my_models_delete_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.DELETE, using="user_id == auth.uid()"))
38
+ @policy(Policy("pol_my_models_update_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.UPDATE, using="user_id == auth.uid()", with_check="user_id == auth.uid()"))
39
+ @policy(Policy("pol_my_models_update_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.INSERT, with_check="user_id == auth.uid()"))
40
+ # Equivalent to:
41
+ # @policy(Policy("pol_my_models_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.ALL, using="user_id == auth.uid()", with_check="user_id == auth.uid()"))
42
+ class MyModel(BaseModel):
43
+ ...
44
+ ```
45
+
46
+ ### Using RLS Decorator
47
+ Only intended for projects with majority of tables without RLS enabled. Usually only for existing projects with most tables not protected using RLS that are only using RLS for a niche use case
48
+
49
+ This is not recommended for other use cases as it makes it easy for a developer to forget to enable RLS and expose a security vulnerability.
50
+ ```python
51
+
52
+ from sqlalchemy.orm import declarative_base
53
+ from pg_rls import rls, policy, Policy, PolicyType, PolicyCommands
54
+
55
+ BaseModel = declarative_base()
56
+
57
+ @rls()
58
+ @policy(Policy("pol_my_models_select_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.SELECT, using="user_id == auth.uid()"))
59
+ @policy(Policy("pol_my_models_delete_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.DELETE, using="user_id == auth.uid()"))
60
+ @policy(Policy("pol_my_models_update_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.UPDATE, using="user_id == auth.uid()", with_check="user_id == auth.uid()"))
61
+ @policy(Policy("pol_my_models_update_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.INSERT, with_check="user_id == auth.uid()"))
62
+ # Equivalent to:
63
+ # @policy(Policy("pol_my_models_primary", as_=PolicyType.PERMISSIVE, for_=PolicyCommands.ALL, using="user_id == auth.uid()", with_check="user_id == auth.uid()"))
64
+ class MyModel(BaseModel):
65
+ ...
66
+ ```
67
+
@@ -0,0 +1,2 @@
1
+ from .sqlalchemy import rls_base, rls, policy
2
+ from .policy import PolicyType, PolicyCommands, Policy
@@ -0,0 +1 @@
1
+ from .operations import EnableRlsOp, DisableRlsOp, DropPolicyOp, CreatePolicyOp, AlterPolicyOp, RenamePolicyOp, PolicySql
@@ -0,0 +1,39 @@
1
+ from alembic.autogenerate import comparators
2
+ from alembic.operations import Operations, MigrateOperation
3
+ from sqlalchemy import Table
4
+
5
+ from pg_rls.sqlalchemy import RlsData
6
+ from .operations import EnableRlsOp, DisableRlsOp
7
+ from .. import Policy
8
+
9
+
10
+ @comparators.dispatch_for("table")
11
+ def compare_rls(autogen_context, modify_ops, schemaname, tablename, conn_table, metadata_table: Table):
12
+ rls: RlsData = metadata_table.info.get('rls')
13
+
14
+ db_table, rls_enabled_db = get_table_rls_data(autogen_context, schemaname, tablename)
15
+ if db_table is None:
16
+ return
17
+
18
+ compare_rls_enabled(modify_ops, rls, rls_enabled_db, schemaname, tablename)
19
+
20
+
21
+ def compare_rls_enabled(modify_ops, rls, rls_enabled_db, schemaname, tablename):
22
+ if rls.active is True and rls_enabled_db is False:
23
+ modify_ops.ops.append(
24
+ EnableRlsOp(tablename, schema=schemaname)
25
+ )
26
+ if rls.active is False and rls_enabled_db is True:
27
+ modify_ops.ops.append(
28
+ DisableRlsOp(tablename, schema=schemaname)
29
+ )
30
+
31
+
32
+ def get_table_rls_data(autogen_context, schemaname, tablename):
33
+ results = autogen_context.connection.execute(
34
+ 'select relname, relrowsecurity, relforcerowsecurity from pg_class where relnamespace = %s and relname = %s;',
35
+ (schemaname, tablename)
36
+ )
37
+ db_table = results.fetchone()
38
+ rls_enabled_db = db_table['relrowsecurity'] if db_table is not None else None
39
+ return db_table, rls_enabled_db
@@ -0,0 +1 @@
1
+ from .rls import DisableRlsOp, EnableRlsOp
@@ -0,0 +1,57 @@
1
+ from alembic.operations import MigrateOperation, Operations
2
+
3
+
4
+ @Operations.register_operation("enable_rls")
5
+ class EnableRlsOp(MigrateOperation):
6
+ """Enable RLS on a table."""
7
+
8
+ def __init__(self, table_name, schema=None):
9
+ self.table_name = table_name
10
+ self.schema = schema
11
+
12
+ @classmethod
13
+ def enable_rls(cls, operations, table_name, **kw):
14
+ """Issue a "ALTER TABLE ENABLE ROW LEVEL SECURITY" instruction."""
15
+
16
+ op = EnableRlsOp(table_name, **kw)
17
+ return operations.invoke(op)
18
+
19
+ def reverse(self):
20
+ # only needed to support autogenerate
21
+ return DisableRlsOp(self.table_name, schema=self.schema)
22
+
23
+
24
+ @Operations.register_operation("disable_rls")
25
+ class DisableRlsOp(MigrateOperation):
26
+ """Disable RLS on table."""
27
+
28
+ def __init__(self, table_name, schema=None):
29
+ self.table_name = table_name
30
+ self.schema = schema
31
+
32
+ @classmethod
33
+ def disable_rls(cls, operations, sequence_name, **kw):
34
+ """Issue a "ALTER TABLE DISABLE ROW LEVEL SECURITY" instruction."""
35
+
36
+ op = DisableRlsOp(sequence_name, **kw)
37
+ return operations.invoke(op)
38
+
39
+ def reverse(self):
40
+ # only needed to support autogenerate
41
+ return EnableRlsOp(self.sequence_name, schema=self.schema)
42
+
43
+ @Operations.implementation_for(EnableRlsOp)
44
+ def enable_rls(operations, operation: EnableRlsOp):
45
+ if operation.schema is not None:
46
+ name = "%s.%s" % (operation.schema, operation.table_name)
47
+ else:
48
+ name = operation.table_name
49
+ operations.execute("ALTER TABLE %s ENABLE ROW LEVEL SECURITY;" % name)
50
+
51
+ @Operations.implementation_for(DisableRlsOp)
52
+ def disable_rls(operations, operation: DisableRlsOp):
53
+ if operation.schema is not None:
54
+ name = "%s.%s" % (operation.schema, operation.table_name)
55
+ else:
56
+ name = operation.table_name
57
+ operations.execute("ALTER TABLE %s DISABLE ROW LEVEL SECURITY;" % name)
@@ -0,0 +1,43 @@
1
+ from enum import Enum
2
+
3
+ from alembic_utils.pg_policy import PGPolicy
4
+ from sqlalchemy import Table, BinaryExpression
5
+
6
+
7
+ class PolicyType(Enum):
8
+ PERMISSIVE = 'PERMISSIVE'
9
+ RESTRICTIVE = 'RESTRICTIVE'
10
+
11
+
12
+ class PolicyCommands(Enum):
13
+ ALL = 'ALL'
14
+ SELECT = 'SELECT'
15
+ INSERT = 'INSERT'
16
+ UPDATE = 'UPDATE'
17
+ DELETE = 'DELETE'
18
+
19
+
20
+ class Policy:
21
+ def __init__(
22
+ self,
23
+ name: str,
24
+ as_: PolicyType = PolicyType.PERMISSIVE,
25
+ for_: PolicyCommands = PolicyCommands.ALL,
26
+ using: str | BinaryExpression | None = None,
27
+ with_check: str | BinaryExpression | None = None
28
+ ):
29
+ self.name = name
30
+ self.with_check = str(with_check)
31
+ self.using = str(using)
32
+ self.for_ = for_
33
+ self.as_ = as_
34
+
35
+ @classmethod
36
+ def from_pg_policy(cls, row):
37
+ return Policy(
38
+ name=row['policyname'],
39
+ as_=PolicyType(row['permissive']),
40
+ for_=PolicyCommands(row['cmd']),
41
+ using=row['qual'],
42
+ with_check=row['with_check'],
43
+ )
@@ -0,0 +1,56 @@
1
+ from sqlalchemy import Table
2
+
3
+ from .policy import Policy
4
+
5
+
6
+ class PolicySql:
7
+ def __init__(self, policy: Policy, table_name: str, schema: str):
8
+ self.policy = policy
9
+ self.table_name = table_name
10
+ self.schema = schema
11
+
12
+ def qualify(self, name):
13
+ return f"{self.schema}.{name}"
14
+
15
+ @property
16
+ def tab(self):
17
+ return self.qualify(self.table_name)
18
+
19
+ @property
20
+ def pol(self):
21
+ return self.qualify(self.policy.name)
22
+
23
+ # Full
24
+ def rename_sql(self, old_name):
25
+ return f"alter policy {old_name} on {self.tab} rename to {self.pol}"
26
+
27
+ def drop_sql(self):
28
+ return f"drop policy {self.pol} on {self.tab}"
29
+
30
+ def create_sql(self):
31
+ return self._create_fragment() + self._as_fragment() + self._from_fragment() + self._using_fragment() + self._with_check_fragment()
32
+
33
+ def alter_sql(self):
34
+ return self._alter_fragment() + self._using_fragment() + self._with_check_fragment()
35
+
36
+ # Fragments
37
+ def _create_fragment(self):
38
+ return f"create policy {self.pol} on {self.tab}\n"
39
+
40
+ def _alter_fragment(self):
41
+ return f"alter policy {self.pol} on {self.tab}\n"
42
+
43
+ def _as_fragment(self):
44
+ return f"as {self.policy.as_}\n" if self.policy.as_ else ""
45
+
46
+ def _from_fragment(self):
47
+ return f"for {self.policy.for_}\n" if self.policy.for_ else ""
48
+
49
+ def _with_check_fragment(self):
50
+ return f"with check {self.policy.with_check}\n" if self.policy.with_check else ""
51
+
52
+ def _using_fragment(self):
53
+ return f"using {self.policy.using}\n" if self.policy.using else ""
54
+
55
+ def definition_sql(self):
56
+ return self._as_fragment() + self._from_fragment() + self._using_fragment() + self._with_check_fragment()
@@ -0,0 +1,66 @@
1
+ from typing import Type, List, Optional
2
+
3
+ from alembic_utils.pg_policy import PGPolicy
4
+ from sqlalchemy import Table
5
+ from sqlalchemy.event import listens_for
6
+ from sqlalchemy.orm import DeclarativeBase, Mapper
7
+
8
+ from .policy import Policy, PolicyType
9
+ from .policy_sql import PolicySql
10
+
11
+
12
+ class RlsData:
13
+ def __init__(self, active):
14
+ self.active = active
15
+ self.policies: List[Policy] = []
16
+
17
+
18
+ def rls_base(Base: Type[DeclarativeBase], default_active: bool = True):
19
+ class WithRls(Base):
20
+ __rls__ = RlsData(default_active)
21
+
22
+ @listens_for(WithRls, 'after_configured')
23
+ def receive_mapper_configured(mapper: Mapper, class_: Type[WithRls]):
24
+ table: Table = mapper.mapped_table()
25
+ rls = getattr(class_, '__rls__')
26
+ table.info.setdefault('rls', rls)
27
+ if rls.active:
28
+ for policy in rls.policies:
29
+ attach_policy(policy, table)
30
+
31
+ return WithRls
32
+
33
+
34
+ def rls(enabled=True, policies: Optional[List[Policy]] = None):
35
+ def wrapper(Model: Type[DeclarativeBase]):
36
+ Model.__rls__ = RlsData(enabled)
37
+ Model.__rls__.policies = policies or []
38
+ return Model
39
+ return wrapper
40
+
41
+
42
+ def rls_for_table(enabled=True, policies: Optional[List[Policy]] = None):
43
+ def wrapper(table: Table):
44
+ data = RlsData(enabled)
45
+ data.policies = policies or []
46
+ table.info.setdefault('rls', data)
47
+ return Table
48
+ return wrapper
49
+
50
+
51
+ def policy(pol: Policy):
52
+ def wrapper(Model: Type[DeclarativeBase]):
53
+ if not Model.__rls__:
54
+ Model.__rls__ = RlsData(True)
55
+ Model.__rls__.policies.append(pol)
56
+ return Model
57
+ return wrapper
58
+
59
+
60
+ def attach_policy(policy: Policy, table: Table):
61
+ return PGPolicy(
62
+ on_entity=table.name,
63
+ schema=table.schema,
64
+ signature=policy.name,
65
+ definition=PolicySql(policy, table.name, table.schema)
66
+ )
@@ -0,0 +1,21 @@
1
+ [tool.poetry]
2
+ name = "pgalchemy"
3
+ version = "0.1.2"
4
+ description = ""
5
+ authors = ["Matthew Beatty <beattyml1@gmail.com>"]
6
+ readme = "README.md"
7
+ include = [{path="README.md"}, {path="LICENSE"}]
8
+ packages = [
9
+ { include = "pgalchemy", from = "." },
10
+ ]
11
+
12
+ [tool.poetry.dependencies]
13
+ python = "^3.11"
14
+ sqlalchemy = "^2.0.32"
15
+ alembic = "^1.13.2"
16
+ alembic-utils = "^0.8.4"
17
+
18
+
19
+ [build-system]
20
+ requires = ["poetry-core"]
21
+ build-backend = "poetry.core.masonry.api"