Skip to content

多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696

Description

@baozhoutao

一句话说明

unique: true 被物化成不带租户列的全局唯一索引,与平台自己按租户分裂的 autonumber 序列直接矛盾:多租户部署下,各租户从 1 起发号 → 撞进全局唯一索引 → SQLITE_CONSTRAINT_UNIQUE。不需要用户做错任何事,平台左手发的号被平台右手拒掉。

(下游项目实测记录:steedos-labs/os-project-titanwind-ehr#599,排查「导入用户报错」时定位到)

两段平台代码互相打架

子系统 行为 位置(16.1.0 dist)
发号 _objectstack_sequences 主键含 tenant_id(object, tenant_id, field, scope),每个租户各自从 1 起 driver-sql 序列表
unique 物化 只建单列唯一索引,永不掺租户列 driver-sql/dist/index.js 两处,见下

建表路径(createTable):

if (field.unique) col.unique();   // index.js:2860 — 单列 UNIQUE

重建路径(schema-drift rebuild):

if (field?.unique && keptSet.has(name)) {
  const idx = `uniq_${table}_${name}`;
  await this.knex.raw("CREATE UNIQUE INDEX IF NOT EXISTS ?? ON ?? (??)", [idx, table, name]);  // index.js:1993
}

两条路径都无视对象的 tenancy: { enabled, tenantField } 元数据。租户隔离的承诺在读侧、写侧、发号器三个环节都兑现了,唯独 unique 物化不兑现。

影响面

  1. 功能:全平台所有多租户部署的所有 unique 字段。任何 unique: true 字段(编号、编码、工号、名称…)在多租户下变成「全平台抢注」:租户 A 占了 PROD-00001,其他所有租户永远插不进同值。配合租户分裂的 autonumber,是必然撞而非偶然撞。
  2. 安全:跨租户存在性探测 oracle。租户 B 插入时收到 UNIQUE 冲突 = 平台告诉 B「某个别的租户已有这个值」。拿邮箱/编码/名称批量试探即可枚举其他租户的数据存在性——多租户平台的经典泄露口。
  3. 语义:与业界 SaaS 口径相反。Salesforce 等平台 unique 默认是 org 内唯一,全局唯一才是显式特例(如微信 openid、平台级账号标识)。当前默认恰好反了,且没有任何语法能表达「租户内唯一」。

建议修法

  1. driver 物化 unique 时(建表 + rebuild + declared indexes 三条路径),对 tenancy.enabled 的对象自动升为 (tenantField, field) 复合唯一。
  2. 真需要全局唯一的字段给显式语法,例如 unique: 'global'(openid 这类平台级身份)。unique: true 默认 = 租户内唯一。
  3. 单租户部署零影响:租户列恒定,复合唯一退化等价于单列唯一。

存量迁移:纯放松,零数据处理

旧「全局唯一」严格于新「租户内唯一」,存量数据天然满足新约束。迁移只需 drop 旧 uniq_<table>_<field> 单列索引 → create 复合索引,无需查重、无需清洗、不可能失败。建议进 os migrate 的 plan/apply。

验证方式

  1. 开多租户,两个租户各建同编码记录:修前第二个租户报 SQLITE_CONSTRAINT_UNIQUE,修后各自成功。
  2. 同一租户内建同编码记录:修前修后都应被拒(租户内唯一不放松)。
  3. unique: 'global' 字段跨租户同值:仍被拒。

Activity

  1. baozhoutao commented on Jul 27, 2026

    @baozhoutao
    ContributorAuthor

    补充:发布的软件包如何装进单租户/多租户环境 —— 唯一性语义必须由环境决定,不能由包作者写死

    软件包是一份 dist 工件装进不同拓扑的环境(单租户自部署 / 多租户 SaaS / 先单后多)。包作者在元数据里只能表达业务意图(「编码在一个数据空间内唯一」),不可能预知安装环境的租户拓扑。这带来三个必须由平台收口的问题:

    1. 作者手写复合索引是死路——单租户环境下唯一性会静默失效

    如果让下游项目自己绕(删 unique: true、手写 (organization_id, display_name) 复合唯一),这份包装到单租户环境时 organization_id 恒为 NULL/缺失,而 SQLite/Postgres 默认 NULL 互不相等(NULLs distinct)——复合唯一索引对全部存量行一条都不约束,唯一性静默关闭,比撞号更糟(重复数据无声进库,没有任何报错)。

    反过来,作者写单列 unique: true 装进多租户环境,就是本 issue 的跨租户撞号。

    同一份包,两种环境,两种写法各废一头 → 物化决策只能在安装/启动时由 driver 按环境的 tenancy 模式做,不属于包作者。

    2. 期望的安装矩阵

    包声明 单租户环境 多租户环境
    unique: true(默认=数据空间内唯一) 物化为单列 UNIQUE 物化为 (tenantField, field) 复合 UNIQUE
    unique: 'global'(平台级身份,如 openid) 单列 UNIQUE 单列 UNIQUE(跨租户仍全局)

    包作者零感知:同一份 dist,os install/启动同步时 driver 读环境 tenancy 配置决定物化形态。

    3. 环境形态迁移(先单后多 / 先多后单)

    拓扑不是一次定终身:典型路径是先单租户上线、后开多组织(下游项目 os-tianshun-ehr 正是这个路径,#457/#463 关过又要开)。索引形态必须跟着环境迁移:

    • 单→多:drop 单列 UNIQUE → create 复合 UNIQUE。纯放松,存量天然满足,零数据处理;但存量行的租户列须已回填(平台打标机制保证),否则 NULL 行落在约束外(见第 1 点)。
    • 多→单:复合 → 单列是收紧,可能存在跨租户同值存量,os migrate plan 必须先查重报告冲突,不能盲 apply。

    建议这两个转换都进 os migrate 的 plan/apply,作为 tenancy 模式切换的标准动作,而不是留给各项目手工 DDL。

  2. self-assigned this
    on Jul 27, 2026
  3. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    Contributor

    已由 #3717 修复(合并为 b90086a),配套的 cloud 侧标注是 objectstack-ai/cloud#890。

    这条 issue 被 Closes 自动关成 completed,但你提的三条建议里有一条我没有采纳,不希望它被埋在关闭状态里 —— 说明理由,如果你不同意请直接 reopen 或回复。

    未采纳:建议 #1 里的「declared indexes」那条路径

    你建议三条物化路径(建表 / rebuild / declared indexes)都自动升为 (tenantField, field) 复合唯一。前两条做了,第三条刻意没做。

    原因是它会打断真正需要全局唯一的索引。cloud 仓库里的实例:

    sys_slug_reservation  { fields: ['slug'], unique: true }            ← 全局 slug 抢注,加租户列直接失效
    sys_environment       { fields: ['hostname'], unique: true }         ← DNS 主机名,加租户列 = 两个租户抢同一域名
    sys_billing_customer  { fields: ['stripe_customer_id'], unique: true } ← 外部 provider id
    sys_billing_invoice   { fields: ['invoice_number'], unique: true }
    sys_package_version   { fields: ['package_id', 'version'], unique: true }
    

    而且声明式索引的租户语义一直是显式表达的,本仓库里早就有先例:

    sys_team           { fields: ['name', 'organization_id'] }
    sys_member         { fields: ['organization_id', 'user_id'] }
    sys_business_unit  { fields: ['code', 'organization_id'] }
    sys_metadata       { fields: ['type', 'name', 'organization_id', 'package_id'] }
    

    关键区别在于:字段级 unique 没有任何语法能表达复合,所以默认值必须承担租户作用域 —— 这正是你诊断对的地方。而声明式索引里作者已经把列清单写死了,自动注入会与既有约定打架,并且悄悄改掉一批本来正确的索引。

    所以最终契约是:

    声明 物化
    字段级 unique: true + 租户列 复合 (tenantField, field)
    字段级 unique: 'global' 单列全局
    声明式 indexes[] 按列表逐字物化,从不注入租户列

    'global' 在声明式索引上作为 true 的同义词被接受,只为两种写法共用一套词汇,不改变行为。

    部分采纳:存量迁移没进 os migrate

    你建议迁移进 os migrate 的 plan/apply。我改成在 schema sync 时就地收敛 —— 因为没跑过 migrate 的部署会一直坏着,而故障形态是插入被拒。收敛之后 os migrate plan 永远看不到待处理的索引漂移。

    但这留下一个你原本要的东西:DDL 现在发生在启动时,只有一行日志,运维没有预检手段。已单独开 #3727 跟踪。

    一处补充:你没提到的第 4 条路径

    driver-mongodb 有同样的单字段 unique 物化。那里刻意保持原样 —— 该 driver 完全没有行级租户隔离(读不加谓词、写不打戳),造复合索引会假装有隔离,比单字段更糟。租户缺失本身已开 #3724。

    另外

    你诊断里「影响面:全平台所有多租户部署」这个判断比看上去还要准。objectql/registry.ts 的 applySystemFields 给每个注册对象无条件注入 organization_id,而 computeTenantField 有隐式 organization_id 兜底 —— 所以即使对象没声明 tenancy,driver 也把它当租户对象。覆盖面确实是全量。


    Generated by Claude Code

  4. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    Contributor

    更正:上一条里的「已单独开 #3727 跟踪」编号写错了,os migrate 可见性那条实际是 #3728。

    本 issue 衍生的三条跟踪:


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions