9 September 2026 · Luc Richelet.
Every number below comes from a real execution log, under real PostgreSQL
login roles, on two clean databases. Nothing is inferred from reading code.
Most AI-agent security content tells you what to do. This post tells you what
we did to ourselves, and what broke.
We run a small platform kernel on PostgreSQL. AI agents work inside it: they
claim jobs, write artifacts, record health checks, emit signals. Every one of
those actions goes through a SECURITY DEFINER function that is supposed to
check who is calling and what they’re allowed to touch. We had fifteen of
those functions. The registry said they were declared but unproven.
So instead of reading them, we attacked them — as a real database role with a
real, narrow mandate, never as the superuser. Here’s what happened.
The setup
- One branch-scoped worker role,
v14_ms, whose only mandate is theMOVESCANbranch. - Fifteen
SECURITY DEFINERfunctions, each declaredBRANCH_SCOPED. - Attacks: impersonate another actor, write into a foreign branch, distinguish “foreign” from “absent”, inject a branch name, claim a job you don’t own.
- Rule: a green obtained as
postgrescounts for nothing. Our identity helper returnedtruefor any superuser. We only trust greens from login roles.
Red #1 — luc_artifact_put: impersonation just worked
B2 own actor, foreign branch -> created=false err=LK037 (correct)
B3 p_actor='v14-ei' -> created=TRUE (wrote into EL_INKA)
B4 p_actor='v14-global' -> created=TRUE (wrote into OPPORTUNITYFIT)
Enter fullscreen mode Exit fullscreen mode
The function checked the claimed actor’s write scope. It never checked that
the caller was that actor. Worse: the audit log recorded actor_id = v14-ei.
The forgery was attributed to the victim.
Red #2 — luc_artifact_superseder: impersonation, plus an existence leak
SUP2 foreign artifact, own actor -> LK037 · no write mandate on this branch
SUP4 absent artifact -> artefact inconnu : ccc…
Enter fullscreen mode Exit fullscreen mode
Two different answers. LK037 means it exists somewhere else. inconnu
means it doesn’t exist. A MOVESCAN-only role could enumerate whether any
SHA existed in any other branch. We had closed exactly this leak on another
function one release earlier. We hadn’t closed it here.
Red #3 — luc_job_fail: cross-branch denial of service
FAIL2 foreign leased job, p_worker='v14-ei' -> DEAD
Enter fullscreen mode Exit fullscreen mode
A MOVESCAN worker killed an EL_INKA job by claiming to be its owner. The
sibling function luc_job_complete refused the identical attack. Same module,
two identity contracts.
Red #4 and #5 — declared BRANCH_SCOPED, actually global
luc_stale_actors returned actors from every branch to a scheduler scoped to
one. luc_job_reclaim_expired reclaimed an expired lease belonging to another
branch. The registry declared both branch-scoped. A false declaration in a
safety registry is worse than no declaration — it looks like proof.
The one that hurt the most: the audit log never named the attacker
called as postgres : session_user=postgres current_user=postgres
called as v14_ms : session_user=v14_ms current_user=postgres
Enter fullscreen mode Exit fullscreen mode
Inside a SECURITY DEFINER function, current_user is the owner. Two of our
functions logged current_user on refusal. So:
- when an attack succeeded, the log named the victim (the impersonated actor);
- when an attack failed, the log named
postgres.
In neither case did the real caller appear. A post-incident investigation on
that log would have blamed the wrong party every single time.
What we changed
One primitive, on the existing binding registry, no second identity system:
create function luc_actor_is_exact_caller(p_actor text) returns boolean ...
-- the actor's current binding names session_user, textually
-- the login role carries exactly one actor
-- no pg_has_role (transitive), no superuser exception
Enter fullscreen mode Exit fullscreen mode
Tested against direct, transitive, collective, stale, current, nonexistent and
superuser identities. Then rewired the write gates to it, made “foreign” and
“absent” return byte-identical responses, moved the branch check into the
function with a returned-and-logged refusal instead of a raw constraint error,
and logged session_user everywhere — with the claimed actor kept as a
claim, not as an author.
Result, on two clean rebuilds: 69 OK · 0 KO, deterministic. Historical
suites: zero regression from the fix itself.
What it cost us
Honesty section. Along the way we introduced four defects of our own — a
NOT NULL column that rolled back the very refusal trace we were trying to
write, a two-transaction migration the real installer rightly refused, an event
block rewritten with a value a foreign key rejected, and a variable/column name
collision. All four were found by execution, none by rereading. The full
before/after logs ship with the package.
The thing we’d tell any small team on Postgres or Supabase
Row Level Security decides which rows. It does not decide which actions,
under whose authority, with what proof, and who answers for it. If your agent
can call a SECURITY DEFINER function with an actor name as a parameter, ask
one question: does the function check the parameter, or the caller? Then
don’t trust your reading. Log in as the narrowest role you have and try to lie.
If you’d rather we did that to yours
We run the same adversarial battery against your agent’s Postgres surface —
identity, scope, existence leaks, branch injection, audit attribution — as real
login roles, and hand you the matrix: what your agent managed to do that it
shouldn’t have, with the log. Two days, fixed price, and you keep the harness.
Luc Richelet — Calle Asturias 15A, 38660 Adeje, Tenerife, Canary Islands (ES)
Email: [email protected]