Review security model for executing Event Trigger code. (#12463)

When a function is owned by a superuser (bootstrap user or otherwise),
we consider it safe to run it. Only a superuser could have installed it,
typically from CREATE EXTENSION script: we trust the code to execute.

## Problem

This is intended to solve running pg_graphql Event Triggers
graphql_watch_ddl and graphql_watch_drop which are executing the secdef
function graphql.increment_schema_version().

## Summary of changes

Allow executing Event Trigger function owned by a superuser and with
SECURITY DEFINER properties. The Event Trigger code runs with superuser
privileges, and we consider that it's fine.

---------

Co-authored-by: Tristan Partin <tristan.partin@databricks.com>
This commit is contained in:
Dimitri Fontaine
2025-07-10 10:06:33 +02:00
committed by GitHub
parent 13e38a58a1
commit 1a45b2ec90
6 changed files with 174 additions and 50 deletions

View File

@@ -953,7 +953,9 @@ neon_fmgr_hook(FmgrHookEventType event, FmgrInfo *flinfo, Datum *private)
/*
* Fire Event Trigger if both function owner and current user are
* superuser, or none of them are.
* superuser. Allow executing Event Trigger function that belongs to a
* superuser when connected as a non-superuser, even when the function is
* SECURITY DEFINER.
*/
else if (event == FHET_START
/* still enable it to pass pg_regress tests */
@@ -976,32 +978,7 @@ neon_fmgr_hook(FmgrHookEventType event, FmgrInfo *flinfo, Datum *private)
function_is_owned_by_super = superuser_arg(function_owner);
/*
* 1. Refuse to run SECURITY DEFINER function that belongs to a
* superuser when the current user is not a superuser itself.
*/
if (!role_is_super
&& function_is_owned_by_super
&& function_is_secdef)
{
char *func_name = get_func_name(flinfo->fn_oid);
ereport(WARNING,
(errmsg("Skipping Event Trigger"),
errdetail("Event Trigger function \"%s\" is owned by \"%s\" "
"and is SECURITY DEFINER",
func_name,
GetUserNameFromId(function_owner, false))));
/*
* we can't skip execution directly inside the fmgr_hook so
* instead we change the event trigger function to a noop
* function.
*/
force_noop(flinfo);
}
/*
* 2. Refuse to run functions that belongs to a non-superuser when the
* Refuse to run functions that belongs to a non-superuser when the
* current user is a superuser.
*
* We could run a SECURITY DEFINER user-function here and be safe with
@@ -1009,7 +986,7 @@ neon_fmgr_hook(FmgrHookEventType event, FmgrInfo *flinfo, Datum *private)
* infrastructure maintenance operations, where we prefer to skip
* running user-defined code.
*/
else if (role_is_super && !function_is_owned_by_super)
if (role_is_super && !function_is_owned_by_super)
{
char *func_name = get_func_name(flinfo->fn_oid);

View File

@@ -1795,6 +1795,33 @@ def neon_env_builder(
record_property("preserve_database_files", builder.preserve_database_files)
@pytest.fixture(scope="function")
def neon_env_builder_local(
neon_env_builder: NeonEnvBuilder,
test_output_dir: Path,
pg_distrib_dir: Path,
) -> NeonEnvBuilder:
"""
Fixture to create a Neon environment for test with its own pg_install copy.
This allows the test to edit the list of available extensions in the
local instance of Postgres used for the test, and install extensions via
downloading them when a remote extension is tested, for instance, or
copying files around for local extension testing.
"""
test_local_pginstall = test_output_dir / "pg_install"
log.info(f"copy {pg_distrib_dir} to {test_local_pginstall}")
# We can't copy only the version that we are currently testing because other
# binaries like the storage controller need specific Postgres versions.
shutil.copytree(pg_distrib_dir, test_local_pginstall)
neon_env_builder.pg_distrib_dir = test_local_pginstall
log.info(f"local neon_env_builder.pg_distrib_dir: {neon_env_builder.pg_distrib_dir}")
return neon_env_builder
@dataclass
class PageserverPort:
pg: int

View File

@@ -0,0 +1,32 @@
\echo Use "CREATE EXTENSION test_event_trigger_extension" to load this file. \quit
CREATE SCHEMA event_trigger;
create sequence if not exists event_trigger.seq_schema_version as int cycle;
create or replace function event_trigger.increment_schema_version()
returns event_trigger
security definer
language plpgsql
as $$
begin
perform pg_catalog.nextval('event_trigger.seq_schema_version');
end;
$$;
create or replace function event_trigger.get_schema_version()
returns int
security definer
language sql
as $$
select last_value from event_trigger.seq_schema_version;
$$;
-- On DDL event, increment the schema version number
create event trigger event_trigger_watch_ddl
on ddl_command_end
execute procedure event_trigger.increment_schema_version();
create event trigger event_trigger_watch_drop
on sql_drop
execute procedure event_trigger.increment_schema_version();

View File

@@ -0,0 +1,8 @@
default_version = '1.0'
comment = 'Test extension with Event Trigger'
# make sure the extension objects are owned by the bootstrap user
# to check that the SECURITY DEFINER event trigger function is still
# called during non-superuser DDL events.
superuser = true
trusted = true

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import os
import platform
import shutil
import tarfile
from enum import StrEnum
from pathlib import Path
@@ -31,27 +30,6 @@ if TYPE_CHECKING:
from werkzeug.wrappers.request import Request
# use neon_env_builder_local fixture to override the default neon_env_builder fixture
# and use a test-specific pg_install instead of shared one
@pytest.fixture(scope="function")
def neon_env_builder_local(
neon_env_builder: NeonEnvBuilder,
test_output_dir: Path,
pg_distrib_dir: Path,
) -> NeonEnvBuilder:
test_local_pginstall = test_output_dir / "pg_install"
log.info(f"copy {pg_distrib_dir} to {test_local_pginstall}")
# We can't copy only the version that we are currently testing because other
# binaries like the storage controller need specific Postgres versions.
shutil.copytree(pg_distrib_dir, test_local_pginstall)
neon_env_builder.pg_distrib_dir = test_local_pginstall
log.info(f"local neon_env_builder.pg_distrib_dir: {neon_env_builder.pg_distrib_dir}")
return neon_env_builder
@final
class RemoteExtension(StrEnum):
SQL_ONLY = "test_extension_sql_only"

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, cast
import pytest
from fixtures.log_helper import log
from fixtures.paths import BASE_DIR
if TYPE_CHECKING:
from pathlib import Path
from fixtures.neon_fixtures import (
NeonEnvBuilder,
)
from fixtures.pg_version import PgVersion
# use neon_env_builder_local fixture to override the default neon_env_builder fixture
# and use a test-specific pg_install instead of shared one
@pytest.fixture(scope="function")
def neon_env_builder_event_trigger_extension(
neon_env_builder_local: NeonEnvBuilder,
test_output_dir: Path,
pg_version: PgVersion,
) -> NeonEnvBuilder:
test_local_pginstall = test_output_dir / "pg_install"
# Now copy the SQL only extension test_event_trigger_extension in the local
# pginstall extension directory on-disk
test_event_trigger_extension_dir = (
BASE_DIR / "test_runner" / "regress" / "data" / "test_event_trigger_extension"
)
test_local_extension_dir = (
test_local_pginstall / f"v{pg_version}" / "share" / "postgresql" / "extension"
)
log.info(f"copy {test_event_trigger_extension_dir} to {test_local_extension_dir}")
for f in [
test_event_trigger_extension_dir / "test_event_trigger_extension.control",
test_event_trigger_extension_dir / "test_event_trigger_extension--1.0.sql",
]:
shutil.copy(f, test_local_extension_dir)
return neon_env_builder_local
def test_event_trigger_extension(neon_env_builder_event_trigger_extension: NeonEnvBuilder):
"""
Test installing an extension that contains an Event Trigger.
The Event Trigger function is owned by the extension owner, which at
CREATE EXTENSION is going to be the Postgres bootstrap user, per the
extension control file where both superuser = true and trusted = true.
Also this function is SECURTY DEFINER, to allow for making changes to
the extension SQL objects, in our case a sequence.
This test makes sure that the event trigger function is fired correctly
by non-privileged user DDL actions such as CREATE TABLE.
"""
env = neon_env_builder_event_trigger_extension.init_start()
env.create_branch("test_event_trigger_extension")
endpoint = env.endpoints.create_start("test_event_trigger_extension")
extension = "test_event_trigger_extension"
database = "test_event_trigger_extension"
endpoint.safe_psql(f"CREATE DATABASE {database}")
endpoint.safe_psql(f"CREATE EXTENSION {extension}", dbname=database)
# check that the extension is owned by the bootstrap superuser (cloud_admin)
pg_bootstrap_superuser_name = "cloud_admin"
with endpoint.connect(dbname=database) as pg_conn:
with pg_conn.cursor() as cur:
cur.execute(
f"select rolname from pg_roles r join pg_extension e on r.oid = e.extowner where extname = '{extension}'"
)
owner = cast("tuple[str]", cur.fetchone())[0]
assert owner == pg_bootstrap_superuser_name, (
f"extension {extension} is not owned by bootstrap user '{pg_bootstrap_superuser_name}'"
)
# test that the SQL-only Event Trigger (SECURITY DEFINER function) runs
# correctly now that the extension has been installed
#
# create table to trigger the event trigger, twice, check sequence count
with endpoint.connect(dbname=database) as pg_conn:
log.info("creating SQL objects (tables)")
with pg_conn.cursor() as cur:
cur.execute("CREATE TABLE foo1(id int primary key)")
cur.execute("CREATE TABLE foo2(id int)")
cur.execute("SELECT event_trigger.get_schema_version()")
res = cast("tuple[int]", cur.fetchone())
ver = res[0]
log.info(f"schema version is now {ver}")
assert ver == 2, "schema version is not 2"