跳转到内容

多租户与行级安全

一个机构 = 一个租户。租户之间的数据隔离,OpSkool 放在数据库层,不放在应用层。

隔离靠 RLS,不靠应用层过滤

章节:隔离靠 RLS,不靠应用层过滤

业务查询一律经 modules/permissionswithOrg 事务入口,事务内 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_studentsclass_teacherslesson_studentslesson_teachersteacher_campuses 等关联表同样没有单独建 RLS,走的是同一种「本身无 org_id、经外键间接落到机构里」的结构性模式(exam_scores 所在的迁移文件自己就点名了 class_students/lesson_students 是先例)。这类表数量更多、模式统一,不在本节逐一列举。

三个数据库角色

章节:三个数据库角色
连接串角色权限
DATABASE_URL应用非 owner,受 RLS 约束
DATABASE_URL_MIGRATEowner仅迁移用
DATABASE_URL_AUTH窄权限 + BYPASSRLSmodules/identity

为什么不能合成一个:

  • 合并应用owner:owner 在 PostgreSQL 里默认不受自己表上的 RLS 约束(除非显式 FORCE ROW LEVEL SECURITY)。用 owner 跑业务查询,等于把上面整套隔离关掉——代码看起来一模一样,隔离却已经不在了,而且没有任何报错提示你。
  • 合并应用authDATABASE_URL_AUTHBYPASSRLS,因为它必须在 org_id 未知时查得到用户。把这个能力交给跑业务查询的角色,同样等于取消隔离。
  • 合并 ownerauth:等于给一条日常在线使用的连接串配上建表改表的权力,一个 SQL 注入就从「读到不该读的行」升级为「改掉整个 schema」。

三条连接串的配置见环境变量参考

校区(scope_units)住在哪一层

章节:校区(scope_units)住在哪一层

校区是底座概念,住在 modules/identity,是权限范围的锚,不隶属于任何业务积木。

这解释了两件事:

  1. 为什么权限总是「按校区划」的——校区就是 RBAC 里的范围单元(scope unit),「校区管理员只能管西湖校区」里的那个「只能」,落地就是它。
  2. 为什么关掉排课积木、校区还在——它属于底座,底座永不受模块开通状态影响。

概念层面的解释见核心概念


本页是 CE README「仓库结构」一节的展开;两处不一致时,以 README 为准。