A permission change that has not taken effect yet
A role changed in the console or the SDK is enforced on the next request. What lags is an open tab, a 5-minute API key cache, or a role written by hand. Here is which.
In short
BuildBase enforces a role change in the console or SDK on the next request. Workspace roles are read per request, so only an open tab lags, until refreshWorkspaces(). An org role change signs the person out. API keys cache for 5 minutes. A role written outside the change-role route revokes nothing and waits on caches.
If you changed the role through the console or the SDK, the server is almost certainly enforcing it already. Whatever still looks old is usually an open browser tab, or a role that was written without going through the route that tells everything else. The one lag inside the server is a five-minute API key cache.
An earlier version of this guide said "up to an hour" and blamed the copy of the
role held in each session. That was wrong for anything an SDK call does. The
session does carry a role, and SessionService.getSession does re-read it once
SESSION_REFRESH_INTERVAL = 60 * 60 * 1000 has passed. But nothing under
/api/v1/public reads that copy back. What the hour still does is in
why a permission change no longer waits for the hourly refresh.
This page is the checklist.
Before you start
You changed a role, or what a role may do, and something still behaves as though you had not. Know which tier you touched. A workspace role says what someone may do inside one workspace; an org role says who they are across your whole app, and decides their console access. What is RBAC covers why there are two.
Which change did you make?
Find your row, read its section, and stop when you find the cause.
| You changed | Enforced from | Usually stuck in |
|---|---|---|
| A workspace role | The next request | The open browser tab |
| An org role, in the console | The next request, after a sign-in | Nowhere: the person was signed out |
| What an org role can do (its grants) | One pub/sub message, 60 s at worst | Nowhere, unless you meant SDK calls |
| An API token, or the person who created it | The next request, or within 5 min | The token's cache entry |
users.role, outside the change-role route | Whenever something re-reads the row | The profile cache and the console token |
1. A workspace role: the server has it, the tab does not
This is the common one. And the server is not the side that is behind.
The guards on the SDK's workspace routes, requireWorkspaceMember and
requirePermission in server/src/middleWares/workspaceGuard.ts, look the
member up in the workspaces_users rows on every request, with no cache in
front. bb.workspace.update, for instance, sits behind
requirePermission(WorkspacePermission.SETTINGS_EDIT). Change someone's role
with bb.users.updateRole(workspaceId, userId, role), or from the members
screen of the SDK's settings dialog, and the row is rewritten. Their next request is judged by the new role.
Their open tab is not. usePermissions, WhenPermission and
WhenWorkspaceRoles take the role from the workspace list the SDK loaded
earlier, and nothing updates that tab's copy. Demote an editor to viewer
and they keep seeing an editor's buttons, then get a 403 for clicking one. A
promotion runs the other way: allowed, but shown nothing new.
So before you go hunting, ask the server, signed in as the affected user:
// check-role.ts
import BuildBase from '@buildbase/sdk';
const bb = BuildBase({ serverUrl, orgId, getSessionId });
// Resolved by the server on this request, from the membership rows.
const { role, isOwner, permissions } =
await bb.workspace.permissions(workspaceId);
console.log({
role,
isOwner,
canEditSettings: permissions.includes('workspace:settings:edit'),
});bb.workspace.permissions() returns what the server enforces now. Then look at
what the tab holds:
'use client';
import { usePermissions, useSaaSWorkspaces } from '@buildbase/sdk/react';
export function RoleInThisTab() {
const { role, isOwner } = usePermissions();
const { refreshWorkspaces } = useSaaSWorkspaces();
return (
<button type="button" onClick={() => void refreshWorkspaces()}>
This tab thinks: {role ?? 'not a member'}
{isOwner ? ' (workspace creator)' : ''}
</button>
);
}Server says viewer, tab says editor? Found it. Call refreshWorkspaces()
when the open tab is behind, or reload. Both snippets typecheck against
@buildbase/sdk 0.0.82. I have not run them against a live API, so treat them
as the shape of the check rather than a transcript.
One answer is not a lag at all. If isOwner comes back true, this person
created the workspace, and assertWorkspacePermission lets the creator through
every permission the server knows about, whatever their membership row says. Demoting a
workspace's creator changes the row and nothing they can do.
Edits to the workspace permission matrix land the same way, on the next request.
Per-workspace roles in Next.js
walks through both sides, including where refreshWorkspaces() goes so a stale
tab fails once instead of twice.
2. An org role, changed in the console: they were signed out
Nothing is stuck. The person was signed out on purpose.
The console's PATCH /users/:id/change-role, in
server/src/routes/users/index.ts, ends with two calls:
SessionService.revokeUserSessions(orgId, user_id, 'role_changed') and
markCredentialsChanged(orgId, user_id). Why two? Two kinds of credential.
revokeUserSessions() deletes every SDK session the person holds, so the next
call their app makes gets a 401 and the SDK fires onSessionExpired. Then
the person signs in again, and the new session carries the new role.
markCredentialsChanged stamps credentialsChangedAt on the user, and a console
token minted before that instant gets a 401 with the code CREDENTIALS_CHANGED.
The console handles that itself in client/src/services/axios/base.ts: it drops
the token and reloads, which re-runs the org switch and mints a token carrying
the new role.
It does this on every change, promotions included. Promote someone to admin
and they are signed out of every device too. For a demotion that is the point.
For a promotion it buys nothing, since the stale copy was the smaller role.
Now the case that sends people here. An SDK call still answers 403 after an org
role change? Then the org role was never what decided it. The session gate in
server/src/routes/v1/public/index.ts copies session.user.role into
req.tokenData.role, and no route or middleware under /api/v1/public reads it
back. SDK routes authorize on workspace membership and workspace permission, so
go back to section 1.
Where the org role does show up is the browser. WhenRoles reads it from the
profile the SDK loads with the session, while WhenWorkspaceRoles reads the
workspace list, and the docs on
conditional rendering
lay the two side by side.
3. What an org role can do: one message, on every instance
Editing an org role's grants lands in the time a pub/sub message takes.
Saving a role runs setRolePermissions in accessControl.controller.ts. It
deletes the role's Redis entry, which otherwise lives TTL.PERMISSIONS = 300
seconds, and calls publishPermissionChange. That clears the writer's own
in-process Map directly and announces the change on rbac:permissions:invalidate so every
other instance drops its copy too. A Map entry lives AC_MEMORY_TTL, 60
seconds, so a lost message costs a minute at most.
Check who you expected to notice. These grants are read by authenticate, which
guards the console's routes, reached with a console token or an API token. An
SDK session never passes through it. If you edited an org role's grants to change
what someone can do inside your app, the knob you wanted was the workspace
permission matrix.
4. An API key: five minutes, unless you edited the key
SessionService.validateApiToken caches what it resolves about a key for 5
minutes (TOKEN: 300 in server/src/vendors/redis/domains/auth.redis.ts): who
created it and which role it acts as. Only the role's name is cached. The grants
behind it are read per request, so section 3 applies to keys unchanged.
Editing or deleting the token row evicts its entry (tokens.controller.ts), so
deactivating a key lands on the next request. Two changes do not evict it. A key
issued without a role of its own acts with its creator's role, so changing the
creator's role reaches the key only when the entry lapses. A blocked creator is
likewise noticed only on a cache miss. Either way, within 5 minutes.
5. A role written around the route: this is where the hour lives
A write to users.role that skips the change-role route revokes nothing. Those
writes are real. The org switch seeds a role from an invitation, and it drops a
former owner to admin after an ownership transfer
(server/src/routes/v1/auth/org-switch.roles.ts). So can a migration, a script
or a hand edit in a database shell.
Three places can go on holding the old role:
- The session's copy. Re-read once
SESSION_REFRESH_INTERVALhas passed, whichSESSION_DEFAULTS.permissionRefreshcallshourly. No SDK route reads it, so this one matters least. On a database error,getSessionserves the cached session rather than failing the request, and tries again on the next. - The profile.
getMyProfilesits behind a Redis entry that lasts 3,600 seconds, and writes throughUsersController.updateByIdclear it. The org switch writes that way. A hand edit does not, soWhenRolescan show the old org role for up to an hour. - The console token. Nothing stamped
credentialsChangedAt, so after a script or a hand edit a console token keeps its old role claim until the next org switch mints a new one, or its 7 days (DEFAULT_TOKEN_EXPIRY) run out.
The fix is to change roles through the route, scripts included. Call
PATCH /users/:id/change-role rather than writing to the collection, and the
revocation comes with it.
The case that must not wait: locking someone out now
Block them, then deal with their API tokens. Two steps, because they are two caches.
Blocking (PATCH /users/:id/block) calls
SessionService.revokeUserSessions(orgId, user_id, 'user_blocked') and
markCredentialsChanged, the same pair as the change-role route. Their SDK
sessions are deleted and their console token is refused on its next request.
There is no TTL to wait out, because there is nothing left to expire.
Then delete or deactivate every API token they created. Blocking does not evict a
token's cache entry, and validateApiToken only notices a blocked creator on a
cache miss, so a key they made can keep working for up to 5 minutes. Deleting
the token, or setting it inactive, evicts the entry and lands on the next
request.
The hourly refresh is the backstop under all of this, not the mechanism. If a
script blocks someone without going through the route, the next getSession
past the hour reads the row, sees blocked, deletes the session and returns
null. Removing someone from one workspace needs none of it: membership is read
per request, as in section 1.
If 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 gating is something the customer paid for rather than something a person's role allows, that is a different system: entitlements are not feature flags.
Install
npm i @buildbase/sdk