diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 369311873..54cb46a65 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -551,6 +551,37 @@ encode_post_codec encode_post_codec the tail column holds no row the heap does n encode_post_codec encode_post_codec the tail column preserves its checksum 15;16;17;18;19 2026-10-13 the reader returning row 5 of every row group as NULL encode_post_codec encode_post_codec the tail column reads back exactly what the heap holds 15;16;17;18;19 2026-10-13 the reader returning row 5 of every row group as NULL encode_post_codec encode_post_codec while a column where encoding genuinely wins still encodes 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege coverage: inputs == sum(buckets) 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege coverage: no entry point is unbucketed 15;16;17;18;19 2026-10-14 the install script gains a pgcolumnar function no bucket names +entry_point_privilege entry_point_privilege coverage: relation-taking == pinned + claimed-guarded + catalog-governed 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege every catalog-governed name is still a relation-taking entry point 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege export_arrow refuses a caller with no privilege before touching the file 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege export_parquet refuses a caller with no privilege before touching the file 15;16;17;18;19 2026-10-14 export_parquet without its SELECT check +entry_point_privilege entry_point_privilege get_storage_id refuses an unprivileged caller with 42501 15;16;17;18;19 2026-10-14 get_storage_id without its SELECT check +entry_point_privilege entry_point_privilege import_arrow refuses a caller with no privilege before touching the file 15;16;17;18;19 2026-10-14 import_arrow without its INSERT check +entry_point_privilege entry_point_privilege import_parquet refuses a caller with no privilege before touching the file 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege no exempt name actually takes a relation 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege no pinned name is stale (all still exist and take a relation) 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege no relation-taking C entry point is still ungated 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege premise: at least one relation-taking entry point was found 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege premise: t_ep can open a session 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege premise: t_ep cannot read the table by any ordinary route 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege premise: t_ep holds the server-file roles, so it reaches the table check 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege premise: t_ep now holds EXECUTE, so a refusal below can only be the C check 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege premise: the extension is installed and C entry points were found 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege premise: the install script declares MODULE_PATHNAME symbols at all 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege read_projection refuses an unprivileged caller with 42501 15;16;17;18;19 2026-10-14 read_projection without its SELECT check +entry_point_privilege entry_point_privilege reconstruct_via_projection refuses an unprivileged caller with 42501 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege the catalog and the install script agree on the symbol set 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege the catalog-governed class is the six from #560 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege the owner can call get_storage_id, so a refusal below is a refusal 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege the owner can call read_projection, so a refusal below is a refusal 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege the owner can call reconstruct_via_projection, so a refusal below is a refusal 15;16;17;18;19 2026-10-14 reconstruct_via_projection refuses even a superuser owner +entry_point_privilege entry_point_privilege the owner can call vm_is_visible, so a refusal below is a refusal 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege the owner can call vm_selftest, so a refusal below is a refusal 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege the relation-by-other-means class is the three that are known 15;16;17;18;19 never - +entry_point_privilege entry_point_privilege vm_is_visible refuses an unprivileged caller with 42501 15;16;17;18;19 2026-10-14 vm_is_visible without its owner check +entry_point_privilege entry_point_privilege vm_selftest refuses an unprivileged caller with 42501 15;16;17;18;19 never - fdw_plan_privilege fdw_plan_privilege a privileged planner estimates the Iceberg table from its manifests 15;16;17;18;19 never - fdw_plan_privilege fdw_plan_privilege a privileged planner estimates the Parquet table from the file's size 15;16;17;18;19 never - fdw_plan_privilege fdw_plan_privilege an unprivileged planner gets the flat Parquet estimate 15;16;17;18;19 2026-10-14 the Parquet planner stats the file for any role (no pg_read_server_files gate) diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index 9fcfd70e7..0e9094fe5 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -15,7 +15,7 @@ # # Adding a check to a suite that is already covered does not move it, which is # what makes it safe to bound. -suites_not_covered 241 +suites_not_covered 240 # # checks_never_observed_red -- A CENSUS. NOT a ceiling, and it must not become # one. @@ -2061,4 +2061,32 @@ suites_not_covered 241 # never 1965 - 3 = 1962 # dated 588 + 3 = 591 # 1962 + 591 == 2553 -checks_never_observed_red 1962 +# +# #1341, entry_point_privilege seeded with its pytest twin (OffgridwithJD): 1962 -> 1988, +# and suites_not_covered 241 -> 240. The suite had no rows. It enters with all 31 +# checks from five-major runs (31/31 on each of 15-19). Five are dated, one per +# mutation: get_storage_id and read_projection without their SELECT check (each +# 42501 arm), export_parquet without its SELECT and import_arrow without its INSERT +# check (each reached the file layer), and the install script gaining a function no +# bucket names (the unbucketed arm). The other 26 arrive never. A relation-taking +# function added with no check at all survives this suite, in both twins: it is +# counted as claimed-guarded. Filed as #1386 rather than claimed here. +# +# ledger rows 2553 + 31 = 2584 +# never 1962 + 26 = 1988 +# dated 591 + 5 = 596 +# 1988 + 596 == 2584 +# +# #1387 review (OffgridwithJD): 1988 -> 1986. The five owner-call checks lose the premise +# prefix: a function refusing even its owner is a product change, and the shell now +# reads the call by psql's exit status ("q ... && echo ok" always printed ok). The +# reconstruct one is dated by reconstruct_via_projection refusing a superuser, and +# vm_is_visible's 42501 arm by its owner check removed. The shell grants EXECUTE by OID +# on every overload, so the EXECUTE premise depends on the fixture alone and stays one. +# Five-major runs: 31/31 on 15-19. +# +# ledger rows 2584 = 2584 +# never 1988 - 2 = 1986 +# dated 596 + 2 = 598 +# 1986 + 598 == 2584 +checks_never_observed_red 1986 diff --git a/test/entry_point_privilege.sh b/test/entry_point_privilege.sh index 4342e4d6b..ea4e9c8a6 100755 --- a/test/entry_point_privilege.sh +++ b/test/entry_point_privilege.sh @@ -369,8 +369,15 @@ call_for() { # call_for -> a valid argument vector for that function # # Granting EXECUTE puts the role PAST the SQL layer on purpose, so the only thing # left that can refuse it is the C check. Now 42501 attributes to one layer. +# +# On every overload, by OID (#1387 review): a grant on a hard-coded signature, with +# its error discarded, left the role without EXECUTE when a declaration changed, and +# the premise below then reddened for the fixture's reason rather than the product's. for fn in $GUARDED_BEHAVIOURAL; do - psql_run "GRANT EXECUTE ON FUNCTION pgcolumnar.$fn($(case "$fn" in read_projection|reconstruct_via_projection) echo "regclass,text" ;; *) echo "regclass,int" ;; esac)) TO t_ep;" 2>/dev/null + while IFS= read -r sig; do + [ -n "$sig" ] && psql_run "GRANT EXECUTE ON FUNCTION $sig TO t_ep;" + done <<<"$(q "SELECT p.oid::regprocedure FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'pgcolumnar' AND p.proname = '$fn';")" done check "premise: t_ep now holds EXECUTE, so a refusal below can only be the C check" \ "$(q "SELECT bool_and(has_function_privilege('t_ep', p.oid, 'EXECUTE')) @@ -380,8 +387,12 @@ check "premise: t_ep now holds EXECUTE, so a refusal below can only be the C che for fn in $GUARDED_BEHAVIOURAL; do sql="$(call_for "$fn")" - check "premise: the owner can call $fn, so a refusal below is a refusal" \ - "$(q "$sql" >/dev/null 2>&1 && echo ok || echo broken)" "ok" + # A check, not a premise (#1236; #1387 review): a function refusing even its owner + # is a product change. Read by psql's exit status: q ends in "|| true", so + # "q ... && echo ok" printed ok whatever the call did. + check "the owner can call $fn, so a refusal below is a refusal" \ + "$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" \ + -X -At -v ON_ERROR_STOP=1 -c "$sql" >/dev/null 2>&1 && echo ok || echo broken)" "ok" check "$fn refuses an unprivileged caller with 42501" \ "$(as_ep "$sql")" "42501" done @@ -405,7 +416,7 @@ check "premise: t_ep holds the server-file roles, so it reaches the table check" # at which point they reddened and named themselves, which is what a pin is for. for fn in export_arrow export_parquet import_arrow import_parquet; do out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U t_ep -d "$PGC_DB" -At \ - -c "SELECT pgcolumnar.$fn('ep_target','/nonexistent/dir/x');" 2>&1 | head -1)" + -c "SELECT pgcolumnar.$fn('ep_target','/nonexistent/dir/x');" 2>&1 | grep -m1 'ERROR:')" case "$out" in *"permission denied for table"*) state=refused ;; *"could not open file"*) state=reached-the-file-layer ;; diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 642129d7a..eb7bedce3 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -154,6 +154,7 @@ behaviour, the source of that number is named. - [106. test_every_suite_job_installs_the_extensions.py: every job that runs the suites installs the extension packages](#106-test_every_suite_job_installs_the_extensionspy-every-job-that-runs-the-suites-installs-the-extension-packages) - [107. test_fdw_plan_privilege.py: planning a foreign table reads no server file for a role the scan would refuse](#107-test_fdw_plan_privilegepy-planning-a-foreign-table-reads-no-server-file-for-a-role-the-scan-would-refuse) - [108. test_arrow_import.py: import_arrow round trips, reads a foreign file, and refuses what it cannot decode](#108-test_arrow_importpy-import_arrow-round-trips-reads-a-foreign-file-and-refuses-what-it-cannot-decode) +- [109. test_entry_point_privilege.py: every relation-taking entry point has a decided privilege bar](#109-test_entry_point_privilegepy-every-relation-taking-entry-point-has-a-decided-privilege-bar) ## 1. How to read a test in here @@ -6876,3 +6877,13 @@ The pytest twin of `test/arrow_import.sh` (ported for #1341), every check by the | `test_the_refusals_and_every_temporal_carrier` | the dictionary and compressed refusals, every carrier and unit, the 22008 guards, the 42804 type matches with their controls, an empty target after a refusal, and nested units | | `test_an_import_maintains_the_tables_indexes` | the plan really uses the index, index and sequential scans agree, the unique violation and its row count, and the partial index | | `test_a_fifo_is_refused_not_a_hang` | XX001 within the wall clock, and the backend still up | + +## 109. test_entry_point_privilege.py: every relation-taking entry point has a decided privilege bar + +The pytest twin of `test/entry_point_privilege.sh` (#569, ported for #1341), every check by the shell's name. The population comes from the catalog, never the script: `prosrc` is the C symbol and `proargtypes[0] = regclass` is "takes a relation", and the install script's symbols are checked against the catalog as a set. Every entry point lands in exactly one bucket (exempt, relation-taking, relation by other means), the exempt list may not hold a relation-taking name, and the relation-taking class splits into catalog-governed (the six from #560) and claimed-guarded with none pinned as ungated. Then behaviourally: a login role holding EXECUTE, so the C check is the only refuser, is refused 42501 by the five guarded names after the owner's identical call succeeds, and the four file siblings refuse it at the table before touching the file. + +### Every test + +| test | what it holds | +| --- | --- | +| `test_every_relation_taking_entry_point_has_a_decided_bar` | the enumeration premises, the symbol-set agreement, the bucket and class counts, the five 42501 refusals with their owner premises, and the four file siblings refused at the table | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index 4dcf90a27..2cb5a0c97 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -651,4 +651,6 @@ guard_tests 433 # six tests, by collection over the cluster files on this tree. # cluster_tests 549 -> 550 for #1341's test_a_half_declaration_is_no_retention in # test_ttl_expire.py, by collection over the cluster files on this tree. -cluster_tests 550 +# cluster_tests 550 -> 551 for #1341's test_entry_point_privilege.py, the twin of +# entry_point_privilege.sh: one test, by collection over the cluster files on this tree. +cluster_tests 551 diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index 84cf640a9..d705ec243 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -101,6 +101,7 @@ "column_extent", "differential", "encode_post_codec", + "entry_point_privilege", "format_downgrade", "hilbert_cluster", "hilbert_locality", diff --git a/test/pytest/test_entry_point_privilege.py b/test/pytest/test_entry_point_privilege.py new file mode 100644 index 000000000..a86c81396 --- /dev/null +++ b/test/pytest/test_entry_point_privilege.py @@ -0,0 +1,195 @@ +"""Every relation-taking entry point has a decided privilege bar (#569): the pytest +twin of `test/entry_point_privilege.sh` (#1341). Every check carries the shell's name. + +The shell's three design rules hold here, and are why this file is shaped as it is: + + 1. ENUMERATE FROM THE CATALOG. pg_proc.prosrc IS the C symbol, so no naming + convention can drop a member (two hand-written enumerations once lost + get_storage_id and columnar_handler that way). The install script is then + cross-checked against the catalog as a SET. + 2. BUCKET ON THE TYPE. "Takes a relation" is proargtypes[0] = regclass, never a + parameter name or a line of the script. + 3. ASSERT SQLSTATE. Only aclcheck_error produces 42501; "permission denied" text + is also satisfied by a login FATAL or a schema refusal. + +The deny arms run as a role on its own LOGIN that holds EXECUTE on each function, +so the SQL grant layer is cleared and the C check is the only refuser left. Each +is preceded by the owner making the identical call, so a refusal is not a broken +call. The role holds USAGE on this test's schema, as the shell's role holds it on +public, so no refusal is the schema's. +""" +import pathlib +import re + +import pytest + +ROLE = "t_ep_py" +ROOT = pathlib.Path(__file__).resolve().parents[2] + +# Functions that take no relation, by name, each with its reason in the shell twin. +EXEMPT = {"columnar_handler", "parquet_fdw_handler", "parquet_fdw_validator", "iceberg_fdw_handler", + "iceberg_fdw_validator", "iceberg_catalog_fdw_validator", "parquet_schema", "read_parquet", + "file_split_offsets", "iceberg_current_snapshot", "iceberg_data_files", "iceberg_scan", + "iceberg_rest_table_location", "iceberg_rest_scan", "iceberg_rest_namespaces", + "iceberg_rest_tables", "read_avro_manifest", "read_manifest_list", "row_tid"} +# Relation-taking entry points with no privilege check of any kind: none, and a +# name added here is a claim that something is ungated. +KNOWN_UNGUARDED: set = set() +# Act on a relation without declaring one as their first argument. +RELATION_BY_OTHER_MEANS = {"alter_table_set_access_method", "vacuum_full", "create_rollup"} +# Not C: governed by the ACL on the catalog tables they read (#560). +CATALOG_GOVERNED = {"analyze", "rebuild_projections", "reset_options", "set_options", "sort_status", "stats"} +# Asserted behaviourally below, each with a call the owner can make. +GUARDED_BEHAVIOURAL = { + "read_projection": "SELECT pgcolumnar.read_projection('ep_target','p1')", + "reconstruct_via_projection": "SELECT pgcolumnar.reconstruct_via_projection('ep_target','p1')", + "vm_is_visible": "SELECT pgcolumnar.vm_is_visible('ep_target',0)", + "vm_selftest": "SELECT pgcolumnar.vm_selftest('ep_target',0)", + "get_storage_id": "SELECT pgcolumnar.get_storage_id('ep_target')", +} +FILE_SIBLINGS = ("export_arrow", "export_parquet", "import_arrow", "import_parquet") + + +def _words(conn, sql): + with conn.cursor() as cur: + cur.execute(sql) + return {r[0] for r in cur.fetchall()} + + +def _one(conn, sql): + with conn.cursor() as cur: + cur.execute(sql) + row = cur.fetchone() + return row[0] if row else None + + +def _as_ep(cluster, schema, sql): + """-> (sqlstate or 'noerror', primary message or '', first value or None) on a login as ROLE.""" + import psycopg + dsn = f"host=127.0.0.1 port={cluster.port} user={ROLE} dbname=postgres" + try: + with psycopg.connect(dsn, autocommit=True) as c: + c.execute(f'SET search_path TO "{schema}", pgcolumnar, public') + cur = c.execute(sql) + row = cur.fetchone() if cur.description else None + return "noerror", "", (row[0] if row else None) + except psycopg.Error as e: + return e.sqlstate or "?", e.diag.message_primary or "", None + + +def _install_symbols(): + """The MODULE_PATHNAME symbols the install script declares, comments blanked first.""" + control = (ROOT / "pgcolumnar.control").read_text() + ver = re.search(r"default_version *= *'([^']+)'", control).group(1) + text = (ROOT / f"pgcolumnar--{ver}.sql").read_text() + text = " ".join(re.sub(r"--.*", "", line) for line in text.split("\n")) + return set(re.findall(r"AS 'MODULE_PATHNAME'[, ]*'([a-z_]*)'", text)) + + +def test_every_relation_taking_entry_point_has_a_decided_bar(pgc_conn, pgc_cluster, expect): + conn = pgc_conn + schema = _one(conn, "SELECT current_schema()") + in_ext = ("FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'pgcolumnar'") + is_c = " AND p.prolang = (SELECT oid FROM pg_language WHERE lanname = 'c')" + installed = _one(conn, f"SELECT count(*) {in_ext}{is_c}") + symbols = _words(conn, f"SELECT DISTINCT p.prosrc {in_ext}{is_c}") + # NO LANGUAGE FILTER on the population: one once dropped the six sql/plpgsql + # relation-taking functions while the coverage arm still balanced. + relfns = _words(conn, f"SELECT DISTINCT p.proname {in_ext} AND p.proargtypes[0] = 'regclass'::regtype") + allfns = _words(conn, f"SELECT DISTINCT p.proname {in_ext}") + + # ---- premises: the enumeration is real + expect.at_least(installed, 20, "premise: the extension is installed and C entry points were found") + expect.text("yes" if relfns else "no", "yes", "premise: at least one relation-taking entry point was found") + src_syms = _install_symbols() + expect.at_least(len(src_syms), 20, "premise: the install script declares MODULE_PATHNAME symbols at all") + expect.text("same" if src_syms == symbols else "differs: " + " ".join(sorted(src_syms ^ symbols)), "same", + "the catalog and the install script agree on the symbol set") + + # ---- the coverage assertion: every entry point lands in exactly one bucket + exempt_seen = rel_seen = other_seen = 0 + unbucketed = [] + for fn in sorted(allfns): + if fn in EXEMPT: + exempt_seen += 1 + elif fn in relfns: + rel_seen += 1 + elif fn in RELATION_BY_OTHER_MEANS: + other_seen += 1 + else: + unbucketed.append(fn) + missing_cg = sorted(CATALOG_GOVERNED - relfns) + expect.text(" ".join(missing_cg) or "none", "none", + "every catalog-governed name is still a relation-taking entry point") + expect.num(len(allfns), exempt_seen + rel_seen + other_seen + len(unbucketed), + "coverage: inputs == sum(buckets)") + expect.num(other_seen, 3, "the relation-by-other-means class is the three that are known") + expect.text(" ".join(unbucketed) or "none", "none", "coverage: no entry point is unbucketed") + # EXEMPT is an escape hatch: a relation-taking name inside it leaves the + # population and the coverage arm still balances. + expect.text(" ".join(sorted(EXEMPT & relfns)) or "none", "none", "no exempt name actually takes a relation") + + stale = sorted(KNOWN_UNGUARDED - relfns) + expect.text(" ".join(stale) or "none", "none", "no pinned name is stale (all still exist and take a relation)") + catalog_n = len(relfns & CATALOG_GOVERNED) + pinned_n = len((relfns - CATALOG_GOVERNED) & KNOWN_UNGUARDED) + guarded_n = len(relfns - CATALOG_GOVERNED - KNOWN_UNGUARDED) + expect.num(len(relfns), pinned_n + guarded_n + catalog_n, + "coverage: relation-taking == pinned + claimed-guarded + catalog-governed") + expect.num(catalog_n, 6, "the catalog-governed class is the six from #560") + expect.num(pinned_n, 0, "no relation-taking C entry point is still ungated") + + # ---- the behavioural arms + with conn.cursor() as cur: + cur.execute("CREATE TABLE ep_target (id int, v text) USING pgcolumnar") + cur.execute("INSERT INTO ep_target SELECT g, 'v'||g FROM generate_series(1,10) g") + cur.execute("REVOKE ALL ON ep_target FROM PUBLIC") + cur.execute(f"DO $$ BEGIN CREATE ROLE {ROLE} NOSUPERUSER LOGIN; " + "EXCEPTION WHEN duplicate_object THEN NULL; END $$") + cur.execute(f"GRANT USAGE ON SCHEMA pgcolumnar TO {ROLE}") + cur.execute(f'GRANT USAGE ON SCHEMA "{schema}" TO {ROLE}') + expect.text(str(_as_ep(pgc_cluster, schema, "SELECT 1")[2]), "1", "premise: t_ep can open a session") + expect.text(_as_ep(pgc_cluster, schema, "SELECT count(*) FROM ep_target")[0], "42501", + "premise: t_ep cannot read the table by any ordinary route") + + with conn.cursor() as cur: + cur.execute("SELECT pgcolumnar.add_projection('ep_target','p1',ARRAY['id'],ARRAY['id'])") + cur.execute("SELECT pgcolumnar.rebuild_projections('ep_target')") + # EXECUTE on every overload, by OID: the role must clear the SQL layer, or + # the REVOKE answers 42501 whether or not the C check exists. + for fn in GUARDED_BEHAVIOURAL: + cur.execute(f"SELECT p.oid::regprocedure::text {in_ext} AND p.proname = %s", (fn,)) + for (sig,) in cur.fetchall(): + cur.execute(f"GRANT EXECUTE ON FUNCTION {sig} TO {ROLE}") + expect.text(str(_one(conn, f"SELECT bool_and(has_function_privilege('{ROLE}', p.oid, 'EXECUTE')) {in_ext}" + " AND p.proname IN ('read_projection','reconstruct_via_projection'," + "'vm_is_visible','vm_selftest')")), "True", + "premise: t_ep now holds EXECUTE, so a refusal below can only be the C check") + + import psycopg + expect.num(len(GUARDED_BEHAVIOURAL), 5, "premise: the behavioural loop covers the five guarded names") + for fn, sql in sorted(GUARDED_BEHAVIOURAL.items()): + try: + _one(conn, sql) + owner = "ok" + except psycopg.Error: + owner = "broken" + expect.text(owner, "ok", f"the owner can call {fn}, so a refusal below is a refusal") + expect.text(_as_ep(pgc_cluster, schema, sql)[0], "42501", f"{fn} refuses an unprivileged caller with 42501") + + # The four file siblings gate on a server-file role BEFORE the table, so the role + # holds both or it is refused by the wrong layer. A path that cannot exist still + # discriminates: an unguarded function reaches the file layer and fails there. + with conn.cursor() as cur: + cur.execute(f"GRANT pg_read_server_files, pg_write_server_files TO {ROLE}") + expect.text(str(_as_ep(pgc_cluster, schema, f"SELECT pg_has_role('{ROLE}','pg_read_server_files','member')")[2]), + "True", "premise: t_ep holds the server-file roles, so it reaches the table check") + for fn in FILE_SIBLINGS: + _, msg, _ = _as_ep(pgc_cluster, schema, f"SELECT pgcolumnar.{fn}('ep_target','/nonexistent/dir/x')") + if "permission denied for table" in msg: + state = "refused" + elif "could not open file" in msg: + state = "reached-the-file-layer" + else: + state = f"unexpected: {msg}" + expect.text(state, "refused", f"{fn} refuses a caller with no privilege before touching the file")