#3547 收尾时的溢出发现(Prime Directive #10:越界发现开 issue,不静默扩范围)。
#3391 一系列工作把 数据面 的字段级权限(FLS)泄露堵住了:list 掩码数据、export 表头与 list 可读列对齐(#3498 → #3561 → #3649)。但 元数据面 完全没有对应的掩码,症状最明显的地方是导入模板。
一、症状:导入模板的列不受调用者字段权限约束
objectui 的 ImportWizard「下载模板」由 buildImportTemplateCsv(fields) 生成,fields 来自 ObjectView.tsx 对 objectDef.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
- 该形状只存在于 objectui:
objectui/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 分——app 走 filterAppForUser(:2847-2862)、dashboard 走 requiresService(:2866-2873)、book/doc 走 audience 门(:2879-2921),object 什么都没有。
- 协议层根本拿不到调用者:
packages/metadata-protocol/src/protocol.ts:2007 的 getMetaItem 请求形状里没有 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
讨论中一度想直接复用 #3547 的 getReadableFields。这是错的——导入是写路径,需要的是可写掩码。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/permissions(packages/plugins/plugin-hono-server/src/hono-plugin.ts:1093,字段图构建在 :1183 起)已经按调用者返回 fields: Record<"object.field", { readable, editable }>,objectui 侧也已有消费者:MePermissionsProvider(objectui/packages/permissions/src/MePermissionsProvider.tsx:72)+ useFieldPermissions / checkField。
也就是说 UI 有一条可用的 per-caller 字段权限通道,只是导入模板/表单/表格那三处没走它,走的是永远为空的 field.permissions。
七、建议的处理顺序
关联:#3391、#3498、#3547、#3561、#3649。
#3547 收尾时的溢出发现(Prime Directive #10:越界发现开 issue,不静默扩范围)。
#3391 一系列工作把 数据面 的字段级权限(FLS)泄露堵住了:list 掩码数据、export 表头与 list 可读列对齐(#3498 → #3561 → #3649)。但 元数据面 完全没有对应的掩码,症状最明显的地方是导入模板。
一、症状:导入模板的列不受调用者字段权限约束
objectui 的 ImportWizard「下载模板」由
buildImportTemplateCsv(fields)生成,fields来自ObjectView.tsx对objectDef.fields的过滤:前两个条件是编写期声明(计算字段、readonly),与调用者无关。第三个条件
def?.permissions?.write !== false看起来是权限守卫——但它是死代码。二、
field.permissions是 declared ≠ enforcedobjectui/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:546objectui/packages/app-shell/src/views/ObjectView.tsx:1800objectui/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检查。packages/rest/src/rest-server.ts:2586起注册GET /meta/:type/:name,未命中缓存时 :2835 调p.getMetaItem(...)。per-caller 过滤是存在的,但只按 type 分——app走filterAppForUser(:2847-2862)、dashboard走requiresService(:2866-2873)、book/doc走 audience 门(:2879-2921),object什么都没有。packages/metadata-protocol/src/protocol.ts:2007的getMetaItem请求形状里没有userId、没有执行上下文、没有 permission sets。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
讨论中一度想直接复用 #3547 的
getReadableFields。这是错的——导入是写路径,需要的是可写掩码。spec 里的字段权限本来就是两位:所以要么给 security 服务加一个
getEditableFields对偶(与getReadableFields同源),要么走下面这条已经能用的路。六、已经存在且能用的通道
GET /auth/me/permissions(packages/plugins/plugin-hono-server/src/hono-plugin.ts:1093,字段图构建在 :1183 起)已经按调用者返回fields: Record<"object.field", { readable, editable }>,objectui 侧也已有消费者:MePermissionsProvider(objectui/packages/permissions/src/MePermissionsProvider.tsx:72)+useFieldPermissions/checkField。也就是说 UI 有一条可用的 per-caller 字段权限通道,只是导入模板/表单/表格那三处没走它,走的是永远为空的
field.permissions。七、建议的处理顺序
useFieldPermissions的editable位,替换死掉的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。