Laravel 中 RBAC 的实战深度解析
引言
授权问题通常开始得很朴素。
也许是先加一个 is_admin 字段,再添上几条 if,一切看起来都挺好。但随着应用规模变大,这种简朴会反过来成为负担。
现在需要一位可以处理账单但不能删除项目的计费经理;需要可以邀请成员但范围只限于自己团队的团队管理员;还需要可以读取所有内容但不能修改任何内容的审计员。于是"简单"的授权规则开始散落到控制器、队列作业、Livewire 组件、Blade 模板以及随手散落的 Service 类中。
RBAC 正是在这个时候开始显出它的意义。
基于角色的访问控制(Role-Based Access Control)给授权提供了更清晰的形状:用户拥有角色,角色拥有权限,应用检查的是权限,而不是把职位头衔硬编码遍布代码库。
本文将对 RBAC 做一次深度剖析,并在不使用任何第三方包的前提下,用 Laravel 落地一套实现。
首先会讲理论,因为跳过设计部分的话,很容易构造出一套"第一天看起来干净、半年后成为痛点"的权限体系。
然后会构建一份切合实践的实现,涵盖:
- 以团队为边界的角色
- 通过角色传递的权限继承
- 职责分离约束(Separation of Duty)
- Policy 与 Gate
- 带缓存的权限解析
- 针对关键授权流程的测试
它不会做到企业级 IAM 的复杂度,但会显著比一个扁平的 isAdmin() 检查更贴近真实应用。
RBAC 究竟是什么
本质上,RBAC 是一种通过角色授予访问权限、而不是对每个用户单独赋权的模型。
其核心逻辑很直接:
- 一个用户可以被分配一个或多个角色
- 一个角色聚合一组权限
- 系统检查用户当前激活的角色是否授予了所需权限
这在今天听起来很显然,但其中有一个重要的设计优势:角色往往比用户和单条访问规则更稳定。
成员会加入团队、调动部门、升职离职、临时兼任职责。如果权限直接挂在每个用户身上,授权模型的运维成本会迅速上升。
NIST 形式化的经典 RBAC 通常分层描述:
- Core RBAC:用户、角色、权限
- Hierarchical RBAC:角色之间可以彼此继承权限
- Constrained RBAC:引入如"职责分离"等约束
最后一层在真实应用中非常重要。
"系统有了角色"并不等同于"系统就安全"。经常还需要附加约束,例如:
- 审计员不应在同一团队里同时担任计费经理
- 发出邀请的人,不应有权分配比自己等级更高的角色
- 被停用的团队,应让所有写权限失败,哪怕用户仍持有角色
因此一个有用的心智模型是:RBAC 提供了一个稳固的基础模型,真实系统通常会在其之上叠加上下文规则。
RBAC 不等于"用户和角色"
许多应用声称自己"有 RBAC",但它们实际拥有的只是一套"分组标志位"。
这个区别很重要。
在真正的 RBAC 模型里:
- 权限挂在角色上
- 用户通过角色获得权限
- 授权检查谈论的是能力,而非角色名
这意味着代码应更倾向于这样问:
$user->can('deploy', $project);而不是:
$user->isAdmin() || $user->isOwner()后一种写法把组织结构泄露进了应用逻辑;前一种则让领域语言聚焦于用户被允许做什么。
这也让系统更容易演进。如果哪天 team-admin 与 release-manager 都可以部署项目,只要更新"角色 → 权限"映射即可,不需要改动代码库里每一个控制器。
为什么 RBAC 在真实应用中会走样
团队在过快引入 RBAC 时,会反复落入几个陷阱。
到处都是直挂在用户上的权限。
这通常起于图方便:"那就在用户表上放一个带权限的 JSON 列吧。"这条路在你需要审计"谁能做什么"、或要对上百个用户套同一条规则时就会崩溃。
有角色却没有权限。
另一个常见问题是在代码里到处直接检查角色名:
if ($user->role === 'admin') {
// ...
}这样脆弱,因为代码和具体角色命名紧耦合在了一起。
没有作用域。
这是最大的那一类。一个用户可能在 A 团队是管理员、在 B 团队只是普通观看者。如果角色是全局的,就没法干净地表达这种情况。
没有约束。
一旦财务、合规或审计场景进入讨论,仅仅"一列角色"就不够用了。需要的是护栏。
所以一套可用的 RBAC 实现通常必须具备:作用域、约束,以及明确的"在给定上下文中解析出用户有效权限"的方式。
设计一套具备团队作用域的 RBAC 模型
接下来为一个 SaaS 风格的应用构建实现,场景是:用户属于团队,并在团队内与项目打交道。
采用的规则:
- 用户在同一个团队中可以持有多个角色
- 角色授予权限
- 有效权限是用户在该团队中所有角色权限的并集
- 部分角色组合在同一团队内被禁止同时出现
- 平台管理员(platform admin)绕过普通的团队规则
模型的结构大致如下:
具备能力于
User —————————————▶ Team
│ │
│ │
│ 角色分配 │
└────────▶ Team Role ◀── Role ─┐
│
│ Role–Permission 关联
▼
Permission
│
▼
Project一个带团队作用域的 RBAC 模型:用户通过在团队内被指派的角色获得权限。
这已经比一列 users.role 现实得多,因为它捕捉到了真实业务的一个常见需求:同一个人在不同边界里可以意味着不同的东西。
先从权限开始,而不是角色
避免 RBAC 设计走歪最有效的一条经验,是先定义权限。
角色是组织标签。权限才是真实的应用能力。
以下是示例中的一组权限:
enum Permission: string
{
case ViewTeam = 'team.view';
case UpdateTeam = 'team.update';
case InviteMembers = 'team.invite-members';
case UpdateMemberRoles = 'team.update-member-roles';
case RemoveMembers = 'team.remove-members';
case ViewProject = 'project.view';
case CreateProject = 'project.create';
case UpdateProject = 'project.update';
case DeleteProject = 'project.delete';
case DeployProject = 'project.deploy';
case ManageBilling = 'billing.manage';
}有了这些,代码就可以围绕 project.deploy 这样的动作来组织,而不是 owner、admin、developer 这些角色名。
现在再把角色映射到权限上。
定义角色及其层级
建模层级最朴素的方式,是给每个角色一个数值 level 与一组权限集合。
示例里采用的角色:
owneradminbilling-managerdeveloperviewerauditor
先定义一个 RoleDefinition 值对象:
final readonly class RoleDefinition
{
/**
* @param list<Permission> $permissions
*/
public function __construct(
public string $name,
public int $level,
public array $permissions,
) {}
public function has(Permission $permission): bool
{
return in_array($permission, $this->permissions, true);
}
}再为内置角色准备一个注册表:
final class RoleRegistry
{
/** @return array<string, RoleDefinition> */
public static function all(): array
{
return [
'owner' => new RoleDefinition(
name: 'owner',
level: 100,
permissions: Permission::cases(),
),
'admin' => new RoleDefinition(
name: 'admin',
level: 80,
permissions: [
Permission::ViewTeam,
Permission::UpdateTeam,
Permission::InviteMembers,
Permission::UpdateMemberRoles,
Permission::RemoveMembers,
Permission::ViewProject,
Permission::CreateProject,
Permission::UpdateProject,
Permission::DeleteProject,
Permission::DeployProject,
],
),
'billing-manager' => new RoleDefinition(
name: 'billing-manager',
level: 60,
permissions: [
Permission::ViewTeam,
Permission::ManageBilling,
],
),
'developer' => new RoleDefinition(
name: 'developer',
level: 40,
permissions: [
Permission::ViewTeam,
Permission::ViewProject,
Permission::CreateProject,
Permission::UpdateProject,
Permission::DeployProject,
],
),
'viewer' => new RoleDefinition(
name: 'viewer',
level: 10,
permissions: [
Permission::ViewTeam,
Permission::ViewProject,
],
),
'auditor' => new RoleDefinition(
name: 'auditor',
level: 20,
permissions: [
Permission::ViewTeam,
Permission::ViewProject,
],
),
];
}
public static function get(string $name): RoleDefinition
{
return self::all()[$name]
?? throw new InvalidArgumentException("Unknown role [{$name}].");
}
}即便未来要把角色与权限持久化到数据库,从这样一个清晰的权限模型起步,也能避免许多模糊的设计决策。
职责分离比多数人想象的更重要
把形式化 RBAC 中最有用的一部分——约束——带到应用里,很多时候就是把"职责分离"规则明确下来。
比如规定:审计员不能在同一团队中同时兼任计费经理。这就是一条职责分离规则。
它不是运行时请求上的权限检查,而是角色分配的合法性规则。也就是说,最好的执行时机是在角色被分配时,而不是用户后续要执行某个操作时。
final class TeamRoleConstraint
{
/**
* @return list<array{0: string, 1: string}>
*/
public function forbiddenPairs(): array
{
return [
['auditor', 'billing-manager'],
];
}
/**
* @param list<string> $roleNames
*/
public function assertValid(array $roleNames): void
{
foreach ($this->forbiddenPairs() as [$first, $second]) {
if (in_array($first, $roleNames, true) && in_array($second, $roleNames, true)) {
throw new DomainException("Roles [{$first}] and [{$second}] cannot be combined.");
}
}
}
}这种规则能让授权模型保持诚实。没有它,在技术上或许算 RBAC,却仍允许业务从未预期过的危险角色组合。
数据库结构
把这套模型落到数据库:
roles表permissions表permission_role关联表team_user_roles关联表,用来在团队内把角色分配给用户
最后一张表是整个方案的关键。
Schema::create('roles', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->unsignedInteger('level')->default(0);
$table->timestamps();
});
Schema::create('permissions', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
Schema::create('permission_role', function (Blueprint $table): void {
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->foreignId('permission_id')->constrained()->cascadeOnDelete();
$table->primary(['role_id', 'permission_id']);
});
Schema::create('team_user_roles', function (Blueprint $table): void {
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['team_id', 'user_id', 'role_id']);
$table->index(['team_id', 'user_id']);
});这种设计带来几项关键能力:
- 一个用户在同一团队里可以持有多个角色
- 同一个角色可以在多个团队中复用
- 有效权限可以通过 JOIN 解析出来
- 角色分配本身可被审计
如果应用规则是"每位用户在每个团队内只能担任一个角色",可以把这张表简化为带 role 字段的成员表。但保留多对多分配,会得到一套更忠实的 RBAC 模型。
Eloquent 关联
模型保持直观:
final class Role extends Model
{
public function permissions(): BelongsToMany
{
return $this->belongsToMany(PermissionModel::class, 'permission_role');
}
}
final class Team extends Model
{
public function roleAssignments(): HasMany
{
return $this->hasMany(TeamUserRole::class);
}
}
final class User extends Authenticatable
{
public function teamRoleAssignments(): HasMany
{
return $this->hasMany(TeamUserRole::class);
}
public function rolesForTeam(Team $team): Collection
{
return $this->teamRoleAssignments()
->with('role.permissions')
->where('team_id', $team->id)
->get()
->pluck('role')
->filter();
}
}
final class TeamUserRole extends Model
{
protected $table = 'team_user_roles';
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}结构有了。接下来是让这套系统用起来舒服的那一部分:有效权限的解析。
解析有效权限
Policy 里不应每次授权检查都手工去 JOIN 角色和权限——这样的逻辑会散落到各处。更好的做法是做一个 action 或 service,专门回答一个问题:
此刻,这名用户在这个团队里有效持有哪些权限?
use Illuminate\Contracts\Cache\Repository as Cache;
final readonly class ResolveTeamPermissions
{
public function __construct(private Cache $cache) {}
/**
* @return array<string, true>
*/
public function handle(User $user, Team $team): array
{
$key = "rbac:team:{$team->id}:user:{$user->id}:permissions";
return $this->cache->remember($key, now()->addMinutes(10), function () use ($user, $team): array {
return $user->rolesForTeam($team)
->flatMap(fn (Role $role) => $role->permissions)
->pluck('name')
->unique()
->mapWithKeys(fn (string $permission) => [$permission => true])
->all();
});
}
}这样能得到一张查表式的权限映射:
[
'team.view' => true,
'project.deploy' => true,
'billing.manage' => true,
]然后在它之上构建一个专门的授权器:
use Illuminate\Auth\Access\Response;
final readonly class TeamAuthorizer
{
public function __construct(private ResolveTeamPermissions $resolver) {}
public function allows(User $user, Team $team, Permission $permission): Response
{
if ($user->is_platform_admin) {
return Response::allow();
}
if ($team->is_suspended) {
return $permission === Permission::ViewTeam || $permission === Permission::ViewProject
? Response::allow()
: Response::deny('This team is suspended.');
}
$permissions = $this->resolver->handle($user, $team);
return isset($permissions[$permission->value])
? Response::allow()
: Response::deny('You do not have the required permission in this team.');
}
}这里是整体业务级护栏最合适的放置位置——它们位于"单纯的角色成员关系"之上。
更稳妥的角色分配流程
接下来把角色分配做稳。这也是另一个常被偷工减料、直接在控制器里调一次 attach() 就完事的地方。
把规则集中到一个专用 action 里更稳:
use Illuminate\Support\Facades\DB;
final readonly class AssignRolesToTeamMember
{
public function __construct(private TeamRoleConstraint $constraint) {}
/**
* @param list<int> $roleIds
*/
public function handle(User $actor, Team $team, User $member, array $roleIds): void
{
DB::transaction(function () use ($actor, $team, $member, $roleIds): void {
$roles = Role::query()
->whereKey($roleIds)
->get(['id', 'name', 'level']);
$roleNames = $roles->pluck('name')->all();
$this->constraint->assertValid($roleNames);
$highestRequestedLevel = (int) $roles->max('level');
$highestActorLevel = (int) $actor->rolesForTeam($team)->max('level');
if ($highestRequestedLevel >= $highestActorLevel && ! $actor->is_platform_admin) {
throw new DomainException('You cannot assign a role equal to or higher than your own.');
}
TeamUserRole::query()
->where('team_id', $team->id)
->where('user_id', $member->id)
->delete();
foreach ($roles as $role) {
TeamUserRole::create([
'team_id' => $team->id,
'user_id' => $member->id,
'role_id' => $role->id,
'assigned_by' => $actor->id,
]);
}
});
}
}这里有两件重要的事情:
- 角色约束在写入时就被强制
- 角色提升在被持久化之前就被拦下
这让数据更干净,授权层更值得信任。
与 Gate 和 Policy 的集成
把 RBAC 接入 Laravel 的授权体系。框架已经通过 Gate 与 Policy 给了一套关于"能力"的干净词汇表——不如顺势使用,而不是另造 API。
先给平台管理员一个全局旁路:
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::before(function (User $user, string $ability) {
return $user->is_platform_admin ? true : null;
});
}这和 Laravel 的授权模型契合得很好:before 返回非 null 时,该结果胜出。
接着是项目的 Policy:
use Illuminate\Auth\Access\Response;
final readonly class ProjectPolicy
{
public function __construct(private TeamAuthorizer $authorizer) {}
public function view(User $user, Project $project): Response
{
return $this->authorizer->allows($user, $project->team, Permission::ViewProject);
}
public function update(User $user, Project $project): Response
{
return $this->authorizer->allows($user, $project->team, Permission::UpdateProject);
}
public function deploy(User $user, Project $project): Response
{
return $this->authorizer->allows($user, $project->team, Permission::DeployProject);
}
}于是调用方非常干净:
Gate::authorize('deploy', $project);对于需要更多上下文的场景,比如"邀请成员并指定角色",Laravel 的能力方法可以接收一组参数:
final readonly class TeamPolicy
{
public function __construct(private TeamAuthorizer $authorizer) {}
public function inviteMember(User $user, Team $team, Role $requestedRole): Response
{
$permissionCheck = $this->authorizer->allows($user, $team, Permission::InviteMembers);
if ($permissionCheck->denied()) {
return $permissionCheck;
}
$highestActorLevel = (int) $user->rolesForTeam($team)->max('level');
return $requestedRole->level < $highestActorLevel
? Response::allow()
: Response::deny('You cannot invite a member with that role.');
}
}然后控制器层:
public function store(StoreTeamInvitationRequest $request, Team $team): JsonResponse
{
$requestedRole = Role::query()->findOrFail($request->integer('role_id'));
Gate::authorize('inviteMember', [$team, $requestedRole]);
// Persist invitation...
}这类场景恰是 Laravel 与 RBAC 贴合得最好的地方:Policy 方法拿到资源加额外上下文,控制器保持轻薄。
授权完整流程
把这一切接上之后,一次授权的路径大致如此:
Controller ──authorize('deploy', project)──▶ Gate / Policy
│
▼
TeamAuthorizer
│
检查平台管理员 / 团队状态
│
▼
ResolveTeamPermissions
│
▼
Cache / 数据库
加载权限映射并回传
│
▼
允许 / 拒绝的 Response
│
▼
Controller 得到授权结果或 AuthorizationException一次典型请求里,Policy 把真正的 RBAC 判断下推到专用授权器。
这种分层值得保留。Policy 擅长做面向 HTTP 的授权门面;但专用授权器让这套领域规则在队列作业、action、事件监听器以及任何非控制器的地方都可复用。
缓存与失效
如果每次请求都去解析角色与权限关系,RBAC 检查很快就会变得查询密集。因此给"有效权限映射"加一层缓存通常是值得的。
但缓存一旦开启,清理策略也必须清晰。
当以下任一发生时,应当清理对应 team/user 对的缓存:
- 角色分配被新增或移除
- 某个角色的权限被修改
- 团队被停用或恢复
final readonly class ForgetTeamPermissionCache
{
public function __construct(private Cache $cache) {}
public function handle(int $teamId, int $userId): void
{
$this->cache->forget("rbac:team:{$teamId}:user:{$userId}:permissions");
}
}看起来是件小事,但它正是"RBAC 层很快"与"任何渲染大量 @can 检查的页面都陷入 N+1 风格的性能痛苦"之间的分水岭。
认真地给 RBAC 写测试
授权代码正是那种值得被认真测试的代码。它保护的是关键行为,随时间会不断增长,细微的回归又很容易被引入。
对这种类型的实现,把测试拆成两组比较合适:
- 单元测试:聚焦权限解析与角色约束
- Feature 测试:通过 HTTP 端点或 Gate 验证 Policy 的行为
下面是几段 Pest 示例。
针对有效权限的聚焦测试:
it('unions permissions from multiple roles in the same team', function () {
$user = User::factory()->create();
$team = Team::factory()->create();
$developer = Role::factory()->create(['name' => 'developer']);
$billingManager = Role::factory()->create(['name' => 'billing-manager']);
$developer->permissions()->attach([
PermissionModel::fromName(Permission::DeployProject)->id,
]);
$billingManager->permissions()->attach([
PermissionModel::fromName(Permission::ManageBilling)->id,
]);
TeamUserRole::factory()->create([
'team_id' => $team->id,
'user_id' => $user->id,
'role_id' => $developer->id,
]);
TeamUserRole::factory()->create([
'team_id' => $team->id,
'user_id' => $user->id,
'role_id' => $billingManager->id,
]);
$permissions = app(ResolveTeamPermissions::class)->handle($user, $team);
expect($permissions)->toHaveKeys([
Permission::DeployProject->value,
Permission::ManageBilling->value,
]);
});约束测试:
it('rejects forbidden role combinations', function () {
$constraint = new TeamRoleConstraint();
expect(fn () => $constraint->assertValid(['auditor', 'billing-manager']))
->toThrow(DomainException::class, 'Roles [auditor] and [billing-manager] cannot be combined.');
});Feature 风格的 Policy 测试:
it('allows a developer to deploy projects in their own team', function () {
$user = User::factory()->create();
$team = Team::factory()->create();
$project = Project::factory()->for($team)->create();
$developer = Role::factory()->create(['name' => 'developer', 'level' => 40]);
$deployPermission = PermissionModel::factory()->create([
'name' => Permission::DeployProject->value,
]);
$developer->permissions()->attach($deployPermission);
TeamUserRole::factory()->create([
'team_id' => $team->id,
'user_id' => $user->id,
'role_id' => $developer->id,
]);
expect(Gate::forUser($user)->allows('deploy', $project))->toBeTrue();
});关键的不是覆盖率,而是信心。当未来新增一个角色或权限时,这些测试能让调整更踏实,而不是全靠猜。
需要避开的陷阱
有一些坑几乎总会反复出现。
角色爆炸。 如果每一种细微差别都新建一个角色,整套模型会越来越难推理。更好的做法是维护一个较小的角色集合加上清晰的权限集合。模型一旦过度依赖上下文,就说明需要在 RBAC 之上叠加一些 ABAC 风格的检查。
把业务规则写进控制器。 控制器应当触发授权,而不是定义授权。一个接口里是 $user->isAdmin(),另一个接口里是 $user->belongsToTeam($team),系统已经在漂移。
角色分配没有审计线索。 如果角色变更频繁,至少要保留 assigned_by、时间戳,理想情况下还要一份专门的审计日志。访问控制是最后一个还能容忍"我记得上周改过吧"这种模糊说法的领域。
把所有权当成授权。 所有权可能顺带授予很多权限,但它本质仍是一个业务概念。应该把它建模为某个角色或某条专门规则,而不是散落各处的一次性检查。
只测 happy path。 授权 bug 往往藏在负面场景里:错误的团队、违规的角色提升、被停用的租户、互斥的角色组合。这些才是测试需要真正投入注意力的地方。
RBAC 不够用的场景
RBAC 在许多 Laravel 应用中都很好用,但它不是万能药。有时候还需要基于以下维度的规则:
- 所有权
- 时间窗口
- 订阅计划
- 地理位置
- 文档状态
- 用户与资源之间的关系
这并不是 RBAC 的失败,只是意味着授权模型的边界大于"角色本身"。实际中,成熟系统常常出现这样的分层组合:
- 用 RBAC 承载稳定的权限基线
- 用 Policy 承载资源感知的检查
- 用额外约束承载上下文规则
这种组合通常比"把每一条规则都塞进角色名"要可扩展得多。
结语
RBAC 远不只是 users 表上的一个 role 列。
只要实现得当,它能提供一个清晰的授权模型,建立在角色、权限、作用域与约束之上。其价值在于:重复逻辑更少、权限管理更安全、代码讨论的是"能力"而不是"职位头衔"。
在 Laravel 里,一个比较舒适的落点通常是:把 RBAC 模型本身显式地放在领域里,用一个专用 service 解析有效权限,再通过 Gate 与 Policy 暴露规则。这样控制器保持轻薄、授权逻辑保持集中,测试也有稳定的靶子。
要把这套方案落到真实项目中,可以从小处入手:先定义好权限;把角色分配限定在合适的边界上;加上一两条职责分离约束;像对待 happy path 一样认真地测试负面场景。
这样下来,走得远远比"在应用里随手加上 is_admin 检查、指望它能越用越顺"要靠谱得多。