Why a permission change no longer waits for the hourly refresh
RBAC permission propagation delay, from our code: role and grant changes land at the write, and the hourly refresh only catches writes that announce nothing.
In short
BuildBase re-reads the user behind each session hourly, but no change an admin makes in the console or SDK waits for it. A role change revokes sessions and stamps the console token, a grant edit busts every instance's cache, and workspace roles are read per request. The hour backs up writes that announce nothing.
SESSION_DEFAULTS.permissionRefresh in
packages/shared/src/constants/platform-stats.ts reads hourly, and our docs
repeat it as "permission sync". That is accurate about one loop in one file. It
is not how a permission change reaches the check that enforces it. Not any more.
We stopped asking a clock to propagate permissions. A role change, a block or a grant edit that an admin makes through the console, or its API with an API token, now fires at the moment it is written: it deletes sessions, stamps the user's credentials, or busts a cache on every server instance. A workspace role changed through the SDK needs no push at all, because it is read per request. The hourly refresh survived because it was left a smaller job. It catches the writes that do not fire anything, and for that job an hour sits at the cheap end of a real trade.
The hour used to be the mechanism
When we audited org-level RBAC in August, the remediation plan described the role gap in one line: "a demotion is effective only at the next hourly refresh".
That was the good case. The console's tenant token carries the role as a JWT
claim, and at the time it was verified by signature alone, with nothing behind
it to re-read. The comment on
server/src/services/auth/credential-version.service.ts records what that
meant: demoting an admin "left their existing token fully privileged
until it expired". DEFAULT_TOKEN_EXPIRY in packages/server-core is '7d'.
Seven days, for a demotion.
Grants had their own version. The authenticate middleware keeps a per-process
Map of each role's grants with a 60-second TTL, in front of a Redis entry that
lives for 300 seconds. Saving a role deleted the Redis entry and left every
Map alone, so a removed permission kept working for up to a minute on every
instance, including the one that removed it.
An hour, seven days or a minute, depending on which credential you held and which half of the permission you changed. From the admin's chair all of them looked the same: I changed it and nothing happened.
The fix we did not take: a smaller number
The obvious fix is to shrink the clocks. The plan considered it in both places.
For the grant Map it wrote down a minimum viable option, "drop
AC_MEMORY_TTL to a few seconds". For roles it wrote the opposite instruction:
"prefer explicit revocation over lowering SESSION_REFRESH_INTERVAL". We took
the second shape for both.
Why? Because a TTL is a tax on every session whether or not anything changed,
and it never reaches zero. Here is what the refresh does when it fires, from
SessionService.getSession in server/src/services/auth/session.service.ts,
trimmed:
const lastRefreshed = session.userRefreshedAt ?? session.createdAt;
if (Date.now() - lastRefreshed > SESSION_REFRESH_INTERVAL) {
const freshUser = await UsersController.getInstance().findById(
session.orgId,
session.userId
);
if (!freshUser || freshUser.blocked) {
await deleteAuthSession(sessionId);
return null;
}
session.user = {
role: freshUser.role,
emailVerified: freshUser.emailVerified,
};
session.userRefreshedAt = Date.now();
const remainingTtl = await AuthRedis.getSessionTTL(sessionId);
await setAuthSession(
sessionId,
session,
remainingTtl > 0 ? remainingTtl : SESSION_TTL
);
if (session.deviceId) {
AuthSessionsController.getInstance().touch(session.orgId, sessionId);
}
void recordActiveUser(session.orgId, session.userId);
}SESSION_REFRESH_INTERVAL is 60 * 60 * 1000. When it lapses, that is a read
of the user row from the org's own database (every org has its own, which is the
database-per-tenant model), a TTL
lookup and a rewrite of the session blob in Redis, a write to the session mirror
for a device-bound session, and a mark in the monthly-active-users set. At an
hour, a busy session pays that once an hour. At a minute it can pay it 60 times,
and so does every other busy session in every org, whether or not anyone there
changed a role today.
And a minute is still not instant. It is a minute of a demoted admin doing admin things. A revoke at the write closes the gap to the next request, and it is paid once per change rather than once per session per interval. That asymmetry is the whole decision.
Fire at the write instead
This only works because an SDK session has something behind it to delete. A BuildBase session is a random 35-character ID that points at a blob in Redis. Delete the blob and the next request carrying that ID gets a 401. There is no expiry to wait out.
So the console's change-role route, the block route and a password reset all
call SessionService.revokeUserSessions, which reads the user's session index
and deletes every session in it.
The console token was the harder case, because a signed JWT has no record to
delete. It got a credentials epoch instead. Every authority change stamps
credentialsChangedAt on the user, the token carries the epoch it was minted
with, and the console gate compares the two on every request:
// server/src/services/auth/credential-version.service.ts
export async function isTokenStale(
orgId: string,
userId: string,
tokenEpoch?: number
): Promise<boolean> {
if (!orgId || !userId) return false;
const current = await getChangedAt(orgId, userId);
if (!current) return false;
return (tokenEpoch ?? 0) !== current;
}That is a lookup per request, which is what we refused to do for the role, so
why is it fine here? Because the thing it reads only changes when somebody's
authority does. The epoch sits in Redis with a 300-second TTL, and
markCredentialsChanged writes the new value straight into Redis rather than
waiting for that to lapse. A stale token gets a 401 with the code
CREDENTIALS_CHANGED, and the console re-runs the org switch to get a token
that carries the new role.
Grants got the same treatment one layer down. publishPermissionChange
invalidates the writing process's own Map directly, and announces the change on a channel
named rbac:permissions:invalidate for every other instance. If a message is
lost, the 60-second TTL is the backstop. Same design, smaller numbers.
Here is where each change lands now:
| Change | Reaches enforcement | How |
|---|---|---|
| Org role, through the change-role route | Next request, after a new session | revokeUserSessions and markCredentialsChanged |
| User blocked | Next request | The same two calls |
| A role's grants edited | One pub/sub message | Redis entry deleted, Map busted on every instance |
| Workspace role changed or removed | Next request | Read from the membership rows per request, no cache |
| API token deactivated or deleted | Next request | Its 5-minute cache entry is evicted on the write |
| Org role, seen by a key with no role of its own | Within 5 minutes | The key's cache entry is not evicted by a role change |
| User blocked by a script or a hand edit | Within the hour, on SDK sessions | The hourly refresh |
Workspace roles were never on the hourly clock at all. The workspace guard on
the SDK routes reads the member's role from the workspaces_users rows on every
guarded request, with nothing in front of it. It is the same call the
membership middleware guide makes for
your own server, and for the same reason: a cache there turns its TTL into the
time a removed member keeps access.
What the hour still does
Four jobs, and only one of them still decides who may do what.
It ends sessions a revoke missed. A script, a migration or a hand edit in a
database shell can block a user without going through the route that revokes.
The next refresh past the hour reads the row, finds blocked, deletes the
session and returns null. That is the security job, and it is a backstop: the
routes the product drives never leave it anything to do. What the client
sees when that happens is covered from the other side in
token expired mid-request.
It counts monthly active users. recordActiveUser rides the refresh. A
sign-in counts as well, because, as the comment in createSession puts it,
"waiting an hour to count somebody who just logged in would undercount every
short visit". The count is a set of user IDs per org per month, so a faster
refresh would not change the number. It would only mark the same person more
often.
It keeps "last active" accurate to about an hour. The touch on the session
mirror is throttled to the refresh on purpose. The session list's last-active
time is read from that mirror, so it moves about once an hour, and this is why.
It refreshes a role copy that the SDK routes do not read. This is the part
the docs call permission sync, and it matters least today. The session gate in
server/src/routes/v1/public/index.ts copies session.user.role into
req.tokenData.role. Then nothing reads it. Not one route or middleware under
/api/v1/public touches that field: the SDK routes authorize on workspace
membership and workspace permission, read per request. The org-level grant
check, authenticate, guards the console routes. Those are reached with a
console token or an API token, never with a session ID.
There is one more hour in the code, and it is the one an SDK user is most likely
to see, because it sits in front of the profile. The profile the SDK fetches
carries the org role that WhenRoles checks. The server caches it in Redis for
3,600 seconds. Every write through UsersController.updateById deletes the
entry, so a role changed through the console shows up on the next profile
fetch. A hand edit gets no such
help: the old role can sit in that cache for up to an hour.
What I would change
The role copy, and the word "sync". We carry a role in every SDK session,
refresh it hourly, and authorize nothing from it. Either a guard should read it
or the session should stop carrying it. Meanwhile the constant is named
permissionRefresh and the
sessions doc
tells people role changes "update automatically" on that schedule, which sends anyone debugging a permission to
the one clock that is not involved. The honest name for what is left is a
session refresh.
Promotions sign people out. The change-role route revokes on every change,
up or down. Promote someone to admin and every session they hold, on every
device, is deleted, and any console token they carry is refused until the
console mints a new one. Revoking is the right default for a demotion. For a
promotion it is a cost with no security return, since the stale copy is the
smaller role.
The backstop is also not the same length everywhere, and both gaps have the
same cause: a write that does not announce itself. A hand edit to users.role
never stamps the credentials epoch, so a console token keeps the old role claim
until the next org switch mints a new one or its seven days run out. And
blocking a person does not evict their API tokens' cache entries, so a key whose
creator was just blocked keeps working for up to five minutes, until the cached
entry lapses and the next lookup sees the block.
If you script role changes on a self-hosted deployment, call the route, not the collection. The route is the part that tells everything else.