这篇文章系统介绍了基于 CASL 的前端权限管理方案,重点阐述了其通过”能力-规则-操作-主体”模型实现细粒度权限控制的机制。方案支持字段级权限校验和动态条件判断,提供 Vue 框架下的路由守卫集成、组件级权限指令、类型安全增强等实践方案,特别适合中大型项目实现灵活可维护的权限体系,通过前后端统一的权限规则格式,有效保障了系统安全性和开发效率。
基于CASL的前端权限管理方案
介绍
什么是 CASL
CASL 是一套专为 JavaScript 和 TypeScript 开发者设计的工具,用于简化和管理前端应用程序中的访问控制。通过 CASL,您可以轻松地定义和检查用户对应用程序中各种资源的权限,从而确保您的应用在安全性和用户体验方面达到最佳状态。
为什么选择 CASL
- 灵活性: CASL 提供了灵活的规则定义机制,使您能够根据业务需求轻松创建细粒度的权限规则。
- 易用性: CASL 的 API 设计简洁直观,使得权限管理在代码中变得容易理解和维护。
- 框架无关性: CASL 不依赖于特定的前端框架,因此您可以在任何框架(如 React、Angular、Vue 等)中使用。
安装和配置
安装 CASL
使用 pnpm 安装:
pnpm add @casl/vue @casl/ability
创建 CASL Ability
CASL Ability 是定义权限规则的核心。您可以在应用程序的适当位置创建 CASL Ability,并配置它以反映您的权限结构。
1 2 3 4 5 6 7 8 9 10
| import { defineAbility } from "@casl/ability";
const ability = defineAbility((can) => { can("read", "Project"); can("create", "Project"); can("update", "Project"); can("delete", "Project"); }); export default ability;
|
在上述示例中,我们创建了一个简单的 CASL Ability,表示用户可以对项目进行读取、创建、更新和删除操作。
集成 CASL Ability 到应用程序
集成 CASL Ability 到应用程序,以便在整个应用程序中共享和使用它。
1 2 3 4 5 6 7 8 9
| import { createApp } from "vue"; import { abilitiesPlugin } from "@casl/vue"; import ability from "./services/ability";
createApp() .use(abilitiesPlugin, ability, { useGlobalProperties: true, }) .mount("#app");
|
基本概念
Abilities(能力)的定义
在 CASL 中,”能力” 是指用户在应用程序中执行操作的权限。能力由一组规则(rules)组成,每个规则定义了用户对特定主体(subject)执行特定操作(action)的条件。能力的定义通常在应用程序初始化时完成。
1 2 3 4 5
| const ability = defineAbility((can) => { can("read", "Project"); can("update", "Project", ["name"]); });
|
上述示例中,定义了一个简单的能力,允许用户读取(read)和更新(update)名为 “Project” 的主体,其中更新操作仅允许修改 “name” 字段。
Actions(操作)和 Subjects(主体)的概念
- Actions(操作): 操作表示可以在主体上执行的具体行为。常见的操作包括 “create”、”read”、”update” 和 “delete”。能力的规则将指定用户是否可以执行这些操作。
- Subjects(主体): 主体是用户执行操作的实体或资源。例如,”Project” 可能是一个主体,用户可以对其执行读取、更新等操作。
Rules(规则)的创建和使用
规则定义了用户对主体执行操作的条件。每个规则包含一个动作(action)、一个主体(subject)以及可选的字段(fields)。
1 2
| can("update", "Project", ["name"]);
|
上述示例中,规则表示用户可以对 “Project” 主体执行更新操作,但仅限于修改 “name” 字段。
基本流程
在组件中使用 Abilities
在组件中,我们可以使用 CASL 提供的 useAbility hook 来获取当前用户的 Abilities,并据此控制组件的渲染或行为。
1 2 3 4 5 6 7 8 9 10 11 12
| <template> <div> <div v-if="can('create', 'Post')"> <a @click="createPost">Add Post</a> </div> </div> </template>
<script setup> import { useAbility } from "@casl/vue"; const { can } = useAbility(); </script>
|
在路由守卫中使用 Abilities
对于基于路由的应用程序,我们可以使用 CASL Abilities 控制路由守卫,确保用户只能访问其具有权限的页面。
1 2 3 4 5
| // 示例:在路由守卫中使用 Abilities import { createRouter, createWebHistory } from 'vue-router'; const router = createRouter({ history: createWebHistory(), routes: [ { path: '/project/:id', component: ProjectDetail, meta: { // 使用 meta 字段定义需要的权限 ability: 'read', subject: 'Project', }, }, ], }); export default router;
|
在 permit.ts 中定义路由守卫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { useAbility } from "@casl/vue"; router.beforeEach((to, from, next) => { const ability = useAbility(); const requiredAbility = to.meta.ability; const requiredSubject = to.meta.subject;
if (requiredAbility && requiredSubject) { if (ability.can(requiredAbility, requiredSubject)) { next(); } else { next("/access-denied"); } } else { next(); } });
|
在应用程序中定义权限规则
角色和权限的概念
在权限管理中,通常使用角色和权限的概念来组织和分配用户的权限。
- 角色: 角色是一组权限的集合,通常代表用户在系统中的身份或职能。例如,”管理员”、”编辑者”、”普通用户” 等都可以是角色。
- 权限: 权限是用户能够执行的具体操作。例如,”创建项目”、”编辑文档” 等都可以是权限。
规划权限规则
在使用 CASL 时,规划权限规则是设计健壮且易于维护的权限管理系统的关键一步。我们可以按照角色和权限的层次结构定义规则。
这里 manage 和 all 是 casl 中的特殊关键字,表示可以操控全部的 actions 和全部的 subjects
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const ability = defineAbility((can) => { if (user.isAdmin) { can("manage", "all"); } else if (user.isEditor) { can(["create", "update"], ["Project", "Document"]); can("delete", "Project"); } else { can("read", "all"); } });
|
将权限规则映射到 Abilities
一旦我们规划了权限规则,就可以将其映射到 CASL 的 Abilities 中。这通常在用户登录时进行,以确保用户在整个会话中具有正确的权限。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function mapRulesToAbilities(user) { return defineAbility((can) => { if (user.isAdmin) { can("manage", "all"); } else if (user.isEditor) { can(["create", "update"], ["Project", "Document"]); can("delete", "Project"); } else { can("read", "all"); } }); }
const user = loginUser(); const userAbilities = mapRulesToAbilities(user);
|
权限规则的更新
除了覆盖规则,我们会遇到动态规则的场景,在这个场景下就需要进行规则的更新;
- 如果是采用了 new AbilityBuilder 的 ability,可以这样进行更新
1 2
| const ability = new AbilityBuilder(); ability.rules = rules;
|
- 其他
权限规则与后端的集成
casl 是参考自 mongodb 的一个权限管理库,因此他的权限模型也和 mongo 对象类型类似;我们更期望后端能够传递如下类型的数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { ... rules: [{ action: 'read', subject: 'Article', }, { action: 'update', subject: 'Article', conditions: { published: true, } }] .... }
|
示例和实践
文档中有描述很多实践操作,比如增强视图类型,组合 api,组合 actions(别名),can 组件等;这里搬运一点经常使用到的功能;
增强视图类型
TypeScript 没有其他方法可以在不进行增强的情况下了解全局属性的类型。为此,我们添加 src/shims-ability.d.ts 文件,其中包含以下内容:
1 2 3 4 5 6 7 8
| import { AppAbility } from "./AppAbility";
declare module "vue" { interface ComponentCustomProperties { $ability: AppAbility; $can(this: this, ...args: Parameters<this["$ability"]["can"]>): boolean; } }
|
组合 Api
我们可以创建一个单独的 useAppAbility 钩子,这样我们就不需要导入 useAbility 和 AppAbility 在每个组件中,我们想要检查权限,但只需导入一个钩子:
1 2 3 4
| import { useAbility } from "@casl/vue"; import { AppAbility } from "../AppAbility";
export const useAppAbility = () => useAbility<AppAbility>();
|
ability 类型定义
- 创建 Actions 类型
- 创建 Subjects 类型
- 创建由 MatchConditions 拓展的 Conditions 类型,包含了可能存在的操作
- 组合类型 CreateAbliity
- 构造 ability,
- 可提供 fieldMatcher 用于字段为数组时候的判断;
- 可提供 conditionsMatcher 针对 condition 项进行配置
- 导出 CreateAbliityRules 规则(这部分规则其实可以是后端提供相同类型)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| import { type SubjectRawRule, AbilityBuilder, createMongoAbility, type MongoAbility, type MongoQuery, type InferSubjects, } from "@casl/ability";
export type Actions = "create" | "read" | "update" | "delete" | "download";
type ConditionsExtra = { published?: boolean; drop?: boolean; recover?: boolean; newVersion?: boolean; }; class Folder { drop?: boolean = false; recover?: boolean = false; published?: boolean = false; newVersion?: boolean = false; } class File { drop?: boolean = false; recover?: boolean = false; published?: boolean = false; newVersion?: boolean = false; } export type Subjects = | "Global" | "Project" | InferSubjects<typeof Folder | typeof File> | "Folder" | "File" | "all"; export type CreateAbliity = MongoAbility<[Actions, Subjects], MongoQuery>; export const createAbliity = new AbilityBuilder<CreateAbliity>( createMongoAbility );
export const createForUser = () => { const ability = new AbilityBuilder( createMongoAbility<[Actions, Subjects], MongoQuery> ); return ability.build({ fieldMatcher: (fields) => (field) => fields.includes(field), }); }; export const createUserAbility = createForUser();
export type CreateAbliityRules = SubjectRawRule< Actions, "Global" | "Project" | "Folder" | "File", ConditionsExtra >;
|
Subject helper
用于判断对应的 condition 条件
1 2 3 4
| import { subject } from "@casl/ability"; if (cannot("create", subject("File", { published: true, newVersion: false }))) { }
|
Can 组件
将 can 组件作为全局组件注册
1 2 3 4 5 6 7 8 9 10
| import { Can } from "@casl/vue"; app.component(Can.name, Can); 导出Can组件类型; import { Can } from "@casl/vue";
declare module "vue" { export interface GlobalComponents { Can: typeof Can; } }
|
Can 组件由于不支持 object 类型,所以使用 subject helper 的时候需要注意改为 function,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <Can I="update" :a="() => subject('File', { recover: true })" passThrough v-slot="{ allowed }" > <NxButton v-if="props.showButtons?.includes('move')" :disabled="!allowed || isDisabled" variant="outline" @click="handleClick('move')"> <template #icon> <IconTool icon="bi:arrow-bar-left" size="16" /> </template> 移动 </NxButton> </Can>
|
参考资料