Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(/basic-rbac) add middleware, reorg and refactor copy #1560

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 70 additions & 64 deletions docs/guides/basic-rbac.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,35 @@ title: Implement basic Role Based Access Control (RBAC) with metadata
description: Learn how to leverage Clerk's publicMetadata to implement your own basic Role Based Access Controls.
---

To control which users can access certain parts of your application, you can leverage Clerk's [roles](/docs/organizations/roles-permissions#roles) feature. Although Clerk offers a roles feature as part of the feature set for [organizations](/docs/organizations/overview), not every app implements organizations. This guide will cover a workaround for setting up a basic Role Based Access Control (RBAC) system for products that don't use Clerk's organizations or roles.
To control which users can access certain parts of your application, you can leverage Clerk's [roles](/docs/organizations/roles-permissions#roles) feature. Although Clerk offers a roles feature as part of the feature set for [organizations](/docs/organizations/overview), not every app implements organizations. This guide covers a workaround to set up a basic Role Based Access Control (RBAC) system for products that don't use Clerk's organizations or roles.

This guide assumes that you are using Next.js App Router. The concepts here can be adapted to Next.js Pages Router and Remix.
This guide assumes that you're using Next.js App Router. The concepts here can be adapted to Next.js Pages Router and Remix.

## Configure the session token

Clerk provides [user metadata](/docs/users/metadata#user-metadata), which is a tool that can be leveraged to build flexible custom logic into your application. Metadata can be used to store information, and in this case, it can be used to store a user's role.
Clerk provides [user metadata](/docs/users/metadata#user-metadata), a tool that can be leveraged to build flexible custom logic into your application. Metadata can be used to store information, and in this case, it can be used to store a user's role.

`unsafeMetadata` can be read and updated in the browser, and because the user could modify this metadata it should be treated as unsafe and validated by your application before trusting it. `privateMetadata` can not be read or modified in the browser. `publicMetadata` can be read by the browser and can only be updated server-side or in the Clerk Dashboard, making it the safest and best choice for this use case.
`unsafeMetadata` can be read and updated in the browser. Since users can modify this metadata, it should be considered untrusted and must be validated by your application before use. `privateMetadata` can not be accessed or modified in the browser. `publicMetadata` can be read by the browser and can only be updated server-side or in the Clerk Dashboard, making it the safest and best choice for this use case.
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved

To build a basic RBAC system, first, you need to make `publicMetadata` available to the application directly from the session token. With `publicMetadata` attached directly to the user's session, a fetch and network request isn't required every time you need to access the data.
To build a basic RBAC system, you first need to make `publicMetadata` available to the application directly from the session token. By attaching `publicMetadata` to the user's session, you can access the data without needing to make a fetch or network request each time.

In the Clerk Dashboard, navigate to [**Sessions**](https://dashboard.clerk.com/last-active?path=sessions). In the **Customize session token** section, select **Edit**. In the modal that opens, enter the following JSON. If you have already customized your session token, you may need to merge this with what you currently have.
1. In the Clerk Dashboard, navigate to [**Sessions**](https://dashboard.clerk.com/last-active?path=sessions).
1. Under the **Customize session token** section, select **Edit**.
1. In the modal that opens, enter the following JSON and select **Save**. If you have already customized your session token, you may need to merge this with what you currently have.

```json
{
"metadata": "{{user.public_metadata}}"
}
```

![The Sessions page in the Clerk Dashboard with the 'Customize session token' modal opened. The modal has a text field with the JSON 'metadata': '\{\{user.public\_metadata}}'.](/docs/images/guides/basic-rbac/customize-session-token.webp)

> [!CAUTION]
> The entire session token has a limit of 4kb of data. Exceeding this size can have adverse effects, including a possible infinite redirect loop for users who exceed this size in Next.js applications.
> It's recommended to move particularly large claims out of the JWT and fetch these using a separate API call from your backend.
> The session token has a 4KB size limit. Exceeding this limit can have adverse effects, such as an infinite redirect loop for users in Next.js applications.
> To avoid this, it's recommended to move large claims out of the JWT and fetch them via a separate API call from your backend.

## Provide a global TypeScript definition

In your application's root folder, add a `types` directory. Inside of the `types` directory, add a `globals.d.ts` file. This file will provide auto-complete, prevent TypeScript errors when working with the role, and control the roles that are allowed in the application. For this guide, only an `admin` and `moderator` role will be defined.
In your application's root folder, create a `types` directory. Inside this directory, add a `globals.d.ts` file. This file will provide auto-completion, prevent TypeScript errors when working with roles, and control which roles are allowed in the application. For this guide, only the `admin` and `moderator` roles will be defined.

```ts {{ filename: 'types/globals.d.ts' }}
export {}
Expand All @@ -47,48 +47,53 @@ declare global {

## Set the admin role for your user

Later, you will add a basic admin tool to change the user's role, but for now, let's manually add a role to your own user account. In the Clerk Dashboard, navigate to [**Users**](https://dashboard.clerk.com/last-active?path=users) and select your own user account. Scroll down to the **Metadata** section and next to the **Public** option, select **Edit**. Add the following JSON and select **Save**.
Later in the, you will add a basic admin tool to change a user's role. For now, let's manually add a role to your own user account.

1. In the Clerk Dashboard, navigate to [**Users**](https://dashboard.clerk.com/last-active?path=users) and select your own user account.
1. Scroll down to the **User metadata** section and next to the **Public** option, select **Edit**.
1. Add the following JSON and select **Save**.

```json
{
"role": "admin"
}
```

![The Users page in the Clerk Dashboard with the 'Edit public metadata' modal open. The modal has a text field with the JSON 'role': 'admin'.](/docs/images/guides/basic-rbac/edit-public-metadata.webp)
## Create and protect an admin dashboard

## Create an admin dashboard and protect it
Now that your user has the `admin` role, let's build an admin dashboard manage user roles easily.

Now that your user has the `admin` role, let's build an admin dashboard to help you easily change users' roles. In your `app/` directory, create an `admin/` folder. Within the `admin/` folder, create a `dashboard/` folder. In the `dashboard/` folder, create a file named `page.tsx`. Copy the following code and paste it into the file.
1. In your `app/` directory, create an `admin/` folder.
1. Inside the `admin/` folder, create a `dashboard/` folder
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
1. In the `dashboard/` folder, create a file named `page.tsx`.
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
1. Copy the following code and paste it into the file.

You want the dashboard to only be available to users with the `admin` role. [As you configured earlier](#configure-the-session-token), a user's role can be found in the metadata stored in the session token. You can access the session token's claims using Clerk's `auth()` hook.
You want the dashboard to be accessible only to users with the `admin` role. As configured earlier in the [Configure the session token](#configure-the-session-token) section, a user's role is stored in the metadata within the session token. You can access the session token's claims using Clerk's `auth()` hook.

```tsx {{ filename: 'app/admin/dashboard/page.tsx' }}
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default function AdminDashboard() {
export default async function AdminDashboard() {
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
const { sessionClaims } = auth()

// If the user does not have the admin role, redirect them to the home page
if (sessionClaims?.metadata.role !== 'admin') {
redirect('/')
}

return (
<>
<h1>This is the admin dashboard</h1>
<p>This page is restricted to users with the 'admin' role.</p>
</>
)
return <p>This is the protected admin dashboard restricted to users with the `admin` role.</p>
}
```

The `/admin/dashboard` route now requires the user to sign into the application. It also requires that the user have a `publicMetadata` of `{"role": "admin" }`.
The `/admin/dashboard` route now requires the user to sign in to access the application. Additionally, users must have a `publicMetadata` value of `{"role": "admin"}` to access this route.
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved

## Create a reusable function to check roles

Let's create a helper function to make checking roles easier. The first step is modifying `globals.d.ts`. Create a `type` for `Roles` so that the union type for the roles can be used in other places in the application. Then, modify the interface you previously added to use the new `Roles` type.
Let's create a helper function to simplify checking roles. Start by modifying the `globals.d.ts` file.

1. Define a `Roles` type so that the union type for roles can be reused throughout the application.
1. Update the interface you previously created to use the new `Roles` type.

```ts {{ filename: 'types/globals.d.ts', mark: [[3, 4], 9] }}
export {}
Expand All @@ -105,55 +110,57 @@ declare global {
}
```

The next step is creating the helper function. Create the `utils/` directory and inside, add the file `roles.ts`. In the `roles.ts` file, add the following code.
The next step is to create the helper function for checking roles.

1. Create a `utils/` directory in your application's root folder.
1. Inside this directory, add a `roles.ts` file.
1. Add the following code to `roles.ts`.

```ts {{ filename: 'utils/roles.ts' }}
import { Roles } from '@/types/globals'
import { Roles } from '../types/globals'
import { auth } from '@clerk/nextjs/server'

export const checkRole = (role: Roles) => {
const { sessionClaims } = auth()

return sessionClaims?.metadata.role === role
}
```

This `checkRole()` helper will accept a role using the `Roles` type and will return `true` if the user has that role, or `false` if the user does not.
This `checkRole()` helper will accept a role of type `Roles` and return `true` if the user has that role, or `false` if the they do not.

Next, refactor the admin dashboard to use the `checkRole()` helper.

Next, the admin dashboard can be refactored to use the `checkRole()` helper. Navigate back to the admin dashboard file. In the `if()` statement, remove the check that was used previously and replace it with the new `checkRole()` helper with `"admin"` as the argument.
1. Navigate back to the admin dashboard file.
1. In the `if()` statement, remove the previous role check.
1. Replace it with the new `checkRole()` helper, passing `"admin"` as the argument.

```tsx {{ filename: 'app/admin/dashboard/page.tsx', mark: [2, [5, 8]] }}
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
import { redirect } from 'next/navigation'
import { checkRole } from '@/utils/roles'

export default function AdminDashboard() {
export default async function AdminDashboard() {
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
// If the user does not have the admin role, redirect them to the home page
if (!checkRole('admin')) {
redirect('/')
}

return (
<>
<h1>This is the admin dashboard</h1>
<p>This page is restricted to users with the 'admin' role.</p>
</>
)
return <p>This is the protected admin dashboard restricted to users with the `admin` role.</p>
}
```

> [!NOTE]
> You can modify the behavior of the helper function to meet your needs. Maybe it will return the roles that the user has, or you could create a `protectByRole()` and have that function handle the redirect.
> You can customize the behavior of the `checkRole()` helper function to suit your needs. For example, you could modify it to return the roles a user has or create a `protectByRole()` function that handles role-based redirects.

## Add admin tools to find users and add roles

You can leverage the `checkRole()` function you added along with server actions to build basic tools for finding users and managing roles.
You can use the `checkRole()` function along with server actions to build basic tools for finding users and managing roles.

Start with the server action. The `setRole()` action below will check the role of the current user using the `checkRoles()` helper to confirm that the user is an `admin`. It will then set the role for the selected user to the specified role.
Start with the server action. The following `setRole()` action will first check the role of the current user using the `checkRoles()` helper to confirm that they are an `admin`. It will then set the specified role for the selected user.

```ts {{ filename: 'app/admin/dashboard/_actions.ts' }}
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
'use server'

import { checkRole } from '@/utils/roles'
import { checkRole } from '../../../utils/roles'
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
import { clerkClient } from '@clerk/nextjs/server'

export async function setRole(formData: FormData) {
Expand All @@ -173,7 +180,7 @@ export async function setRole(formData: FormData) {
}
```

With the server action in place, you can build the `<SearchUsers />` component. This will have a form that can be used to search for users. On form submission, the search term is added to the URL as a search parameter. The page component, which is a server component that you will refactor next, will perform a query based on this change.
With the server action in place, you can now build the `<SearchUsers />` component. This component will include a form for searching users. When the form is submitted, the search term is appended to the URL as a search parameter. The page component, which is a server component that you will refactor next, will perform a query based on the updated URL.

```tsx {{ filename: 'app/admin/dashboard/_search-users.tsx' }}
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
'use client'
Expand Down Expand Up @@ -204,11 +211,13 @@ export const SearchUsers = () => {
}
```

With the server action and the search form in place, you'll refactor the server component for the `app/admin/dashboard` route. It will now check if a search parameter has been added to the URL by the search form, and if there is a search parameter present, it will search for users that match the entered term. If an array of one or more users is returned, then the component will render a list of users using their first and last name, primary email address, current role, and 'Make Admin' and 'Make Moderator' buttons. The buttons include hidden inputs for the user ID and the role, and they use the `setRole()` server action to update the role for the user.
With the server action and the search form in place, you'll now refactor the server component for the `app/admin/dashboard` route. The component will check if a search parameter has been added to the URL by the search form. If a search parameter is present, it will search for users matching the entered term.
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved

If an array of one or more users is returned, then the component will display a list of users including their first and last names, primary email address, and current role. Each user will have `Make Admin` and `Make Moderator` buttons. These buttons include hidden inputs for the user ID and role, and they use the `setRole()` server action to update the user's role.

```tsx {{ filename: 'src/app/admin/dashboard/page.tsx', mark: [[3, 5], 7, 12, 14, 21, [23, 52]] }}
```tsx {{ filename: 'app/admin/dashboard/page.tsx', mark: [[3, 5], 7, 12, 14, 17, [20, 52]] }}
victoriaxyz marked this conversation as resolved.
Show resolved Hide resolved
import { redirect } from 'next/navigation'
import { checkRole } from '@/utils/roles'
import { checkRole } from '../../../utils/roles'
import { SearchUsers } from './_search-users'
import { clerkClient } from '@clerk/nextjs/server'
import { setRole } from './_actions'
Expand All @@ -224,8 +233,7 @@ export default async function AdminDashboard(params: { searchParams: { search?:

return (
<>
<h1>This is the admin dashboard</h1>
<p>This page is restricted to users with the 'admin' role.</p>
<p>This is the protected admin dashboard restricted to users with the `admin` role.</p>

<SearchUsers />

Expand All @@ -235,27 +243,27 @@ export default async function AdminDashboard(params: { searchParams: { search?:
<div>
{user.firstName} {user.lastName}
</div>

<div>
{
user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)
?.emailAddress
}
</div>

<div>{user.publicMetadata.role as string}</div>
<div>
<form action={setRole}>
<input type="hidden" value={user.id} name="id" />
<input type="hidden" value="admin" name="role" />
<button type="submit">Make Admin</button>
</form>
</div>
<div>
<form action={setRole}>
<input type="hidden" value={user.id} name="id" />
<input type="hidden" value="moderator" name="role" />
<button type="submit">Make Moderator</button>
</form>
</div>

<form action={setRole}>
<input type="hidden" value={user.id} name="id" />
<input type="hidden" value="admin" name="role" />
<button type="submit">Make Admin</button>
</form>

<form action={setRole}>
<input type="hidden" value={user.id} name="id" />
<input type="hidden" value="moderator" name="role" />
<button type="submit">Make Moderator</button>
</form>
</div>
)
})}
Expand All @@ -264,6 +272,4 @@ export default async function AdminDashboard(params: { searchParams: { search?:
}
```

## Finished 🎉

The building blocks needed for a custom RBAC system are in place. Roles are attached directly to the user's session, providing them to your application without needing a separate fetch and network request. The helper function is in place to check the user's role, reducing the code and simplifying the process. The final piece is an admin dashboard that allows admin's to find users and set their roles.
The building blocks for a custom RBAC system are now in place. Roles are attached directly to the user's session, allowing your application to access them without requiring a separate fetch or network request. The helper function simplifies role checks and reduces code complexity. The final piece is the admin dashboard, which enables admins to find users and set their roles efficiently.
Loading