Skip to content

Commit 7ca221f

Browse files
committed
Make ag_catalog ownership and built-in resolution explicit
AGE places all of its objects in the ag_catalog schema. Make the assumptions around that schema explicit so installs and upgrades behave predictably regardless of how a database is provisioned: - Ownership-checked install: CREATE EXTENSION age installs into ag_catalog only when that schema does not already exist under a different owner, keeping ownership of AGE's catalog well-defined. - Deterministic name resolution: the pg_upgrade helper functions resolve built-ins from pg_catalog first and schema-qualify their format()/hashtext() calls, so their behavior does not depend on what else is defined in ag_catalog. - README note describing ag_catalog ownership and the install-time check. The upgrade script applies the same helper changes so existing installations get them on ALTER EXTENSION UPDATE. Adds an extension_security regression test covering the ownership check and the qualified-call / search_path properties. Assisted-by: GitHub Copilot (Claude Opus 4.8) modified: Makefile modified: README.md modified: age--1.7.0--y.y.y.sql new file: regress/expected/extension_security.out new file: regress/sql/extension_security.sql modified: sql/age_main.sql modified: sql/age_pg_upgrade.sql
1 parent 14732bf commit 7ca221f

7 files changed

Lines changed: 265 additions & 22 deletions

File tree

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,8 @@ REGRESS = scan \
184184
security \
185185
reserved_keyword_alias \
186186
agtype_jsonb_cast \
187-
containment_selectivity
187+
containment_selectivity \
188+
extension_security
188189

189190
ifneq ($(EXTRA_TESTS),)
190191
REGRESS += $(EXTRA_TESTS)

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,16 @@ LOAD 'age';
215215
SET search_path = ag_catalog, "$user", public;
216216
```
217217

218+
### Note on `ag_catalog` ownership
219+
220+
AGE installs all of its objects into the `ag_catalog` schema. Install AGE
221+
(`CREATE EXTENSION age`) **before** granting the `CREATE` privilege on the
222+
database to other roles. A role that can create schemas could otherwise
223+
pre-create `ag_catalog` and own it; `CREATE EXTENSION age` therefore refuses to
224+
install when `ag_catalog` already exists and is owned by a different role. If you
225+
hit that error, drop the stray schema (`DROP SCHEMA ag_catalog CASCADE`) or
226+
transfer its ownership to the installing role, then retry.
227+
218228
<h2><img height="20" src="/img/contents.svg">&nbsp;&nbsp;Using AGE with Non-Autocommit Clients (psycopg, JDBC, etc.)</h2>
219229

220230
If you are using AGE from a database client that does **not** default to autocommit — most commonly `psycopg` v3 or JDBC — you must understand how PostgreSQL's transaction semantics apply to AGE's setup and DDL-like functions. Otherwise, you may see graphs or labels that appear to be created successfully, but are not visible from new connections.

age--1.7.0--y.y.y.sql

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@
4141
CREATE FUNCTION ag_catalog.age_prepare_pg_upgrade()
4242
RETURNS void
4343
LANGUAGE plpgsql
44-
SET search_path = ag_catalog, pg_catalog
44+
-- Resolve built-in functions and operators from pg_catalog first so they
45+
-- are not overridden by same-named objects defined in ag_catalog. The
46+
-- ag_catalog objects referenced here are schema-qualified.
47+
SET search_path = pg_catalog, ag_catalog
4548
AS $function$
4649
DECLARE
4750
graph_count integer;
@@ -108,7 +111,10 @@ COMMENT ON FUNCTION ag_catalog.age_prepare_pg_upgrade() IS
108111
CREATE FUNCTION ag_catalog.age_finish_pg_upgrade()
109112
RETURNS void
110113
LANGUAGE plpgsql
111-
SET search_path = ag_catalog, pg_catalog
114+
-- Resolve built-in functions and operators from pg_catalog first so they
115+
-- are not overridden by same-named objects defined in ag_catalog. The
116+
-- ag_catalog objects referenced here are schema-qualified.
117+
SET search_path = pg_catalog, ag_catalog
112118
AS $function$
113119
DECLARE
114120
mapping_count integer;
@@ -231,7 +237,7 @@ BEGIN
231237
-- and preserve original schema ownership.
232238
--
233239
RAISE NOTICE 'Invalidating AGE caches...';
234-
PERFORM pg_catalog.pg_advisory_xact_lock(hashtext('age_finish_pg_upgrade'));
240+
PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('age_finish_pg_upgrade'));
235241
DECLARE
236242
graph_rec RECORD;
237243
cache_invalidated boolean := false;
@@ -245,8 +251,8 @@ BEGIN
245251
BEGIN
246252
-- Touch schema by changing owner to current_user then back to original
247253
-- This triggers cache invalidation without permanently changing ownership
248-
EXECUTE format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, current_user);
249-
EXECUTE format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, graph_rec.owner_name);
254+
EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, current_user);
255+
EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, graph_rec.owner_name);
250256
cache_invalidated := true;
251257
EXCEPTION WHEN insufficient_privilege THEN
252258
-- If we can't change ownership, skip this schema
@@ -273,7 +279,10 @@ COMMENT ON FUNCTION ag_catalog.age_finish_pg_upgrade() IS
273279
CREATE FUNCTION ag_catalog.age_revert_pg_upgrade_changes()
274280
RETURNS void
275281
LANGUAGE plpgsql
276-
SET search_path = ag_catalog, pg_catalog
282+
-- Resolve built-in functions and operators from pg_catalog first so they
283+
-- are not overridden by same-named objects defined in ag_catalog. The
284+
-- ag_catalog objects referenced here are schema-qualified.
285+
SET search_path = pg_catalog, ag_catalog
277286
AS $function$
278287
BEGIN
279288
-- Check if namespace column is oid type (needs reverting)
@@ -306,7 +315,7 @@ BEGIN
306315
-- Invalidate AGE's internal caches by touching each graph's namespace
307316
-- We use xact-level advisory lock and preserve original ownership
308317
--
309-
PERFORM pg_catalog.pg_advisory_xact_lock(hashtext('age_revert_pg_upgrade'));
318+
PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('age_revert_pg_upgrade'));
310319
DECLARE
311320
graph_rec RECORD;
312321
BEGIN
@@ -318,8 +327,8 @@ BEGIN
318327
LOOP
319328
BEGIN
320329
-- Touch schema by changing owner to current_user then back to original
321-
EXECUTE format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, current_user);
322-
EXECUTE format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, graph_rec.owner_name);
330+
EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, current_user);
331+
EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, graph_rec.owner_name);
323332
EXCEPTION WHEN insufficient_privilege THEN
324333
RAISE NOTICE 'Could not invalidate cache for schema % (insufficient privileges)', graph_rec.ns_name;
325334
END;
@@ -345,7 +354,10 @@ CREATE FUNCTION ag_catalog.age_pg_upgrade_status()
345354
message text
346355
)
347356
LANGUAGE plpgsql
348-
SET search_path = ag_catalog, pg_catalog
357+
-- Resolve built-in functions and operators from pg_catalog first so they
358+
-- are not overridden by same-named objects defined in ag_catalog. The
359+
-- ag_catalog objects referenced here are schema-qualified.
360+
SET search_path = pg_catalog, ag_catalog
349361
AS $function$
350362
DECLARE
351363
ns_type text;
@@ -447,7 +459,7 @@ BEGIN
447459
AND t.tgname = '_age_cache_invalidate'
448460
)
449461
THEN
450-
EXECUTE format(
462+
EXECUTE pg_catalog.format(
451463
'CREATE TRIGGER _age_cache_invalidate '
452464
'AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE '
453465
'ON %I.%I '
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
LOAD 'age';
20+
SET search_path TO ag_catalog;
21+
--
22+
-- pg_upgrade helper functions resolve built-ins from pg_catalog first.
23+
--
24+
-- Each helper must place pg_catalog ahead of ag_catalog in its search_path, so
25+
-- that built-in functions and operators always resolve to pg_catalog and are
26+
-- not overridden by same-named objects defined in ag_catalog.
27+
--
28+
SELECT p.proname,
29+
array_to_string(p.proconfig, ', ') AS proconfig
30+
FROM pg_proc p
31+
JOIN pg_namespace n ON n.oid = p.pronamespace
32+
WHERE n.nspname = 'ag_catalog'
33+
AND p.proname IN ('age_prepare_pg_upgrade', 'age_finish_pg_upgrade',
34+
'age_revert_pg_upgrade_changes', 'age_pg_upgrade_status')
35+
ORDER BY p.proname;
36+
proname | proconfig
37+
-------------------------------+------------------------------------
38+
age_finish_pg_upgrade | search_path=pg_catalog, ag_catalog
39+
age_pg_upgrade_status | search_path=pg_catalog, ag_catalog
40+
age_prepare_pg_upgrade | search_path=pg_catalog, ag_catalog
41+
age_revert_pg_upgrade_changes | search_path=pg_catalog, ag_catalog
42+
(4 rows)
43+
44+
--
45+
-- The helper bodies must not contain unqualified format()/hashtext() calls;
46+
-- those built-ins are explicitly schema-qualified to pg_catalog.
47+
--
48+
SELECT p.proname,
49+
(p.prosrc ~ '[^.]\mformat\s*\(') AS has_unqualified_format,
50+
(p.prosrc ~ '[^.]\mhashtext\s*\(') AS has_unqualified_hashtext
51+
FROM pg_proc p
52+
JOIN pg_namespace n ON n.oid = p.pronamespace
53+
WHERE n.nspname = 'ag_catalog'
54+
AND p.proname IN ('age_finish_pg_upgrade', 'age_revert_pg_upgrade_changes')
55+
ORDER BY p.proname;
56+
proname | has_unqualified_format | has_unqualified_hashtext
57+
-------------------------------+------------------------+--------------------------
58+
age_finish_pg_upgrade | f | f
59+
age_revert_pg_upgrade_changes | f | f
60+
(2 rows)
61+
62+
--
63+
-- Install-time ownership check: CREATE EXTENSION age installs into ag_catalog
64+
-- only when that schema does not already exist under a different owner. The
65+
-- check compares schema ownership against the installing role. Verify the
66+
-- underlying detection both ways with a probe schema, without disturbing the
67+
-- already-installed extension.
68+
--
69+
CREATE ROLE age_probe_role NOLOGIN;
70+
CREATE SCHEMA age_probe AUTHORIZATION age_probe_role;
71+
-- A schema owned by a different role is detected as foreign-owned.
72+
SELECT EXISTS (
73+
SELECT 1
74+
FROM pg_catalog.pg_namespace n
75+
WHERE n.nspname = 'age_probe'
76+
AND n.nspowner <> (SELECT r.oid FROM pg_catalog.pg_roles r
77+
WHERE r.rolname = current_user)
78+
) AS foreign_owner_detected;
79+
foreign_owner_detected
80+
------------------------
81+
t
82+
(1 row)
83+
84+
-- ag_catalog, owned by the current (installing) role here, is not flagged
85+
-- (the check does not false-positive on a normal install).
86+
SELECT EXISTS (
87+
SELECT 1
88+
FROM pg_catalog.pg_namespace n
89+
WHERE n.nspname = 'ag_catalog'
90+
AND n.nspowner <> (SELECT r.oid FROM pg_catalog.pg_roles r
91+
WHERE r.rolname = current_user)
92+
) AS installer_owned_flagged;
93+
installer_owned_flagged
94+
-------------------------
95+
f
96+
(1 row)
97+
98+
DROP SCHEMA age_probe;
99+
DROP ROLE age_probe_role;

regress/sql/extension_security.sql

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
LOAD 'age';
21+
SET search_path TO ag_catalog;
22+
23+
--
24+
-- pg_upgrade helper functions resolve built-ins from pg_catalog first.
25+
--
26+
-- Each helper must place pg_catalog ahead of ag_catalog in its search_path, so
27+
-- that built-in functions and operators always resolve to pg_catalog and are
28+
-- not overridden by same-named objects defined in ag_catalog.
29+
--
30+
SELECT p.proname,
31+
array_to_string(p.proconfig, ', ') AS proconfig
32+
FROM pg_proc p
33+
JOIN pg_namespace n ON n.oid = p.pronamespace
34+
WHERE n.nspname = 'ag_catalog'
35+
AND p.proname IN ('age_prepare_pg_upgrade', 'age_finish_pg_upgrade',
36+
'age_revert_pg_upgrade_changes', 'age_pg_upgrade_status')
37+
ORDER BY p.proname;
38+
39+
--
40+
-- The helper bodies must not contain unqualified format()/hashtext() calls;
41+
-- those built-ins are explicitly schema-qualified to pg_catalog.
42+
--
43+
SELECT p.proname,
44+
(p.prosrc ~ '[^.]\mformat\s*\(') AS has_unqualified_format,
45+
(p.prosrc ~ '[^.]\mhashtext\s*\(') AS has_unqualified_hashtext
46+
FROM pg_proc p
47+
JOIN pg_namespace n ON n.oid = p.pronamespace
48+
WHERE n.nspname = 'ag_catalog'
49+
AND p.proname IN ('age_finish_pg_upgrade', 'age_revert_pg_upgrade_changes')
50+
ORDER BY p.proname;
51+
52+
--
53+
-- Install-time ownership check: CREATE EXTENSION age installs into ag_catalog
54+
-- only when that schema does not already exist under a different owner. The
55+
-- check compares schema ownership against the installing role. Verify the
56+
-- underlying detection both ways with a probe schema, without disturbing the
57+
-- already-installed extension.
58+
--
59+
CREATE ROLE age_probe_role NOLOGIN;
60+
CREATE SCHEMA age_probe AUTHORIZATION age_probe_role;
61+
62+
-- A schema owned by a different role is detected as foreign-owned.
63+
SELECT EXISTS (
64+
SELECT 1
65+
FROM pg_catalog.pg_namespace n
66+
WHERE n.nspname = 'age_probe'
67+
AND n.nspowner <> (SELECT r.oid FROM pg_catalog.pg_roles r
68+
WHERE r.rolname = current_user)
69+
) AS foreign_owner_detected;
70+
71+
-- ag_catalog, owned by the current (installing) role here, is not flagged
72+
-- (the check does not false-positive on a normal install).
73+
SELECT EXISTS (
74+
SELECT 1
75+
FROM pg_catalog.pg_namespace n
76+
WHERE n.nspname = 'ag_catalog'
77+
AND n.nspowner <> (SELECT r.oid FROM pg_catalog.pg_roles r
78+
WHERE r.rolname = current_user)
79+
) AS installer_owned_flagged;
80+
81+
DROP SCHEMA age_probe;
82+
DROP ROLE age_probe_role;

sql/age_main.sql

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,33 @@
2020
-- complain if script is sourced in psql, rather than via CREATE EXTENSION
2121
\echo Use "CREATE EXTENSION age" to load this file. \quit
2222

23+
--
24+
-- Ensure ag_catalog is created and owned by the installing role.
25+
--
26+
-- CREATE EXTENSION places all of AGE's objects in ag_catalog. A normal install
27+
-- creates that schema, owned by the installer. If ag_catalog already exists and
28+
-- is owned by a different role, that role would retain control over the schema
29+
-- that holds AGE's catalog objects. To keep ownership well-defined, refuse to
30+
-- install into a pre-existing ag_catalog owned by another role. Ownership is
31+
-- compared directly (not via role membership) so the check is exact even for a
32+
-- superuser, who is otherwise considered a member of every role.
33+
--
34+
DO $age_install_guard$
35+
BEGIN
36+
IF EXISTS (
37+
SELECT 1
38+
FROM pg_catalog.pg_namespace n
39+
WHERE n.nspname = 'ag_catalog'
40+
AND n.nspowner <> (SELECT r.oid
41+
FROM pg_catalog.pg_roles r
42+
WHERE r.rolname = current_user)
43+
) THEN
44+
RAISE EXCEPTION 'schema "ag_catalog" already exists and is not owned by the installing role "%"', current_user
45+
USING HINT = 'Apache AGE will not install into a pre-existing ag_catalog owned by another role. Drop it (DROP SCHEMA ag_catalog CASCADE) or transfer its ownership to the installing role, then retry CREATE EXTENSION age.';
46+
END IF;
47+
END
48+
$age_install_guard$;
49+
2350
--
2451
-- catalog tables
2552
--

0 commit comments

Comments
 (0)