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)
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…
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
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
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
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: luc.richelet@hotmail.com
Top comments (4)
The
session_userversuscurrent_userfailure is a great example of why audit evidence needs adversarial tests of its own. A guard can block correctly and still leave a useless incident trail. I would add assertions for three separate identities on every sensitive call: authenticated caller, claimed actor, and function owner. They should never be collapsed, even when two happen to match in the happy path.The audit-log finding is the one I'd put on every reviewer's desk: inside a SECURITY DEFINER function
current_useris the owner, so a successful attack logged the victim and a failed one loggedpostgres— the record you trust most at the worst hour named the wrong party in both directions.The
luc_actor_is_exact_callershape looks right: textual session_user match, exactly one actor per login role, nopg_has_role, no superuser exception. One question: did the 69-attack harness stay as a regression suite? If a later helper reintroduces a transitive-role shortcut, I'd want CI to go red on that same matrix — a green that only exists in a pen-test notebook tends to decay within a release or two.Audit integrity is a critical but overlooked part of AI security. If the control and audit layers disagree about who performed an action, incident response can be built on misleading evidence so audit trails themselves need adversarial testing, not just the security controls.
The foreign-vs-absent leak (Red #2) is the same principle the recent RBAC work on text-to-SQL benchmarks keeps hitting: a restricted object must be indistinguishable from a nonexistent one, or the error channel becomes an enumeration oracle. And the audit-log paragraph deserves to be framed on a wall — inside SECURITY DEFINER, current_user names the function owner, so logging it on refusal while logging the claimed actor on success means your forensic trail blames the victim in both directions. session_user everywhere, with the actor kept as a claim rather than an author, is the only shape that survives a post-incident review.