Back to writing

Cached permissions go stale in three ways

User version, catalog version and the clock. None of them can depend on a cron job.

Keeping the effective permissions in the session is a reasonable optimization. Recomputing roles, grants and partitions on every request is expensive, and the result is almost always the same. The problem is the “almost”. A materialized session is a cache, and a cache without an invalidation strategy is sticky privilege waiting to happen.

The payload usually carries something like this:

user_id
roles
permissions
org_units
permission_version
authz_catalog_version
permissions_valid_until

The last three fields exist because a session can go stale for three different reasons, and each one needs its own mechanism.

1. The user changed

They gained or lost a role, moved units, received or lost a direct grant. A per-user counter solves it: every authorization mutation bumps permission_version in the same transaction as the change. The session keeps the version it was resolved with, and the difference between the two gives the stale cache away.

2. The catalog changed

This is the case that usually slips through. Someone adds a permission to a role, or removes one, or changes what a wildcard role means. No user was mutated directly, so no permission_version changed, and every open session keeps the old set. A global authz_catalog_version, bumped on every catalog change, covers exactly that hole.

3. The clock moved

A temporary grant expires at 22:00. The clock going from 21:59 to 22:00 doesn't modify a single row. If the session is only invalidated when some version changes, the expired privilege stays active until someone touches the user.

The temptation is a job that runs every five minutes and revokes whatever expired. It works until the job runs late, fails or gets switched off during maintenance, and then the expired-privilege window becomes the interval between runs, or infinite. The session needs to carry the next instant at which the materialized set stops being trustworthy:

permissions_valid_until = min(expires_at of active direct grants)

Expiry has to hold even if no job ever runs.

The re-resolve predicate

With those three fields, deciding to recompute is a cheap comparison done on every request:

needs_reresolve(session, db) :=
      session.permission_version    != db.user_permission_version
   OR session.authz_catalog_version != db.catalog_version
   OR now() >= session.permissions_valid_until

When it's true, the server recomputes roles, permissions, partitions and the next expiry, updates the session and carries on with the request. Nobody has to be logged out just because their authorization changed. Both versions fit in an indexed lookup, or in a cache invalidated by events, and the clock is free.

Invalidating authorization is not killing a session

They're similar problems with different solutions. Deactivating a user, resetting a credential or responding to an incident means tearing down active sessions, not just recomputing what they can do. That's why a per-user session index, in Redis or similar, is still useful. Versioning answers “what can this session do now”; a session kill answers “this session shouldn't exist”.

The bypass bootstrap

A permission that crosses RLS creates a chicken-and-egg problem. To know whether a connection may get a tenant bypass, you have to read the user's permissions before opening the tenant scope. So the minimum needed to resolve that authority is read in a narrow, controlled system scope, and only then does the application decide whether the connection gets an ordinary context or an explicit bypass.

Connection context and pooling

When RLS reads the tenant from a Postgres session variable, that variable's lifecycle is part of security. With connection pooling, a plain SET survives the connection going back to the pool, and the next request, from another tenant, inherits the previous context. Use transaction-local settings:

SET LOCAL app.tenant_id = '...';
SET LOCAL app.is_platform_bypass = 'false';

-- or, from inside a function:
SELECT set_config('app.tenant_id', '...', true);

And test it the way it breaks: an integration test that deliberately reuses the same connection across two tenants and proves that tenant and bypass were reset. It's the kind of bug no unit test catches and that only shows up in production under load.