Access Control (RBAC)
Permission and role checks to hide or block actions a user can't perform. While AuthGuard only answers "are they logged in?", Access Control answers "can this user delete a post?" — <resource>:<action> granularity.
Why does this exist beyond AuthGuard?
AuthGuard is binary: authenticated or not. But inside a logged-in app, an editor can create posts and a reader can't; an admin sees the delete button and the rest don't. Hiding those controls in the UI (and ideally blocking them in the backend) is RBAC's job. Access Control gives you a pluggable contract (AccessControl), a ready-made role-based strategy, and the UI utilities (useCan, <Can>) to tie it together.
Frontend RBAC is UX, not security
Hiding a button does not protect the endpoint. The authorization decision that matters happens in the backend (in the tempest-fastapi-sdk). Access Control here improves the experience — it avoids showing actions that would fail with a 403 — but the server remains the source of truth.
When to use
- Hide/disable buttons and links based on permissions (
<Can>). - Branch logic by permission inside a component (
useCan). - Derive the user's permission set from the JWT (
permissionsFromToken).
Provider — <AccessControlProvider>
Wrap the app with <AccessControlProvider control={…}>, passing an AccessControl strategy. Every check (useCan, <Can>) reads from that strategy via context:
import { AccessControlProvider, createRoleAccessControl } from "tempest-react-sdk";
import { App } from "./App";
const accessControl = createRoleAccessControl({
role: "editor",
roles: {
editor: ["posts:create", "posts:update", "comments:read"],
admin: ["*"],
},
});
export function Root() {
return (
<AccessControlProvider control={accessControl}>
<App />
</AccessControlProvider>
);
}
No provider → allow all
If no <AccessControlProvider> sits above in the tree, useCan/<Can> treat every check as allowed. This keeps the SDK opt-in: you turn enforcement on by dropping in a provider, and off by removing it. Handy in dev or in apps without RBAC yet — components using <Can> keep working, they just don't block anything.
createRoleAccessControl
Builds an RBAC strategy from a static permission set. The signature is createRoleAccessControl({ permissions?, roles?, role? }):
permissions— strings granted directly, regardless of role.roles— a map ofrole name → permissionsthat role grants.role— the active role(s) (string or array). Their permissions (fromroles) are merged in.
The effective set is permissions plus, for each active role in role, the permissions listed in roles[role].
Matching rules
Each permission is a "<resource>:<action>" string (e.g. "posts:create") or a bare "<action>". The wildcards:
| Granted permission | What it allows |
|---|---|
"*" |
everything — any action on any resource |
"posts:*" |
any action on the posts resource |
"posts:create" |
exactly the create action on the posts resource |
"export" |
the global export action (a check without resource) |
import { createRoleAccessControl } from "tempest-react-sdk";
const ac = createRoleAccessControl({
role: "editor",
roles: { editor: ["posts:*", "comments:read"] },
});
ac.can({ action: "create", resource: "posts" }); // { can: true } — matches "posts:*"
ac.can({ action: "read", resource: "comments" }); // { can: true } — exact match
ac.can({ action: "delete", resource: "users" }); // { can: false, reason: "missing permission" }
Combine permissions and roles
permissions is for direct grants (a one-off override on a specific user), roles + role is for the common role-based case. The two add up — a direct permission applies even if no role grants it.
permissionsFromToken — permissions from the JWT
Instead of maintaining the permission list by hand, derive it from the user's JWT. permissionsFromToken(token, { claim }):
- Reads the configured claim (default
"permissions"). - If absent, falls back to the OAuth
"scopes"then"scope"claims. - Array claims are used as-is; string claims are split on whitespace (the OAuth
scopeconvention). - Returns
[]on any decode failure or when no recognizable claim is present.
import { createRoleAccessControl, permissionsFromToken } from "tempest-react-sdk";
const token = getAccessTokenFromSomewhere();
// Reads the "permissions" claim (default), falling back to "scopes"/"scope"
const permissions = permissionsFromToken(token);
const accessControl = createRoleAccessControl({ permissions });
permissionsFromToken does not verify the signature
It only decodes the JWT payload (via decodeJWT) to read the claim — it does not verify the signature. It's defensive reading for UX, just like decodeJWT. Trusting these permissions for real security is the backend's job.
For a custom claim (e.g. the backend emits "perms"):
const permissions = permissionsFromToken(token, { claim: "perms" });
useCan — programmatic check
useCan({ action, resource }) resolves the check against the strategy in context and returns { allowed, isLoading, reason }. It works with a synchronous can (boolean or CanResult) and with a Promise (remote policies), re-running whenever the params change:
import { useCan } from "tempest-react-sdk";
export function PostActions({ postId }: { postId: string }) {
const { allowed, isLoading } = useCan({ action: "update", resource: "posts" });
if (isLoading) return null; // async check in flight
return (
<button disabled={!allowed} onClick={() => editPost(postId)}>
Edit
</button>
);
}
reason explains the false
When allowed is false, reason usually carries why ("missing permission" from createRoleAccessControl, or the error message from an async policy that rejected). Useful for a tooltip ("You don't have permission to X") instead of just hiding.
<Can> — conditional render
<Can action resource fallback> renders children when the action is allowed, otherwise the fallback (or nothing). While an async check is pending, it renders the fallback (or nothing):
import { Can } from "tempest-react-sdk";
<Can action="create" resource="posts" fallback={<p>No permission to create.</p>}>
<NewPostButton />
</Can>;
useAccessControl — the raw strategy
When you need the strategy itself (calling can outside render, checking many
permissions in a loop, deciding a route before mounting the tree),
useAccessControl() returns the AccessControl from context — or null when
no provider is present:
import { useAccessControl } from "tempest-react-sdk";
function useBulkPermissions(ids: string[]) {
const control = useAccessControl();
return ids.filter((id) => control?.can({ action: "delete", resource: "posts", params: { id } }) ?? true);
}
null means allow, not deny
With no provider in the tree, useAccessControl() returns null — and the
SDK convention is to read that as "allow everything", same as useCan/<Can>.
Writing control?.can(...) ?? false inverts the rule and locks the whole app
in any environment that has not mounted the provider yet. The ?? true above
is not an oversight.
Complete example — a gated "Delete" button
Everything together: derive permissions from the JWT, configure the strategy, and gate the delete action both by render (<Can>) and by disabled state (useCan):
import {
AccessControlProvider,
Can,
createRoleAccessControl,
permissionsFromToken,
useCan,
} from "tempest-react-sdk";
// 1. Strategy derived from the logged-in user's JWT.
function buildAccessControl(token: string) {
return createRoleAccessControl({
// e.g. token with { permissions: ["posts:read", "posts:delete"] }
permissions: permissionsFromToken(token),
});
}
// 2. Provider at the top of the app.
export function Root({ token }: { token: string }) {
return (
<AccessControlProvider control={buildAccessControl(token)}>
<PostRow id="post-1" title="Hello world" />
</AccessControlProvider>
);
}
// 3a. Hide the action entirely with <Can>.
function PostRow({ id, title }: { id: string; title: string }) {
return (
<div>
<span>{title}</span>
<Can action="delete" resource="posts" fallback={null}>
<DeleteButton id={id} />
</Can>
</div>
);
}
// 3b. Always show, but disable + explain with useCan.
function DeleteButton({ id }: { id: string }) {
const { allowed, isLoading, reason } = useCan({ action: "delete", resource: "posts" });
return (
<button
disabled={!allowed || isLoading}
title={allowed ? "Delete post" : reason}
onClick={() => deletePost(id)}
>
Delete
</button>
);
}
<Can> hides; useCan disables
Use <Can> when the action simply shouldn't exist for those who can't perform it (clean UI). Use useCan when you want to keep the control visible but inert, with a title/tooltip explaining why — useful for discoverability ("this exists, but needs another role").
Recap
<AccessControlProvider control={…}>injects anAccessControlstrategy; with no provider, every check allows (opt-in).createRoleAccessControl({ permissions, roles, role })builds static RBAC; matching by"<resource>:<action>", with wildcards"*"(everything) and"<resource>:*"(whole resource).permissionsFromToken(token, { claim })reads permissions from the JWT (default claim"permissions", fallback"scopes"/"scope"); it does not verify the signature.useCan({ action, resource })→{ allowed, isLoading, reason }, supporting both sync and async checks.<Can action resource fallback>hides UI;useCandisables and explains withreason.- Frontend RBAC is UX — the backend remains the source of truth for authorization.
See also
- Auth + Guard —
AuthGuard(authenticated?) anddecodeJWT/isJWTExpired - Data Provider — gate CRUD actions by role/permission