mirror of
https://github.com/neondatabase/neon.git
synced 2025-12-22 21:59:59 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
102
test_runner/regress/test_event_trigger_extension.py
Normal file
102
test_runner/regress/test_event_trigger_extension.py
Normal 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"
|
||||
Reference in New Issue
Block a user