Skip to content

Commit 41ebf9b

Browse files
authored
Merge pull request #30 from kraken-tech/forbid-user-without-access
Stop user without access permissions
2 parents ab60bdb + 180dcd6 commit 41ebf9b

4 files changed

Lines changed: 438 additions & 3 deletions

File tree

src/psycopack/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
InvalidIndexes,
1111
InvalidPrimaryKeyTypeForConversion,
1212
InvalidStageForReset,
13+
NoCreateAndUsagePrivilegeOnSchema,
14+
NoReferencesPrivilege,
15+
NoReferringTableOwnership,
16+
NotTableOwner,
1317
PrimaryKeyNotFound,
1418
ReferringForeignKeyInDifferentSchema,
1519
Repack,
@@ -30,6 +34,10 @@
3034
"InvalidIndexes",
3135
"InvalidPrimaryKeyTypeForConversion",
3236
"InvalidStageForReset",
37+
"NoCreateAndUsagePrivilegeOnSchema",
38+
"NoReferencesPrivilege",
39+
"NoReferringTableOwnership",
40+
"NotTableOwner",
3341
"PrimaryKeyNotFound",
3442
"ReferringForeignKeyInDifferentSchema",
3543
"Repack",

src/psycopack/_introspect.py

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ class ReferringForeignKey:
3030
is_validated: bool
3131
referring_table: str
3232
schema: str
33+
is_owned_by_user: bool
34+
35+
36+
@dataclasses.dataclass
37+
class ReferredForeignKey:
38+
name: str
39+
schema: str
40+
privileges: list[str]
3341

3442

3543
@dataclasses.dataclass
@@ -239,7 +247,16 @@ def get_referring_fks(self, *, table: str) -> list[ReferringForeignKey]:
239247
pg_get_constraintdef(pg_constraint.oid) AS definition,
240248
pg_constraint.convalidated AS is_validated,
241249
referring_pg_class.relname AS referring_table,
242-
pg_namespace.nspname AS schema
250+
pg_namespace.nspname AS schema,
251+
(
252+
SELECT
253+
tableowner = current_user
254+
FROM
255+
pg_tables
256+
WHERE
257+
schemaname = {schema}
258+
AND tablename = referring_pg_class.relname
259+
) AS is_owned_by_user
243260
FROM
244261
pg_catalog.pg_constraint
245262
INNER JOIN
@@ -260,7 +277,10 @@ def get_referring_fks(self, *, table: str) -> list[ReferringForeignKey]:
260277
definition;
261278
""")
262279
)
263-
.format(table=psycopg.sql.Literal(table))
280+
.format(
281+
table=psycopg.sql.Literal(table),
282+
schema=psycopg.sql.Literal(self.schema),
283+
)
264284
.as_string(self.conn)
265285
)
266286
results = self.cur.fetchall()
@@ -272,8 +292,74 @@ def get_referring_fks(self, *, table: str) -> list[ReferringForeignKey]:
272292
is_validated=is_validated,
273293
referring_table=referring_table,
274294
schema=schema,
295+
is_owned_by_user=is_owned_by_user,
275296
)
276-
for name, definition, is_validated, referring_table, schema in results
297+
for name, definition, is_validated, referring_table, schema, is_owned_by_user in results
298+
]
299+
300+
def get_referred_fks(self, *, table: str, schema: str) -> list[ReferredForeignKey]:
301+
self.cur.execute(
302+
psycopg.sql.SQL(
303+
dedent("""
304+
SELECT
305+
referred_pg_class.relname AS referred_table,
306+
referred_pg_namespace.nspname AS referred_schema,
307+
array_agg(
308+
table_privileges.privilege_type
309+
ORDER BY table_privileges.privilege_type
310+
) AS privileges
311+
FROM
312+
pg_catalog.pg_constraint
313+
INNER JOIN
314+
pg_catalog.pg_class AS referring_pg_class
315+
ON (pg_constraint.conrelid = referring_pg_class.oid)
316+
INNER JOIN
317+
pg_catalog.pg_class AS referred_pg_class
318+
ON (pg_constraint.confrelid = referred_pg_class.oid)
319+
INNER JOIN
320+
pg_catalog.pg_namespace AS referring_pg_namespace
321+
ON (referring_pg_namespace.oid = referring_pg_class.relnamespace)
322+
INNER JOIN
323+
pg_catalog.pg_namespace AS referred_pg_namespace
324+
ON (referred_pg_namespace.oid = referred_pg_class.relnamespace)
325+
LEFT OUTER JOIN
326+
information_schema.table_privileges
327+
ON (
328+
table_privileges.table_name = referred_pg_class.relname
329+
AND table_privileges.table_schema = referred_pg_namespace.nspname
330+
)
331+
WHERE
332+
pg_constraint.conrelid = referring_pg_class.oid
333+
AND referring_pg_class.relname = {table}
334+
AND referring_pg_namespace.nspname = {schema}
335+
AND pg_constraint.contype = 'f'
336+
GROUP BY
337+
referred_pg_namespace.nspname,
338+
referred_pg_class.relname
339+
ORDER BY
340+
referred_pg_namespace.nspname,
341+
referred_pg_class.relname;
342+
""")
343+
)
344+
.format(
345+
table=psycopg.sql.Literal(table),
346+
schema=psycopg.sql.Literal(schema),
347+
)
348+
.as_string(self.conn)
349+
)
350+
results = self.cur.fetchall()
351+
assert results is not None
352+
return [
353+
ReferredForeignKey(
354+
name=name,
355+
schema=schema,
356+
privileges=(
357+
privileges.strip("{}").split(",")
358+
if "NULL" not in privileges
359+
else []
360+
),
361+
)
362+
for name, schema, privileges in results
277363
]
278364

279365
def table_is_empty(self, *, table: str) -> int:
@@ -529,3 +615,48 @@ def get_backfill_batch(self, *, table: str) -> BackfillBatch | None:
529615
if not (result := self.cur.fetchone()):
530616
return None
531617
return BackfillBatch(id=result[0], start=result[1], end=result[2])
618+
619+
def get_user(self) -> str:
620+
self.cur.execute(psycopg.sql.SQL("SELECT current_user;").as_string(self.conn))
621+
result = self.cur.fetchone()
622+
assert result is not None
623+
return str(result[0])
624+
625+
def has_create_and_usage_privilege_on_schema(self) -> bool:
626+
self.cur.execute(
627+
psycopg.sql.SQL(
628+
dedent("""
629+
SELECT
630+
has_schema_privilege({schema}, 'CREATE'),
631+
has_schema_privilege({schema}, 'USAGE');
632+
""")
633+
)
634+
.format(schema=psycopg.sql.Literal(self.schema))
635+
.as_string(self.conn)
636+
)
637+
result = self.cur.fetchone()
638+
assert result is not None
639+
return bool(result[0] and result[1])
640+
641+
def is_table_owner(self, *, table: str, schema: str) -> bool:
642+
self.cur.execute(
643+
psycopg.sql.SQL(
644+
dedent("""
645+
SELECT
646+
tableowner = current_user
647+
FROM
648+
pg_tables
649+
WHERE
650+
schemaname = {schema}
651+
AND tablename = {table}
652+
""")
653+
)
654+
.format(
655+
schema=psycopg.sql.Literal(schema),
656+
table=psycopg.sql.Literal(table),
657+
)
658+
.as_string(self.conn)
659+
)
660+
result = self.cur.fetchone()
661+
assert result is not None
662+
return bool(result[0])

src/psycopack/_repack.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ class ReferringForeignKeyInDifferentSchema(BaseRepackError):
6464
pass
6565

6666

67+
class NoCreateAndUsagePrivilegeOnSchema(BaseRepackError):
68+
pass
69+
70+
71+
class NotTableOwner(BaseRepackError):
72+
pass
73+
74+
75+
class NoReferringTableOwnership(BaseRepackError):
76+
pass
77+
78+
79+
class NoReferencesPrivilege(BaseRepackError):
80+
pass
81+
82+
6783
class PostBackfillBatchCallback(typing.Protocol):
6884
def __call__(
6985
self, batch: _introspect.BackfillBatch, /
@@ -175,6 +191,7 @@ def __init__(
175191
)
176192
self._pk_column = ""
177193
self.schema = schema
194+
self._check_user_permissions()
178195

179196
@property
180197
def pk_column(self) -> str:
@@ -776,3 +793,60 @@ def _create_referring_fks(self) -> None:
776793
self.command.validate_constraint(
777794
table=fk.referring_table, constraint=constraint_name
778795
)
796+
797+
def _check_user_permissions(self) -> None:
798+
if not self.introspector.has_create_and_usage_privilege_on_schema():
799+
user = self.introspector.get_user()
800+
raise NoCreateAndUsagePrivilegeOnSchema(
801+
f"Psycopack requires the database user to have CREATE and USAGE "
802+
f"privilege on the {self.schema} schema to create auxiliary "
803+
f"objects. You can grant it to your user via:\n"
804+
f"GRANT CREATE, USAGE ON SCHEMA {self.schema} TO {user};"
805+
)
806+
807+
if not self.introspector.is_table_owner(table=self.table, schema=self.schema):
808+
user = self.introspector.get_user()
809+
raise NotTableOwner(
810+
f"Psycopack requires the database user to have ownership of "
811+
f"the table {self.schema}.{self.table}. You can grant it to "
812+
f"your user via:\n"
813+
f"ALTER TABLE {self.schema}.{self.table} OWNER TO {user};"
814+
)
815+
816+
referring_tables_without_ownership = [
817+
f"{fk.schema}.{fk.referring_table}"
818+
for fk in self.introspector.get_referring_fks(table=self.table)
819+
if not fk.is_owned_by_user
820+
]
821+
if referring_tables_without_ownership:
822+
user = self.introspector.get_user()
823+
message = (
824+
f"Psycopack requires the user to have ownership of tables that "
825+
f"have foreign keys pointing to the table {self.schema}.{self.table}. "
826+
f"You can grant ownership to your user via the following commands:\n"
827+
)
828+
for table in referring_tables_without_ownership:
829+
message += f"ALTER TABLE {table} OWNER TO {user};\n"
830+
raise NoReferringTableOwnership(message)
831+
832+
referred_fks_without_references_privilege = [
833+
fk
834+
for fk in self.introspector.get_referred_fks(
835+
table=self.table, schema=self.schema
836+
)
837+
if "REFERENCES" not in fk.privileges
838+
]
839+
if referred_fks_without_references_privilege:
840+
user = self.introspector.get_user()
841+
tables_missing_ownership = [
842+
f"{fk.schema}.{fk.name}"
843+
for fk in referred_fks_without_references_privilege
844+
]
845+
message = (
846+
f"Psycopack requires the user to REFERENCES privilege on tables that "
847+
f"{self.schema}.{self.table} has foreign keys to. "
848+
f"You can grant this privilege to your user via the following commands:\n"
849+
)
850+
for table in tables_missing_ownership:
851+
message += f"GRANT REFERENCES ON TABLE {table} TO {user};\n"
852+
raise NoReferencesPrivilege(message)

0 commit comments

Comments
 (0)