Skip to content

Commit 7c7c3b4

Browse files
committed
User must have REFERENCES privilege on referred tables
Prior to this change, there was no check to verify that the user had the REFERENCES privilege on the referred tables (tables the table to be repacked has a fk to). This is a problem because Psycopack needs to create new foreign keys on the shadow table that point to the referred tables, and that is not possible unless the user has REFERENCES privilege. This change adds an explicit check to ensure the user has this privilege for all referred tables before proceeding.
1 parent f07cc33 commit 7c7c3b4

4 files changed

Lines changed: 137 additions & 0 deletions

File tree

src/psycopack/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
InvalidPrimaryKeyTypeForConversion,
1212
InvalidStageForReset,
1313
NoCreateAndUsagePrivilegeOnSchema,
14+
NoReferencesPrivilege,
1415
NoReferringTableOwnership,
1516
NotTableOwner,
1617
PrimaryKeyNotFound,
@@ -34,6 +35,7 @@
3435
"InvalidPrimaryKeyTypeForConversion",
3536
"InvalidStageForReset",
3637
"NoCreateAndUsagePrivilegeOnSchema",
38+
"NoReferencesPrivilege",
3739
"NoReferringTableOwnership",
3840
"NotTableOwner",
3941
"PrimaryKeyNotFound",

src/psycopack/_introspect.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ class ReferringForeignKey:
3333
is_owned_by_user: bool
3434

3535

36+
@dataclasses.dataclass
37+
class ReferredForeignKey:
38+
name: str
39+
schema: str
40+
privileges: list[str]
41+
42+
3643
@dataclasses.dataclass
3744
class Trigger:
3845
name: str
@@ -290,6 +297,71 @@ def get_referring_fks(self, *, table: str) -> list[ReferringForeignKey]:
290297
for name, definition, is_validated, referring_table, schema, is_owned_by_user in results
291298
]
292299

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
363+
]
364+
293365
def table_is_empty(self, *, table: str) -> int:
294366
self.cur.execute(
295367
psycopg.sql.SQL(

src/psycopack/_repack.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ class NoReferringTableOwnership(BaseRepackError):
7676
pass
7777

7878

79+
class NoReferencesPrivilege(BaseRepackError):
80+
pass
81+
82+
7983
class PostBackfillBatchCallback(typing.Protocol):
8084
def __call__(
8185
self, batch: _introspect.BackfillBatch, /
@@ -824,3 +828,25 @@ def _check_user_permissions(self) -> None:
824828
for table in referring_tables_without_ownership:
825829
message += f"ALTER TABLE {table} OWNER TO {user};\n"
826830
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)

tests/test_repack.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
InvalidPrimaryKeyTypeForConversion,
1414
InvalidStageForReset,
1515
NoCreateAndUsagePrivilegeOnSchema,
16+
NoReferencesPrivilege,
1617
NoReferringTableOwnership,
1718
NotTableOwner,
1819
PrimaryKeyNotFound,
@@ -1785,3 +1786,39 @@ def test_user_without_referring_table_ownership(
17851786
cur=cur,
17861787
schema=schema,
17871788
)
1789+
1790+
1791+
def test_user_without_referred_table_references_privilege(
1792+
connection: _psycopg.Connection,
1793+
) -> None:
1794+
schema = "sweet_schema"
1795+
with connection.cursor() as cur:
1796+
cur.execute(f"CREATE SCHEMA {schema};")
1797+
cur.execute(f"REVOKE CREATE ON SCHEMA {schema} FROM PUBLIC;")
1798+
factories.create_table_for_repacking(
1799+
connection=connection,
1800+
cur=cur,
1801+
table_name="to_repack",
1802+
rows=10,
1803+
schema=schema,
1804+
)
1805+
cur.execute("DROP USER IF EXISTS sweet_user;")
1806+
cur.execute("CREATE USER sweet_user;")
1807+
cur.execute("GRANT CREATE, USAGE ON SCHEMA sweet_schema TO sweet_user;")
1808+
cur.execute("ALTER TABLE sweet_schema.to_repack OWNER TO sweet_user;")
1809+
cur.execute("ALTER TABLE sweet_schema.referring_table OWNER TO sweet_user;")
1810+
cur.execute(
1811+
"ALTER TABLE sweet_schema.not_valid_referring_table OWNER TO sweet_user;"
1812+
)
1813+
cur.execute("SET ROLE sweet_user;")
1814+
with pytest.raises(
1815+
NoReferencesPrivilege,
1816+
match="GRANT REFERENCES ON TABLE sweet_schema.referred_table TO sweet_user;",
1817+
):
1818+
Repack(
1819+
table="to_repack",
1820+
batch_size=1,
1821+
conn=connection,
1822+
cur=cur,
1823+
schema=schema,
1824+
)

0 commit comments

Comments
 (0)