Laravel 中如何设计嵌套的复杂 ACL
结论
不要在 F 模型中另写一套 authorizeForF()。用户角色和权限解决的是“能不能执行操作”,付费状态解决的是“当前是否享有这项功能”,两者属于不同规则:
spatie/laravel-permission判断用户是否有操作权限。- 订阅、账单和服务期限决定用户当前是否拥有对应的功能权益。
可以把 Laravel Policy 作为统一授权入口,由 Policy 一并检查操作权限、资源归属和功能权益。Controller、路由中间件和 API 都调用同一个 ability,避免在不同入口重复检查 invoice。
推荐的职责划分
可以这样组织:
Controller / Middleware
↓
Laravel Policy
├── spatie permission:能否执行该操作
├── resource ownership:资源是否属于当前账户
└── EntitlementService:当前套餐是否允许使用
↓
subscription / invoice / grace period
不要把付费状态直接等同于 invoice 状态。账单可能刚完成支付,但异步回调尚未处理;系统也可能存在宽限期、人工赠送额度或退款。此时只检查 invoice.status 很容易误判。更稳妥的方式是把账单结果转换为明确的“功能权益”。
封装功能权益判断
先定义一个服务,集中处理套餐、期限和账单规则:
<?php
namespace App\Services;
use App\Models\Account;
use App\Models\Feature;
final class EntitlementService
{
public function allows(
Account $account,
Feature $feature,
string $capability
): bool {
$entitlement = $account->entitlements()
->where('feature_id', $feature->id)
->where('starts_at', '<=', now())
->where(function ($query) {
$query
->whereNull('ends_at')
->orWhere('ends_at', '>', now());
})
->first();
if ($entitlement === null) {
return false;
}
return $entitlement->allows($capability);
}
}
这里的 entitlements、starts_at、ends_at 和 allows() 只是示例领域模型,需要结合项目的数据结构实现。其他代码只需询问用户是否拥有某项权益,不必了解 invoice、续费或宽限期的具体判断逻辑。
如果功能到期后只限制部分操作,可以使用更细的 capability,例如:
feature.view
feature.edit
feature.export
feature.create_invoice
这样,功能到期后仍可开放 feature.view,同时拒绝 feature.edit 和 feature.export。
在 Policy 中合并全部规则
为 Feature 或实际使用的业务资源创建 Policy:
<?php
namespace App\Policies;
use App\Models\Feature;
use App\Models\User;
use App\Services\EntitlementService;
use Illuminate\Auth\Access\Response;
final class FeaturePolicy
{
public function __construct(
private readonly EntitlementService $entitlements
) {
}
public function edit(User $user, Feature $feature): Response
{
if (! $user->hasPermissionTo('feature.edit')) {
return Response::deny('你没有编辑该功能的权限。');
}
if ($user->account_id !== $feature->account_id) {
return Response::deny('该资源不属于当前账户。');
}
if (! $this->entitlements->allows(
$user->account,
$feature,
'feature.edit'
)) {
return Response::deny('当前套餐或服务期限不允许执行此操作。');
}
return Response::allow();
}
}
hasPermissionTo() 来自 spatie/laravel-permission。如果项目已经通过 Laravel Gate 集成 Spatie,也可以使用 $user->can(...)。需要避免 Policy ability 与内部检查的 permission 名称发生递归。例如,Policy 的 ability 使用 edit,内部检查 feature.edit,两者的含义更容易区分。
Controller 中只需授权一次:
public function edit(Feature $feature)
{
$this->authorize('edit', $feature);
return view('features.edit', compact('feature'));
}
也可以使用路由中间件:
Route::get('/features/{feature}/edit', [FeatureController::class, 'edit'])
->middleware('can:edit,feature');
写操作应在执行点再次授权
隐藏页面按钮无法保护收入相关功能,攻击者仍然可以直接请求 API。因此,创建、更新和导出等写操作都必须在服务端完成授权。
如果核心业务可能由 HTTP、队列或命令行等多个入口调用,可以把授权放在应用服务边界:
<?php
namespace App\Actions;
use App\Models\Feature;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
final class UpdateFeature
{
public function execute(
User $user,
Feature $feature,
array $data
): Feature {
Gate::forUser($user)->authorize('edit', $feature);
$feature->update($data);
return $feature->refresh();
}
}
Controller、API 和其他同步入口都可以复用 UpdateFeature,不必分别组合权限判断。
队列任务执行时通常没有当前登录用户。如果任务对应用户发起的受限操作,应在入队前授权,并根据业务风险决定执行时是否再次检查权益。对于耗时操作,执行时复查可以避免用户在排队期间订阅已经到期。
避免遗漏的措施
只约定“开发者记得调用 Policy”仍有遗漏的可能,可以再加几层约束:
- 把受保护的路由统一放进带有
can中间件的路由组,默认拒绝未授权访问。 - 所有会产生费用或提供付费能力的操作都通过统一的 Action 或 Service 执行。
- 前端权限判断只控制界面展示,后端 Policy 才是安全边界。
- 权益到期时间使用统一的时区和边界规则,并明确
ends_at是否包含指定时刻。 - 在支付成功、退款、撤销或宽限期结束时统一更新 entitlement,同时清理相关权限缓存。
- 针对“有权限且已付费”“有权限但已过期”“已付费但没有角色权限”“资源不属于当前账户”等组合编写 Feature Test。
例如:
public function test_expired_user_cannot_edit_feature(): void
{
$user = User::factory()->create();
$feature = Feature::factory()->for($user->account)->create();
$user->givePermissionTo('feature.edit');
Entitlement::factory()
->for($user->account)
->for($feature)
->expired()
->create();
$this->actingAs($user)
->patch("/features/{$feature->id}", [
'name' => 'Updated name',
])
->assertForbidden();
}
最终,Policy 负责统一作出授权决定,EntitlementService 则是付费权益规则的唯一来源。以后增加续费、试用、宽限期、退款或套餐降级规则时,只需修改权益层,不必逐个检查 Controller。
备注:内容仅供参考。
