Skip to content

vacuum: the online rewrites run the owner's index code as the owner (#2053) - #2056

Open
OffgridwithJD wants to merge 1 commit into
mainfrom
fix/2053-index-rebuild-owner
Open

OffgridwithJD wants to merge 1 commit into
mainfrom
fix/2053-index-rebuild-owner

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Fixes #2053.

The hole, measured

compact_rewrite, recluster and recluster_hilbert put each moved row back into every index themselves (PgColumnarIndexInsertRow, src/columnar_index.c:237/240). That evaluates the table's index expressions and partial-index predicates, and they ran as the caller.

The maintenance daemon calls these verbs as the bootstrap superuser (columnar_autovacuum.c), and a superuser may call them on any table. So an unprivileged table owner could put a function in an index and have it run as a superuser on the daemon's next pass.

On main (PG 18), a superuser running each verb on a plain role's table got:

verb main this PR
compact_rewrite user=postgres restricted=no user=pgc_mallory restricted=yes
recluster user=postgres restricted=no owner, restricted
recluster_hilbert user=postgres restricted=no owner, restricted
vacuum, vacuum_sorted, cluster, cluster_hilbert owner, restricted (through index_build) unchanged; kept as controls
compact, expire evaluate no index code (zero notices) not arms

The two things you asked to confirm first

  • index_build switches on every supported major. Checked in the source of 15.19, 16.15, 17.11, 18.6 and 19rc1:
    • index_build, validate_index and reindex_index call SetUserIdAndSecContext(relowner, … | SECURITY_RESTRICTED_OPERATION) plus NewGUCNestLevel();
    • from 17 they also call RestrictSearchPath();
    • cluster_rel does the same.
  • Locking: nothing changes. Both verbs keep their ShareUpdateExclusiveLock (online reads and writes continue). The switch happens after the lock is taken and the caller's ownership check has run, so the check still judges the caller.

Change

src/columnar_vacuum.c adds switch_to_owner and restore_caller, core's pattern:

  • GetUserIdAndSecContext, then SetUserIdAndSecContext(relowner, ctx | SECURITY_RESTRICTED_OPERATION);
  • NewGUCNestLevel(), plus RestrictSearchPath() under #if PG_VERSION_NUM >= 170000;
  • on return, AtEOXact_GUC and the caller's identity are put back.

They wrap the rewrite in pgcolumnar_compact_rewrite and recluster_verb. An error needs no restore: transaction and subtransaction abort put back the user, the security context and the GUC level.

Deliberately not changed: the importers (import_arrow, import_parquet). They run index code as the caller, as an ordinary INSERT does, and core doesn't switch there either. Say if you want that revisited.

Docs (sql-reference.md, under recluster and compact_rewrite) and CHANGELOG are updated.

Test: test_maintenance_runs_as_owner.py (7 arms, TESTS.md §363)

Fixture: pgc_mallory owns the table and is nothing else. Her seen() function is one index's expression and another's predicate. It raises a NOTICE with current_user and whether setting role is refused, which is how a security-restricted operation shows from SQL. A block that always ends in an exception rolls the setting back.

Each arm: a superuser runs one verb in a single transaction. The arm checks:

  • premises: the caller is a superuser, and seen() ran at least once;
  • every notice reads user=pgc_mallory restricted=yes;
  • afterwards, current_user and search_path are the caller's again.

Mutants (PG 18): each one fails at least one arm.

mutant fails
no switch in compact_rewrite compact_rewrite
no switch in recluster_verb recluster, recluster_hilbert
no SECURITY_RESTRICTED_OPERATION the three rewrite verbs
switch to the caller's id the three rewrite verbs
caller's id not restored the three rewrite verbs
no AtEOXact_GUC (search_path stays restricted) the three rewrite verbs

Regression: the new file and every pytest file that calls compact_rewrite or recluster (42 files) pass on all five majors, so the verbs still work under the restriction and the restricted search_path:

major result
PG 15 643 passed
PG 16 643 passed
PG 17 643 passed
PG 18 643 passed
PG 19 643 passed

Gate: gpush passed (guards, collection cluster_tests 5365 → 5372, census 9660/4894 unchanged, self-test PASSED). No ledger change.

🤖 Generated with Claude Code

…2053)

compact_rewrite, recluster and recluster_hilbert put each moved row back
into every index themselves (PgColumnarIndexInsertRow), which evaluates
the table's index expressions and partial-index predicates. They ran as
the caller. The maintenance daemon calls these verbs as the bootstrap
superuser, and a superuser may call them on any table, so an
unprivileged owner's function in an index ran as a superuser.

After the caller's ownership check, each verb now switches to the
table owner with SECURITY_RESTRICTED_OPERATION and a GUC nest level
(plus RestrictSearchPath from PG 17), as core's cluster_rel and
index_build do on every supported major, and restores the caller on
return. Abort restores both on error. vacuum, vacuum_sorted and cluster
rebuild through index_build, which already switched. The importers keep
running as the caller, as an INSERT does.

test_maintenance_runs_as_owner.py: a superuser runs each row-rewriting
verb on a table owned by a plain role whose function is an index
expression and a predicate. Every evaluation must report that role,
security-restricted, and current_user and search_path must be the
caller's afterwards. Red on main for the three rewrite verbs
(user=postgres restricted=no). Six mutants each fail it.

cluster_tests 5365 -> 5372.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

Potential security issue with index rebuilds

2 participants