Skip to content

元数据面未做字段级权限掩码:/meta 返回完整 fields,导入模板列不受 FLS 约束,客户端 field.permissions 守卫是死代码 #3661

Description

@os-zhuang

#3547 收尾时的溢出发现(Prime Directive #10:越界发现开 issue,不静默扩范围)。

#3391 一系列工作把 数据面 的字段级权限(FLS)泄露堵住了:list 掩码数据、export 表头与 list 可读列对齐(#3498#3561#3649)。但 元数据面 完全没有对应的掩码,症状最明显的地方是导入模板。

一、症状:导入模板的列不受调用者字段权限约束

objectui 的 ImportWizard「下载模板」由 buildImportTemplateCsv(fields) 生成,fields 来自 ObjectView.tsxobjectDef.fields 的过滤:

// objectui/packages/app-shell/src/views/ObjectView.tsx:1795-1801
.filter(([, def]: [string, any]) =>
  ['formula', 'summary', 'autonumber'].includes(def?.type) &&
  !def?.readonly &&
  def?.permissions?.write !== false,

前两个条件是编写期声明(计算字段、readonly),与调用者无关。第三个条件 def?.permissions?.write !== false 看起来是权限守卫——但它是死代码

二、field.permissions 是 declared ≠ enforced

  • 该形状只存在于 objectuiobjectui/packages/types/src/field-types.ts:83-87 定义 permissions?: { read?, write?, edit? }
  • @objectstack/spec没有这个键:packages/spec/src/data/field.zod.ts 的安全相关键只有 hidden(:530)和 requiredPermissions(:539);object.zod.ts 也没有字段级 permissions
  • 两个仓库里都搜不到任何写入点 —— 没有 field.permissions =,没有 permissions: { read/write/edit } 的赋值(非测试)。

所以三个消费点全部永久短路为「允许」(都是 !field?.permissions / !== false 形式):

  • objectui/packages/plugin-form/src/ObjectForm.tsx:546
  • objectui/packages/app-shell/src/views/ObjectView.tsx:1800
  • objectui/packages/plugin-grid/src/ObjectGrid.tsx:1232

三、根因:/meta 的 object schema 不做 per-caller 掩码

  • 运行时分发:packages/runtime/src/domains/meta.ts:185-229,object 分支在 :202-206 原样返回 protocol.getMetaItem(...);全文件唯一的门是 :58-66 的匿名/requireAuth 检查。
  • REST:packages/rest/src/rest-server.ts:2586 起注册 GET /meta/:type/:name,未命中缓存时 :2835 调 p.getMetaItem(...)per-caller 过滤是存在的,但只按 type 分——appfilterAppForUser(:2847-2862)、dashboardrequiresService(:2866-2873)、book/doc 走 audience 门(:2879-2921),object 什么都没有
  • 协议层根本拿不到调用者:packages/metadata-protocol/src/protocol.ts:2007getMetaItem 请求形状里没有 userId、没有执行上下文、没有 permission sets。
  • plugin-security 在元数据路径上没有任何注册:FieldMasker(security-plugin.ts:287)只在 ObjectQL 引擎中间件里用(maskResults @ :1602),协议侧只有 publish/uninstall/authoring 这些门。

后果:一个无权读取某字段的调用者,仍然从 schema 里拿到该字段的名称、类型、label,以及守卫它的 capability 名requiredPermissions 也一并下发)。这与 #3391 在 export 表头上堵掉的泄露同构,只是换到了元数据面。

四、一个结构性阻碍

getMetaItemCached 的 ETag 快路径(rest-server.ts:2776-2827)对 object 读生效,且缓存键只含 type/name/locale,没有调用者维度。在不改缓存键的前提下给该路径加掩码,会把一个用户的掩码后 schema 串给另一个用户。任何方案必须先处理这一点。

五、更正一处直觉错误:导入需要的是 editable,不是 readable

讨论中一度想直接复用 #3547getReadableFields这是错的——导入是路径,需要的是可写掩码。spec 里的字段权限本来就是两位:

// packages/spec/src/security/permission.zod.ts:177-182
readable: z.boolean().default(true),
editable: z.boolean().default(false),

所以要么给 security 服务加一个 getEditableFields 对偶(与 getReadableFields 同源),要么走下面这条已经能用的路。

六、已经存在且能用的通道

GET /auth/me/permissionspackages/plugins/plugin-hono-server/src/hono-plugin.ts:1093,字段图构建在 :1183 起)已经按调用者返回 fields: Record<"object.field", { readable, editable }>,objectui 侧也已有消费者:MePermissionsProviderobjectui/packages/permissions/src/MePermissionsProvider.tsx:72)+ useFieldPermissions / checkField

也就是说 UI 一条可用的 per-caller 字段权限通道,只是导入模板/表单/表格那三处没走它,走的是永远为空的 field.permissions

七、建议的处理顺序

  • ① 低成本止血(objectui):让 ImportWizard 的目标字段集改用 useFieldPermissionseditable 位,替换死掉的 def?.permissions?.write !== false。同时清理 ObjectForm / ObjectGrid 两处死守卫——要么接真通道,要么删掉,别留「看起来在防」的代码。
  • ② 决策:field.permissions 留还是删。它是 Phase 3.2.6 的遗留,与 MePermissionsProvider 并存但永不填充。按 ADR-0049「enforce-or-remove」,倾向删除该形状,统一走 /auth/me/permissions;若决定保留则必须有人填充它。
  • ③ 决策:/meta 要不要做 per-caller 字段掩码。真做的话需要先解决第四节的缓存键问题(把调用者的权限集指纹并入缓存键,或让 object 读绕过共享缓存),并给协议层的 getMetaItem 加上下文入参。这一步半径明显更大,建议独立评估——也可能结论是「元数据面不掩码,由 /auth/me/permissions 承担」,那就该把这个决策写进 ADR,而不是像现在这样是个未言明的默认。

关联:#3391#3498#3547#3561#3649

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions