Skip to content

Standardize server-file privilege on roles (#330) - #331

Merged
jdatcmd merged 4 commits into
jdatcmd:mainfrom
ChronicallyJD:feat/330-server-file-privs
Aug 2, 2026
Merged

Standardize server-file privilege on roles (#330)#331
jdatcmd merged 4 commits into
jdatcmd:mainfrom
ChronicallyJD:feat/330-server-file-privs

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

Resolves #330. Standardizes every server-file function on the core roles (option 1 from the issue), the model core COPY ... FROM/TO 'file' uses.

Change

Relaxed the seven superuser() checks to has_privs_of_role(...):

function was now
import_parquet, read_parquet, parquet_schema, parquet FDW scan, import_arrow superuser() pg_read_server_files
export_parquet, export_arrow superuser() pg_write_server_files

parallel_copy and file_split_offsets already used pg_read_server_files; parallel_export_parquet (this stack) uses pg_write_server_files. A superuser holds both roles, so this only widens access to role holders.

  • docs/administration.md — the blanket "requires superuser" sentence replaced, the table completed with all ten entry points and their role, the untrusted-input note updated.
  • CHANGELOG.md — the deliberate-loosening entry.
  • test/server_file_privilege.sh — rebuilt over the full set. Per point it asserts a role-less caller is refused by the role name and a role holder gets past the gate; plus a coverage check that turns red if a SQL function declaring a file-path argument is missing from the list, so a new server-file function can't slip past. This is the durable half you asked for.

Validation

  • Gate green: preflight 0-warning on all five majors (15–19); full matrix ALL VERSIONS PASSED on pg18a/pg19a; server_file_privilege, parquet_export/parquet_import, arrow_export/arrow_import, native_read_parquet, native_parquet_fdw, parallel_copy, parallel_export_parquet all pass. server_file_privilege.sh is 30/30.

The security call I want you to make

This is a deliberate loosening, and the sharp edge is the read side: import_parquet, read_parquet, parquet_schema, the FDW, and import_arrow parse files this project wrote, so a role short of superuser can now reach those parsers. #216 kept them at superuser precisely until the parser fuzzing is done, and Arrow fuzzing (#214) is still open. I implemented option 1 because you leaned that way and it is the coherent model, and I documented the exposure in docs/administration.md and the CHANGELOG. But if you'd rather keep the untrusted-parser readers at superuser for now and only role-gate the writers + the text-path readers (parallel_copy, file_split_offsets, which use core's trusted COPY parser), that is a one-line-per-site revert — say the word and I'll adjust.

Stacked on #329 (export_parquet's privilege is coupled to that PR's refactor + parallel_export_parquet lives there). This PR therefore shows #329's two commits as well as the #330 commit (0cf8fb4) until #329 merges, after which the diff reduces to #330 alone. Review the 0cf8fb4 commit for the #330 change; #329 you already verified. No sanitizer run: the change is privilege checks, and #329's code already went through pg18_san.

ChronicallyJD and others added 3 commits August 2, 2026 08:18
…cmd#300)

The symmetric counterpart to parallel_copy (parallel import). N read-only
background workers each write a disjoint slice of the source to its own
part-NNNN.parquet file under one directory, which pgcolumnar.read_parquet and
the parquet FDW read back as a single relation. Export is read-only, so unlike
parallel_copy there is no coordinator and no two-phase commit -- the SQL
function is the dispatcher. All workers restore ONE serialized MVCC snapshot
(SerializeSnapshot/RestoreSnapshot), so the directory is a consistent
point-in-time image.

Two target kinds mirror parallel_copy:
- a single columnar table, split by row-group index ranges (each worker calls
  ColumnarReadRestrictToGroups on its slice);
- a partitioned columnar table, one file per leaf partition (oid-sorted list
  sliced across workers).

- src/columnar_parquet.c: extract the serial writer body into a reusable
  ColumnarWriteParquetFile(rel, snapshot, filepath, restrictGroups, n); the
  serial columnar_export_parquet becomes a thin wrapper. Add
  ColumnarParquetCheckExportable for a fail-fast dispatcher pre-check.
- src/columnar_parallel_export.c (new): the dispatcher + read-only worker,
  reusing the parallel_copy DSM/bgworker scaffolding. Privilege:
  pg_write_server_files + SELECT. Create-or-require-empty output directory
  (read_parquet unions every *.parquet, so a stale file must not linger).
- SQL: pgcolumnar.parallel_export_parquet(target, path, workers DEFAULT NULL).
- test/parallel_export_parquet.sh: parallel == serial == source via
  read_parquet + pgc_set_hash, single-table + partitioned + empty, W distinct
  files, and error cases. Registered in run_all_versions.sh.

Compiles clean; 27/27 on pg18a assert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
…atcmd#329 review)

jdatcmd's blocker, reproduced: exporting inside a transaction with uncommitted
rows duplicated the table and dropped the caller's rows. Two defects compounding:

1. Snapshot handoff used the PARALLEL-QUERY mechanism (SerializeSnapshot /
   RestoreSnapshot), which is only sound for members of the leader's parallel
   group. These workers are independent backends, so the serialized snapshot
   lacked the dispatcher's own xid and the workers saw fewer row groups. Switch
   to the cross-transaction mechanism: dispatcher ExportSnapshot(), workers run
   a repeatable-read transaction and ImportSnapshot() -- the pg_dump parallel
   mechanism. Every worker now sees the identical committed image.
2. ColumnarWriteParquetFile treated an empty restrict list as 'no restriction'
   (export everything). Once the views disagreed, a worker's empty slice
   exported the whole table -> duplication. Now NULL means whole table and a
   non-NULL list (even empty) restricts to exactly those groups, so an empty
   slice writes nothing. The worker always passes a non-NULL list.

Also from the review:
- validate EACH leaf partition's tupdesc in the dispatcher, not just the parent;
- ship the oid-sorted leaf list from the dispatcher so a concurrent ATTACH cannot
  desynchronise the workers (they no longer re-derive it from the live catalog);
- BgWorkerStart_ConsistentState so a read-only export runs on a hot standby;
- PG_ENSURE_ERROR_CLEANUP so a dispatcher FATAL terminates the workers instead of
  orphaning them past the exported snapshot;
- a per-partition memory context in the worker loop, so buffers do not accumulate.

test/parallel_export_parquet.sh: add the in-transaction fixture (BEGIN; INSERT
uncommitted; export; COMMIT) that asserts no duplication, no duplicate ids, and
that uncommitted rows are absent -- the coverage the suite lacked. 30/30 pg18a.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Server-file functions had drifted: parallel_copy and file_split_offsets gated on
pg_read_server_files while import_parquet/read_parquet/parquet_schema/the parquet
FDW/import_arrow/export_parquet/export_arrow gated on superuser(), and the docs
asserted superuser for all. parallel_export_parquet (jdatcmd#329) would add a third
role-based writer. Two bars for the same capability, and the boundary test and
docs covered only one.

Resolution (issue jdatcmd#330, option 1): relax the seven superuser() checks to the core
server-file roles -- pg_read_server_files for the readers, pg_write_server_files
for the writers -- matching COPY ... FROM/TO 'file'. A superuser holds both roles,
so this only widens access to role holders.

This is a deliberate loosening. The read functions parse Parquet/Arrow files this
project wrote, so a role short of superuser can now reach those parsers; the docs
and CHANGELOG say so, and the mitigation is the parser fuzzing in jdatcmd#214 (Arrow
still open). If the security posture should keep the untrusted-parser readers at
superuser for now, that is a one-line-per-site change -- flag it and I will.

- src/columnar_parquet.c, columnar_arrow.c, columnar_parquet_reader.c: the seven
  checks, with the pg_authid_d.h + utils/acl.h includes.
- docs/administration.md: the blanket 'requires superuser' sentence replaced, the
  table completed with all ten entry points and their role, the untrusted-input
  note updated.
- CHANGELOG.md: the deliberate-loosening entry.
- test/server_file_privilege.sh: rebuilt over the full set. Per point it asserts a
  role-less caller is refused by name and a role holder gets past; plus a coverage
  check that a SQL function with a file-path argument missing from the list turns
  the gate red. 30/30 on pg18a.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
@jdatcmd

jdatcmd commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Review: the right resolution, and the durable half actually works

This is option 1 from #330 and I think it is the correct call: the role gate is
what core COPY ... FROM/TO 'file' uses, and the roles exist precisely so this
capability can be delegated without superuser.

Verified myself

The role mapping is right. I enumerated every gate in the branch: writers
(export_parquet, export_arrow, parallel_export_parquet) take
pg_write_server_files; readers (import_parquet, read_parquet,
parquet_schema, the FDW scan, import_arrow, file_split_offsets,
parallel_copy) take pg_read_server_files. No writer is gated on the read role,
which was the thing worth checking.

The coverage check works, proved by removal. I added an ungated function to the
extension SQL:

CREATE FUNCTION pgcolumnar.peek_server_file(path text) RETURNS bigint ...
FAIL  server-file function is covered by the boundary test: peek_server_file: got [no] want [yes]

30 checks clean, 31 with the injection, and it names the offending function. That
is the part of #330 I actually cared about: the docs drifted once and would drift
again, and now they cannot do so silently.

The anti-vacuity assertion on the discovery itself ("coverage check found the file
functions (not an empty scan)", requiring at least 7) is the right instinct. Without
it a broken awk would make the whole coverage loop pass by finding nothing.

The CHANGELOG says the quiet part. "This is a deliberate loosening", plus the
note that the read functions parse files this project wrote and are now reachable
below superuser while Arrow parser fuzzing (#214) is incomplete. That is the
sentence a reader needs and the easiest one to omit.

One leftover, and it is now self-contradicting

src/columnar_parquet_reader.c:3547, in pqfdwGetForeignRelSize:

/* ... The stat() is gated on superuser -- the same bar the scan
 * enforces -- so a non-privileged planner cannot use the EXPLAIN estimate to
 * probe whether a server-side path exists or how big it is. */
if (superuser() && path != NULL && stat(path, &st) == 0 && st.st_size > 0)

The guard itself is good thinking: it stops the row estimate becoming a
file-existence side channel. But its stated justification, "the same bar the scan
enforces", is exactly what this PR changes. The scan now enforces
pg_read_server_files.

Consequence is not a security hole, it is the reverse: a user who legitimately
holds pg_read_server_files can read the file through the FDW but gets the flat
1000-row default instead of the size-derived estimate, so they plan worse than
before for no reason. The gate should follow the scan to pg_read_server_files,
and the comment updated with it.

It also escaped the new coverage check, which is fair, since that check scans the
extension SQL for file-path arguments and this is an internal planner hook rather
than a SQL function. Worth knowing the check's boundary rather than assuming it
catches everything of this shape.

Note on scope

This branch also carries #329's parallel_export_parquet (+653 and its suite), so
merging this merges that. The snapshot blocker there is fixed and I verified it,
but the open items from my #329 review still apply: the parent-versus-leaf tupdesc
check, the leaf list re-derived from the live catalog, BgWorkerStart_RecoveryFinished
blocking hot-standby export, the missing dsm-detach callback, per-call buffer
leaks, and no cleanup of partial output on failure.

If you would rather land the privilege standardisation on its own, splitting it out
would make both easier to reason about, since one is a security-model change and
the other is a feature.

…datcmd#330 review)

jdatcmd's jdatcmd#331 review: pqfdwGetForeignRelSize gated its size-estimate stat() on
superuser() with the comment 'the same bar the scan enforces'. This PR moved the
scan to pg_read_server_files, so that comment became self-contradicting, and a
role holding pg_read_server_files could read the file through the FDW yet got the
flat 1000-row default estimate -- worse plans for no reason. Move the guard to the
same role and update the comment. (My original grep was for !superuser(); this
positive-form guard slipped past it.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

Fixed the FDW estimate gate in c395c39: pqfdwGetForeignRelSize's stat() now gates on pg_read_server_files, matching the scan, and the comment is corrected. Good catch — my grep was for !superuser(), so this positive-form guard slipped past; it is now the last superuser() in the server-file paths (grep confirms zero remain). A role holding pg_read_server_files gets the size-derived estimate again instead of the flat default. Re-gated: build clean, server_file_privilege 30/30, native_parquet_fdw and native_parquet_pushdown green.

On the #329 items in your scope note

The parent-versus-leaf tupdesc check, the leaf list re-derived from the live catalog, BgWorkerStart_RecoveryFinished, the dsm-detach callback, and the per-call buffer leaks are all addressed in 0a3f87d — the commit you verified the snapshot fix on — so I think the note crossed with my #329 reply. Concretely on that branch: the dispatcher validates each leaf and ships the oid-sorted leaf list in the DSM (workers no longer re-derive it); workers start at BgWorkerStart_ConsistentState; the spawn/wait is wrapped in PG_ENSURE_ERROR_CLEANUP; each partition runs in its own memory context.

The one I did not change is partial output on failure: a failed export leaves its part-*.parquet behind, the same as a failed COPY TO, and the create-or-require-empty directory policy stops a later run from mixing them. Happy to add best-effort unlink on failure if you want it.

On splitting the privilege change from the feature

I'd like to, and the coupling is only two spots: export_parquet's role change sits on #329's refactored wrapper, and the boundary test's data list includes parallel_export_parquet, which lives in #329. A #331 rebased straight onto main without #329 would leave export_parquet at superuser and drop that one test row until #329 lands. So the clean separation falls out of merge order: merge #329, then #331's diff auto-reduces to the model change alone (0cf8fb4 + c395c39). If you'd rather I physically split them now, I can, at the cost of that temporary inconsistency — your call.

@jdatcmd
jdatcmd merged commit 8760182 into jdatcmd:main Aug 2, 2026
11 checks passed
ChronicallyJD added a commit that referenced this pull request Aug 2, 2026
parallel_export_parquet (merged in #329) was in administration.md's role table
but had no reference entry, feature note, or user-guide example, unlike its
sibling parallel_copy. Add all three, including the consistency guarantee (one
exported snapshot, so the files are the committed image at call time) and the
partial-output cleanup on failure.

Also correct six places that still said the server-file functions "require
superuser". #330 and #331 moved them to the pg_read_server_files and
pg_write_server_files roles, which superusers hold; administration.md was updated
then, but these were missed:
- sql-reference.md: the import/export intro and the read-in-place section
- features.md: the interoperability note
- user-guide.md: the read-in-place note
- limitations.md: the type-coverage note and the read-in-place limits

The remaining superuser mentions in configuration.md are GUC-set permissions, not
server-file access, and are correct. STE and docs_style gates pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
ChronicallyJD added a commit that referenced this pull request Aug 2, 2026
…ence

docs: document parallel_export_parquet; fix stale superuser claims after #331
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server-file privilege model has drifted: docs claim superuser, two functions require only a role, and the boundary test covers neither

2 participants