diff --git a/Makefile b/Makefile index 8db0333..beabefe 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ EXTENSION = pg_tle -EXTVERSION = 1.5.2 +EXTVERSION = 1.5.3 SCHEMA = pgtle MODULE_big = $(EXTENSION) @@ -9,7 +9,8 @@ OBJS = src/tleextension.o src/guc-file.o src/feature.o src/passcheck.o src/uni_a EXTRA_CLEAN = src/guc-file.c pg_tle.control pg_tle--$(EXTVERSION).sql DATA = pg_tle.control pg_tle--1.0.0.sql pg_tle--1.0.0--1.0.1.sql pg_tle--1.0.1--1.0.4.sql pg_tle--1.0.4.sql pg_tle--1.0.4--1.1.1.sql \ pg_tle--1.1.0--1.1.1.sql pg_tle--1.1.1.sql pg_tle--1.1.1--1.2.0.sql pg_tle--1.2.0--1.3.0.sql pg_tle--1.3.0--1.3.3.sql \ - pg_tle--1.3.3--1.3.4.sql pg_tle--1.3.4--1.4.0.sql pg_tle--1.4.0--1.5.0.sql pg_tle--1.5.0--1.5.2.sql + pg_tle--1.3.3--1.3.4.sql pg_tle--1.3.4--1.4.0.sql pg_tle--1.4.0--1.5.0.sql pg_tle--1.5.0--1.5.2.sql \ + pg_tle--1.5.2--1.5.3.sql TESTS = $(wildcard test/sql/*.sql) REGRESS = $(patsubst test/sql/%.sql,%,$(TESTS)) diff --git a/docs/03_managing_extensions.md b/docs/03_managing_extensions.md index c6b678b..2458747 100644 --- a/docs/03_managing_extensions.md +++ b/docs/03_managing_extensions.md @@ -278,6 +278,33 @@ This functions returns `true` on success. * `name`: The name of the extension. This is the value used when calling `CREATE EXTENSION`. * `version`: The version of the extension to set the default. +### `pgtle.set_extension_schema(name text, schema text)` + +`set_extension_schema` sets, changes, or clears the schema recorded for an installed extension. The schema is the value that PostgreSQL uses to place the extension's objects when `CREATE EXTENSION` is called. This is helpful for pinning an extension that was installed without a schema, for example before schema support was added in `pg_tle` 1.5.0, to a specific schema without uninstalling and reinstalling it (which would drop any data in its tables). + +Pass `NULL` as `schema` to clear the recorded schema, reverting the extension to having no fixed schema. + +This only affects future `CREATE EXTENSION` calls (including restoring from a `pg_dump`). It does not move an extension that is already created; use `ALTER EXTENSION ... SET SCHEMA` for that. + +If the extension in `name` does not already exist, this returns an error. + +This function returns `true` on success. + +#### Role + +`pgtle_admin` + +#### Arguments + +* `name`: The name of the extension. This is the value used when calling `CREATE EXTENSION`. +* `schema`: The schema in which the extension's objects are created, or `NULL` to clear the recorded schema. + +#### Example + +```sql +SELECT pgtle.set_extension_schema('pg_tle_test', 'tle_schema'); +``` + ### `pgtle.uninstall_extension(extname text)` `uninstall_extension` removes all versions of an extension from a database. This prevents future calls of `CREATE EXTENSION` from installing the extension. If the extension does not exist in the database, then an error is raised. diff --git a/include/tleextension.h b/include/tleextension.h index 58583af..5d0234e 100644 --- a/include/tleextension.h +++ b/include/tleextension.h @@ -25,6 +25,14 @@ #define PG_TLE_INNER_STR "$_pgtle_i_$" #define PG_TLE_ADMIN "pgtle_admin" +/* + * Characters that cannot be quoted consistently both inside and outside of + * string literals. Identifiers substituted into extension scripts, or stored + * where they will be substituted later, are rejected if they contain any of + * these. + */ +#define PG_TLE_QUOTING_RELEVANT_CHARS "\"$'\\" + /* * creating_extension is only true while running a CREATE EXTENSION or ALTER * EXTENSION UPDATE command. It instructs recordDependencyOnCurrentExtension() diff --git a/pg_tle--1.5.2--1.5.3.sql b/pg_tle--1.5.2--1.5.3.sql new file mode 100644 index 0000000..c050638 --- /dev/null +++ b/pg_tle--1.5.2--1.5.3.sql @@ -0,0 +1,40 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION pg_tle" to load this file. \quit + +CREATE FUNCTION pgtle.set_extension_schema +( + name text, + schema text +) +RETURNS boolean +SET search_path TO 'pgtle' +AS 'MODULE_PATHNAME', 'pg_tle_set_extension_schema' +LANGUAGE C; + +REVOKE EXECUTE ON FUNCTION pgtle.set_extension_schema +( + name text, + schema text +) FROM PUBLIC; + +GRANT EXECUTE ON FUNCTION pgtle.set_extension_schema +( + name text, + schema text +) TO pgtle_admin; diff --git a/src/tleextension.c b/src/tleextension.c index 91b92d1..0b7c2e2 100644 --- a/src/tleextension.c +++ b/src/tleextension.c @@ -1411,16 +1411,6 @@ execute_extension_script(Oid extensionOid, ExtensionControlFile *control, char *c_sql = read_extension_script_file(control, filename); Datum t_sql; - /* - * We filter each substitution through quote_identifier(). When the - * arg contains one of the following characters, no one collection of - * quoting can work inside $$dollar-quoted string literals$$, - * 'single-quoted string literals', and outside of any literal. To - * avoid a security snare for extension authors, error on substitution - * for arguments containing these. - */ - const char *quoting_relevant_chars = "\"$'\\"; - /* We use various functions that want to operate on text datums */ t_sql = CStringGetTextDatum(c_sql); @@ -1450,11 +1440,20 @@ execute_extension_script(Oid extensionOid, ExtensionControlFile *control, t_sql, CStringGetTextDatum("@extowner@"), CStringGetTextDatum(qUserName)); - if (strpbrk(userName, quoting_relevant_chars)) + + /* + * We filter each substitution through quote_identifier(). When + * the arg contains one of these characters, no one collection of + * quoting can work inside $$dollar-quoted string literals$$, + * 'single-quoted string literals', and outside of any literal. To + * avoid a security snare for extension authors, error on + * substitution for arguments containing these. + */ + if (strpbrk(userName, PG_TLE_QUOTING_RELEVANT_CHARS)) ereport(ERROR, (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), errmsg("invalid character in extension owner: must not contain any of \"%s\"", - quoting_relevant_chars))); + PG_TLE_QUOTING_RELEVANT_CHARS))); } /* @@ -1474,11 +1473,11 @@ execute_extension_script(Oid extensionOid, ExtensionControlFile *control, t_sql, CStringGetTextDatum("@extschema@"), CStringGetTextDatum(qSchemaName)); - if (t_sql != old && strpbrk(schemaName, quoting_relevant_chars)) + if (t_sql != old && strpbrk(schemaName, PG_TLE_QUOTING_RELEVANT_CHARS)) ereport(ERROR, (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), errmsg("invalid character in extension \"%s\" schema: must not contain any of \"%s\"", - control->name, quoting_relevant_chars))); + control->name, PG_TLE_QUOTING_RELEVANT_CHARS))); } /* @@ -5229,6 +5228,139 @@ pg_tle_set_default_version(PG_FUNCTION_ARGS) PG_RETURN_BOOL(true); } +Datum pg_tle_set_extension_schema(PG_FUNCTION_ARGS); + +/* + * Set (or clear) the target schema recorded in an extension's control + * function. This rewrites just the control function in place, so future + * CREATE EXTENSION (or a fresh database restored from pg_dump) honors the new + * schema while an already-created extension is left untouched. A NULL schema + * clears it, leaving the extension with no fixed schema. + */ +PG_FUNCTION_INFO_V1(pg_tle_set_extension_schema); +Datum +pg_tle_set_extension_schema(PG_FUNCTION_ARGS) +{ + int spi_rc; + char *extname; + char *schemaName = NULL; + char *ctlname; + Oid ctlfuncid; + StringInfo ctlstr; + char *ctlsql; + ExtensionControlFile *control; + char *filename; + + if (PG_ARGISNULL(0)) + ereport(ERROR, + (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("\"name\" is a required argument."))); + + extname = text_to_cstring(PG_GETARG_TEXT_PP(0)); + check_valid_extension_name(extname); + + /* + * Verify that extname does not already exist as a standard file-based + * extension. + */ + filename = get_extension_control_filename(extname); + if (filestat(filename)) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("control file already exists for the %s extension", extname))); + + /* + * A NULL schema clears the recorded schema; otherwise validate it. The + * schema name is embedded verbatim into the control function and later + * substituted for @extschema@, so reject characters that cannot be quoted + * consistently both inside and outside of string literals. + */ + if (!PG_ARGISNULL(1)) + { + schemaName = text_to_cstring(PG_GETARG_TEXT_PP(1)); + + /* + * Reject the empty string rather than record schema = '', which would + * pin the extension to an unusable zero-length schema. Use a NULL + * argument to clear the schema instead. + */ + if (schemaName[0] == '\0') + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("extension schema must not be empty"), + errhint("Pass NULL as the schema to clear it."))); + + if (strpbrk(schemaName, PG_TLE_QUOTING_RELEVANT_CHARS)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), + errmsg("invalid character in extension schema: must not contain any of \"%s\"", + PG_TLE_QUOTING_RELEVANT_CHARS))); + } + + /* + * Verify that the extension exists as a TLE extension by looking up its + * control function. + */ + ctlname = psprintf("%s.control", extname); + ctlfuncid = get_tlefunc_oid_if_exists(ctlname); + if (ctlfuncid == InvalidOid) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("extension \"%s\" does not exist", extname), + errhint("Try installing the extension with \"%s.install_extension\".", PG_TLE_NSPNAME))); + + /* + * Load the current control state, then replace the schema. + */ + control = build_default_extension_control_file(extname); + + SET_TLEEXT; + parse_extension_control_file(control, NULL); + UNSET_TLEEXT; + + control->schema = schemaName; + + ctlstr = build_extension_control_file_string(control); + + /* + * Validate that there are no injections using the dollar-quoted strings + */ + if (!(validate_tle_sql(ctlstr->data))) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("invalid character in extension definition"), + errdetail("Use of string delimiters %s and %s are forbidden in extension definitions.", + PG_TLE_OUTER_STR, PG_TLE_INNER_STR))); + + ctlsql = psprintf( + "CREATE OR REPLACE FUNCTION %s.%s() RETURNS TEXT AS %s" + "SELECT %s%s%s%s LANGUAGE SQL", + quote_identifier(PG_TLE_NSPNAME), quote_identifier(ctlname), + PG_TLE_OUTER_STR, PG_TLE_INNER_STR, + ctlstr->data, + PG_TLE_INNER_STR, PG_TLE_OUTER_STR); + + /* flag that we are manipulating pg_tle artifacts */ + SET_TLEART; + + if (SPI_connect() != SPI_OK_CONNECT) + elog(ERROR, "SPI_connect failed"); + + spi_rc = SPI_exec(ctlsql, 0); + if (spi_rc != SPI_OK_UTILITY) + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("failed to update schema for \"%s\"", extname))); + + if (SPI_finish() != SPI_OK_FINISH) + elog(ERROR, "SPI_finish failed"); + + /* flag that we are done manipulating pg_tle artifacts */ + UNSET_TLEART; + + PG_RETURN_BOOL(true); +} + /* * Convert text array to list of strings. * diff --git a/test/expected/pg_tle_extension_schema.out b/test/expected/pg_tle_extension_schema.out index 06a013d..d872384 100644 --- a/test/expected/pg_tle_extension_schema.out +++ b/test/expected/pg_tle_extension_schema.out @@ -16,6 +16,9 @@ * * 4. pgtle.available_extensions() and pgtle.available_extension_versions() * print the correct output for a variety of extensions. + * + * 5. pgtle.set_extension_schema() sets, changes, or clears the schema recorded + * for an already-installed extension. */ \pset pager off /* @@ -280,7 +283,118 @@ DROP FUNCTION pgtle."my_tle_2.control" CASCADE; DROP FUNCTION pgtle."my_tle_1--1.0.sql" CASCADE; NOTICE: drop cascades to extension my_tle_1 DROP FUNCTION pgtle."my_tle_1.control" CASCADE; -DROP EXTENSION pg_tle CASCADE; -DROP SCHEMA pgtle; +/* + * 5. pgtle.set_extension_schema() sets, changes, or clears the schema recorded + * for an already-installed extension. This lets an extension installed + * without a schema (e.g. before pg_tle 1.5) adopt one without being + * uninstalled and reinstalled. + */ +-- set_extension_schema was added in pg_tle 1.5.3; the earlier sections leave +-- pg_tle at 1.5.0, so update to the latest version first. +ALTER EXTENSION pg_tle UPDATE; +-- Reset the schemas used here (earlier sections may have left them behind). +DROP SCHEMA IF EXISTS my_tle_schema_1 CASCADE; +DROP SCHEMA IF EXISTS my_tle_schema_2 CASCADE; +CREATE SCHEMA my_tle_schema_1; +CREATE SCHEMA my_tle_schema_2; +-- Install an extension without a schema, as pg_tle did before 1.5. +SELECT pgtle.install_extension('my_tle', '1.0', 'My TLE', + $_pgtle_$ + CREATE OR REPLACE FUNCTION my_tle_func() RETURNS INT LANGUAGE SQL AS + 'SELECT 1'; + $_pgtle_$); + install_extension +------------------- + t +(1 row) + +SELECT name, schema FROM pgtle.available_extensions() WHERE name = 'my_tle'; + name | schema +--------+-------- + my_tle | +(1 row) + +-- Adopt a schema after the fact. +SELECT pgtle.set_extension_schema('my_tle', 'my_tle_schema_1'); + set_extension_schema +---------------------- + t +(1 row) + +SELECT name, schema FROM pgtle.available_extensions() WHERE name = 'my_tle'; + name | schema +--------+----------------- + my_tle | my_tle_schema_1 +(1 row) + +-- A newly created extension honors the adopted schema and is pinned to it. +CREATE EXTENSION my_tle; +SELECT n.nspname FROM pg_extension e + INNER JOIN pg_namespace n ON e.extnamespace = n.oid + WHERE e.extname = 'my_tle'; + nspname +----------------- + my_tle_schema_1 +(1 row) + +SELECT my_tle_schema_1.my_tle_func(); + my_tle_func +------------- + 1 +(1 row) + +DROP EXTENSION my_tle; +-- Cannot be created in a different schema. +CREATE EXTENSION my_tle SCHEMA my_tle_schema_2; +ERROR: extension "my_tle" must be installed in schema "my_tle_schema_1" +-- Changing the schema is allowed. +SELECT pgtle.set_extension_schema('my_tle', 'my_tle_schema_2'); + set_extension_schema +---------------------- + t +(1 row) + +SELECT name, schema FROM pgtle.available_extensions() WHERE name = 'my_tle'; + name | schema +--------+----------------- + my_tle | my_tle_schema_2 +(1 row) + +-- Passing NULL clears the recorded schema. +SELECT pgtle.set_extension_schema('my_tle', NULL); + set_extension_schema +---------------------- + t +(1 row) + +SELECT name, schema FROM pgtle.available_extensions() WHERE name = 'my_tle'; + name | schema +--------+-------- + my_tle | +(1 row) + +-- Negative cases. +-- The extension must exist. +SELECT pgtle.set_extension_schema('does_not_exist', 'my_tle_schema_1'); +ERROR: extension "does_not_exist" does not exist +HINT: Try installing the extension with "pgtle.install_extension". +-- The schema name must not contain characters that cannot be quoted safely. +SELECT pgtle.set_extension_schema('my_tle', 'bad"schema'); +ERROR: invalid character in extension schema: must not contain any of ""$'\" +-- The empty string is rejected; NULL is the way to clear the schema. +SELECT pgtle.set_extension_schema('my_tle', ''); +ERROR: extension schema must not be empty +HINT: Pass NULL as the schema to clear it. +-- "name" is required. +SELECT pgtle.set_extension_schema(NULL, 'my_tle_schema_1'); +ERROR: "name" is a required argument. +SELECT pgtle.uninstall_extension('my_tle'); + uninstall_extension +--------------------- + t +(1 row) + DROP SCHEMA my_tle_schema_1; DROP SCHEMA my_tle_schema_2; +DROP EXTENSION pg_tle CASCADE; +DROP SCHEMA pgtle; diff --git a/test/sql/pg_tle_extension_schema.sql b/test/sql/pg_tle_extension_schema.sql index 1697a70..78e001c 100644 --- a/test/sql/pg_tle_extension_schema.sql +++ b/test/sql/pg_tle_extension_schema.sql @@ -17,6 +17,9 @@ * * 4. pgtle.available_extensions() and pgtle.available_extension_versions() * print the correct output for a variety of extensions. + * + * 5. pgtle.set_extension_schema() sets, changes, or clears the schema recorded + * for an already-installed extension. */ \pset pager off @@ -172,7 +175,67 @@ DROP FUNCTION pgtle."my_tle_2--1.0.sql" CASCADE; DROP FUNCTION pgtle."my_tle_2.control" CASCADE; DROP FUNCTION pgtle."my_tle_1--1.0.sql" CASCADE; DROP FUNCTION pgtle."my_tle_1.control" CASCADE; -DROP EXTENSION pg_tle CASCADE; -DROP SCHEMA pgtle; + +/* + * 5. pgtle.set_extension_schema() sets, changes, or clears the schema recorded + * for an already-installed extension. This lets an extension installed + * without a schema (e.g. before pg_tle 1.5) adopt one without being + * uninstalled and reinstalled. + */ + +-- set_extension_schema was added in pg_tle 1.5.3; the earlier sections leave +-- pg_tle at 1.5.0, so update to the latest version first. +ALTER EXTENSION pg_tle UPDATE; + +-- Reset the schemas used here (earlier sections may have left them behind). +DROP SCHEMA IF EXISTS my_tle_schema_1 CASCADE; +DROP SCHEMA IF EXISTS my_tle_schema_2 CASCADE; +CREATE SCHEMA my_tle_schema_1; +CREATE SCHEMA my_tle_schema_2; + +-- Install an extension without a schema, as pg_tle did before 1.5. +SELECT pgtle.install_extension('my_tle', '1.0', 'My TLE', + $_pgtle_$ + CREATE OR REPLACE FUNCTION my_tle_func() RETURNS INT LANGUAGE SQL AS + 'SELECT 1'; + $_pgtle_$); +SELECT name, schema FROM pgtle.available_extensions() WHERE name = 'my_tle'; + +-- Adopt a schema after the fact. +SELECT pgtle.set_extension_schema('my_tle', 'my_tle_schema_1'); +SELECT name, schema FROM pgtle.available_extensions() WHERE name = 'my_tle'; + +-- A newly created extension honors the adopted schema and is pinned to it. +CREATE EXTENSION my_tle; +SELECT n.nspname FROM pg_extension e + INNER JOIN pg_namespace n ON e.extnamespace = n.oid + WHERE e.extname = 'my_tle'; +SELECT my_tle_schema_1.my_tle_func(); +DROP EXTENSION my_tle; +-- Cannot be created in a different schema. +CREATE EXTENSION my_tle SCHEMA my_tle_schema_2; + +-- Changing the schema is allowed. +SELECT pgtle.set_extension_schema('my_tle', 'my_tle_schema_2'); +SELECT name, schema FROM pgtle.available_extensions() WHERE name = 'my_tle'; + +-- Passing NULL clears the recorded schema. +SELECT pgtle.set_extension_schema('my_tle', NULL); +SELECT name, schema FROM pgtle.available_extensions() WHERE name = 'my_tle'; + +-- Negative cases. +-- The extension must exist. +SELECT pgtle.set_extension_schema('does_not_exist', 'my_tle_schema_1'); +-- The schema name must not contain characters that cannot be quoted safely. +SELECT pgtle.set_extension_schema('my_tle', 'bad"schema'); +-- The empty string is rejected; NULL is the way to clear the schema. +SELECT pgtle.set_extension_schema('my_tle', ''); +-- "name" is required. +SELECT pgtle.set_extension_schema(NULL, 'my_tle_schema_1'); + +SELECT pgtle.uninstall_extension('my_tle'); DROP SCHEMA my_tle_schema_1; DROP SCHEMA my_tle_schema_2; + +DROP EXTENSION pg_tle CASCADE; +DROP SCHEMA pgtle;