@simplix-react/access
v0.2.2
Published
Authorization and access control for @simplix-react with CASL integration
Readme
@simplix-react/access
Opt-in authorization and access control powered by CASL.
Define who can do what. Derive everything else.
Features
- Adapter-based — JWT, REST API, or static rules. Bring your own auth source.
- Framework-agnostic core — Works without React. Add
/reactfor bindings. - Opt-in — No
AccessProvider? Permission checks default totrue. - Super-admin bypass — Flag or custom checker grants
manage:all. - Persistence — Persist and rehydrate access state across page reloads.
- Auth auto-sync — Subscribes to
@simplix-react/authstate changes automatically. - Type-safe — Generic
TActionsandTSubjectsparameters narrow permissions.
Installation
pnpm add @simplix-react/access @casl/abilityReact bindings require react >= 18:
pnpm add reactFor auto-sync with @simplix-react/auth:
pnpm add @simplix-react/authQuick Example
import { createAccessPolicy, createApiAdapter } from "@simplix-react/access";
const policy = createAccessPolicy({
adapter: createApiAdapter({
endpoint: "/api/v1/user/me/permissions",
}),
});
// After login
await policy.update(null);
policy.can("view", "Pet"); // true
policy.cannot("delete", "Pet"); // true
policy.hasRole("ADMIN"); // trueWith React:
import { AccessProvider, useCan, Can } from "@simplix-react/access/react";
function App() {
return (
<AccessProvider policy={policy} auth={auth}>
<PetList />
</AccessProvider>
);
}
function PetList() {
const canCreate = useCan("create", "Pet");
return (
<div>
{canCreate && <CreateButton />}
<Can I="delete" a="Pet">
<DeleteButton />
</Can>
</div>
);
}Exports
The package provides two entry points:
| Entry | Import path | Requires React |
| --- | --- | --- |
| Core | @simplix-react/access | No |
| React | @simplix-react/access/react | Yes |
API Overview
Core (@simplix-react/access)
| Export | Kind | Description |
| --- | --- | --- |
| createAccessPolicy | Function | Creates an AccessPolicy backed by CASL |
| createJwtAdapter | Function | Extracts rules from JWT claims |
| createApiAdapter | Function | Fetches rules from a REST endpoint |
| createStaticAdapter | Function | Returns hardcoded rules (testing/dev) |
| normalizePermissionMap | Function | { Resource: [actions] } to CASL rules |
| normalizeFlatPermissions | Function | ["RESOURCE:action"] to CASL rules |
| normalizeScopePermissions | Function | OAuth2 scope string to CASL rules |
| normalizeRole | Function | Ensures ROLE_ prefix |
| hasRole | Function | Checks role with prefix normalization |
| hasAnyRole | Function | Checks if any role matches |
| AccessDeniedError | Class | Thrown by requireAccess on failure |
React (@simplix-react/access/react)
| Export | Kind | Description |
| --- | --- | --- |
| AccessProvider | Component | Provides policy to the tree, syncs with auth |
| useCan | Hook | Returns boolean for a permission check |
| useAccess | Hook | Returns the full AccessPolicy object |
| Can | Component | Declarative permission guard |
| requireAccess | Function | Route guard for TanStack Router beforeLoad |
| useMenuFilter | Hook | Recursively filters menu trees by permission |
Types
| Type | Description |
| --- | --- |
| AccessPolicy | Main policy interface (can, cannot, user, roles, ...) |
| AccessPolicyConfig | Configuration for createAccessPolicy |
| AccessAdapter | Adapter interface with extract(authData) method |
| AccessExtractResult | Return type of extract |
| AccessRule | CASL raw rule (action + subject) |
| AccessUser | User info (userId, username, roles, isSuperAdmin) |
| AccessSnapshot | Serializable state snapshot |
| PermissionMap | { [resource]: string[] } map |
| DefaultActions | "list" \| "view" \| "create" \| "edit" \| "delete" \| ... |
| AuthLike | Minimal auth interface for auto-sync |
| CanProps | Props for Can component |
| RouteGuardOptions | Options for requireAccess |
Key Concepts
AccessPolicy
The central object managing CASL ability, user info, and roles.
const policy = createAccessPolicy({
adapter, // Required — how to extract rules
publicAccess, // Optional — rules for unauthenticated users
isSuperAdmin, // Optional — custom super-admin checker
persist, // Optional — localStorage persistence
});Methods:
| Method | Description |
| --- | --- |
| can(action, subject) | Returns true if the action is allowed |
| cannot(action, subject) | Inverse of can |
| hasRole(role) | Checks role with ROLE_ prefix normalization |
| hasAnyRole(roles) | Checks if the user has any of the given roles |
| update(authData) | Extracts rules via adapter and applies them |
| setRules(rules, user?, roles?) | Directly sets rules without adapter |
| clear() | Resets all state and clears persistence |
| loadPublicAccess() | Loads unauthenticated access rules |
| rehydrate() | Restores state from persisted storage |
| subscribe(listener) | Subscribes to state changes (returns unsubscribe) |
| getSnapshot() | Returns serializable state for useSyncExternalStore |
Properties:
| Property | Type | Description |
| --- | --- | --- |
| ability | AccessAbility | Underlying CASL MongoAbility |
| user | AccessUser \| null | Current user info |
| roles | string[] | Current role names |
Adapters
Adapters bridge auth sources and the CASL-based policy. All adapters implement the AccessAdapter interface:
interface AccessAdapter {
extract(authData: unknown): Promise<AccessExtractResult>;
}Built-in adapters:
createJwtAdapter
Extracts permissions directly from JWT claims. No server round-trip required.
const adapter = createJwtAdapter({
permissionFormat: "map", // "map" | "flat" | "scopes"
permissionsKey: "permissions", // JWT claim key
rolesKey: "roles",
userIdKey: "sub",
usernameKey: "username",
superAdminKey: "isSuperAdmin",
});Permission formats:
| Format | JWT claim shape | Example |
| --- | --- | --- |
| "map" | { "Pet": ["list", "view"] } | Spring Security PermissionMap |
| "flat" | ["PET:list", "PET:view"] | Spring Security authorities |
| "scopes" | "pet:read pet:write" | OAuth2 scopes |
createApiAdapter
Fetches permissions from a REST endpoint.
const adapter = createApiAdapter({
endpoint: "/api/v1/user/me/permissions",
fetchFn: auth.fetchFn, // Injects auth headers automatically
transformResponse: (res) => ({
permissions: (res as any).data.body.permissions,
roles: (res as any).data.body.roles,
}),
});The default response format is { permissions: PermissionMap }. Use transformResponse to handle custom server responses.
createStaticAdapter
Returns fixed rules. Useful for testing and defining public access.
const adapter = createStaticAdapter(
[{ action: "view", subject: "Pet" }],
{ userId: "anon", username: "anonymous", roles: [] },
);Permission Normalization
Three helpers convert common permission formats into CASL rules:
// { Resource: [actions] } → CASL rules
normalizePermissionMap({ Pet: ["list", "view"] });
// → [{ action: "list", subject: "Pet" }, { action: "view", subject: "Pet" }]
// ["RESOURCE:action"] → CASL rules
normalizeFlatPermissions(["PET:list", "PET:view"]);
// → [{ action: "list", subject: "PET" }, { action: "view", subject: "PET" }]
// OAuth2 scopes → CASL rules
normalizeScopePermissions("pet:read pet:write");
// → [{ action: "read", subject: "pet" }, { action: "write", subject: "pet" }]Super-Admin Bypass
When a user is identified as a super-admin, the policy grants manage:all (all actions on all subjects).
Two detection methods:
// 1. AccessUser.isSuperAdmin flag (default)
const user: AccessUser = { userId: "1", username: "admin", roles: [], isSuperAdmin: true };
// 2. Custom checker function
const policy = createAccessPolicy({
adapter,
isSuperAdmin: (user) => user.roles.includes("ROLE_SYSTEM_ADMIN"),
});Persistence
Persist access state to localStorage (or any Storage-compatible backend) for instant availability on page reload.
const policy = createAccessPolicy({
adapter,
persist: {
storage: localStorage, // Default
key: "simplix-access", // Default
},
});
// Restore on app startup
const restored = policy.rehydrate(); // true if state was foundReact Bindings
AccessProvider
Provides the policy to the component tree and optionally auto-syncs with auth.
import { createAccessPolicy, createApiAdapter } from "@simplix-react/access";
import { AccessProvider } from "@simplix-react/access/react";
const policy = createAccessPolicy({
adapter: createApiAdapter({ endpoint: "/api/v1/user/me/permissions" }),
persist: { storage: localStorage },
});
function App() {
return (
<AccessProvider policy={policy} auth={auth}>
<Router />
</AccessProvider>
);
}Auth auto-sync behavior:
| Auth event | Policy action |
| --- | --- |
| Login (authenticated) | policy.update(accessToken) |
| Logout (unauthenticated) | policy.clear() then policy.loadPublicAccess() |
| Mount | policy.rehydrate() then initial sync |
The auth prop accepts any object implementing the AuthLike interface:
interface AuthLike {
isAuthenticated(): boolean;
getAccessToken(): string | null;
subscribe(listener: () => void): () => void;
}This avoids a hard dependency on @simplix-react/auth. Any auth implementation that satisfies this interface works.
useCan
Returns a boolean for a single permission check. Reactively updates when the policy changes.
function EditButton() {
const canEdit = useCan("edit", "Pet");
if (!canEdit) return null;
return <button>Edit</button>;
}Opt-in behavior: When there is no AccessProvider in the tree, useCan returns true. This allows gradual adoption — access control is applied only where you wire it up.
useAccess
Returns the full AccessPolicy object. Throws if used outside an AccessProvider.
function UserBadge() {
const access = useAccess();
return (
<span>
{access.user?.displayName} ({access.roles.join(", ")})
</span>
);
}Can
Declarative permission guard component.
<Can I="edit" a="Pet">
<EditButton />
</Can>
<Can I="delete" a="Pet" fallback={<span>No permission</span>}>
<DeleteButton />
</Can>
{/* Invert: render when permission is ABSENT */}
<Can I="manage" a="all" not>
<span>Limited access</span>
</Can>| Prop | Type | Description |
| --- | --- | --- |
| I | string | Action to check |
| a | string | Subject to check |
| not | boolean | Invert the check |
| fallback | ReactNode | Rendered when check fails |
| children | ReactNode | Rendered when check passes |
useMenuFilter
Recursively filters a menu tree based on permissions. Items without a permission field are always shown. If all children of a parent are filtered out, the parent is also removed.
const menu = [
{ label: "Dashboard" },
{
label: "Management",
children: [
{ label: "Pets", permission: { action: "list", subject: "Pet" } },
{ label: "Orders", permission: { action: "list", subject: "Order" } },
],
},
];
function Sidebar() {
const filtered = useMenuFilter(menu);
return filtered.map((item) => <MenuItem key={item.label} {...item} />);
}The FilterableMenuItem interface:
interface FilterableMenuItem {
permission?: { action: string; subject: string };
children?: FilterableMenuItem[];
[key: string]: unknown;
}requireAccess (Route Guard)
Checks a permission synchronously. Throws AccessDeniedError on failure. Designed for TanStack Router beforeLoad.
import { requireAccess } from "@simplix-react/access/react";
export const Route = createFileRoute("/admin")({
beforeLoad: () => {
requireAccess(policy, {
action: "view",
subject: "BACKOFFICE_ACCESS",
});
},
});Handling the rehydration race condition:
TanStack Router beforeLoad runs before React mounts, which means AccessProvider has not yet called rehydrate(). Use storageFallback to check persisted state directly:
requireAccess(policy, {
action: "view",
subject: "BACKOFFICE_ACCESS",
storageFallback: {
key: "simplix-access",
check: (stored) => {
const data = stored as { rules?: Array<{ action: string; subject: string }> };
return data.rules?.some(
(r) => r.action === "view" && r.subject === "BACKOFFICE_ACCESS",
) ?? false;
},
},
});Advanced Examples
Spring Security JWT Integration
Spring Security backend returning permissions in JWT claims:
import { createAccessPolicy, createJwtAdapter } from "@simplix-react/access";
const policy = createAccessPolicy({
adapter: createJwtAdapter({
permissionFormat: "map",
permissionsKey: "permissions", // { "PET": ["list", "view"], "ORDER": ["list"] }
rolesKey: "roles", // ["ROLE_ADMIN", "ROLE_USER"]
superAdminKey: "isSuperAdmin",
}),
isSuperAdmin: (user) => user.roles.includes("ROLE_SYSTEM_ADMIN"),
persist: { storage: localStorage },
});Spring Security REST API Integration
Fetching permissions from a dedicated endpoint:
import { createAccessPolicy, createApiAdapter } from "@simplix-react/access";
const policy = createAccessPolicy({
adapter: createApiAdapter({
endpoint: "/api/v1/user/me/permissions",
fetchFn: auth.fetchFn,
transformResponse: (res) => {
const data = res as {
permissions: Record<string, string[]>;
roles: string[];
isSuperAdmin: boolean;
};
return {
permissions: data.permissions,
roles: data.roles,
isSuperAdmin: data.isSuperAdmin,
};
},
}),
persist: { storage: localStorage },
});Public Access Rules
Allow unauthenticated users to access specific resources:
// Static public rules
const policy = createAccessPolicy({
adapter,
publicAccess: [
{ action: "list", subject: "Pet" },
{ action: "view", subject: "Pet" },
],
});
// Or fetch from server
const policy = createAccessPolicy({
adapter,
publicAccess: createApiAdapter({
endpoint: "/api/v1/public/user/permissions",
}),
});
// Load on app init (before login)
await policy.loadPublicAccess();Full Provider Setup with Auth Sync
import { createAuth, bearerScheme } from "@simplix-react/auth";
import { createAccessPolicy, createApiAdapter } from "@simplix-react/access";
import { AccessProvider } from "@simplix-react/access/react";
const auth = createAuth({
scheme: bearerScheme({ loginPath: "/api/auth/login" }),
});
const policy = createAccessPolicy({
adapter: createApiAdapter({
endpoint: "/api/v1/user/me/permissions",
fetchFn: auth.fetchFn,
}),
publicAccess: createApiAdapter({
endpoint: "/api/v1/public/user/permissions",
}),
isSuperAdmin: (user) => user.roles.includes("ROLE_SYSTEM_ADMIN"),
persist: { storage: localStorage },
});
function App() {
return (
<AuthProvider auth={auth}>
<AccessProvider policy={policy} auth={auth}>
<Router />
</AccessProvider>
</AuthProvider>
);
}Custom Adapter
Implement AccessAdapter for any auth source:
import type { AccessAdapter, AccessExtractResult } from "@simplix-react/access";
const customAdapter: AccessAdapter = {
async extract(authData: unknown): Promise<AccessExtractResult> {
const session = authData as { token: string };
const res = await fetch("/api/permissions", {
headers: { Authorization: `Bearer ${session.token}` },
});
const data = await res.json();
return {
rules: data.permissions.map((p: any) => ({
action: p.action,
subject: p.resource,
})),
user: {
userId: data.user.id,
username: data.user.name,
roles: data.user.roles,
},
roles: data.user.roles,
};
},
};Role Checking
Role utilities normalize the ROLE_ prefix automatically:
policy.hasRole("ADMIN"); // checks against "ROLE_ADMIN"
policy.hasRole("ROLE_ADMIN"); // same check
policy.hasAnyRole(["ADMIN", "MANAGER"]); // true if user has eitherType-Safe Permissions
Narrow action and subject types with generics:
type MyActions = "list" | "view" | "create" | "edit" | "delete";
type MySubjects = "Pet" | "Order" | "User";
const policy = createAccessPolicy<MyActions, MySubjects>({
adapter: createJwtAdapter(),
});
policy.can("view", "Pet"); // OK
policy.can("view", "all"); // OK (wildcard always allowed)
// policy.can("fly", "Pet"); // Type error: "fly" is not in MyActions
// policy.can("view", "Rocket"); // Type error: "Rocket" is not in MySubjectsError Handling
AccessDeniedError
Thrown by requireAccess when a permission check fails.
import { AccessDeniedError } from "@simplix-react/access";
try {
requireAccess(policy, { action: "delete", subject: "Pet" });
} catch (error) {
if (error instanceof AccessDeniedError) {
console.log(error.action); // "delete"
console.log(error.subject); // "Pet"
console.log(error.message); // 'Access denied: cannot "delete" on "Pet"'
}
}In TanStack Router, catch this in a route's onError or a global error boundary to redirect unauthorized users.
Testing
Use @simplix-react/testing utilities:
import { createMockPolicy } from "@simplix-react/testing";
import { createAccessTestWrapper } from "@simplix-react/testing";
// Default: allows everything (manage:all)
const policy = createMockPolicy();
// Restrict to specific rules
const policy = createMockPolicy({
allowAll: false,
rules: [{ action: "view", subject: "Pet" }],
user: { userId: "1", username: "test", roles: ["ROLE_USER"] },
});
// Wrap components for testing
const wrapper = createAccessTestWrapper({ policy });
render(<MyComponent />, { wrapper });Related Packages
| Package | Relationship |
| --- | --- |
| @simplix-react/auth | Optional. Provides AuthLike for auto-sync. |
| @simplix-react/testing | Provides createMockPolicy and createAccessTestWrapper. |
| @casl/ability | Required peer. Powers permission evaluation engine. |
