From c13f496f48adbf603e82adba1d17cb2bf3523de3 Mon Sep 17 00:00:00 2001 From: rithyKabir Date: Fri, 10 Jul 2026 10:16:19 +0600 Subject: [PATCH] fix(pg_introspect): read FKs from pg_constraint so read-only roles get 'references' edges (#1746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — per the PostgreSQL docs). A read-only introspection role therefore gets zero FK rows, while tables, views and routines all still appear (SELECT is enough for those views) — so --postgres built a graph with contains/reads_from edges but not a single 'references' edge, exactly as reported. Query pg_catalog.pg_constraint (contype = 'f') instead, which is not privilege-filtered. This also fixes a latent second bug: constraint names in PostgreSQL are only unique per table, so the old name-based key_column_usage joins cross-matched same-named constraints on sibling tables (verified live: two tables sharing a 'fk_owner' constraint produced columns = {user_id,user_id,user_id,user_id}). pg_constraint keys by oid, and conkey/confkey unnest WITH ORDINALITY preserves composite-FK column order. Verified against a live PostgreSQL 16 with a SELECT-only role: referential_constraints returned 0 FK rows, the new query returned all 3 (single-column, composite, and same-named sibling constraints), and introspect_postgres emitted all 3 references edges end-to-end. Co-Authored-By: Claude Fable 5 --- graphify/pg_introspect.py | 59 +++++++++++++++++++++---------------- tests/test_pg_introspect.py | 41 +++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/graphify/pg_introspect.py b/graphify/pg_introspect.py index 24567fa18..dc7b2bbf8 100644 --- a/graphify/pg_introspect.py +++ b/graphify/pg_introspect.py @@ -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: diff --git a/tests/test_pg_introspect.py b/tests/test_pg_introspect.py index a133cdad3..919d7ba40 100644 --- a/tests/test_pg_introspect.py +++ b/tests/test_pg_introspect.py @@ -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 @@ -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() @@ -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 [] @@ -75,6 +78,7 @@ def info(self): "host": host, "dbname": dbname, } + mock_psycopg._executed_queries = executed_queries return mock_psycopg @@ -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."""