这篇文章系统介绍了基于 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
// 示例:创建 CASL Ability
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
// 示例:将权限规则映射到 Abilities
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);

权限规则的更新

除了覆盖规则,我们会遇到动态规则的场景,在这个场景下就需要进行规则的更新;

  1. 如果是采用了 new AbilityBuilder 的 ability,可以这样进行更新
1
2
const ability = new AbilityBuilder();
ability.rules = rules;
  1. 其他
1
ability.update(rules);

权限规则与后端的集成

casl 是参考自 mongodb 的一个权限管理库,因此他的权限模型也和 mongo 对象类型类似;我们更期望后端能够传递如下类型的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// user
{
...
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
);
// 定义user的权限,后期可根据userId改造等
export const createForUser = () => {
const ability = new AbilityBuilder(
createMongoAbility<[Actions, Subjects], MongoQuery>
);
return ability.build({
// detectSubjectType: subject => {
// return subject.__caslSubjectType__ as ExtractSubjectType<Subjects>
// },
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>

参考资料