Skip to content

架构:把「服务端托管字段」收敛成一个声明式 schema 概念(统一 owner_id / 公开表单托管集 / organization_id 的中间件特例) #3058

Description

@os-zhuang

背景

写路径上「哪些字段由服务端托管、客户端不得自由写」的规则,目前散落成 plugin-security 中间件里的 N 个互相独立的特例,每个都是单独硬编码 + 单独执法:

  1. owner_id(属主锚点,安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 / PR fix(security): 守卫 owner_id 属主锚点 + bulk 写按属主收敛 (#3004, #2982) #3018) — step 3.5 硬编码字段名 owner_id:insert 空值回填 acting user、伪造/转移/弃权需 allowTransfer/modifyAllRecords;非 scalar 拒绝、原型链防护、数组 change-set fail-closed;级联 set_null 经 __referentialFieldClear 豁免(属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 / PR fix(security): 级联 set_null 豁免属主转移守卫 (#3023) #3048)。
  2. 公开表单托管集(安全:公开表单(publicFormGrant)提交绕过 owner_id 属主守卫 → 匿名可伪造属主 #3022 / PR fix(security): 公开表单不可再伪造服务端托管锚点(owner_id 等)(#3022) #3036) — 新增 @objectstack/spec/security 的 PUBLIC_FORM_SERVER_MANAGED_FIELDS(id / owner_id / organization_id / tenant_id / 审计列 / is_deleted / deleted_at / __search),在匿名公开表单路径逐行剥离;路由层白名单无条件排除。
  3. organization_id(租户墙,ADR-0095 / security(authz): 多组织下伪造 organization_id 的 insert 可越租户墙 — Layer 0 未门控 insert post-image #2937 / fix(plugin-security): 堵跨租户 UPDATE 写 + org_admin private 对象越墙(security) #2946) — step 3.7 硬编码字段名 organization_id:供给值必须过 Layer 0 租户 CHECK,否则拒绝(防跨租户搬运)。

三处各自定义「托管字段是哪些、怎么执法」,彼此不共享抽象。

问题(为什么这是债)

提议(结构性,需 ADR)

把「服务端托管字段」提升为一个声明式 schema 概念,一处声明、一处执法:

  • 在 packages/spec 的 Field/Object schema 上引入一个声明(草案命名,待 ADR 定稿),例如字段级 systemManaged: { onInsert: 'stampActor' | 'reject' | 'strip', onUpdate: 'reject' | 'strip' | 'immutable', requiresCapability?: string },或对象级的托管字段策略表。
    • owner_id → onInsert: stampActor(空值回填)、onUpdate: reject(除非 allowTransfer);
    • 审计列(created_by 等)→ immutable/strip;
    • organization_id → 由租户层(@objectstack/organizations)贡献其托管策略(跨租户 CHECK);
    • 公开表单托管集 → 从同一声明派生(PUBLIC_FORM_SERVER_MANAGED_FIELDS 不再手维护)。
  • 单一执法点:中间件读该声明统一执行(insert 回填/拒绝、update 拒绝/剥离、能力门),已认证与匿名公开表单共用同一派生集合;__referentialFieldClear 这类引擎内部豁免也纳入统一模型。
  • explain 引擎据同一声明解释「为什么这个字段写不进去」。

范围与非目标

关联

Activity

  1. os-zhuang commented on Jul 18, 2026

    @os-zhuang
    ContributorAuthor

    架构评估:声明式 systemManaged 抽象暂缓,先收敛漂移与补齐测试(PR #3168)

    探过三处特例(step 3.5 owner_id、step 3.7 organization_id、匿名公开表单 PUBLIC_FORM_SERVER_MANAGED_FIELDS)的现状后,结论是:本 issue 提议的完整声明式抽象目前尚不划算,而它真正点出的债可以用更轻的手段收掉。

    为什么暂缓做 systemManaged 声明层

    • 托管字段名单是封闭集,不是增长轴。会增长的是写入口面(公开表单之后的 share-link / portal / AI 写入),新入口需要的只是「一份可派生的权威名单」,而非一套 onInsert/onUpdate 策略机器。
    • 三处语义本质异质:stamp-actor(含 no-op echo 容忍、批量 fail-closed、__referentialFieldClear 豁免)/ 租户 Layer 0 CHECK / 匿名逐行剥离。做成枚举后引擎里仍是那三段代码,只是多一层查表分发——名义统一,却要搬动经过对抗式审查的安全关键代码。
    • 本 issue 真正的债是「入口间漂移」(安全:公开表单(publicFormGrant)提交绕过 owner_id 属主守卫 → 匿名可伪造属主 #3022 正是这样被发现的),而漂移用一致性测试就能钉死,不需要新 schema 概念。

    PR #3168 的右尺寸做法(零运行时行为变更)

    1. 一致性测试(objectql):把 PUBLIC_FORM_SERVER_MANAGED_FIELDS 钉成一个精确划分——「开核实际注入的字段(从 applySystemFields 动态枚举 + __search + id)」∪「有文档的纵深防御保留名(tenant_id/is_deleted/deleted_at)」。新增一个注入的系统字段若没同步进名单,会在此处报错并给出可操作提示,而不再是匿名面上的静默写洞。
    2. step 3.7 写侧单测(plugin-security):补上租户墙缺失的包内写侧覆盖(insert 伪造 / update 重指向被拒;同租户与缺失值放行;isSystem 豁免)。此前只有读侧 Layer 0 与 dogfood 覆盖。
    3. spec 文档修正:把 systemFields 的过期 JSDoc/describe 对齐 registry(organization_id 列无条件注入、仅索引受多租户门控;audit 是已接线的 opt-out;owner_id 由 ownership 属性治理而非 owner 键)。

    顺带发现(未在本 PR 改)

    • 安全/设计:静态 readonly 的 INSERT 豁免让审批/状态字段可在创建时被直接播种(比 #3003 少一步) #3043 已经关掉了认证 insert 伪造审计列的口子:readonly 现已在数据写入 ingress 上于 INSERT 也剥离。原计划的这条后续因此在 main 上已失效。
    • schema.ownership 存在命名冲突:packages/cli/.../info.ts 按 'own'|'extend'(元数据归属)读,registry.ts 的 applySystemFields 按 'user'|'org'|'none'(记录归属模型)读。这正是本 PR 不把 ownership 声明进 ObjectSchema 的原因——那会强行提前拍板一个真实的双语义冲突。建议单开 issue 处理。

    重启本 issue 的触发条件(满足其一再起 ADR-0100)

    1. 出现用户自定义字段需要声明托管语义的真实需求;
    2. 第二个匿名/低信任写入口落地(share-link 写 / portal / AI agent 面);
    3. explain 引擎需要 field-writability 层的产品拉动。

    在此之前,owner_id / organization_id 两个字段的守卫已由 step 3.5/3.7 覆盖,漂移由上面的一致性测试兜住。


    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

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions