DEV Community

Cover image for How to Secure Your Next.js E-commerce Site with RBAC and Permit.io
uma victor
uma victor

Posted on

How to Secure Your Next.js E-commerce Site with RBAC and Permit.io

When building an e-commerce application, or any application that has to factor in user roles or some role level access, proper authorization becomes a very important business detail. Not only do you need to make sure an authenticated user can perform an action, but you also need to verify that the authenticated user has permission to perform said action.

There are a lot of ways to build authorization into your app and it’s not an easy task to carry out as your application gets bigger and scales. This is why we will be using Permit.io in this tutorial. Permit is a full stack authorization as a service platform that allows you to build and manage permissions for your application with a friendly SDK and API. Some of the Authorization methods you can carry out with Permit.io are:

RBAC is a method of regulating access to computer or network resources based on the roles of individual users within an organization. The main components of RBAC include:

  • Roles: Defined categories that represent a set of permissions (e.g., Admin, Customer).
  • Permissions: Specific actions that can be performed on resources (e.g., read, write, delete).
  • Users: Individuals who are assigned roles.
  • Resources: The objects that users interact with (e.g., products, orders). ## What are we building?

In this tutorial, we will be building an e-commerce site that:

  • Allows a user to sign up
  • Allows the user to create a store
  • Allows store owners to add a store manager

I’m sure you can already picture this application using some kind of RBAC policy. As we go through this tutorial, we’ll learn how we can use Permit.io to implement role-based access control in our Next.js e-commerce app, how to sync users between our app and permit.io, and how to use Role Access to block user access to certain authorized pages and API calls.

Prerequisites and Tech Stack

For you to be able to follow along with the tutorial without any hiccups, you should:

  • Have a solid understanding of authentication and authorization
  • Experience with React
  • A Permit.io account

You can play around with the live application here and access the code in this github repo.
For the tech stack, we have:

Let’s get started with the tutorial.

Project Setup

To save time getting started and following along, you can use create-next-app to scaffold the demo from my starter branch:

npx create-next-app@14.2.0-canary.41 -e "https://github.com/uma-victor1/Next.js-RBAC-with-Permit.io-Demo#starter"
Enter fullscreen mode Exit fullscreen mode

Now we have our project setup, let’s install the necessary dependencies.

npm install drizzle-orm @vercel/postgres
Enter fullscreen mode Exit fullscreen mode

Install the permit SDK

npm i permitio
Enter fullscreen mode Exit fullscreen mode

From the starter template, we have authentication set up with sessions. And we can track and get the user sessions with the getUser() server function.

We also have a managed Postgres database we set up with Vercel Postgres, and our ORM of choice is drizzleORM for querying our database.

For us to continue, we need to understand the feature requirements for this demo, lay out our schema for the e-commerce store, implement a well-structured role permission management system in our permit.io dashboard, and secure API calls that mutate our database, making sure only the user with the right permission is allowed.

Feature Requirements for Our Ecommerce Store

So the main idea/feature we want to implement is co-ownership. What this means is in our e-commerce store, we want a user to be able to sign into the e-commerce application, create an e-commerce store, add products to the store, and manage the products, sales, and analytics through a dashboard.

dashboard

On the dashboard, the user also has the ability to add a store manager to manage their store. The store manager has some manager privileges that allow them to add store items and view Customer information, but they can’t delete an item and view the orders and analytics page in the dashboard.

We can start with having a rough idea of the roles this application will need:

  • Customer Role
  • Admin Role
  • Manager Role

In short, here is an overview of the pages we need for our e-commerce application:

Page Path Purpose Roles
Home / Display product listings; general access All
Create Store /create-store Create New Store Customer
Dashboard /dashboard Show role-specific actions and data Manager, Admin

Here are the key roles and the access they have:

Page/Resource Customer Manager Admin
Home View/Add to Cart View View
Create store View/ can create store Not Accessible Not accessible
Dashboard Not Accessible View Limited Store Data Full Access to Store
Dashboard(Analytics/orders) Not Accessible Not Accessible Accessible
Dashboard(Products/customers) Not Accessible Accessible Accessible
Add Manager Can’t Add Can’t Add Can Add
Delete Item Can’t Delete Can’t Delete Can Delete
Add Item Can’t Add Can Add Can Add

Don't worry. We’ll also look at how we can have a table like this on our permit dashboard when we set up roles and permissions there. For now, with these 3+ pages, we can effectively demonstrate RBAC, showing how different roles interact with the app.

Now let’s take a look at the database Schema

Database Schema for our app

For our e-commerce app with roles and permissions, the database will need tables for Users, Products, Stores and StoreAccess. These four tables would be enough to demonstrate RBAC in our app. Let’s look at what each table schema looks like and what we’ll use it for:

// user table for users
    export const users = pgTable(
      'users',
      {
        id: serial('id').primaryKey(),
        name: text('name').notNull(),
        email: text('email').unique().notNull(),
        password: text('password').notNull(),
      },
      (users) => {
        return {
          uniqueIdx: uniqueIndex('unique_idx').on(users.email),
        };
      },
    );
    // Stores Table for our stores
    export const stores = pgTable('stores', {
      id: serial('id').primaryKey(),
      name: varchar('name', { length: 255 }).notNull(),
      description: text('description').notNull(),
      createdAt: timestamp('created_at').defaultNow().notNull(),
    });
    // StoreAccess Table for Managing Roles in Stores
    export const storeAccess = pgTable('store_access', {
      id: serial('id').primaryKey(),
      storeId: integer('store_id')
        .notNull()
        .references(() => stores.id),
      userId: integer('user_id')
        .notNull()
        .references(() => users.id),
      role: varchar('role', { length: 50 }).notNull(),
      assignedAt: timestamp('assigned_at').defaultNow().notNull(),
    });
    // Products Table
    export const products = pgTable('products', {
      id: serial('id').primaryKey(),
      storeId: integer('store_id')
        .notNull()
        .references(() => stores.id),
      name: varchar('name', { length: 255 }).notNull(),
      description: text('description').notNull(),
      quantity: integer('quantity').notNull(),
      price: integer('price').notNull(),
      createdAt: timestamp('created_at').defaultNow().notNull(),
      updatedAt: timestamp('updated_at').defaultNow().notNull(),
    });
Enter fullscreen mode Exit fullscreen mode

How does Permit.io fit into our Application?

Using Permit.io for role-based access control (RBAC) in this setup allows us to manage roles and permissions for the e-commerce application easily. So far from the previous sections, we know exactly what roles we need and the permission we need for the roles. All we have to do is create those roles in our permit.io dashboard, create the resources, and manage the permissions for resources in the policy editor.

First, create a new project.

dashboard

After you’re done creating a new project, your screen should look similar to the image above. You’re good to go, creating roles and managing resources, which is all done in the policy editor screen. Let’s create a resource.

In Permit.io, resources refer to the objects or entities within your application that require permission management. In the case of our e-commerce site, that would be the dashboard page, analytics page, etc.

Here is a list of all the resources we want to protect and add permissions to:

  • Store
  • Analytics
  • Storefront
  • Dashboard

Here is how we can add these resources to our permit dashboard.
Click on the policy tab on the left sidebar, and create your first resource products.

dashboard

Now that we have created these resources, we need to create different user roles and add permissions to the roles so each user assigned a role has permission access to carry out actions that are determined for the role.

Creating User Roles on Permit

In our e-commerce application, we only need three roles:

  • Admin
  • Customer
  • Manager

To create a role, navigate to the Policy section in the Permit dashboard, click on the Roles tab, and add the required roles.

dashboard permit

For each role we create, we need to define the specific permissions associated with that role. For example, an Admin might have permissions to create, read, update, and delete resources, while a customer might only have read permissions.

Navigate to the Policy editor tab and adjust permission so it looks like this.

permit dashboard

permit dashboard

permit dashboard

We now have everything setup in our permit dashboard. Let’s get back to some code.

Testing and Enforcing Permissions in our codebase

In the previous sections, we have seen how to set roles and permissions in our permit dashboard. In this section, we will see how we can use the pemit API to test and enforce this permission in our Next.js ecommerce site.

First, we need a lib folder at the root of our directory. In the lib folder, we’ll have a permit.ts configuration file for our permit API configuration. This is what it looks like:

// lib/permit.ts
    import { getUser } from '@/app/auth/03-dal';
    import { type User } from '@/app/auth/definitions';
    import { Permit } from 'permitio';
    import { unstable_cache } from 'next/cache';

    // This line initializes the SDK and connects your app
    // to the Permit.io Cloud PDP.
    const permit = new Permit({
      pdp: process.env.PERMIT_IO_PDP_URL,
      // your API Key
      token: process.env.PERMIT_IO_API_KEY,
    });
    const TEN_MINUTES = 60 * 10;
    export type Actions = 'create' | 'read' | 'update' | 'delete';
    export type Resources =
      | 'Product'
      | 'Store'
      | 'Analytics'
      | 'Storefront'
      | 'Dashboard';

    const check = unstable_cache(
      async (action: Actions, resource: Resources, id: string) => {
        const permitted = await permit.check(id, action, resource);
        console.log(permitted, 'permitted');
        return permitted;
      },
      ['permitKey'],
      { revalidate: TEN_MINUTES },
    );
    export const checkPermission = async (action: Actions, resource: Resources) => {
      try {
        const user = await getUser();
        if (!user) {
          throw new Error('No user found');
        }
        const hasPermission = await check(action, resource, user.id.toString());
        return hasPermission;
      } catch (error) {
        if (error instanceof Error) {
          throw new Error(error.message);
        }
      }
    };
    export default permit;
Enter fullscreen mode Exit fullscreen mode

This code defines and configures a permission-checking utility checkPermission using the Permit SDK in our application. With this initialization, we can manage RBAC anywhere in our Next.js application.

    const permit = new Permit({
      pdp: process.env.PERMIT_IO_PDP_URL,
      // your API Key
      token: process.env.PERMIT_IO_API_KEY,
    });
Enter fullscreen mode Exit fullscreen mode

To configure permit, we need a policy decision point URL and an API token provided by permit.

The pdp is a policy engine needed for evaluating authorization queries based on defined policies. It’s very important for checking permission for roles and although permit provides a pdp URL for testing, permit advises us to deploy our own. For now, we are using the provided pdp URL https://cloudpdp.api.permit.io.

For our API key, we can copy it from our permit dashboard on the projects page.

get permit key

Note: we are using Next.js unstable_cache API to cache server response from permit, so our app is more performant and we don’t need to hit the server every time we navigate to a page. The response is cached for 10 minutes, so it’ll take 10 minutes for a change in roles or permission in our permit.io dashboard to reflect in our app.

Without caching our response. Permit RBAC is real-time and updates immediately when you update any role or permission in the dashboard.

Implementing RBAC in our APP

Since we have our configuration set, we can start enforcing some roles and permissions in our app. But something is missing! In our permissions dashboard we didn’t add any users so adding roles and permissions is useless. We need a way to sync the users in our app with the users on permit.io.

We can add users manually to the permit dashboard from the directory tab and assign them the roles we created, but that would be inefficient as we need everything synced from our app.

Let’s do that.

To achieve this we need a unique way to identify our users. It doesn’t matter what method of authentication we are using, we just need a unique id for each user. For this project, we are using JWT, so we can decode our JWT and use the user ID or email to sync users to permit.

The perfect place to do this is during the signup process. In our signup server action, we need to use the permit API to add a Role.

    // signup server action
    export async function signup(
      state: FormState,
      formData: FormData,
    ): Promise<FormState> {
      // Validate form fields
      const validatedFields = SignupFormSchema.safeParse({
        name: formData.get('name'),
        email: formData.get('email'),
        password: formData.get('password'),
      });
      // If any form fields are invalid, return early
      if (!validatedFields.success) {
        return {
          errors: validatedFields.error.flatten().fieldErrors,
        };
      }
      // Prepare data for insertion into database
      const { name, email, password } = validatedFields.data;
      // Check if the user's email already exists
      const existingUser = await db.query.users.findFirst({
        where: eq(users.email, email),
      });
      if (existingUser) {
        return {
          message: 'Email already exists, please use a different email or login.',
        };
      }
      // Hash the user's password
      const hashedPassword = await bcrypt.hash(password, 10);
      // Insert the user into the database or call an Auth Provider's API
      const data = await db
        .insert(users)
        .values({
          name,
          email,
          password: hashedPassword,
        })
        .returning({ id: users.id, email: users.email, name: users.name });
      const user = data[0];
      if (!user) {
        return {
          message: 'An error occurred while creating your account.',
        };
      }
      const userId = user.id.toString();
      const newPermitUser: PermitUser = {
        key: user.id.toString(),
        email: user.email,
        first_name: user.name,
        last_name: '',
        attributes: {},
      };
      const assignedRole: UserRole = {
        role: 'customer',
        tenant: 'default',
        user: userId,
      };
      // Create and sync new user with permit.io
      await permit.api.createUser(newPermitUser);
      await permit.api.assignRole(
        JSON.stringify(assignedRole) as unknown as RoleAssignmentCreate,
      );
      // 4. Create a session for the user
      await createSession(userId);
    }
Enter fullscreen mode Exit fullscreen mode

In the code above, from line 47 - 63, we use permit.io API to create a user with permit.api.createUser and assign that new user a role of customer with permit.api.assignRole

Now, any time a new user signs up, a user is created and synced with permit.io and they are assigned a customer user role just like we pointed out in the table we created earlier on in the tutorial.

Taking a look at the table from before, we know all the places where we should use permit to properly add authorization. One of those places is the create store page.

Securing our Create Store Page

We want only customers to be able to create a store. We can enforce this by using the permit API like this in our create store page.

    // create-store/layout.tsx
    import React from 'react';
    import { Toaster } from '@/components/ui/toaster';
    import { checkPermission } from '@/lib/permit';
    import { redirect } from 'next/navigation';

    export const dynamic = 'force-dynamic';
    async function Layout({ children }: { children: React.ReactNode }) {

      const permitted = await checkPermission('create', 'Store');
      if (!permitted) {
        redirect('/dashboard/products');
      }

      return (
        <div>
          <div>{children}</div>
          <Toaster />
        </div>
      );
    }
    export default Layout;
Enter fullscreen mode Exit fullscreen mode

From the code above, in order to secure the page we had to check if the current signed-in user can create a store. If they are authorized to create a store based on the role they are assigned, the page will render, if not they will be redirected to the dashboard page.

Once a user creates a store, they are assigned the role of admin as instructed in our create store API call:

    'use server';
    import { db } from '@/drizzle/db';
    import { storeAccess, stores } from '@/drizzle/schema';
    import { type Store } from '@/drizzle/schema';
    import { getUser } from '../auth/03-dal';
    import * as z from 'zod';
    import permit from '@/lib/permit';
    import { RoleAssignmentCreate } from 'permitio';
    import { UserRole } from '../auth/definitions';

    type formSchema = {
      storeName: string;
      description: string;
    };

    export const createStoreAction = async (s: formSchema) => {
      const user = await getUser();
      if (!user) {
        throw new Error('no user found');
      }
      const store = {
        name: s.storeName,
        description: s.description,
      };
      try {
        const assignedRole: UserRole = {
          role: 'admin',
          tenant: 'default',
          user: user.id.toString(),
        };
        // Insert the new store into the `stores` table
        const [newStore] = await db.insert(stores).values(store).returning();
        // Add the Admin role for the creator in `storeAccess`
        await db.insert(storeAccess).values({
          storeId: newStore.id,
          userId: user.id,
          role: 'admin',
        });
        // Assign the Admin role in Permit.io
        await permit.api.assignRole(
          JSON.stringify(assignedRole) as unknown as RoleAssignmentCreate,
        );
      } catch (error) {
        throw new Error('error: ' + error);
      }
    };
Enter fullscreen mode Exit fullscreen mode

This API call is a server action that inserts a new store in our database and assigns the role of admin to the user that created the store.

Let’s move over to the dashboard route and see how we can add co-ownership feature for store owners.

Adding Co-Ownership Feature for Store Owners

From the key roles and access table from earlier, we know what we want the access structure to be like for the dashboard route.

Page/Resource Customer Manager Admin
Dashboard(Analytics/orders) Not Accessible Not Accessible Accessible
Dashboard(Products/customers) Not Accessible Accessible Accessible
Add Manager Can’t Add Can’t Add Can Add
Delete Item Can’t Delete Can’t Delete Can Delete
Add Item Can’t Add Can Add Can Add

All we have to do is secure whatever page in our dashboard the same way we secured the create store page.

In the products page, there is a form to add a store manager. We only want the form to be visible to the admin of the store. We can check the permission for the currently signed in user and add a conditional check before rendering the add manger form.

    // dashboard/products/page.tsx
    ....
    export default async function InventoryPage() {
      const inventory: ProductWithStore[] = await fetchInventory();
      const permitted = await checkPermission('create', 'Product');
      return (
        <div className="container mx-auto p-4">
          <h1 className="mb-4 text-2xl font-bold">Store Inventory</h1>
          {permitted && (
            <Card className="mb-8">
              <CardHeader>
                <CardTitle>Add Manager</CardTitle>
              </CardHeader>
              <CardContent>
                <AddManagerForm />
              </CardContent>
            </Card>
          )}
        </div>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Here is what our form looks like:

    'use client';
    import React from 'react';
    import { useForm } from 'react-hook-form';
    import { zodResolver } from '@hookform/resolvers/zod';
    import { z } from 'zod';
    import { toast } from '@/app/hooks/use-toast';
    import { Label } from '@/components/ui/label';
    import { Input } from '@/components/ui/input';
    import { Button } from '@/components/ui/button';
    import { addItem } from '@/app/services/addItem';
    import { Textarea } from '@/components/ui/textarea';
    import { Plus } from 'lucide-react';
    import { Loader } from 'lucide-react';
    export const managerSchema = z.object({
      email: z.string().email({ message: 'Please enter a valid email.' }),
    });
    export default function AddManagerForm() {
      const {
        register,
        handleSubmit,
        formState: { errors, isSubmitting },
      } = useForm<z.infer<typeof managerSchema>>({
        resolver: zodResolver(managerSchema),
      });
      const onSubmit = async (data: z.infer<typeof managerSchema>) => {
        try {
          const res = await fetch('/api/addManager', {
            method: 'POST',
            body: JSON.stringify(data),
          });
          if (!res.ok) throw new Error('Failed to add manager');
          toast({
            title: 'Manager added successfully!',
            description: 'Your just added a manager. Yayy!',
          });
        } catch (error) {
          toast({
            title: 'Uhhh!..We could not add a manager',
            description: 'Its probably our servers. Try again',
          });
          throw new Error('error: ' + error);
        }
      };
      return (
        <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
          <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
            <div className="space-y-2">
              <Label htmlFor="email">Manager Email</Label>
              <Input
                id="email"
                {...register('email')}
                placeholder="Enter manager email"
              />
              {errors.email && (
                <div className="text-red-500">{errors.email.message}</div>
              )}
            </div>
          </div>
          <Button type="submit" className="w-full" disabled={isSubmitting}>
            {isSubmitting ? (
              <>
                <Loader className="animate-spin" /> "Adding manager..."
              </>
            ) : (
              <>
                <Plus className="mr-2 h-4 w-4" /> Add Manager
              </>
            )}
          </Button>
        </form>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Our form only accepts an email as input, and on submision, a request is made to an API route we define at api/addManager/route.ts. This endpoint defines a POST API endpoint that allows an admin user to assign a "Manager" role to another user for a specific store.

    import { db } from '@/drizzle/db';
    import { products, stores, storeAccess, users } from '@/drizzle/schema';
    import { NextRequest, NextResponse } from 'next/server';
    import { getUser } from '@/app/auth/03-dal';
    import { eq, or, and } from 'drizzle-orm';
    import { decrypt } from '@/app/auth/02-stateless-session';
    import permit from '@/lib/permit';
    import { getUserStore } from '@/app/services/addItem';
    import { RoleAssignmentCreate } from 'permitio';
    import { UserRole } from '@/app/auth/definitions';
    export async function POST(req: NextRequest, res: NextResponse) {
      const user = await getUser();
      if (!user) {
        return NextResponse.json({ error: 'Not logged in' }, { status: 401 });
      }
      const adminUserId = user.id;
      const { email: managerEmail } = await req.json();
      // Email of the user to be added as manager
      const store = await getUserStore();
      if (!store || store.length === 0) {
        return NextResponse.json({ message: 'No store found' }, { status: 404 });
      } // Ensure the user has at least one store with Admin access
      const storeId = store[0].stores.id;
      // Verify if the requester is an Admin for this store
      const isAdmin = await db
        .select()
        .from(storeAccess)
        .where(
          and(
            eq(storeAccess.storeId, storeId),
            eq(storeAccess.userId, adminUserId),
            eq(storeAccess.role, 'admin'),
          ),
        );
      if (!isAdmin) {
        return NextResponse.json({ message: 'Not an Admin' }, { status: 403 });
      }
      // Find the user by email
      const manager = await db
        .select()
        .from(users)
        .where(eq(users.email, managerEmail));
      if (!manager) {
        return NextResponse.json({ message: 'User not found' }, { status: 404 });
      }
      // Check if the user is already a manager
      const existingAccess = await db
        .select()
        .from(storeAccess)
        .where(
          and(
            eq(storeAccess.storeId, storeId),
            eq(storeAccess.userId, manager[0].id),
            eq(storeAccess.role, 'manager'),
          ),
        );
      if (existingAccess.length > 0) {
        return NextResponse.json(
          { message: 'User is already a manager' },
          { status: 400 },
        );
      }
      // Add the Manager role in `storeAccess`
      await db.insert(storeAccess).values({
        storeId,
        userId: manager[0].id,
        role: 'manager',
      });
      const unassignRole: UserRole = {
        role: 'customer',
        tenant: 'default',
        user: manager[0].id.toString(),
      };
      const assignedRole: UserRole = {
        role: 'manager',
        tenant: 'default',
        user: manager[0].id.toString(),
      };
      // remove and assign the Manager role in Permit.io
      await permit.api.unassignRole(
        JSON.stringify(unassignRole) as unknown as RoleAssignmentCreate,
      );
      await permit.api.assignRole(
        JSON.stringify(assignedRole) as unknown as RoleAssignmentCreate,
      );
      return NextResponse.json(
        { message: 'Manager added successfully' },
        { status: 200 },
      );
    }
Enter fullscreen mode Exit fullscreen mode

That’s a lot of code, but here's what's basically: First, the code verifies the logged-in user’s credentials and ensures they have "Admin" access to the store. If the user lacks proper access or the store isn’t found, appropriate error messages are returned. The code then fetches the user to be assigned as a manager using their email and checks if they already have the "Manager" role. If not, it updates the database to assign the "Manager" role to the user and synchronizes this change with Permit.io to ensure centralized role management.

To add the manager role, we first had to unassign the role of customer from the user:

      // remove and assign the Manager role in Permit.io
      await permit.api.unassignRole(
        JSON.stringify(unassignRole) as unknown as RoleAssignmentCreate,
      );
      await permit.api.assignRole(
        JSON.stringify(assignedRole) as unknown as RoleAssignmentCreate,
      );
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

And just like that we've have used Permit.io in our Next.js app to enforce permissions and add co-ownership feature for store owners.

This has been a long read. Hopefully, you’ve been able to grasp how Permit.io can be used to implement authorization in your Next.js application. You can learn more by visiting the Permit.io Docs, or hit me up on X @umavictor.

Top comments (0)