Implementing Role Based Access Control in SvelteKit

Implementing Role Based Access Control in SvelteKit

In this guide, you will learn you how to implement Role Based Access Control (RBAC) into a SvelteKit application in mere minutes.

By Oluwabusayo Jacobs ·

Table Of Contents

Prerequisites

Before you begin, you'll need to have the following prerequisites:

  • Basic knowledge of SvelteKit: Familiarity with SvelteKit's core concepts, like routing, components, and stores, will be helpful.
  • Node.js and npm: You'll need to have Node.js and npm (Node Package Manager) installed on your system. If you haven't already, you can download and install them from the official Node.js website.
  • Git: Ensure you have Git installed on your machine. If not, you can download it from the Git website.

Local Image

Understanding Role-Based Access Control (RBAC)

RBAC is a security model that organizes user permissions based on their roles within an application.

Each role is associated with a set of permissions, and users are assigned specific roles to determine what they can access.

Roles and Permissions

Roles represent a collection of related permissions that define what a user can do within an application.

For instance, an application might have roles such as Admin, Editor, and Viewer each with different sets of permissions.

Permissions, on the other hand, are specific actions or operations that a user with a particular role can perform.

These can range from viewing and editing data to managing user accounts or system configurations.

Setting Up the SvelteKit Project

Before we start implementing RBAC, we need a SvelteKit project ready to work with.

To keep things simple, we'll use a demo e-commerce project as our starting template. The complete code for this project can be found in this GitHub repository.

To get the starter template, run the following commands:

git clone https://github.com/TropicolX/svelte-rbac-demo.git
cd svelte-rbac-demo
git checkout starter
npm install

The project structure should look like the one below:

Screenshot of project structure

Next, let's go over the essential files and directories we'll be working with for the project:

  • /lib: This directory will contain all our utilities and components.
  • /routes: Contains all the routes in our application.
    • /(protected): This folder contains all our protected routes. Only signed-in users may be able to navigate these routes.
    • /login: This is the route to the login page.
    • /unauthorized: This is the route we redirect users to when they are unauthorized in accessing a page.
    • +layout.svelte: This contains the UI layout and logic that applies to every page.
    • +page.svelte: This is our home page route.
  • hooks.server.js: This file contains all the server-side logic we need to authenticate users when they navigate to a protected route and to redirect them when necessary. It also fetches the user data on the server and stores it in the event.locals object.

Project Overview

To start the project, run the following command in the root directory:

npm run dev

You should see a page like the one below:

Screenshot of e-commerce project home page

Next, let's sign in to the app. Click the "Sign in" button at the top right corner of the page.

Sign in page

You can sign in as either an admin or a customer using the following credentials:

  • Admin:
    • Email: admin@gmail.com
    • Password: admin123
  • Customer:
    • Email: john@mail.com
    • Password: changeme

After signing in, you will be redirected to the home page and should able to see two new links on the navbar:

  • Admin link
  • Profile link

Initial home page when signed in

This brings us to the current problem with our e-commerce app: We need to ensure that only admins can access the admin page, and similarly, only customers can access the profile page.

Additionally, we need to ensure that only admins and customers with the required permissions can perform specific actions within their respective pages.

For instance, only an admin with permission to create products should be able to do so on the admin page.

Integrating RBAC Configurations and Utilities

Now that we've set up and familiarized ourselves with the Svelekit project let's begin implementing RBAC in our app. We'll start by defining the roles and permissions within the app.

Defining Roles and Permissions

In the SvelteKit project, create a new file called constants.js in the src directory and add the following code:

// src/constants.js
export const ROLES = {
	ADMIN: "admin",
	CUSTOMER: "customer",
};

export const PERMISSIONS = {
	CREATE_PRODUCTS: "create:products",
	UPDATE_PRODUCTS: "update:products",
	DELETE_PRODUCTS: "delete:products",
	READ_PROFILE: "read:profile",
	UPDATE_PROFILE: "update:profile",
};

In the code above, we defined our app's roles and permission constants.

Next, we need to define the permissions each role has. Navigate to the lib directory and create a rolePermissions.js file with the following code:

// src/lib/rolePermissions.js
import { PERMISSIONS, ROLES } from "../constants";

export const rolePermissions = {
	[ROLES.ADMIN]: [
		PERMISSIONS.CREATE_PRODUCTS,
		PERMISSIONS.UPDATE_PRODUCTS,
		PERMISSIONS.DELETE_PRODUCTS,
	],
	[ROLES.CUSTOMER]: [PERMISSIONS.READ_PROFILE, PERMISSIONS.UPDATE_PROFILE],
};

The code above sets up each of the roles with specific permissions. We want admin users to be able to create, update, and delete products.

On the other hand, we want customers to be able to read and update their profile details.

Implementing RBAC Utility Functions

Next, let's create some utility functions to check if a user has a certain role or permission. These functions will enforce access control throughout the app.

In the lib directory, create a rbacUtils.js file and add the following code:

// src/lib/rbacUtils.js
import { rolePermissions } from "./rolePermissions";

// Function to check if a user has a specific role
export function checkRole(user, requiredRole) {
	if (!user) {
		return false;
	}

	return user.role === requiredRole;
}

// Function to check if a user has specific permissions
export function checkPermissions(user, requiredPermissions) {
	if (!user) {
		return false;
	}

	const userPermissions = rolePermissions[user.role];
	return userPermissions?.includes(requiredPermissions);
}

// Function to check if a user has both a specific role and permissions
export function checkRoleAndPermissions(
	user,
	requiredRole,
	requiredPermissions
) {
	return (
		checkRole(user, requiredRole) &&
		checkPermissions(user, requiredPermissions)
	);
}

With these configurations in place, we can start integrating RBAC within our app.

Dynamic Navigation Based on User Roles

The navigation UI is the first area in our app where we'll implement RBAC. We need to ensure that users see only the navigation links relevant to their roles.

Firstly, head over to the +layout.svelte file in the routes folder and add the following code:

<!-- src/routes/+layout.svelte -->
<script>
    ...
	import Loading from "$lib/components/Loading.svelte";
	import { checkRole } from "$lib/rbacUtils";
	import { ROLES } from "../constants";
	import { user, cart, products } from "../stores";
	import "../app.css";

	...
	$: isAdmin = checkRole($user, ROLES.ADMIN);
	$: isCustomer = checkRole($user, ROLES.CUSTOMER);
</script>

...

Here, we defined two variables, isAdmin and isCustomer, which check if the user is an admin or a customer, respectively. We also used reactive declarations to define the variables so that any changes to the user data, e.g., if the user logs out, will update their values.

Next, let's update our navigation links:

<!-- src/routes/+layout.svelte -->
...

<header
	class="bg-gray-900 text-white py-4 px-6 md:px-12 flex items-center justify-between"
>
	...
	<nav class="hidden md:flex items-center space-x-6">
		{#if isAdmin}
			<a class="hover:text-gray-300" href="/admin">Admin</a>
		{/if}
		{#if isCustomer}
			<a class="hover:text-gray-300" href="/user">Profile</a>
		{/if}
	</nav>
    ...
</header>

...

Demo of dynamic navigation implementation

Now, the navigation links for the admin and profile pages will be displayed conditionally based on the user's role.

Securing Routes Based on User Roles

In the previous section, we added dynamic client-side navigation based on user roles. However, users may still be able to navigate to unauthorized routes by using the browser navigation. To prevent this, we need to secure the routes directly by adding role-based protection.

We can do this in Svelte by adding a +layout.server.js file in each route to run a server-side load function. The load function will run whenever the user loads the page and will check whether the user has access to the route based on their role. If they are not authorized, they will be redirected to the /unauthorized page.

Let's start by implementing this on the admin page. Head over to the admin directory in the (protected) folder, create a +layout.server.js file with the following code:

// src/routes/(protected)/admin/+layout.server.js
import { redirect } from "@sveltejs/kit";

import { checkRole } from "$lib/rbacUtils";
import { ROLES } from "../../../constants";

/** @type {import('./$types').LayoutServerLoad} */
export function load({ locals }) {
	const user = locals.user;
	const isAdmin = checkRole(user, ROLES.ADMIN);

	if (!isAdmin) {
		redirect(307, "/unauthorized");
	}
}

Demo for securing admin route

Now, if a non-admin user attempts to access the admin route, they will be redirected appropriately.

Next, let's secure our user route. Within the (protected) folder, navigate to the user directory and create a +layout.server.js file with the following code:

// src/routes/(protected)/user/+layout.server.js
import { redirect } from "@sveltejs/kit";

import { checkRoleAndPermissions } from "$lib/rbacUtils";
import { PERMISSIONS, ROLES } from "../../../constants";

/** @type {import('./$types').LayoutServerLoad} */
export function load({ locals }) {
	const user = locals.user;
	const isCustomerAndCanViewProfile = checkRoleAndPermissions(
		user,
		ROLES.CUSTOMER,
		PERMISSIONS.READ_PROFILE
	);

	if (!isCustomerAndCanViewProfile) {
		redirect(307, "/unauthorized");
	}
}

Demo for securing user route

Here, we're doing things a bit differently by using the checkRoleAndPermissions function to ensure the user is both a customer and has permission to read a profile before being able to access the profile page.

This function can be helpful in more complex scenarios where a role might have additional or custom permissions different from the default permissions.

Next let's look at how to manage role permissions within components.

Managing Permissions Within Components

Now that we've secured our routes, the next step is to safeguard actions and features within those pages to ensure only users with the necessary permissions can access them.

For instance, you can take three main actions on the admin page:

  • Create a product
  • Edit a product
  • Delete a product

Screenshot of admin dashboard

Currently, an admin has the permission to perform all these actions by default. But suppose later in development, a change was made to revoke the create:products permission from the default admin role and to only be given to a select few admins.

In that case, ensuring only those specific admins can create a product becomes crucial.

We can check permissions in our project using the checkPermissions utility function.

For example, in the +page.svelte file in the admin directory, we can add the following code to ensure only admins with create:product permission can access the "Add new product" button:

<!-- src/routes/(protected)/admin/+page.svelte -->
<script>
	import { checkPermissions } from "$lib/rbacUtils";
	import { PERMISSIONS } from "../../../constants";
	import { user, products } from "../../../stores";

	$: canCreateProducts = checkPermissions($user, PERMISSIONS.CREATE_PRODUCTS);
</script>

<div class="container mx-auto px-4 md:px-8 py-12">
	<div class="flex flex-wrap items-center justify-between mb-8">
		<h2 class="text-3xl md:text-4xl font-bold">Admin Dashboard</h2>
		{#if canCreateProducts}
			<button
				class="mt-4 min-[457px]:mt-0 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2 bg-gray-900 text-white hover:bg-gray-800"
			>
				Add new product
			</button>
		{/if}
	</div>
	...
</div>

If we remove PERMISSIONS.CREATE_PRODUCTS from the admin permissions in the rolePermissions.js file, we can see the change taking effect:

// src/lib/rolePermissions.js
...

export const rolePermissions = {
	[ROLES.ADMIN]: [
		PERMISSIONS.UPDATE_PRODUCTS,
		PERMISSIONS.DELETE_PRODUCTS,
	],
	...
};

Screenshot of admin dashboard with no new product button

Let's take things a step further by securing the edit and delete feature as well:

<!-- src/routes/(protected)/admin/+page.svelte -->
<script>
	...
	$: canDeleteProducts = checkPermissions($user, PERMISSIONS.DELETE_PRODUCTS);
	$: canUpdateProducts = checkPermissions($user, PERMISSIONS.UPDATE_PRODUCTS);
</script>

...
<tbody class="[&amp;_tr:last-child]:border-0">
	{#each $products as product (product.id)}
		<tr
			class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"
		>
			...
			<td class="p-4 align-middle [&amp;:has([role=checkbox])]:pr-0">
				<div class="flex items-center space-x-2">
					{#if canUpdateProducts}
						<!-- Update button -->
						<button
							class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 w-10"
						>
							<svg
								xmlns="http://www.w3.org/2000/svg"
								width="24"
								height="24"
								viewBox="0 0 24 24"
								fill="none"
								stroke="currentColor"
								stroke-width="2"
								stroke-linecap="round"
								stroke-linejoin="round"
								class="h-4 w-4"
							>
								<path
									d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"
								></path>
							</svg>
						</button>
					{/if}

					{#if canDeleteProducts}
						<!-- Delete button -->
						<button
							class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 w-10"
						>
							<svg
								xmlns="http://www.w3.org/2000/svg"
								width="24"
								height="24"
								viewBox="0 0 24 24"
								fill="none"
								stroke="currentColor"
								stroke-width="2"
								stroke-linecap="round"
								stroke-linejoin="round"
								class="h-4 w-4"
							>
								<path d="M3 6h18"></path>
								<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
								></path>
								<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
								></path>
							</svg>
						</button>
					{/if}
				</div>
			</td>
		</tr>
	{/each}
</tbody>
...

Next, let's implement the same policy on our profile page. Navigate to the +page.svelte file in the user directory and add the following code:

<!-- src/routes/(protected)/user/+page.svelte -->
<script>
	import { checkPermissions } from "$lib/rbacUtils";
	import { PERMISSIONS } from "../../../constants";
	import { user } from "../../../stores";

	$: canEditProfile = checkPermissions($user, PERMISSIONS.UPDATE_PROFILE);

	let name = $user.name;
	let email = $user.email;
</script>

<div class="h-[calc(100svh-64px)] w-full flex items-center justify-center">
	<div
		class="rounded-lg border bg-card text-card-foreground shadow-sm w-[28rem] max-w-md"
	>
		...
		<div class="p-6 space-y-4">
			<div class="space-y-2">
				...
				<input
					...
					disabled={!canEditProfile}
				/>
			</div>
			<div class="space-y-2">
				...
				<input
					...
					disabled={!canEditProfile}
				/>
			</div>
			<div class="space-y-2">
				...
				<input
					...
					disabled={!canEditProfile}
				/>
			</div>
			<div class="space-y-2">
				...
				<input
					...
					disabled={!canEditProfile}
				/>
			</div>
		</div>
		<div class="flex items-center p-6">
			<button
				...
				disabled={!canEditProfile}
			>
				Save
			</button>
		</div>
	</div>
</div>

Here, we use the canEditProfile variable to determine whether the input fields and save button will be disabled. If the value is false, the input fields and submit button will be disabled and vice versa.

And with that, we should have a fully secured e-commerce web app using RBAC!

Final RBAC demo

Conclusion

In this tutorial, we looked at how to implement Role-Based Access Control (RBAC) in Sveltekit applications.

We began by learning about the fundamentals of RBAC, such as roles and permissions and their importance in web development.

We then examined its implementation, which included configuring custom roles and permissions, integrating RBAC utilities, securing routes, and managing permissions within components.

As you continue developing your SvelteKit apps, remember to regularly review and update your RBAC configuration to adapt to changing requirements and mitigate potential security risks.

Additionally, prioritize continual monitoring, logging, and testing to maintain the integrity and effectiveness of your RBAC implementation.

You can find the complete project code in this GitHub Repo.