Guides
Build guide

A permission change that has not taken effect yet

Up to an hour, and that is the designed number, not a cache somebody forgot to bust. Which clock you are waiting on, and which one you can skip.

Dharmendra Jagodana7 min read

In short

An RBAC permission change can take up to an hour: SESSION_REFRESH_INTERVAL is 60 * 60 * 1000, which SESSION_DEFAULTS.permissionRefresh calls hourly. That hour caps one clock only, the role copy held in a session. Editing a role lands in seconds, and the console change-role route revokes sessions instead of waiting.

Up to an hour.

That is the number, and you should have it before you start reading logs. SESSION_DEFAULTS.permissionRefresh in packages/shared/src/constants/platform-stats.ts reads hourly, and the code it describes is SESSION_REFRESH_INTERVAL = 60 * 60 * 1000 in server/src/routes/v1/auth/constants.ts. So if someone's role went from user to editor 20 minutes ago by any path other than the console's change-role route - a script, a seed, a direct write to the users row - and the API is still answering as though it did not, nothing is broken. You are inside the window.

The rest of this is about which window you are actually inside, because there are three of them and only one is an hour long.

Before you start

You have changed either a member's org role or a role's grants, through the console or the API, and the change is not visible in enforcement. If you are looking at a workspace-level role instead, that is a separate tier and this clock does not govern it.

Why an hour, and not zero

Because the alternative is reading the permission store on every request, for every user, forever.

SessionService.getSession in server/src/services/auth/session.service.ts holds a cached copy of the user's role and verified state inside the session blob. On each request it compares session.userRefreshedAt against SESSION_REFRESH_INTERVAL, and only when that hour has passed does it go back to Mongo for a fresh users row. Every request in between is answered from the blob.

Drop that interval to zero and you have bought instant propagation with a database read on every authenticated call in the platform. That is a real cost and it lands on every tenant, including the overwhelming majority where nobody changed a role today.

An hour is the trade. It is a genuine weakness, and I would rather write that sentence here than have someone else write it in a comparison post: if your product needs permission changes to land in seconds for ordinary role edits, this is a thing to know before you build on it, not after. What the hour buys is that the common path stays cheap. What it costs is an afternoon of confusion the first time you hit it, which is roughly why this page exists.

Key takeaway

The hour is a ceiling on one specific thing: the copy of a user's role carried inside an already-issued session. It is not the refresh rate of the permission system.

Three clocks, and only one of them is the hourly one

You will find two shorter numbers in the code and they are not this one. Keep them apart or you will chase the wrong cache.

The role a person holds lives in the session blob and refreshes hourly, as above. This is the clock almost everyone means when they say a permission change has not applied.

What a role can do is the grant rows, and they sit behind two caches: TTL.PERMISSIONS = 300 seconds in server/src/vendors/redis/domains/access-control.redis.ts, and a per-process Map with AC_MEMORY_TTL = 60 * 1000 inside createAuthenticateMiddleware. Those are the numbers you will stumble over if you grep for a TTL, and both are invalidated explicitly when a role is saved, so in practice neither is what you are waiting on.

A key's authority is its own clock again. validateApiToken caches the resolved token for TOKEN: 300 seconds, five minutes, and that cache holds the role name only. The grants behind the name are read per request, which is what lets you edit a role and change what an existing key can do without re-issuing it.

So: hourly for who someone is, seconds for what their role permits, five minutes for a key's identity. Different questions, different answers.

What actually forces a refresh

Two changes propagate immediately, and it is worth knowing which, because they cover most of what people are trying to do.

Editing a role's grants. add/removePermissions in accessControl.controller.ts deletes the Redis entry and then calls publishPermissionChange, which announces on a Redis channel named rbac:permissions:invalidate. Every instance subscribed to that channel drops its in-process Map entry on receipt, and the publisher invalidates itself directly first, because Redis does not deliver a message back to its own publisher. Take a grant off a role and it is gone across the fleet in the time a pub/sub message takes.

That relay is the fix for a specific bug, and the comment in server/src/services/auth/permission-cache.service.ts says so plainly: before it existed the Redis layer was busted but the memory Map was not, so a revocation stayed live for up to a minute on every instance including the one that made it. An admin removed a permission, watched the old one keep working, and concluded the revoke had failed. They were right to.

Changing a member's org role. The route at server/src/routes/users/index.ts does three things after the write: records a security audit row, calls SessionService.revokeUserSessions(orgId, user_id, 'role_changed'), and calls markCredentialsChanged(orgId, user_id). Two revocations because there are two credential types. Sessions cover the SDK auth path. The credentials stamp covers the console's bearer JWT, which carries the role as a claim and is verified by signature alone, so without the stamp it would keep the old role until it expired.

Which means the docs line telling you to sign out and back in after a role change is describing something the platform already did to you.

What does not force a refresh: reloading the page

A browser reload is the thing that wastes the afternoon, because it looks like it worked.

Here is the mechanism. buildProfile in server/src/routes/users/buildProfile.ts reads the user row with UsersController.findById and the grants with AccessControlController.getAllPermissions, and neither of those goes through a cache. getAllPermissions is a direct find against the org's Mongo database. So the profile your client renders after a reload is genuinely current: new role, new permission list, new menu.

Meanwhile the guard on every other route reads req.tokenData.role, and for the session path that is assigned in server/src/routes/v1/public/index.ts as session.user.role from the cached blob. Which is up to an hour old.

You now have a console showing the new permission and an API refusing to honour it. Same request, two sources of truth, and the one you can see is the one that is right.

import BuildBase from '@buildbase/sdk';

const bb = BuildBase({ serverUrl, orgId, getSessionId });

// Read-through: reflects the change immediately.
const profile = await bb.users.getProfile();

// Guarded by the middleware: still enforcing the cached role.
await bb.workspace.update(workspaceId, { name: 'Demo workspace' });

I read the two routes rather than running this pair, so treat it as the shape of the check and not as a transcript. The point stands either way: if the first call disagrees with the second, you are looking at the hour, not at a bug in your grant rows.

There is one more way to be misled here. getSession wraps its refresh in a try and, on a database error, returns the cached session rather than failing the request. That is the right call for availability. It does mean a refresh that should have happened can silently not happen, and the stale role survives into the next hour.

The case that must not wait: locking someone out now

Someone is leaving under a cloud and you need their access gone this minute. The honest answer is that BuildBase does not update their permissions instantly. It signs them out.

Block or remove the user. server/src/routes/users/index.ts calls SessionService.revokeUserSessions(orgId, user_id, 'user_blocked'), which deletes the session blobs outright and marks the Mongo mirror revoked, and blocking also stamps credentialsChangedAt so any bearer token minted before that instant stops verifying. There is no waiting on a TTL, because there is nothing left to expire.

The hourly refresh is the backstop underneath that, not the mechanism. If a revocation was missed, the next getSession past the hour reads the user row, sees blocked, deletes the session and returns null. Within the hour, always.

Two things to check before you call it done:

API tokens are a separate revocation. A token carries its own role and resolves independently. validateApiToken refuses a row whose active is false and refuses a token whose creator is blocked, but both decisions sit behind the five-minute token cache on a hit. Deactivate the token row as well as blocking the person.

Workspace membership is a different tier. Removing someone from an org is not the same as removing them from a workspace, and this clock does not cover the second.

If you are here because you are still deciding how much of this to own yourself, the tenancy model underneath it is in database-per-tenant vs shared schema, and the workspace layer these roles attach to is in add multi-tenant workspaces in an afternoon. If the thing you are actually gating is a plan capability rather than a person's role, that is a different system and mixing them causes its own version of this bug: entitlements are not feature flags.

Install

npm i @buildbase/sdk
rbac
sessions
multi-tenancy

Frequently Asked Questions

How long does a role change take to apply in BuildBase?

Up to an hour for the role stored on an existing session, set by SESSION_REFRESH_INTERVAL = 60 * 60 * 1000 in server/src/routes/v1/auth/constants.ts. The console change-role route skips that wait by revoking the member's sessions, so the new role applies at their next sign-in. Changing a role grant is different again: that busts the Redis entry and every instance memory cache on a pub/sub channel, so it lands in seconds.

Why does the console show the new permission but the API still returns 401?

The profile endpoint reads users.role and the grant rows straight from Mongo, so it shows the change immediately. The guard on every other route reads session.user.role from the cached session blob, which is refreshed hourly. The UI is fresh and the enforcement is not.

Does reloading the page pick up a new role?

No. A reload re-fetches the profile, which is read-through, but it does not touch the session blob that the authorize middleware reads. Only the hourly refresh, a role change through the console route, or a sign-out replaces that copy.

How do I revoke someone right now instead of waiting?

Block or remove them. Both call SessionService.revokeUserSessions, which deletes their sessions outright, and blocking also stamps credentialsChangedAt so the console bearer token stops verifying. The mechanism is a sign-out, not an instant permission update.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime