PAYATHON 2026

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。

备注:内容仅供参考。