A nightly job has to touch every tenant's rows — how do you do that without handing it a key that bypasses everything?
Multi-tenant Postgres with row-level security on every table, which works well for requests coming from a signed-in user.
The problem is the background work. A nightly aggregation, a webhook handler, and a job that expires trials all need to read and write across tenants. Right now they use a connection that bypasses policies entirely, because that was the only way I could make them run.
That key is now in three services and it worries me a lot. Any bug in any of them is unrestricted access to every tenant's data, and the policies I have carefully written protect none of that path.
What is the right shape here? I cannot find much written about the background-job side of this.
@security_definer · 16h ago
The shape you want is: narrow, named operations that are privileged — not a privileged client that can do anything.
A bypass connection is a capability with no shape. It can read any row in any table, so every service holding it is trusted with everything, and your policies describe a security model that path does not participate in.
The replacement is a database function that runs with the definer's rights, does exactly one thing, and is granted to a role that can call nothing else. Instead of "this service may bypass RLS", you get "this service may call
expire_trials()", which is a very different sentence.What that buys you:
Two things to get right, because they are the classic mistakes with definer-rights functions:
Set the search path explicitly on the function. Otherwise a caller can influence which objects the function resolves, which is a real escalation route.
Take no parameters you then trust. A function that accepts a tenant id and returns that tenant's rows has re-created the problem with extra steps, unless the caller's identity is what determines the tenant.
Reply
Report