Access control is critical for securing enterprise applications. Refine provides a flexible and agnostic API through the accessControlProvider that allows you to integrate different access control methods like RBAC (Role-Based Access Control), ABAC (Attribute-Based Access Control), ACL (Access Control Lists), and more.
Access Control Provider
The accessControlProvider is the core interface for managing access control in Refine. At minimum, it must implement a can method:
import { AccessControlProvider } from "@refinedev/core";
export const accessControlProvider: AccessControlProvider = {
can: async ({ resource, action, params }) => {
// Your access control logic
return { can: true };
},
options: {
buttons: {
enableAccessControl: true,
hideIfUnauthorized: false,
},
},
};
Parameters
resource: The resource being accessed (e.g., “posts”, “users”)
action: The action being performed (e.g., “list”, “create”, “edit”, “delete”, “show”)
params: Additional parameters including:
id: The record ID (for edit, show, delete actions)
resource: The full resource object with metadata
- Custom parameters passed via
meta
Return Value
interface CanReturnType {
can: boolean;
reason?: string; // Optional reason for denial (shown in tooltips)
}
Integrating with Casbin
Casbin is a powerful authorization library that supports various access control models. Here’s how to integrate it with Refine:
Installation
Setting up Models and Policies
import { newModel, StringAdapter, newEnforcer } from "casbin";
// Define the access control model
export const model = newModel(`
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act, eft
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
[matchers]
m = g(r.sub, p.sub) && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)
`);
// Define policies
export const adapter = new StringAdapter(`
p, admin, posts, (list)|(create)
p, admin, posts/*, (edit)|(show)|(delete)
p, admin, users, (list)|(create)
p, admin, users/*, (edit)|(show)|(delete)
p, editor, posts, (list)|(create)
p, editor, posts/*, (edit)|(show)
p, editor, posts/hit, field, deny
p, viewer, posts, list
p, viewer, posts/*, show
`);
Implementing the Access Control Provider
import { AccessControlProvider } from "@refinedev/core";
import { newEnforcer } from "casbin";
import { model, adapter } from "./accessControl";
export const accessControlProvider: AccessControlProvider = {
can: async ({ resource, action, params }) => {
const enforcer = await newEnforcer(model, adapter);
const role = localStorage.getItem("role") ?? "viewer";
// Handle ID-based access (edit, show, delete)
if (action === "delete" || action === "edit" || action === "show") {
const can = await enforcer.enforce(
role,
`${resource}/${params?.id}`,
action
);
return { can };
}
// Handle field-level access
if (action === "field") {
const can = await enforcer.enforce(
role,
`${resource}/${params?.field}`,
action
);
return { can };
}
// Handle standard resource actions
const can = await enforcer.enforce(role, resource, action);
return { can };
},
};
Role-Based Access Control (RBAC)
RBAC assigns permissions based on user roles:
type Role = "admin" | "editor" | "viewer";
const rolePermissions: Record<Role, string[]> = {
admin: ["posts:*", "users:*", "categories:*"],
editor: ["posts:list", "posts:create", "posts:edit", "posts:show"],
viewer: ["posts:list", "posts:show"],
};
export const accessControlProvider: AccessControlProvider = {
can: async ({ resource, action, params }) => {
const role = getUserRole(); // Get from auth context
const permissions = rolePermissions[role] || [];
const permission = `${resource}:${action}`;
const wildcardPermission = `${resource}:*`;
const can = permissions.includes(permission) ||
permissions.includes(wildcardPermission);
return {
can,
reason: can ? undefined : `Role '${role}' cannot ${action} ${resource}`,
};
},
};
Attribute-Based Access Control (ABAC)
ABAC grants permissions based on attributes of the user, resource, and environment:
export const accessControlProvider: AccessControlProvider = {
can: async ({ resource, action, params }) => {
const user = await getUser();
// Example: Users can only edit their own posts
if (resource === "posts" && action === "edit") {
const post = await dataProvider.getOne({
resource: "posts",
id: params?.id,
});
return {
can: post.data.authorId === user.id,
reason: "You can only edit your own posts",
};
}
// Example: Users in the same department can view each other's data
if (resource === "users" && action === "show") {
const targetUser = await dataProvider.getOne({
resource: "users",
id: params?.id,
});
return {
can: targetUser.data.department === user.department,
reason: "You can only view users in your department",
};
}
return { can: true };
},
};
Field-Level Access Control
Control access to specific fields within a resource:
// In your access control provider
if (action === "field") {
const enforcer = await newEnforcer(model, adapter);
const role = getUserRole();
const can = await enforcer.enforce(
role,
`${resource}/${params?.field}`,
action
);
return { can };
}
// In your component
import { useCan } from "@refinedev/core";
const PostList = () => {
const { tableProps } = useTable();
const { data: canAccessHit } = useCan({
resource: "posts",
action: "field",
params: { field: "hit" },
});
return (
<Table {...tableProps}>
<Table.Column dataIndex="id" title="ID" />
<Table.Column dataIndex="title" title="Title" />
{canAccessHit?.can && (
<Table.Column
dataIndex="hit"
title="Views"
render={(value) => <NumberField value={value} />}
/>
)}
</Table>
);
};
Using useCan Hook
The useCan hook allows you to check permissions anywhere in your components:
import { useCan } from "@refinedev/core";
const MyComponent = () => {
const { data } = useCan({
resource: "posts",
action: "delete",
params: { id: 123 },
});
if (!data?.can) {
return <div>Access Denied: {data?.reason}</div>;
}
return <DeleteButton recordItemId={123} />;
};
Caching Permissions
Use React Query options to cache permission checks:
const { data } = useCan({
resource: "posts",
action: "edit",
params: { id: 123 },
queryOptions: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
},
});
Using <CanAccess /> Component
Wrap components that require access control:
import { CanAccess } from "@refinedev/core";
const PostEdit = () => {
return (
<CanAccess
resource="posts"
action="edit"
params={{ id: 123 }}
fallback={<div>You don't have permission to edit this post</div>}
>
<EditForm />
</CanAccess>
);
};
Refine automatically controls button visibility and state based on permissions:
import { EditButton, DeleteButton, ShowButton } from "@refinedev/antd";
const PostList = () => {
return (
<List>
<Table>
<Table.Column
title="Actions"
render={(_, record) => (
<Space>
{/* These buttons will be disabled/hidden based on permissions */}
<EditButton recordItemId={record.id} />
<ShowButton recordItemId={record.id} />
<DeleteButton recordItemId={record.id} />
</Space>
)}
/>
</Table>
</List>
);
};
export const accessControlProvider: AccessControlProvider = {
can: async ({ resource, action, params }) => {
// Your logic
},
options: {
buttons: {
enableAccessControl: true,
hideIfUnauthorized: true, // Hide instead of disable
},
},
};
Button-level access control is a UI convenience. Always enforce access control on the server side to ensure security.
Resources without list permission won’t appear in the sidebar:
// This resource won't show in the menu if user lacks 'list' permission
const resources = [
{
name: "posts",
list: "/posts",
create: "/posts/create",
},
];
Integrating with Cerbos
Cerbos is a powerful authorization service:
import { GRPC as Cerbos } from "@cerbos/grpc";
const cerbos = new Cerbos("localhost:3593", { tls: false });
export const accessControlProvider: AccessControlProvider = {
can: async ({ resource, action, params }) => {
const user = await getUser();
const decision = await cerbos.check({
principal: {
id: user.id,
roles: user.roles,
attr: user.attributes,
},
resource: {
kind: resource,
id: params?.id || "new",
},
actions: [action],
});
return {
can: decision.isAllowed(action),
};
},
};
Use resource metadata for fine-grained control:
const resources = [
{
name: "posts",
list: "/posts",
meta: {
requiredRole: "editor",
department: "content",
},
},
];
export const accessControlProvider: AccessControlProvider = {
can: async ({ resource, action, params }) => {
const resourceMeta = params?.resource?.meta;
const user = await getUser();
if (resourceMeta?.requiredRole) {
return {
can: user.roles.includes(resourceMeta.requiredRole),
reason: `Requires ${resourceMeta.requiredRole} role`,
};
}
return { can: true };
},
};
- Cache permission checks: Use React Query’s caching
- Batch permission checks: Check multiple permissions at once
- Lazy load permissions: Load permissions only when needed
- Server-side caching: Cache permission decisions on the server
// Example: Batch permission checks
const { data: permissions } = useQuery({
queryKey: ["permissions", resource],
queryFn: async () => {
const actions = ["list", "create", "edit", "delete", "show"];
const results = await Promise.all(
actions.map(action =>
accessControlProvider.can({ resource, action, params: {} })
)
);
return actions.reduce((acc, action, index) => {
acc[action] = results[index].can;
return acc;
}, {} as Record<string, boolean>);
},
staleTime: 5 * 60 * 1000,
});
Best Practices
- Server-side validation: Always validate permissions on the server
- Fail securely: Default to denying access if unsure
- Audit logging: Log all access control decisions
- Clear error messages: Provide helpful denial reasons
- Test thoroughly: Test all permission combinations
- Document policies: Keep clear documentation of access rules
- Use constants: Define permission strings as constants
export const PERMISSIONS = {
POSTS_CREATE: "posts:create",
POSTS_EDIT: "posts:edit",
POSTS_DELETE: "posts:delete",
POSTS_LIST: "posts:list",
POSTS_SHOW: "posts:show",
} as const;
Further Reading