Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 34 additions & 25 deletions graphify/pg_introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,33 +58,42 @@ def introspect_postgres(dsn: str | None = None) -> dict:
""")
routines = cur.fetchall()

# 4. Query foreign keys — grouped by constraint to handle composites
# 4. Query foreign keys — grouped by constraint to handle composites.
# Read pg_catalog.pg_constraint, NOT information_schema.referential_
# constraints: that view only shows constraints where the current
# user has WRITE access to the referencing table (owner or a
# privilege other than SELECT), so a read-only introspection role
# sees zero FK rows while tables/views/routines all appear — the
# graph then silently loses every 'references' edge (#1746).
# pg_constraint is not privilege-filtered. It also keys constraints
# by oid rather than by name (constraint names are only unique per
# table, so the old name-based key_column_usage joins could
# cross-match same-named constraints on sibling tables).
cur.execute("""
SELECT
tc.constraint_name,
kcu1.table_schema,
kcu1.table_name,
ARRAY_AGG(kcu1.column_name ORDER BY kcu1.ordinal_position) AS columns,
kcu2.table_schema AS foreign_table_schema,
kcu2.table_name AS foreign_table_name,
ARRAY_AGG(kcu2.column_name ORDER BY kcu2.ordinal_position) AS foreign_columns
FROM
information_schema.table_constraints AS tc
JOIN information_schema.referential_constraints AS rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
JOIN information_schema.key_column_usage AS kcu1
ON tc.constraint_name = kcu1.constraint_name
AND tc.table_schema = kcu1.table_schema
JOIN information_schema.key_column_usage AS kcu2
ON rc.unique_constraint_name = kcu2.constraint_name
AND rc.unique_constraint_schema = kcu2.table_schema
AND kcu1.position_in_unique_constraint = kcu2.ordinal_position
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')
GROUP BY tc.constraint_name, kcu1.table_schema, kcu1.table_name,
kcu2.table_schema, kcu2.table_name
ORDER BY kcu1.table_schema, kcu1.table_name;
con.conname AS constraint_name,
ns.nspname AS table_schema,
rel.relname AS table_name,
(SELECT ARRAY_AGG(att.attname ORDER BY k.ord)
FROM UNNEST(con.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_catalog.pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = k.attnum
) AS columns,
fns.nspname AS foreign_table_schema,
frel.relname AS foreign_table_name,
(SELECT ARRAY_AGG(att.attname ORDER BY k.ord)
FROM UNNEST(con.confkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_catalog.pg_attribute att
ON att.attrelid = con.confrelid AND att.attnum = k.attnum
) AS foreign_columns
FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
JOIN pg_catalog.pg_class frel ON frel.oid = con.confrelid
JOIN pg_catalog.pg_namespace fns ON fns.oid = frel.relnamespace
WHERE con.contype = 'f'
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY ns.nspname, rel.relname, con.conname;
""")
fks = cur.fetchall()
finally:
Expand Down
41 changes: 40 additions & 1 deletion tests/test_pg_introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def _make_mock_psycopg(tables, views, routines, fks,
``connect_raises``, if set, is an exception *instance* raised by connect().
"""

executed_queries: list[str] = []

class MockCursor:
def __enter__(self):
return self
Expand All @@ -33,6 +35,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):

def execute(self, query, params=None):
self.query = query
executed_queries.append(query)

def fetchall(self):
q = self.query.strip().lower()
Expand All @@ -42,7 +45,7 @@ def fetchall(self):
return views
elif "information_schema.routines" in q:
return routines
elif "information_schema.referential_constraints" in q:
elif "pg_constraint" in q:
return fks
return []

Expand Down Expand Up @@ -75,6 +78,7 @@ def info(self):
"host": host,
"dbname": dbname,
}
mock_psycopg._executed_queries = executed_queries
return mock_psycopg


Expand Down Expand Up @@ -244,6 +248,41 @@ def test_pg_introspect_composite_fk():
)


def test_pg_introspect_fk_query_avoids_privilege_filtered_view():
"""#1746: information_schema.referential_constraints only shows constraints
where the current user has WRITE access to the referencing table (owner or
a privilege other than SELECT). A read-only introspection role therefore
gets zero FK rows — while tables/views/routines all still appear, since
SELECT is enough for those views — and the graph silently loses every
'references' edge. The FK query must read pg_catalog.pg_constraint, which
is not privilege-filtered."""
mock_tables = [
("public", "users", "BASE TABLE"),
("public", "orders", "BASE TABLE"),
]
mock_fks = [
("fk_orders_user_id", "public", "orders", ["user_id"], "public", "users", ["id"]),
]

mock_psycopg = _make_mock_psycopg(mock_tables, [], [], mock_fks)

with patch.dict("sys.modules", {"psycopg": mock_psycopg}):
res = introspect_postgres("postgresql://readonly:secret@myhost/mydb")

constraint_queries = [
q for q in mock_psycopg._executed_queries if "constraint" in q.lower()
]
assert constraint_queries, "no FK query was executed"
assert all(
"referential_constraints" not in q.lower() for q in constraint_queries
), "FK query must not read information_schema.referential_constraints (privilege-filtered, #1746)"
assert any("pg_constraint" in q.lower() for q in constraint_queries)

# And the FK still becomes a references edge end-to-end
ref_edges = [e for e in res["edges"] if e["relation"] == "references"]
assert len(ref_edges) == 1


def test_pg_introspect_connection_error():
"""A psycopg.OperationalError must be re-raised as ConnectionError with a
sanitized message (no DSN/credentials) and no stack-trace noise."""
Expand Down
Loading