diff --git a/src/postgres-util/src/tunnel.rs b/src/postgres-util/src/tunnel.rs index e4d2110d40d79..89b827e3275cb 100644 --- a/src/postgres-util/src/tunnel.rs +++ b/src/postgres-util/src/tunnel.rs @@ -191,6 +191,20 @@ impl Config { let mut postgres_config = self.inner.clone(); configure(&mut postgres_config); + // Pin the search path to `pg_catalog` for every upstream connection. Our catalog queries + // reference `pg_catalog` relations and functions by unqualified name (and user tables by a + // fully-qualified name), so an upstream role default such as + // `ALTER ROLE mz_user SET search_path = attacker, pg_catalog` must not be able to shadow + // them with attacker-owned relations or functions. The `options` startup parameter carries + // GUC source `PGC_S_CLIENT`, which outranks the `PGC_S_USER`/`PGC_S_DATABASE` defaults set + // by `ALTER ROLE`/`ALTER DATABASE`, so this holds even against a hostile per-role default. + let search_path_option = "-c search_path=pg_catalog"; + let options = match postgres_config.get_options() { + Some(existing) => format!("{existing} {search_path_option}"), + None => search_path_option.to_string(), + }; + postgres_config.options(options); + let mut tls = mz_tls_util::make_tls(&postgres_config).map_err(|tls_err| match tls_err { mz_tls_util::TlsError::Generic(e) => PostgresError::Generic(e), mz_tls_util::TlsError::OpenSsl(e) => PostgresError::PostgresSsl(e), diff --git a/src/sql/src/func.rs b/src/sql/src/func.rs index b7f80797264f8..489468fd3521a 100644 --- a/src/sql/src/func.rs +++ b/src/sql/src/func.rs @@ -2697,14 +2697,21 @@ pub static PG_CATALOG_BUILTINS: LazyLock> = LazyLoc // pg_get_viewdef returns the (query part of) the given view's definition. // We currently don't support pretty-printing (the `Bool`/`Int32` parameters). "pg_get_viewdef" => Scalar { + // The text overloads resolve the name through the search-path-aware + // text -> regclass cast, matching PostgreSQL's regclass semantics + // (current database, search path, first match). Matching on the bare + // `name` instead would resolve across every schema and database, so + // any same-named view anywhere would make the lookup ambiguous. params!(String) => sql_impl_func( - "(SELECT definition FROM mz_catalog.mz_views WHERE name = $1)" + "(SELECT definition FROM mz_catalog.mz_views + WHERE oid = CAST(CAST($1 AS pg_catalog.regclass) AS pg_catalog.oid))" ) => String, 1640; params!(Oid) => sql_impl_func( "(SELECT definition FROM mz_catalog.mz_views WHERE oid = $1)" ) => String, 1641; params!(String, Bool) => sql_impl_func( - "(SELECT definition FROM mz_catalog.mz_views WHERE name = $1)" + "(SELECT definition FROM mz_catalog.mz_views + WHERE oid = CAST(CAST($1 AS pg_catalog.regclass) AS pg_catalog.oid))" ) => String, 2505; params!(Oid, Bool) => sql_impl_func( "(SELECT definition FROM mz_catalog.mz_views WHERE oid = $1)" diff --git a/test/sqllogictest/pg_get_viewdef.slt b/test/sqllogictest/pg_get_viewdef.slt index 37cbb1e4936b9..39baaa43a1cfc 100644 --- a/test/sqllogictest/pg_get_viewdef.slt +++ b/test/sqllogictest/pg_get_viewdef.slt @@ -25,16 +25,22 @@ CREATE VIEW t_view AS SELECT t.a, b FROM t # Test pg_get_viewdef(view_name) -query T +# The text overload resolves through regclass, so an unknown name errors +# (matching PostgreSQL) rather than returning NULL. +query error relation "doesnotexist" does not exist SELECT pg_get_viewdef('doesnotexist') ----- -NULL query T SELECT pg_get_viewdef('t_view') ---- SELECT "t"."a", "b" FROM [u1 AS "materialize"."public"."t"]; +# Schema-qualified names resolve (previously returned NULL). +query T +SELECT pg_get_viewdef('public.t_view') +---- +SELECT "t"."a", "b" FROM [u1 AS "materialize"."public"."t"]; + # Test pg_get_viewdef(view_oid) query T @@ -49,15 +55,11 @@ SELECT "t"."a", "b" FROM [u1 AS "materialize"."public"."t"]; # Test pg_get_viewdef(view_name, pretty) -query T +query error relation "doesnotexist" does not exist SELECT pg_get_viewdef('doesnotexist', true) ----- -NULL -query T +query error relation "doesnotexist" does not exist SELECT pg_get_viewdef('doesnotexist', false) ----- -NULL query T SELECT pg_get_viewdef('t_view', true) @@ -112,3 +114,33 @@ query T SELECT pg_get_viewdef('t_view'::regclass::oid) ---- SELECT "t2"."a", "b" FROM [u1 AS "materialize"."public"."t2"]; + +# A same-named view in another schema must not make the text overload ambiguous. +# The name resolves via the search path, not by matching the bare name globally. + +statement ok +CREATE SCHEMA other + +statement ok +CREATE VIEW other.t_view AS SELECT c FROM t2 + +query T +SELECT pg_get_viewdef('t_view') +---- +SELECT "t2"."a", "b" FROM [u1 AS "materialize"."public"."t2"]; + +query T +SELECT pg_get_viewdef('other.t_view') +---- +SELECT "c" FROM [u1 AS "materialize"."public"."t2"]; + +statement ok +SET search_path = other + +query T +SELECT pg_get_viewdef('t_view') +---- +SELECT "c" FROM [u1 AS "materialize"."public"."t2"]; + +statement ok +RESET search_path diff --git a/test/sqllogictest/rbac_mcp_agent.slt b/test/sqllogictest/rbac_mcp_agent.slt index ceedf9fafc63a..b842aac794eb3 100644 --- a/test/sqllogictest/rbac_mcp_agent.slt +++ b/test/sqllogictest/rbac_mcp_agent.slt @@ -765,7 +765,7 @@ DETAIL: Access to system catalog objects is restricted for this role. Contact yo simple conn=agent_restricted,user=agent SELECT pg_get_viewdef('next_arrivals'); ---- -db error: ERROR: access to system object mz_views is restricted +db error: ERROR: access to system object mz_databases is restricted DETAIL: Access to system catalog objects is restricted for this role. Contact your administrator if you need access. # obj_description queries system tables; pg_class is the first blocked ID encountered