搭建多租户启动器那阵子,我打开 Spatie 的配置文件,把 teams 选项设成了 true。那一刻的念头很直接:租户里的角色本来就应该归属于团队,把角色分配划分到 team_id 底下再自然不过了。我的手指在键盘上敲下 setPermissionsTeamId() 的调用,以为整个权限体系的最后一环就这么闭上了。直到测试时发现,一个还没加入任何团队的用户,默默获得了第一个团队里的管理员身份——没有报错,没有异常,数据库里只是多了一行它本不该有的记录。
Spatie 的 laravel-permission 包里内建的团队功能,听起来几乎是专为多租户准备的礼物。启用之后,model_has_roles 表里每一行都会多出一个 team_id 字段,和 model_id、role_id 并列。角色的赋予不再是系统级的,而是被划进某个团队的作用域里。一个用户属于一个团队,他在团队内的权限就由一条带着 team_id 的角色记录来定义。隔离性有了,归属感也有了,似乎多租户权限的地基已经打好。
这套机制的思路其实挺清晰的,我自己甚至早前就在 Filament Mastery 上专门写过这种用法:在租户中间件里通过 setPermissionsTeamId() 把当前团队 ID 注入进去,后续所有角色操作的 where 条件都会自动带上团队过滤。那篇文章底下,有人在评论区问“如果用户还没加入任何团队怎么办”——当时我回复“先让他属于一个默认团队”,敷衍得连自己都信了。直到真正在 Multi-Tenant Starter 里跑起来,才发现这个“默认团队”会把权限逻辑悄悄搅浑。
冲突的根源在于,用户获得“成员”这个应用级角色,不需要等加入团队。有人被邀请注册,有人自行登录,他们落到成员面板里时,甚至都不知道“团队”是什么。但这个成员角色必须被写到 model_has_roles 表中,否则后续的权限中间件根本不放行。问题就在此时浮出水面:Spatie 的迁移脚本已经把 team_id 设成了复合主键的一部分,并且给了它一个默认值 1。
来看看那段建表语句:$table->primary( [ $columnNames[‘team_foreign_key’], $pivotRole, $columnNames[‘model_morph_key’], ‘model_type’ ], ‘model_has_roles_role_model_type_primary’ ); 复合主键一共包进了四个字段,team_foreign_key(也就是 team_id)居于首位。而 SQL 的硬性约束是,复合主键里的任何一列都不能包含 NULL。这不是 Spatie 自己的选择,而是数据库层面的铁律。所以迁移文件紧跟着补了一句:$table->unsignedBigInteger( $columnNames[‘team_foreign_key’] )->default(‘1’); 如果没有这个默认值,任何在 team_id 还不确定时插入的角色分配请求,都会直接在数据库层吃到一个 bad null 报错。于是默认值 1 就像一块自动填补的拼图,把那个 NULL 坑给填上了。
一开始看这个默认值,可能觉得挺贴心的——能跑起来,不会因为空值把整个请求炸掉。可一旦把“能跑”当成安全,背后的静默错误就埋下了。当用户的成员角色在没有团队上下文时被写入,team_id 就被填成 1,而这个 1 指向的正是数据库里第一个团队。于是你什么都没做,一个用户就自动拥有了那个团队的权限,而且不会收到任何警告。没有异常抛出,日志也不记录,只有你偶然点开角色列表,才会发现某个从没加入过团队的用户,居然在这里拥有全权。这种错法比硬失败更折磨人:错了,却没人告诉你错了。
设计团队模式的人大概假设过,角色分配始终发生在一个已知的团队里。所以在他们的脚本里,默认值只是为了让迁移顺利跑通,并不是一个需要被时刻警惕的业务暗号。但真实场景却偏偏打破了这种假设——用户在不属于任何团队的时候就需要权限。反过来一推,这个场景就把复合主键的设计缺陷放大了:因为要保证主列非空,所以必须补默认值;因为有了默认值,那些本该被拒绝的操作变成了静默成功。你几乎不可能在不改动表结构的前提下,让系统既有团队作用域隔离,又能优雅地处理“无团队”状态。
我曾考虑过给 team_id 特殊值 0 或者 -1,然后上层代码判断若 team_id 是特殊值就忽略团队作用域。但这个思路一旦放进 Spatie 的包逻辑里,立刻变得丑陋:所有权限检查的地方都得绕道,所有角色赋予的地方都得额外判断,原本简洁的 API 被一堆特例缠绕。更本质的问题在于,复合主键只是表象,它背后的设计哲学本身就假设了团队始终存在。把默认值 1 改成 0,只不过是把有毒的静默错误换成另一种扭曲的假数据。
至于把 team_id 改成可为空、删掉复合主键、改用普通索引的方案,技术上当然可行,但这就意味着必须亲自接管迁移脚本,自行维护一个与 Spatie 包内部假设不再一致的表。以后每次包升级,都得检查迁移逻辑是否还兼容;每引入一个新的权限特性,都要确认它没在暗处依赖那个复合主键。这种侵入式的改法,等于为了处理一个边缘场景,把整个包拖进自己的维护领域,代价远比想象中大。
所以最后我干脆把 team mode 整个关掉了。与其让一个贴心的默认值把数据悄悄带歪,不如回到没有团队字段的纯净表里,自己写一层明确的作用域逻辑。至于那个成员角色到底怎么在“无团队”状态下运转,以及后续到底用什么方式实现团队级的角色隔离,我在 Filament Mastery 的完整文章里给出了替代做法——不过那是另一次拆解了。眼下最关键的决定,是当发现数据库正在悄无声息地背叛你的业务规则时,选择站起来走开,而不是跪下去修补那个复合主键。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.