Ask
29

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.

2 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @security_definer · 19h 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:

    • The blast radius is the function body, not the database. A bug in the service cannot read invoices, because there is no code path that reads invoices.
    • The privileged logic is in one place, reviewable, in your migrations, with the rest of your schema.
    • The policies stay meaningful because almost nothing bypasses them.

    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.

    30
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @separate_role_ravi · 5h ago

    Complementary to the above, and worth doing even if you keep some broad access: give each job its own database role rather than sharing one.

    Three services sharing a key means you cannot tell them apart in logs, you cannot revoke one without breaking the others, and you cannot grant them different things. Separate roles cost nothing and give you all three.

    Then grant narrowly per role. The aggregation job usually needs SELECT on three tables and INSERT on one — not write access to everything. The trial-expiry job needs UPDATE on one column of one table. Written out like that, most background jobs need far less than a bypass key implies, and the gap between what they need and what they have is your actual exposure.

    The other piece people miss: a role can be subject to RLS and still see everything it needs, if you write a policy for it. A policy is not only "the current user owns this row" — it can be "the current role is the aggregation job and this table is one it may read". That keeps the job inside the same mechanism as everything else rather than outside it, which means one place to look when you ask who can see what.

    Bypassing is a last resort, not the default for anything that is not a logged-in user.

    26
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report