Skip to content

Install without superuser; delete tuples in bulk - #9

Merged
lemuelroberto merged 5 commits into
emfga:mainfrom
lemuelroberto:managed-install
Oct 20, 2026
Merged

lemuelroberto merged 5 commits into
emfga:mainfrom
lemuelroberto:managed-install

Conversation

@lemuelroberto

Copy link
Copy Markdown
Member

Two changes for 0.2.0.

Install without superuser. Five functions declared
SET fga._dispatching = 'on'. PostgreSQL lets only a superuser
store a custom setting in a function definition, so the installer
failed for every other role — including a managed service's master
user (Amazon RDS), which the install story promises to serve. The
compose role is a superuser, so the suite never saw it. Dispatch is
now an argument of internal fga._check / fga._list_objects;
public entry points pass true, the compiled hand-offs pass false.
No measurable cost on fga.check (~210 µs either way).
TestInstallWithoutSuperuser installs twice as a
LOGIN CREATEDB CREATEROLE role and answers through a compiled
relation; it fails on main with
permission denied to set parameter "fga._dispatching".

fga.delete_tuples. Beyond upstream: delete every tuple matching
a filter anchored on a whole subject or a whole object, in one
DELETE … RETURNING, returning the deleted rows (typed,
conditions included). Covers offboarding and resource deletion,
including from a trigger on the application's own table
(docs/INSTALL.md, "Deleting tuples in bulk"). An unanchored filter
is refused; empty arrays match nothing; the model is never
consulted, as with fga.write's deletes.

Decision 8 becomes "one insert site, one delete site":
fga.write's deletes run through the same internal
fga._delete_tuples. A 100-key fga.write delete goes from ~13 ms
to ~16 ms (the exact-key filter takes a static, plan-cached
branch; building the statement per key cost ~26 ms).

CLAUDE.md changes land as their own commits (decision 7's
superuser trap; scope and decision 8).

Five functions declared SET fga._dispatching = 'on' to keep a
compiled relation's hand-off to the generic resolver from
dispatching straight back to the generated function. PostgreSQL
lets only a superuser store a setting for a custom parameter in a
function definition, and only a superuser can GRANT SET ON
PARAMETER for one, so the installer stopped at the first of them
for every other role. That includes the master user of a managed
service such as Amazon RDS, which the install story promises to
serve; it went unseen because the compose role is a superuser.

Dispatch is now an argument instead of session state: fga.check and
the list_objects search take it through internal fga._check and
fga._list_objects, the public entry points pass true and the
hand-offs pass false. Setting and restoring the flag in the body
instead would need an EXCEPTION block, and so a subtransaction, on
every hand-off. The wrapper adds no measurable cost to a check
(about 210us per call either way, 20,000-call loops).

The session escape hatch the flag also provided is gone; the tests
that used it to force generic answers call the internal functions
with dispatch off. A new test installs the bundle twice as a
LOGIN CREATEDB CREATEROLE role into a database it owns, and then
answers through a compiled relation, so a superuser-only statement
fails the suite.
The installer of 0.1.1 needed superuser for one reason nobody
looked for: a custom setting stored in a function definition. The
list in decision 7 named the dependencies a managed service
refuses, but not the privilege, and the compose role being a
superuser hid it. Naming the trap, and the test that now catches
the class, keeps the next contributor from reaching for a
SET clause to carry state.
Revoking everything a subject holds, or everything on an object an
application deletes, means a read followed by deletes of at most
100 full keys under upstream's API. In the database that holds the
application's data it can be one DELETE ... RETURNING, and even a
trigger on the application's own table, so a filtered delete earns
a place in scope as an additive surface like compiled relations.

Decision 8 kept every tuple mutation in one function so a
changelog could be added later. Putting the filter into
fga.write's request would keep that literally true but put a key
upstream does not have into an upstream-shaped API, so the rule
becomes one insert site and one delete site, which is what the
changelog actually needs.
Taking every grant away from a user, or every tuple off an object
being deleted, otherwise means reading the store and sending full
keys back to fga.write 100 at a time, or selecting from fga.tuple
and tying the caller to its layout. fga.delete_tuples does it in one
DELETE ... RETURNING, so a concurrent revoke waits and then gets
nothing back, and it returns the deleted rows, conditions included,
because a caller auditing a revoke needs each key, not a count.

The filter must name a whole subject or a whole object; anything
less is refused, so no call can mean "the whole store", while an
empty array matches nothing and so a trigger with no rows is
harmless. Like fga.write's deletes it never consults the model, so
rows a model change stranded stay deletable. The arguments and the
result are typed rather than upstream-style strings: the surface is
SQL-facing, like compiled relations, and its rows feed straight
back into another call.

fga.write's deletes now go through the same internal delete, the
one site a changelog would hook. Building that statement per key
doubled a 100-key delete (13 ms to 26 ms), so a filter naming every
column takes a static, plan-cached branch: about 16 ms. Any other
filter builds its statement from the filters given, so an index
serves it, which a cached "($n IS NULL OR ...)" plan cannot do.
The release adds a public function and a public type,
fga.delete_tuples and fga.deleted_tuple, so it is a minor version.
It also makes the installer work for a role without superuser,
such as a managed service's master user. The public entry points
answer as before; the one thing gone is the undocumented session
setting fga._dispatching, which forced generic answers. Re-running
the installer upgrades in place.
@lemuelroberto
lemuelroberto merged commit 0f9084d into emfga:main Oct 20, 2026
3 checks passed
@lemuelroberto
lemuelroberto deleted the managed-install branch October 20, 2026 00:33
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.

1 participant