Guides
Build guide

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.

Dharmendra JagodanaUpdated Oct 12, 20268 min read

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 changedEnforced fromUsually stuck in
A workspace roleThe next requestThe open browser tab
An org role, in the consoleThe next request, after a sign-inNowhere: the person was signed out
What an org role can do (its grants)One pub/sub message, 60 s at worstNowhere, unless you meant SDK calls
An API token, or the person who created itThe next request, or within 5 minThe token's cache entry
users.role, outside the change-role routeWhenever something re-reads the rowThe 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_INTERVAL has passed, which SESSION_DEFAULTS.permissionRefresh calls hourly. No SDK route reads it, so this one matters least. On a database error, getSession serves the cached session rather than failing the request, and tries again on the next.
  • The profile. getMyProfile sits behind a Redis entry that lasts 3,600 seconds, and writes through UsersController.updateById clear it. The org switch writes that way. A hand edit does not, so WhenRoles can 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
rbac
sessions
workspaces
multi-tenancy

Frequently Asked Questions

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

Made through the console or the SDK, it is enforced on the next request, with one exception: an API key with no role of its own acts as its creator's old role for up to 5 minutes. Workspace roles are read from the membership rows on every guarded request. The console's change-role route revokes the person's sessions and stamps their credentials, so they sign in again with the new role. Editing a role's grants busts every server instance's cache over pub/sub. Only a write to users.role that skips the change-role route waits for the session refresh, SESSION_REFRESH_INTERVAL = 60 * 60 * 1000.

Why does the UI still show the old permissions after a workspace role change?

Because the browser is the side that is behind. usePermissions, WhenPermission and WhenWorkspaceRoles read the role from the workspace list the SDK already loaded, and nothing updates the copy in an open tab. The server already enforces the new role on every request. Call refreshWorkspaces() from useSaaSWorkspaces, or reload the page.

Does reloading the page pick up a new role?

For a workspace role, yes: a reload fetches the workspace list again. After an org role change through the console there is nothing to pick up, because the person's sessions were deleted and the reload finds them signed out. After a hand edit to users.role, the profile behind WhenRoles can come from a server cache that lasts up to 3,600 seconds.

How do I revoke someone's access right now?

Block them in the console, which deletes every session they hold and stamps credentialsChangedAt so their console token is refused. Then delete or deactivate the API tokens they created. Blocking does not evict a token's cached entry, so a key can keep working for up to 5 minutes; editing or deleting the token evicts it at once.

Ship it

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

7-day free trialNo credit card requiredCancel anytime