pum 1.0.0__py3-none-any.whl

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.
pum/role_manager.py ADDED
@@ -0,0 +1,253 @@
1
+ import enum
2
+ from typing import Optional
3
+ import copy
4
+ import psycopg
5
+ import logging
6
+
7
+ from .sql_content import SqlContent
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class PermissionType(enum.Enum):
13
+ """Enum for permission types.
14
+
15
+ Attributes:
16
+ READ (str): Read permission.
17
+ WRITE (str): Write permission.
18
+ """
19
+
20
+ READ = "read"
21
+ WRITE = "write"
22
+
23
+
24
+ class Permission:
25
+ """Class to represent a permission for a database role.
26
+
27
+ Attributes:
28
+ type: Type of permission (read or write).
29
+ schemas: List of schemas this permission applies to.
30
+ """
31
+
32
+ def __init__(self, type: PermissionType | str, schemas: list[str] = None) -> None:
33
+ if not isinstance(type, PermissionType):
34
+ type = PermissionType(type)
35
+ self.type = type
36
+ self.schemas = schemas
37
+
38
+ def grant(
39
+ self,
40
+ role: str,
41
+ connection: psycopg.Connection,
42
+ commit: bool = False,
43
+ ) -> None:
44
+ """Grant the permission to the specified role.
45
+ Args:
46
+ role: The name of the role to grant the permission to.
47
+ connection: The database connection to execute the SQL statements.
48
+ commit: Whether to commit the transaction. Defaults to False.
49
+ """
50
+ if not isinstance(role, str):
51
+ raise TypeError("Role must be a string.")
52
+
53
+ if not self.schemas:
54
+ raise ValueError("Schemas must be defined for the permission.")
55
+
56
+ for schema in self.schemas:
57
+ logger.info(f"Granting {self.type.value} permission on schema {schema} to role {role}.")
58
+ if self.type == PermissionType.READ:
59
+ SqlContent("""
60
+ GRANT USAGE ON SCHEMA {schema} TO {role};
61
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA {schema} TO {role};
62
+ GRANT SELECT, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA {schema} TO {role};
63
+ ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT SELECT, REFERENCES, TRIGGER ON TABLES TO {role};
64
+ """).execute(
65
+ connection=connection,
66
+ commit=False,
67
+ parameters={
68
+ "schema": psycopg.sql.Identifier(schema),
69
+ "role": psycopg.sql.Identifier(role),
70
+ },
71
+ )
72
+ elif self.type == PermissionType.WRITE:
73
+ SqlContent("""
74
+ GRANT ALL ON SCHEMA {schema} TO {role};
75
+ GRANT ALL ON ALL TABLES IN SCHEMA {schema} TO {role};
76
+ GRANT ALL ON ALL SEQUENCES IN SCHEMA {schema} TO {role};
77
+ ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT ALL ON TABLES TO {role};
78
+ """).execute(
79
+ connection=connection,
80
+ commit=False,
81
+ parameters={
82
+ "schema": psycopg.sql.Identifier(schema),
83
+ "role": psycopg.sql.Identifier(role),
84
+ },
85
+ )
86
+ else:
87
+ raise ValueError(f"Unknown permission type: {self.type}")
88
+
89
+ if commit:
90
+ connection.commit()
91
+
92
+
93
+ class Role:
94
+ """ "
95
+ Represents a database role with associated permissions and optional inheritance.
96
+ The Role class encapsulates the concept of a database role, including its name,
97
+ permissions, optional inheritance from another role, and an optional description.
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ name: str,
103
+ permissions: list[Permission] | list[str],
104
+ *,
105
+ inherit: Optional["Role"] = None,
106
+ description: str | None = None,
107
+ ) -> None:
108
+ """Initialize the Role class.
109
+ Args:
110
+ name: Name of the role.
111
+ permissions: List of permissions associated with the role.
112
+ inherit: Optional role to inherit permissions from.
113
+ description: Optional description of the role.
114
+ """
115
+ self.name = name
116
+ if isinstance(permissions, list) and all(isinstance(p, dict) for p in permissions):
117
+ self._permissions = [Permission(**p) for p in permissions]
118
+ elif isinstance(permissions, list) and all(isinstance(p, Permission) for p in permissions):
119
+ self._permissions = permissions
120
+ else:
121
+ raise TypeError("Permissions must be a list of dictionnaries or Permission instances.")
122
+
123
+ if inherit is not None and not isinstance(inherit, Role):
124
+ raise TypeError("Inherit must be a Role instance or None.")
125
+ self.inherit = inherit
126
+ self.description = description
127
+
128
+ def permissions(self):
129
+ """
130
+ Returns the list of permissions associated with the role.
131
+ """
132
+ return self._permissions
133
+
134
+ def exists(self, connection: psycopg.Connection) -> bool:
135
+ """Check if the role exists in the database.
136
+ Args:
137
+ connection: The database connection to execute the SQL statements.
138
+ Returns:
139
+ bool: True if the role exists, False otherwise.
140
+ """
141
+ SqlContent("SELECT 1 FROM pg_roles WHERE rolname = {name}").execute(
142
+ connection=connection,
143
+ commit=False,
144
+ parameters={"name": psycopg.sql.Literal(self.name)},
145
+ ).fetchone() is not None
146
+
147
+ def create(
148
+ self, connection: psycopg.Connection, grant: bool = False, commit: bool = False
149
+ ) -> None:
150
+ """Create the role in the database.
151
+ Args:
152
+ connection: The database connection to execute the SQL statements.
153
+ grant: Whether to grant permissions to the role. Defaults to False.
154
+ commit: Whether to commit the transaction. Defaults to False.
155
+ """
156
+ if self.exists(connection):
157
+ logger.info(f"Role {self.name} already exists, skipping creation.")
158
+ else:
159
+ logger.info(f"Creating role {self.name}.")
160
+ SqlContent(
161
+ "CREATE ROLE {name} NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION"
162
+ ).execute(
163
+ connection=connection,
164
+ commit=False,
165
+ parameters={"name": psycopg.sql.Identifier(self.name)},
166
+ )
167
+ if self.description:
168
+ SqlContent("COMMENT ON ROLE {name} IS {description}").execute(
169
+ connection=connection,
170
+ commit=False,
171
+ parameters={
172
+ "name": psycopg.sql.Identifier(self.name),
173
+ "description": psycopg.sql.Literal(self.description),
174
+ },
175
+ )
176
+ if self.inherit:
177
+ SqlContent("GRANT {inherit} TO {role}").execute(
178
+ connection=connection,
179
+ commit=False,
180
+ parameters={
181
+ "inherit": psycopg.sql.Identifier(self.inherit.name),
182
+ "role": psycopg.sql.Identifier(self.name),
183
+ },
184
+ )
185
+ if grant:
186
+ for permission in self.permissions():
187
+ permission.grant(role=self.name, connection=connection, commit=commit)
188
+
189
+ if commit:
190
+ connection.commit()
191
+
192
+
193
+ class RoleManager:
194
+ """
195
+ RoleManager manages a collection of Role objects,
196
+ allowing creation and permission management
197
+ for multiple roles in the PostgreSQL database.
198
+ """
199
+
200
+ def __init__(self, roles=list[Role] | list[dict]) -> None:
201
+ """Initialize the RoleManager class.:
202
+ Args:
203
+ roles: List of roles or dictionaries defining roles.
204
+ Each role can be a dictionary with keys 'name', 'permissions', and optional 'description' and 'inherit'.
205
+ """
206
+ if isinstance(roles, list) and all(isinstance(role, dict) for role in roles):
207
+ self.roles = {}
208
+ for role in roles:
209
+ _inherit = role.get("inherit")
210
+ if _inherit is not None:
211
+ if _inherit not in self.roles:
212
+ raise ValueError(
213
+ f"Inherited role {_inherit} does not exist in the already defined roles. Pay attention to the order of the roles in the list."
214
+ )
215
+ role["inherit"] = self.roles[_inherit]
216
+ self.roles[role["name"]] = Role(**role)
217
+ elif isinstance(roles, list) and all(isinstance(role, Role) for role in roles):
218
+ _roles = copy.deepcopy(roles)
219
+ self.roles = {role.name: role for role in _roles}
220
+ else:
221
+ raise TypeError("Roles must be a list of dictionaries or Role instances.")
222
+
223
+ for role in self.roles.values():
224
+ if role.inherit is not None and role.inherit not in self.roles.values():
225
+ raise ValueError(
226
+ f"Inherited role {role.inherit.name} does not exist in the defined roles."
227
+ )
228
+
229
+ def create_roles(
230
+ self, connection: psycopg.Connection, grant: bool = False, commit: bool = False
231
+ ) -> None:
232
+ """Create roles in the database.
233
+ Args:
234
+ connection: The database connection to execute the SQL statements.
235
+ grant: Whether to grant permissions to the roles. Defaults to False.
236
+ commit: Whether to commit the transaction. Defaults to False.
237
+ """
238
+ for role in self.roles.values():
239
+ role.create(connection=connection, commit=False, grant=grant)
240
+ if commit:
241
+ connection.commit()
242
+
243
+ def grant_permissions(self, connection: psycopg.Connection, commit: bool = False) -> None:
244
+ """Grant permissions to the roles in the database.
245
+ Args:
246
+ connection: The database connection to execute the SQL statements.
247
+ commit: Whether to commit the transaction. Defaults to False.
248
+ """
249
+ for role in self.roles.values():
250
+ for permission in role.permissions():
251
+ permission.grant(role=role.name, connection=connection, commit=False)
252
+ if commit:
253
+ connection.commit()
@@ -0,0 +1,306 @@
1
+ import json
2
+ import logging
3
+ import re
4
+
5
+ import packaging
6
+ import psycopg
7
+ import psycopg.sql
8
+
9
+ from .pum_config import PumConfig
10
+ from .exceptions import PumException
11
+ from .sql_content import SqlContent
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ MIGRATION_TABLE_VERSION = "2025.0"
16
+ MIGRATION_TABLE_NAME = "pum_migrations"
17
+
18
+
19
+ class SchemaMigrations:
20
+ """Manage the schema migrations in the database.
21
+ It provides methods to create the schema_migrations table, check its existence,
22
+ set the baseline version, and retrieve migration details.
23
+ """
24
+
25
+ def __init__(self, config: PumConfig) -> None:
26
+ """Initialize the SchemaMigrations class with a database connection and configuration.
27
+
28
+ Args:
29
+ config (PumConfig): An instance of the PumConfig class containing configuration settings for the PUM system.
30
+
31
+ """
32
+ self.config = config
33
+ self.migration_table_identifier = psycopg.sql.SQL(".").join(
34
+ [
35
+ psycopg.sql.Identifier(self.config.config.pum.migration_table_schema),
36
+ psycopg.sql.Identifier(MIGRATION_TABLE_NAME),
37
+ ]
38
+ )
39
+
40
+ def exists(self, connection: psycopg.Connection) -> bool:
41
+ """Check if the schema_migrations information table exists.
42
+
43
+ Args:
44
+ connection: The database connection to check for the existence of the table.
45
+
46
+ Returns:
47
+ bool: True if the table exists, False otherwise.
48
+
49
+ """
50
+ query = psycopg.sql.SQL(
51
+ """
52
+ SELECT EXISTS (
53
+ SELECT 1
54
+ FROM information_schema.tables
55
+ WHERE table_name = 'pum_migrations' AND table_schema = {schema}
56
+ );
57
+ """
58
+ )
59
+
60
+ parameters = {
61
+ "schema": psycopg.sql.Literal(self.config.config.pum.migration_table_schema),
62
+ }
63
+
64
+ cursor = SqlContent(query).execute(connection, parameters=parameters)
65
+ return cursor.fetchone()[0]
66
+
67
+ def exists_in_other_schemas(self, connection: psycopg.Connection) -> list[str]:
68
+ """Check if the schema_migrations information table exists in other schemas.
69
+
70
+ Args:
71
+ connection: The database connection to check for the existence of the table.
72
+
73
+ Returns:
74
+ List[str]: List of schemas where the table exists.
75
+
76
+ """
77
+ query = psycopg.sql.SQL(
78
+ """
79
+ SELECT table_schema
80
+ FROM information_schema.tables
81
+ WHERE table_name = 'pum_migrations' AND table_schema != {schema}
82
+ """
83
+ )
84
+
85
+ parameters = {
86
+ "schema": psycopg.sql.Literal(self.config.config.pum.migration_table_schema),
87
+ }
88
+ cursor = SqlContent(query).execute(connection, parameters=parameters)
89
+ return [row[0] for row in cursor.fetchall()]
90
+
91
+ def create(
92
+ self,
93
+ connection: psycopg.Connection,
94
+ *,
95
+ allow_multiple_schemas: bool = False,
96
+ commit: bool = False,
97
+ ) -> None:
98
+ """Create the schema_migrations information table
99
+ Args:
100
+ connection: The database connection to create the table.
101
+ commit: If true, the transaction is committed. The default is false.
102
+ allow_multiple_schemas: If true, several pum_migrations tables are allowed in
103
+ distinct schemas. Default is false.
104
+ """
105
+ if self.exists(connection):
106
+ logger.info(
107
+ f"{self.config.config.pum.migration_table_schema}.pum_migrations table already exists."
108
+ )
109
+ return
110
+
111
+ if not allow_multiple_schemas and len(self.exists_in_other_schemas(connection)) > 0:
112
+ raise PumException(
113
+ f"Another {self.config.config.pum.migration_table_schema}.{self.config.config.pum.migration_table_name} table exists in another schema (). "
114
+ "Please use the allow_multiple_schemas option to create a new one."
115
+ )
116
+
117
+ # Create the schema if it doesn't exist
118
+ parameters = {
119
+ "version": psycopg.sql.Literal(MIGRATION_TABLE_VERSION),
120
+ "schema": psycopg.sql.Identifier(self.config.config.pum.migration_table_schema),
121
+ "table": self.migration_table_identifier,
122
+ }
123
+
124
+ create_schema_query = None
125
+ if self.config.config.pum.migration_table_schema != "public":
126
+ create_schema_query = psycopg.sql.SQL("CREATE SCHEMA IF NOT EXISTS {schema};")
127
+
128
+ create_table_query = psycopg.sql.SQL(
129
+ """CREATE TABLE IF NOT EXISTS {table}
130
+ (
131
+ id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
132
+ date_installed timestamp without time zone NOT NULL DEFAULT now(),
133
+ module character varying(50), -- TODO: NOT NULL,
134
+ version character varying(50) NOT NULL,
135
+ beta_testing boolean NOT NULL DEFAULT false,
136
+ changelog_files text[],
137
+ parameters jsonb,
138
+ migration_table_version character varying(50) NOT NULL DEFAULT {version}
139
+ );
140
+ """
141
+ )
142
+
143
+ comment_query = psycopg.sql.SQL(
144
+ "COMMENT ON TABLE {table} IS 'version: 1 -- schema_migration table version';"
145
+ )
146
+
147
+ if create_schema_query:
148
+ SqlContent(create_schema_query).execute(connection, parameters=parameters)
149
+ SqlContent(create_table_query).execute(connection, parameters=parameters)
150
+ SqlContent(comment_query).execute(connection, parameters=parameters)
151
+
152
+ logger.info(f"Created {parameters['schema']}.{parameters['table']} table")
153
+
154
+ if commit:
155
+ connection.commit()
156
+
157
+ def set_baseline(
158
+ self,
159
+ connection: psycopg.Connection,
160
+ version: packaging.version.Version | str,
161
+ changelog_files: list[str] | None = None,
162
+ parameters: dict | None = None,
163
+ *,
164
+ beta_testing: bool = False,
165
+ commit: bool = False,
166
+ ) -> None:
167
+ """Set the baseline into the migration table.
168
+
169
+ Args:
170
+ connection: The database connection to set the baseline version.
171
+ version: The version of the current database to set in the information.
172
+ changelog_files: The list of changelog files that were applied.
173
+ parameters: The parameters used in the migration.
174
+ beta_testing: If true, the baseline is set to beta testing mode. The default is false.
175
+ commit: If true, the transaction is committed. The default is False.
176
+
177
+ """
178
+ if isinstance(version, packaging.version.Version):
179
+ version = str(version)
180
+ pattern = re.compile(r"^\d+\.\d+\.\d+$")
181
+ if not re.match(pattern, version):
182
+ raise ValueError(f"Wrong version format: {version}. Must be x.x.x")
183
+
184
+ current = self.baseline(connection=connection)
185
+ if current and current >= version:
186
+ raise PumException(f"Cannot set baseline {version} as it is already set at {current}.")
187
+
188
+ code = psycopg.sql.SQL("""
189
+ INSERT INTO {table} (
190
+ version,
191
+ beta_testing,
192
+ migration_table_version,
193
+ changelog_files,
194
+ parameters
195
+ ) VALUES (
196
+ {version},
197
+ {beta_testing},
198
+ {migration_table_version},
199
+ {changelog_files},
200
+ {parameters}
201
+ );""")
202
+
203
+ query_parameters = {
204
+ "table": self.migration_table_identifier,
205
+ "version": psycopg.sql.Literal(version),
206
+ "beta_testing": psycopg.sql.Literal(beta_testing),
207
+ "migration_table_version": psycopg.sql.Literal(MIGRATION_TABLE_VERSION),
208
+ "changelog_files": psycopg.sql.Literal(changelog_files or []),
209
+ "parameters": psycopg.sql.Literal(json.dumps(parameters or {})),
210
+ }
211
+
212
+ logger.info(
213
+ f"Setting baseline version {version} in {self.config.config.pum.migration_table_schema}.{MIGRATION_TABLE_NAME} table"
214
+ )
215
+ SqlContent(code).execute(connection, parameters=query_parameters, commit=commit)
216
+
217
+ def baseline(self, connection: psycopg.Connection) -> str | None:
218
+ """Return the baseline version from the migration table.
219
+
220
+ Args:
221
+ connection: psycopg.Connection
222
+ The database connection to get the baseline version.
223
+
224
+ Returns:
225
+ str: The baseline version.
226
+
227
+ """
228
+
229
+ if not self.exists(connection=connection):
230
+ return None
231
+
232
+ query = psycopg.sql.SQL(
233
+ """
234
+ SELECT version
235
+ FROM {table}
236
+ WHERE id = (
237
+ SELECT id
238
+ FROM {table}
239
+ ORDER BY version DESC, date_installed DESC
240
+ LIMIT 1
241
+ )
242
+ """
243
+ )
244
+
245
+ parameters = {
246
+ "table": self.migration_table_identifier,
247
+ }
248
+
249
+ cursor = SqlContent(query).execute(connection, parameters=parameters)
250
+ row = cursor.fetchone()
251
+ if row is None:
252
+ return None
253
+ return row[0]
254
+
255
+ def migration_details(self, connection: psycopg.Connection, version: str | None = None) -> dict:
256
+ """Return the migration details from the migration table.
257
+
258
+ Args:
259
+ connection:
260
+ The database connection to get the migration details.
261
+ version:
262
+ The version of the migration to get details for.
263
+ If None, last migration is returned.
264
+
265
+ Returns:
266
+ dict: The migration details.
267
+
268
+ """
269
+ query = None
270
+ if version is None:
271
+ query = psycopg.sql.SQL(
272
+ """
273
+ SELECT *
274
+ FROM {table}
275
+ WHERE id = (
276
+ SELECT id
277
+ FROM {table}
278
+ ORDER BY version DESC, date_installed DESC
279
+ LIMIT 1
280
+ )
281
+ ORDER BY date_installed DESC
282
+ """
283
+ )
284
+
285
+ parameters = {
286
+ "table": self.migration_table_identifier,
287
+ }
288
+ else:
289
+ query = psycopg.sql.SQL(
290
+ """
291
+ SELECT *
292
+ FROM {table}
293
+ WHERE version = {version}
294
+ """
295
+ )
296
+
297
+ parameters = {
298
+ "table": self.migration_table_identifier,
299
+ "version": psycopg.sql.Literal(version),
300
+ }
301
+
302
+ cursor = SqlContent(query).execute(connection, parameters=parameters)
303
+ row = cursor.fetchone()
304
+ if row is None:
305
+ return None
306
+ return dict(zip([desc[0] for desc in cursor.description], row, strict=False))