Blog
Engineering

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.

Dharmendra Jagodana9 min read

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:

ChangeReaches enforcementHow
Org role, through the change-role routeNext request, after a new sessionrevokeUserSessions and markCredentialsChanged
User blockedNext requestThe same two calls
A role's grants editedOne pub/sub messageRedis entry deleted, Map busted on every instance
Workspace role changed or removedNext requestRead from the membership rows per request, no cache
API token deactivated or deletedNext requestIts 5-minute cache entry is evicted on the write
Org role, seen by a key with no role of its ownWithin 5 minutesThe key's cache entry is not evicted by a role change
User blocked by a script or a hand editWithin the hour, on SDK sessionsThe 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.

rbac
sessions
auth
tenancy

Frequently Asked Questions

Why don't role changes take effect instantly?

In BuildBase the ones an admin makes through the console, or its API with an API token, do, because they are pushed at the write rather than picked up by a clock. The one exception is an API key with no role of its own, which acts as its creator's old role for up to 5 minutes. A workspace role changed through the SDK applies on the next request, because workspace roles are read per request. The change-role and block routes delete the person's sessions and stamp their credentials so the console token is refused, and editing a role's grants publishes an invalidation that every server instance acts on. The hourly refresh only catches writes that skip those routes, such as a script editing the users collection.

Why not just make the refresh interval shorter?

Because every refresh is a read of the user row from the org's own database, a rewrite of the session in Redis, and the last-active and monthly-active-user writes that ride on the same throttle. At one minute a busy session can refresh up to 60 times an hour, and the shorter window still is not instant. Our RBAC remediation plan chose explicit revocation at the write over lowering SESSION_REFRESH_INTERVAL.

Does the refresh re-check whether the user was blocked?

Yes. Once the hour has passed, SessionService.getSession re-reads the users row. If the user is gone or blocked it deletes the session and the request is refused; otherwise it copies the current role and email-verified state into the session. On a database error it keeps the cached session rather than failing the request.

Do workspace role changes wait for the hourly refresh?

No. The workspace guard on the SDK routes reads the member's role from the workspace membership rows on every guarded request, with no cache in front of it, so a changed or removed workspace role applies on the next request.

Put this into practice

The modules in this post are one console away. 7-day free trial, no credit card.

7-day free trialNo credit card requiredCancel anytime