如何使用 RBAC 和 Permit.io 保护你的 Next.js 电子商务网站

在构建电子商务应用程序或任何必须考虑用户角色或某些角色级别访问权限的应用程序时,适当的授权将成为非常重要的业务细节。您不仅需要确保经过身份验证的用户可以执行操作,还需要验证经过身份验证的用户是否有权执行该操作。

有很多方法可以将授权构建到您的应用程序中,但随着应用程序变得越来越大和扩展,执行授权并不是一件容易的事。这就是我们将在本教程中使用 Permit.io 的原因。Permit 是一个全栈授权即服务平台,允许您使用友好的 SDK 和 API 为您的应用程序构建和管理权限。您可以使用 Permit.io 执行的一些授权方法包括:

RBAC 是一种根据组织内各个用户的角色来规范对计算机或网络资源的访问的方法。RBAC 的主要组件包括:

  • 角色:定义代表一组权限的类别(例如,管理员、客户)。
  • 权限:可以对资源执行的特定操作(例如读取、写入、删除)。
  • 用户:被分配角色的个人。
  • 资源:用户与之交互的对象(例如产品、订单)。## 我们正在构建什么?
  • 在本教程中,我们将构建一个电子商务网站:

  • 允许用户注册
  • 允许用户创建商店
  • 允许店主添加店长
  • 我相信您已经可以想象这个应用程序使用某种 RBAC 策略。在学习本教程的过程中,我们将学习如何使用 Permit.io 在我们的 Next.js 电子商务应用中实现基于角色的访问控制,如何在我们的应用和 permit.io 之间同步用户,以及如何使用角色访问来阻止用户访问某些授权页面和 API 调用。

    先决条件和技术堆栈

    为了能够顺利完成本教程,您应该:

  • 对身份验证和授权有深入的了解
  • 具有 React 经验
  • Permit.io 帐户
  • 您可以在此处试用实时应用程序并访问此 github repo 中的代码。

    对于技术栈,我们有:

  • Vercel Postgres 我们管理的 Postgres 数据库
  • 为我们的 ORM 提供 Drizzle
  • Next.js 我们的全栈框架
  • 让我们开始本教程。

    项目设置

    为了节省开始和后续操作的时间,您可以使用“create-next-app”从我的启动分支构建演示:

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

    现在我们已经设置好了项目,让我们安装必要的依赖项。

    npm install drizzle-orm @vercel/postgres

    安装许可证 SDK

    npm i permitio

    从入门模板开始,我们已使用会话设置身份验证。我们可以使用 `getUser()` 服务器函数跟踪和获取用户会话。

    我们还有一个使用 Vercel Postgres 设置的托管 Postgres 数据库,并且我们选择的 ORM 是 drizzleORM 来查询我们的数据库。

    为了继续,我们需要了解此演示的功能要求,为电子商务商店制定架构,在我们的 permit.io 仪表板中实现结构良好的角色权限管理系统,以及保护改变数据库的 API 调用,确保只有具有正确权限的用户才被允许。

    我们的电子商务商店的功能要求

    因此,我们想要实现的主要思想/功能是**共同所有权**。这意味着在我们的电子商务商店中,我们希望用户能够登录电子商务应用程序,创建电子商务商店,向商店添加产品,并通过仪表板管理产品、销售和分析。

    dashboard

    在仪表板上,用户还可以添加商店经理来管理他们的商店。商店经理拥有一些经理权限,允许他们添加商店商品和查看客户信息,但他们不能删除商品,也不能查看仪表板中的订单和分析页面。

    我们可以先大致了解一下这个应用程序所需的角色:

  • 客户角色
  • 管理员角色
  • 经理角色
  • 简而言之,以下是我们电子商务应用程序所需页面的概述:

    以下是关键角色及其拥有的权限:

    别担心。我们还将研究如何在许可证仪表板上设置角色和权限时拥有这样的表格。目前,通过这 3 个以上的页面,我们可以有效地演示 RBAC,展示不同角色如何与应用程序交互。

    现在我们来看看数据库模式

    我们的应用程序的数据库架构

    对于具有角色和权限的电子商务应用,数据库将需要**用户、产品、商店和 StoreAccess** 表。这四个表足以在我们的应用中演示 RBAC。让我们看看每个表架构是什么样子的,以及我们将使用它做什么:

    // 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(),
        });

    Permit.io 如何融入我们的应用程序?

    在此设置中使用 Permit.io 进行基于角色的访问控制 (RBAC) 使我们能够轻松管理电子商务应用程序的角色和权限。到目前为止,从前面的部分来看,我们确切地知道我们需要哪些角色以及角色所需的权限。我们所要做的就是在我们的 permit.io 仪表板中创建这些角色,创建资源,并在策略编辑器中管理资源的权限。

    首先,创建一个新项目。

    dashboard

    创建新项目后,您的屏幕应与上图类似。您可以创建角色并管理资源,这一切都在策略编辑器屏幕中完成。让我们创建一个资源。

    在 Permit.io 中,资源是指应用程序中需要权限管理的对象或实体。在我们的电子商务网站中,资源是指仪表板页面、分析页面等。

    以下是我们想要保护并添加权限的所有资源的列表:

  • 店铺
  • 分析
  • 店面
  • 仪表板
  • 以下是我们如何将这些资源添加到许可证仪表板的方法。

    点击左侧栏的策略选项卡,创建您的第一个资源“产品”。

    dashboard

    现在我们已经创建了这些资源,我们需要创建不同的用户角色并为角色添加权限,以便分配了角色的每个用户都有权执行为该角色确定的操作。

    创建许可证上的用户角色

    在我们的电子商务应用中,我们只需要三个角色:

  • 行政
  • 顾客
  • 经理
  • 要创建角色,请导航到“允许”仪表板中的“策略”部分,单击“角色”选项卡,然后添加所需的角色。

    dashboard permit

    对于我们创建的每个角色,我们需要定义与该角色相关的特定权限。例如,管理员可能具有创建、读取、更新和删除资源的权限,而客户可能仅具有读取权限。

    导航到策略编辑器选项卡并调整权限,使其看起来像这样。

    permit dashboardpermit dashboardpermit dashboard

    现在,我们已经在许可证仪表板中设置好了一切。让我们回到一些代码。

    在我们的代码库中测试和执行权限

    在前面的部分中,我们了解了如何在许可仪表板中设置角色和权限。在本节中,我们将了解如何使用 pemit API 在我们的 Next.js 电子商务网站中测试和强制执行此权限。

    首先,我们需要在目录根目录下创建一个 `lib` 文件夹。在 lib 文件夹中,我们将有一个用于许可 API 配置的 `permit.ts` 配置文件。它看起来像这样:

    // 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;

    此代码使用应用程序中的 **Permit** SDK 定义并配置权限检查实用程序“checkPermission”。通过此初始化,我们可以在 Next.js 应用程序中的任何位置管理 RBAC。

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

    要配置许可,我们需要一个策略决策点 URL 和一个许可提供的 API 令牌。

    `pdp` 是一个策略引擎,用于根据定义的策略评估授权查询。它对于检查角色的权限非常重要,尽管 permit 提供了一个 `pdp` URL 供测试,但 permit 建议我们部署自己的 URL。目前,我们使用提供的 pdp URL `https://cloudpdp.api.permit.io`。

    对于我们的 API 密钥,我们可以从项目页面上的许可仪表板复制它。

    get permit key

    注意:我们使用 Next.js 不稳定的缓存 API 来缓存来自 permit 的服务器响应,因此我们的应用性能更高,并且我们不需要每次导航到页面时都访问服务器。响应缓存 10 分钟,因此 permit.io 仪表板中的角色或权限更改需要 10 分钟才能反映在我们的应用中。

    无需缓存我们的响应。Permit RBAC 是实时的,当您在仪表板中更新任何角色或权限时会立即更新。

    在我们的 APP 中实现 RBAC

    由于我们已经设置了配置,我们可以开始在应用中强制执行一些角色和权限。但缺少了一些东西!在我们的权限仪表板中,我们没有添加任何用户,因此添加角色和权限是没用的。我们需要一种方法来将应用中的用户与 permit.io 上的用户同步。

    我们可以从“目录”选项卡手动将用户添加到许可仪表板并为他们分配我们创建的角色,但这样做效率低下,因为我们需要从我们的应用程序同步所有内容。

    我们这样做吧。

    为了实现这一点,我们需要一种独特的方法来识别我们的用户。我们使用哪种身份验证方法并不重要,我们只需要为每个用户提供一个唯一的 ID。对于这个项目,我们使用 JWT,因此我们可以解码我们的 JWT 并使用用户 ID 或电子邮件来同步用户以允许。

    执行此操作的最佳时机是在注册过程中。在我们的“注册”服务器操作中,我们需要使用许可 API 来添加角色。

    // signup server action
        export async function signup(
          state: FormState,
          formData: FormData,
        ): Promise {
          // 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);
        }

    在上面的代码中,从第 47 行到第 63 行,我们使用 permit.io API 通过 `permit.api.createUser` 创建用户,并通过 `permit.api.assignRole` 为该新用户分配客户角色

    现在,每当有新用户注册时,都会创建一个用户并与 permit.io 同步,并且为他们分配一个“客户”用户角色,就像我们在本教程前面创建的表中指出的那样。

    查看之前的表格,我们知道应该使用 permit 来正确添加授权的所有地方。其中一个地方是创建商店页面。

    保护我们的创建商店页面

    我们希望只有客户才能创建商店。我们可以通过在创建商店页面中使用许可 API 来强制执行此操作。

    // 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 (
            
    {children}
    ); } export default Layout;

    从上面的代码中,为了保护页面,我们必须检查当前登录的用户是否可以创建商店。如果他们根据分配的角色被授权创建商店,则页面将呈现,否则,他们将被重定向到仪表板页面。

    一旦用户创建商店,他们就会被分配“管理员”角色,如我们的“创建商店”API 调用中所述:

    '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);
          }
        };

    此 API 调用是一个服务器操作,它在我们的数据库中插入一个新商店,并将管理员角色分配给创建该商店的用户。

    让我们转到仪表板路线,看看如何为店主添加共同所有权功能。

    为店主添加共同所有权功能

    从之前的关键角色和访问表中,我们知道我们希望仪表板路线的访问结构是什么样的。

    我们要做的就是保护仪表板上的所有页面,就像保护创建商店页面一样。

    在产品页面中,有一个用于添加商店经理的表单。我们只希望商店管理员可以看到该表单。我们可以检查当前登录用户的权限,并在呈现添加经理表单之前添加条件检查。

    // dashboard/products/page.tsx
        ....
        export default async function InventoryPage() {
          const inventory: ProductWithStore[] = await fetchInventory();
          const permitted = await checkPermission('create', 'Product');
          return (
            

    Store Inventory

    {permitted && ( Add Manager )}
    ); }

    我们的表单如下所示:

    '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>({
            resolver: zodResolver(managerSchema),
          });
          const onSubmit = async (data: z.infer) => {
            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 (
            
    {errors.email && (
    {errors.email.message}
    )}
    ); }

    我们的表单仅接受电子邮件作为输入,提交后,会向我们在“api/addManager/route.ts”中定义的 API 路由发出请求。此端点定义了一个 **POST** API 端点,允许管理员用户为特定商店的另一个用户分配“经理”角色。

    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 },
          );
        }

    代码很长,但基本内容如下:首先,代码验证登录用户的凭证,确保他们具有商店的“管理员”访问权限。如果用户缺乏适当的访问权限或未找到商店,则会返回相应的错误消息。然后,代码使用电子邮件获取要指定为经理的用户,并检查他们是否已经具有“经理”角色。如果没有,它会更新数据库以将“经理”角色分配给用户,并将此更改与 Permit.io 同步,以确保集中角色管理。

    要添加经理角色,我们首先必须取消用户的“客户”角色:

    // 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,
          );

    总结

    就像这样,我们在 Next.js 应用中使用了 Permit.io 来强制执行权限并为店主添加共同所有权功能。

    这篇文章很长。希望您能够了解如何使用 Permit.io 在 Next.js 应用程序中实现授权。您可以通过访问 Permit.io 文档了解更多信息,或者通过 X @umavictor 联系我。