Installation, schema and privileges
pg_automerge.control sets:
| Flag | Value | Why |
|---|---|---|
relocatable | true | CREATE EXTENSION .. SCHEMA x and ALTER EXTENSION pg_automerge SET SCHEMA y both work (below) |
superuser | true | the functions are LANGUAGE c |
trusted | false | a database owner must not be able to install it without a superuser (below) |
Relocation#
relocatable = true needs every member object in the extension's one
schema, and nothing that names that schema or resolves a member by name
when it runs. What was checked:
- The install script names no schema (no
@extschema@, which Postgres does not substitute for relocatable extensions anyway, and no qualified name).CREATE EXTENSIONruns it withsearch_pathset to the target schema (thenpg_temp, withpg_catalogimplicitly first), so every member lands there;tests/extension.shchecks that no member (type, array type, composite type, function, aggregate, operator) is anywhere else. Casts have no schema. - Catalog references are OIDs: the type's I/O functions, the casts'
functions, the operators' functions and
COMMUTATOR(the||on two documents is its own commutator),merge_agg's transition and final functions,merge'sSUPPORTfunction, the composite types' columns and everyCOMMENT.ALTER EXTENSION .. SET SCHEMAchanges only the namespace of the members, and each of these follows. - The C code never finds its own objects through
search_path: the history functions build rows with their declared result type (looked up by the function's OID);automerge_notify()looks theautomergetype up in the schema of its own function (tgfoid), so it finds theautomergecolumns wherever the extension lives and whatever the session'ssearch_pathholds (tested with a foreign type namedautomergefirst on the path); the OID that the Rust types report forautomerge(IntoDatum::type_oid) is looked up in the extension's current schema (pg_extension.extnamespace). The last one used to beregtypein('automerge'), which resolves throughsearch_path; no current code path calls it, but it would have returned a same-named type earlier on the path (a pg_test pins the new behaviour). - Objects that depend on the extension store parsed expressions with
OIDs: column types, domains, views, expression indexes, stored generated
columns, check constraints, triggers and SQL functions with a
BEGIN ATOMICbody. They keep working after a move (their definitions then print the new schema, e.g.OPERATOR(ext2.||)), and indexes and generated columns need no rebuild: the functions are the same.tests/extension.shmoves the extension under all of these and compares bytes, heads, jsonb and the generated column, uses the indexes, writes through the movedmerge, runs an in-place PL/pgSQL merge andautomerge_changes, and checks the trigger's notification. - What a move does break, as for any relocated extension: everything
that resolves names when it runs — application queries, string-bodied
SQL and PL/pgSQL functions,
SET search_pathclauses of functions,ALTER ROLE/DATABASE .. SET search_path. Unqualifiedautomerge,merge(..)and the||operator are found only throughsearch_path(or asschema.merge,OPERATOR(schema.||)); the casts tojsonband frombyteaare found without it. - Dump and restore:
pg_dumpwritesCREATE EXTENSION .. WITH SCHEMA <current schema>and qualifies every reference, so a moved extension restores into the schema it was moved to (tested with a custom-format dump, which is then moved again). - pg_test builds put the tests in a schema
testsas members of the extension, soSET SCHEMAfails there ("not in the extension's schema"); release and dev builds have no such members.
Why the extension is not trusted#
A trusted extension can be installed by any role with CREATE on the
database; its script runs as the bootstrap superuser. The review, per the
PostgreSQL documentation's "Security Considerations for Extensions":
- Input amplification: was the blocker, now bounded. A few bytes of
Automerge input can describe a document that takes orders of magnitude
more memory and time to load. A compressed save of one 4,000,000-character
text is 4 kB (Automerge deflates the columns; about 1,000 characters per
byte); the bytea cast validates it (load and save) in 4.3 s (one
uninterruptible Automerge call, see
Implementation notes) with a peak of 390 MB of
backend memory, outside Postgres' memory accounting, and it scales
linearly. Automerge allocates with the Rust global allocator, and a
failed Rust allocation aborts the process: reproduced with a server
limited to 700 MB of address space and a 12 kB input, the backend died
with
memory allocation of 768000064 bytes failed/ signal 6, and the postmaster restarted every session of the cluster. The panic guard cannot catch an abort, and an OOM kill does the same. Type input functions are called without anEXECUTEcheck, soREVOKEcannot close this: anyone who can write to anautomergecolumn can send such input. Every path that takes client bytes (text and binary input,COPY, thebyteacast,merge(automerge, bytea),automerge_contains(automerge, bytea)) and every merge result is now priced from its chunk headers and column metadata before Automerge allocates, and refused overpg_automerge.max_load_memorywith53400(see Resource limits);tests/limits.shreproduces the crash in a memory-capped cluster with the limit off and gets clean errors with it on. The setting isPGC_SUSET, which Postgres enforces whoever installed the extension, so a database owner could not raise it in a trusted install either. - Why it stays
trusted = falsefor now. The bounds rest on a cost model of Automerge 0.12 made of the worst measured cost of each unit (every generated, crafted and fuzzed input stays at or below 0.81 of the estimate), not on a proof: an input shape the battery does not cover could cost more than estimated, and then a failed allocation still aborts the backend. Memory stays outside Postgres' accounting, every session can use up to the limit at the same time (plus the stored documents a merge loads, which are not priced), and a load still cannot be cancelled (up to about 16 s at the default). Those are acceptable when a superuser decides to install the extension into a database;trusted = truewould let any database owner decide it. Revisit when the model has had more exposure (and loads can be cancelled).
The rest of the review found nothing that would stop it:
- No
SECURITY DEFINERfunction; every function, including the trigger, runs as the calling role (for the trigger: the role doing the write). automerge_notify()callsAsync_Notifydirectly with its channel argument (checked to be 1 to 63 bytes), so there is no SQL to inject into; the payload is JSON built and escaped in Rust, the table name quoted, the key values rendered byto_json's machinery (the key types' output functions, as the caller). It finds theautomergetype by OID (above), not throughsearch_path.- Functions with
internalarguments (automerge_recv,automerge_merge_support,automerge_merge_agg_trans,automerge_merge_agg_final) cannot be called from SQL, and only a superuser can create an aggregate with aninternalstate or attach aSUPPORTfunction, so no other role can hand them a foreign state. - The install script creates every object (no
CREATE OR REPLACE, nothing pre-existing is altered), references only its own objects andpg_catalogtypes, and an object of the same name already in the target schema makes it fail rather than be adopted (CREATE TYPE automergeof an existing shell type fails too). In a trusted install the members would belong to the bootstrap superuser, so the installing role could not, for example, add aWITHOUT FUNCTIONcast frombyteathat skips validation (that needs ownership of a type). pg_automerge.verify_writesandpg_automerge.max_load_memoryarePGC_SUSET, which Postgres enforces whoever installed the extension. Tested for a non-superuser that owns the database (both settings):SETbefore the library is loaded leaves a placeholder that Postgres discards with a WARNING when the library defines the setting;SETafterwards,ALTER ROLE .. SETandALTER DATABASE .. SETfail with42501;GRANT SET ON PARAMETERstill delegates it.- Recursion: the jsonb walk is iterative and stops at a nesting depth of
1000 (
json::MAX_DEPTH); the history functions and the header parser do not recurse. Set-returning functions materialize their rows in Rust memory (forautomerge_changes, the rebuilt change bytes), bounded by the document's history, which fit the load memory limit when it was written (reads peak at 1.0 to 1.5 times a load).