多租户与行级安全
一个机构 = 一个租户。租户之间的数据隔离,OpSkool 放在数据库层,不放在应用层。
隔离靠 RLS,不靠应用层过滤
章节:隔离靠 RLS,不靠应用层过滤业务查询一律经 modules/permissions 的 withOrg 事务入口,事务内
SET LOCAL app.org_id,PostgreSQL 的行级安全策略据此过滤每一张表的每一行。
这与「应用层过滤」的差别是本质的:
| 应用层过滤 | 行级安全(RLS) | |
|---|---|---|
| 谁负责加租户条件 | 每一处查询的作者 | 数据库 |
| 漏一处会怎样 | 跨租户数据泄漏,且静默 | 查不出来,什么也不会发生 |
| 新人写的第一个查询 | 可能漏 | 天然安全 |
应用层忘记加 where org_id = ? 时,数据库仍然拦得住。第五条依赖铁律
no-raw-db-client-outside-permissions(见架构总览)就是为了保证「没有第二条通往数据库的路」——绕过 withOrg 拿裸客户端,构建会红。
例外
章节:例外modules/identity:登录与 session 解析发生在 org_id 已知之前——用户还没被认出来,当然也就不知道他属于哪个机构,此时 RLS 上下文没法建立。
所以身份模块走一条独立的窄路:只 GRANT 到 identity 相关表的专用角色,拿不到任何业务表。例外被压缩到最小,而不是开一个全局后门。
exam_scores 表:这是学员经营线(点名与课消、成绩与曲线两块积木合起来的数据)上唯一没有建 RLS 策略的表——它没有 org_id 列,机构归属经 exam_id /
student_id 两条外键间接落在各自机构里。隔离不靠数据库策略,靠
createExam / saveExamScores 里的应用层校验把关(机构归属、任教范围、花名册校验各一层)。这条例外的代价写在种子脚本旁边的注释里:手写一行
exam_scores 数据,等于把这三层校验一次全绕过去。
这两处不是 RLS 缺口的全部:class_students、class_teachers、lesson_students、
lesson_teachers、teacher_campuses 等关联表同样没有单独建 RLS,走的是同一种「本身无 org_id、经外键间接落到机构里」的结构性模式(exam_scores 所在的迁移文件自己就点名了 class_students/lesson_students 是先例)。这类表数量更多、模式统一,不在本节逐一列举。
三个数据库角色
章节:三个数据库角色| 连接串 | 角色 | 权限 |
|---|---|---|
DATABASE_URL | 应用 | 非 owner,受 RLS 约束 |
DATABASE_URL_MIGRATE | owner | 仅迁移用 |
DATABASE_URL_AUTH | 窄权限 + BYPASSRLS | 仅 modules/identity 用 |
为什么不能合成一个:
- 合并应用与 owner:owner 在 PostgreSQL 里默认不受自己表上的 RLS 约束(除非显式
FORCE ROW LEVEL SECURITY)。用 owner 跑业务查询,等于把上面整套隔离关掉——代码看起来一模一样,隔离却已经不在了,而且没有任何报错提示你。 - 合并应用与 auth:
DATABASE_URL_AUTH带BYPASSRLS,因为它必须在org_id未知时查得到用户。把这个能力交给跑业务查询的角色,同样等于取消隔离。 - 合并 owner 与 auth:等于给一条日常在线使用的连接串配上建表改表的权力,一个 SQL 注入就从「读到不该读的行」升级为「改掉整个 schema」。
三条连接串的配置见环境变量参考。
校区(scope_units)住在哪一层
章节:校区(scope_units)住在哪一层校区是底座概念,住在 modules/identity,是权限范围的锚,不隶属于任何业务积木。
这解释了两件事:
- 为什么权限总是「按校区划」的——校区就是 RBAC 里的范围单元(scope unit),「校区管理员只能管西湖校区」里的那个「只能」,落地就是它。
- 为什么关掉排课积木、校区还在——它属于底座,底座永不受模块开通状态影响。
概念层面的解释见核心概念。
本页是 CE README「仓库结构」一节的展开;两处不一致时,以 README 为准。
© 2026 OpSkool 办学帮 · 苏ICP备2025224982号-3