Repository navigation
finding: POST /packages/:id/publish-drafts 整包 draft 批量转正,零能力的已认证调用方即可调用(实测 200 vs 同族 _migrate-stored 403) #7023
Description
Activity
os-project-manager commented
on Aug 9, 2026 CollaboratorAuthorMore actions只读普查回报(不落门、无 PR)
基线
origin/main=c3f491626。objectui HEAD =0cf8f0f。
下面每条都标了 实测 / 静态阅读,两者不混写。
0. 先复现:卡片断言成立,而且比卡片更强
实测。 临时 vitest 探针驱动
HttpDispatcher(与仓内既有能力门测试packages/runtime/src/domains/meta-migrate-stored.test.ts同一驱动法),protocol 侧全是vi.fn()双档并记录调用顺序。原始 stdout:##### P1 直接驱动 handler,caller = {userId:'u_portal', systemPermissions:[]} ##### ### publish-drafts HTTP status ### 200 ### publishPackageDrafts invoked? ### 1 ### call trace ### ["publishPackageDrafts(app.hr)","getMetaItem(seed/demo_rows)", "getMetaItems(type=app)","saveMetaItem(app/hr_console _unpublished=false)"] ### _migrate-stored HTTP status (SAME caller) ### 403 ### migrateStoredMetadata invoked? ### 0 ### body ### {"success":false,"error":{"code":"PERMISSION_DENIED", "message":"Rewriting stored metadata requires the `manage_metadata` capability.", "httpStatus":403}}卡片说的「同一调用方,一条 200 一条 403」复现成功。
但还有一档更低的,卡片没量到:
##### P2 走完整 dispatch() 管线,context = { request: {} }(不带任何凭据) ##### ### resolved executionContext ### {"positions":["guest"],"permissions":[],"systemPermissions":[], "isSystem":false,"principalKind":"guest", "org_user_ids":[],"accessible_org_ids":[]} ### publish-drafts HTTP status ### 200 ### publishPackageDrafts invoked? ### 1 ### _migrate-stored HTTP status (anonymous) ### 401 ### migrateStoredMetadata invoked? ### 0实测: 走完整
dispatch()(resolveRequestScope→ 身份解析 →enforceAuthGate→enforceProjectMembership→ 域注册表),一个连userId都没有、身份解析产出principalKind: 'guest'的调用方,打 publish-drafts 仍得 200 并真的调进publishPackageDrafts;同一管线上_migrate-stored得 401。⚠️ 这一档的边界要说清楚:P2 是 dispatcher 层的测量。真实部署(plugin-auth + transport 中间件全装)下匿名请求会不会在到达 dispatcher 之前被别的中间件先拦掉 —— 未实测。静态阅读的旁证是:mountPackagesRoute(packages/runtime/src/dispatcher-plugin.ts:984-1001)直接server.post挂dispatcher.dispatch,不经过mountRouteOnServer那道 401 门(dispatcher-plugin.ts:180-223,那道门只服务插件发出的RouteDefinition,判据是route.auth !== false && !user)。探针已删除、未提交、无分支推送。
1. 今天谁在调这条路由
objectstack(搜索面:全仓 ts/tsx/mts/mjs/js/json/yaml/mdx/md,关键词
publish-drafts/publishDrafts/publishPackageDrafts,排除 node_modules、dist、CHANGELOG)静态阅读。 生产调用方只有 1 个:
packages/client/src/index.ts:1270—— SDKpackages.publishDrafts(id, opts?),POST {baseUrl}{route}/{id}/publish-drafts。
而这个 SDK 方法在两仓内都没有任何生产消费方:objectstack 内唯一引用是
packages/client/src/packages-lifecycle.test.ts:42(路由绑定测试);objectui 全仓搜packages.publishDrafts/.publishDrafts(零命中。搜了但没命中的地方(也是数据):
packages/cli/src——publish-drafts/publishDrafts零命中。os没有任何命令打这条路由。examples/(app-showcase、app-todo 等)—— 零命中。packages/qa/(dogfood 全部 test)—— 零命中。docs/qa/platform-checklist/areas/studio-authoring.json:310/344/346把它写成人工步骤,该条目automated.ref指向的packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts自己不打这条路由。- 其余命中全部是 runtime 自己的路由测试、metadata-protocol/objectql 的协议层测试、ADR/文档、changeset。
objectui(HEAD
0cf8f0f)静态阅读。 生产调用方 4 处,全在
packages/app-shell,全是裸fetch('/api/v1/packages/:id/publish-drafts'),都不走 SDK:packages/app-shell/src/views/metadata-admin/PackagesPage.tsx:359—— Studio 的 Packages 详情页 Publish 按钮。packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:561—— Studio 设计面。packages/app-shell/src/preview/usePublishAllDrafts.ts:76—— 共享 hook,两个宿主:console/home/HomePage.tsx:134(Home 的 pending-drafts 横幅)与preview/DraftPreviewBar.tsx:31(ADR-0037 草稿预览条)。packages/app-shell/src/console/ai/AiChatPage.tsx:2124—— AI 会话里的 Publish 按钮;并且在getRuntimeConfig().features.autoPublishAiBuilds打开时,由ChatbotEnhanced的autoPublishDrafts自动触发(AiChatPage.tsx:2220),无人点击。
packages/plugin-chatbot/src/ChatbotEnhanced.tsx只声明onPublishDrafts回调,自己不 fetch —— 不是独立调用方。搜了但没命中: objectui
e2e/、apps/、examples/搜publish-drafts零命中。未普查
cloud仓 —— 本会话未挂载该仓,没搜。
2. 每个调用方各自持有什么能力
静态阅读(除标注处外)。
Studio 两处(PackagesPage、StudioDesignSurface):
挂在studioapp 下 ——packages/platform-objects/src/apps/studio.app.ts:54requiredPermissions: ['studio.access'];Packages 页是同文件:114的componentRef: 'developer:packages'。app 级requiredPermissions由/me/apps落地(packages/rest/src/rest-server.ts:2621/:2672:不满足就把整个 app 从列表里丢掉)。所以进得了这两处 UI 的人必然持有studio.access。出厂权限集里
studio.access只有admin_full_access一个集合授予(packages/plugins/plugin-security/src/objects/default-permission-sets.ts:120-129),而这个集合同时带manage_users、manage_metadata、manage_platform_settings、manage_sharing、setup.access、setup.write。Console 三处(Home 横幅、DraftPreviewBar、AiChatPage):
不在任何 app 的requiredPermissions之下,代码里也没有任何能力判断。HomePage.tsx:128-160的PendingDraftsBanner唯一的显示条件是client.listDrafts?.({})返回条数大于 0。那个
listDrafts打的是GET /meta/_drafts—— 实测:零能力调用方({userId:'u_portal', systemPermissions:[]})得 200,正常返回 draft 列表。所以 Console 这三处对任何已认证用户都可达。出厂的其余权限集:
权限集 systemPermissions 出处 admin_full_accessmanage_users,manage_metadata,manage_platform_settings,manage_sharing,setup.access,setup.write,studio.accessdefault-permission-sets.ts:120-129organization_adminmanage_org_users,setup.access,setup.writedefault-permission-sets.ts:190(注释明说故意不给studio.access与manage_metadata)member_default无(该集合内没有 systemPermissions键)default-permission-sets.ts:313起showcase_ops(示例)setup.access,showcase.export_dataexamples/app-showcase/src/security/permission-sets.ts:190⚠️ 两个前提必须说在前面:- 以上是出厂默认。部署方可以把任何能力授给任何人,所以第 3 条的「会拦下谁」只在出厂默认权限集这个前提下成立。
- UI 可达性不等于路由可达性。 实测:零能力调用方
GET /packages得 200(拿得到 package id 清单),然后POST /packages/:id/publish-drafts得 200。整条链不需要任何 UI、不需要看见任何按钮。
3. 三条候选门各自会 403 掉谁
前提:出厂默认权限集 + 上面那份调用方清单。
(a)
manage_metadata(与 #6603 已裁的同一道门)- 会被拦下的现有调用方:Console 三处(Home 横幅 / DraftPreviewBar / AiChatPage 含自动发布),当持有者是非平台管理员时。具体是
organization_admin和member_default这两类人 —— 这三处今天对他们可达。 - 不会被拦下:Studio 两处(PackagesPage、StudioDesignSurface)。 进 Studio 已经要
studio.access,出厂只有admin_full_access授予它,而该集合同时带manage_metadata。 ⚠️ 结论:(a) 对「Studio 里的 Publish 按钮」一个都不拦。
(b)
setup.write(或同级发布类能力)- 持有者:
admin_full_access和organization_admin。 - 会被拦下: 只有
member_default这一类(以及任何未被授予上述集合的用户)。organization_admin照旧能整包发布。 - Studio 两处同样一个都不拦。
(c) 专设一个新的发布能力
- 出厂没有任何权限集授予它。所以在它被显式写进
admin_full_access(以及/或被bootstrapSystemCapabilities播种)之前,它会 403 掉上面全部 5 个现有调用方,包括平台管理员 —— Studio 的 Publish 按钮当天就坏。 - 先例:
manage_sharing的注释(packages/spec/src/security/capabilities.ts:52-60)写明「Seeded intoadmin_full_accessso existing admin flows are unchanged」。 - 一旦只播种进
admin_full_access,它对现有调用方的拦截面就与 (a) 完全一致。
⚠️ 直接回答那个问题(a) 不拦下任何 Studio 调用方;(c)(播种进
admin_full_access后)同样不拦。 两者唯一改变的,是 Console 那三处对非平台管理员的可达性。(b) 连organization_admin都不拦,只拦member_default。
4. 兄弟路由
POST /metadata/_migrate-stored的机制与判据静态阅读 + 实测信封。 两道门叠着:
① 域级匿名门 ——
packages/runtime/src/domains/meta.ts:163-177,handleMetadataRequest的第一条语句:if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem })) { return { handled: true, response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }) }; }
实测状态码 401。
② 路由级能力门 ——
packages/runtime/src/domains/meta.ts:460-476,判据就一行:const ec: any = _context.executionContext; if (!ec?.isSystem && !new Set< string >(ec?.systemPermissions ?? []).has('manage_metadata')) { return { handled: true, response: deps.error('Rewriting stored metadata requires the `manage_metadata` capability.', 403) }; }
实测信封:
{ code: 'PERMISSION_DENIED', httpStatus: 403 },且migrateStoredMetadata调用次数 0。照抄时要一起抄的三个姿态:
- 位置 —— 门在
deps.resolveService(_context, 'protocol')之前。注释写明理由:不让调用方拿 501/200 的差别去探测部署装没装这个能力。 isSystem旁路 —— 引擎自调用绕过,与actionPermissionError一致。- 被 pin 住 ——
packages/runtime/src/domains/meta-migrate-stored.test.ts:44-93四个用例:无能力 403 且被调函数一次没进、持能力放行、isSystem旁路、匿名 401。
对照面(实测 grep):
packages/runtime/src/domains/packages.ts全文 739 行,systemPermissions|isSystem|manage_metadata|FORBIDDEN命中数 0;并且没有shouldDenyAnonymous。全仓shouldDenyAnonymous的调用点是/meta、/actions、/automation、/ai、/security五个域加 REST 层 ——/packages不在其中。packages/qa/dogfood/test/authz-conformance.matrix.ts里有anonymous-deny-meta/-actions/-automation三条,没有/packages的任何条目。
5. ADR-0045 那一环:清
app._unpublished发生在哪一步、能否分开发生在哪一步 —— 实测
单次 publish-drafts 调用内的真实调用顺序(探针记录的 trace):
1. publishPackageDrafts(app.hr) ← draft 行转正 2. getMetaItem(seed/demo_rows) ← seed 读回,随后走 applyPublishedSeeds 灌行 3. getMetaItems(type=app) ← 找该 package 名下的 app 4. saveMetaItem(app/hr_console _unpublished=false) ← ADR-0045 发布门被清掉 ### saveMetaItem 实参 ### {"type":"app","name":"hr_console", "item":{"name":"hr_console","_unpublished":false,"hidden":true}, "packageId":"app.hr"} ### 响应 ### 200,data.unhiddenApps = ["hr_console"]即:第 4 步,严格在 draft 转正与 seed 灌入之后。
静态阅读(
packages/runtime/src/domains/packages.ts:230-301):这一步在自己的 try/catch 里,失败只写result.unhideError加一条logger.error,路由照样返回 200;成功的名字累积进result.unhiddenApps。能否与 schema 激活 / seed 灌入分开
-
在这条路由上不能。 没有任何开关或参数让调用方只做其中一件事;三件事在一次 POST 里绑死。(实测:body 里除
actor外没有被读的键;静态阅读packages.ts:160-172确认只从 body 取actor。) -
在代码结构上是分开的。 第 4 步只用
getMetaItems/saveMetaItem两个 meta 原语,不读前两步的返回值,是一个可独立摘出的块。 -
而且这一步单独另有一条门径 —— 路由自己的错误文案就写着(
packages.ts:291):可以直接PUT /meta/app/:name带{"_unpublished": false}补做。实测:零能力调用方打PUT /meta/app/hr_console带{_unpublished:false}得 200、saveMetaItem被调用 1 次;同一条路由匿名打得 401。也就是说,在当前
origin/main(c3f491626)上,单独做 ADR-0045 翻转这条路径同样没有能力门 —— finding: after ADR-0106, a restricted caller's GET → edit → PUT of an object schema DELETES the fields that were masked out of their read #6603 裁下的manage_metadata门尚未落到 main(domains/meta.ts全文manage_metadata唯一命中就是_migrate-stored那处)。
⚠️ 这条里未实测的部分(不拿静态阅读冒充)- seed 到底落了多少行:未实测。 我的 double 缺
metadata.getObject,seedApplied返回{success:false, error:"this.metadata.getObject is not a function"}。已实测到的是:applyPublishedSeeds被这个零能力调用进去了、并已读回 seed body(trace 第 2 步);没实测真实 driver 上的插入行数。
PD #10 —— 普查中撞到的同形无门写面(按令记在此,未另立卡)
同一次实测、同一个零能力调用方
{userId:'u_portal', systemPermissions:[]}:POST /packages/:id/discard-drafts→ 200,discardPackageDrafts被调用。这是破坏性的:丢掉该 package 名下每一条 pending draft。GET /packages/:id/export→ 200,把该 package 名下 27 种 metadata 类型整包读出来(trace 里 27 次getMetaItems)。GET /packages→ 200(package id 枚举面,正是上面那条攻击链的第一步)。PUT /meta/app/:name带{_unpublished:false}→ 200(见第 5 条)。
其余
/packages写面在我的 double 下没跑到底:revert503 /rollback501 /duplicate501 /adopt-orphans501 /enable·disable·DELETE /:id·PATCH /:id500 /POST /packages400。⚠️ 这些非 200 全部是我的 double 缺服务方法,不是授权拒绝 ——packages.ts全文授权判据数为 0,所以它们同样没有门,只是未实测到底,不要读成「这些已经被拦住了」。
探针
临时 vitest 文件
packages/runtime/src/zz-probe-7023.test.ts(一次性 worktree 内),驱动HttpDispatcher.handlePackages/handleMetadata/dispatch,protocol 侧全vi.fn()双档并记录调用顺序。上面所有###行为其原始 stdout。已删除、未提交、未推分支、worktree 已移除。本评论不含修法建议 —— 门的选择留给维护者。
🤖 Generated with Claude Code
Generated by Claude Code
os-project-manager commented
on Aug 9, 2026 CollaboratorAuthorMore actions普查的四条 out-of-scope 发现已分流(PD #10)
普查评论(
5231013640)末尾记了四条不属于本卡的发现,现分流如下,本卡范围不变:发现 去向 POST /packages/:id/discard-drafts零能力 200(破坏性)#7033 GET /packages/:id/export零能力 200#7033 GET /packages零能力 200(枚举面)#7033 PUT /meta/app/:name带{_unpublished:false}零能力 200#7019(已覆盖,未另立卡) 前三条合并为一张卡的理由
它们与本卡
publish-drafts共用同一根因(/packages域级无授权判据 + 不在authz-conformance.matrix.ts里)和同一修法。拆成三条会让三个 PR 各自在packages.ts里加一段形状略有差异的门。#7033 记的是那条结构事实:packages.ts739 行授权判据命中数 0,且该域没有shouldDenyAnonymous,而/meta/actions/automation/ai/security五个域都有。第四条为什么不另立卡(已核,非假设)
普查走的是 dispatcher 管线(
packages/runtime/src/domains/meta.ts),不是packages/rest/src/rest-server.ts。#7027(#6603)只在 rest 侧给PUT /meta/:type/:name落门,不碰 dispatcher —— 所以这一条不会被 #7027 关掉。但它正落在 #7019 的范围内:#7019 的三条肢体之一就是 dispatcher 的
PUT(实测在meta.ts:152-159,卡里原记的:264是读路径,已在 #7019 更正)。#7019 落地后该路由对零能力调用方即为 403。⇒ 重复,不另立卡。本卡状态未变
维护者现行指令:先只派普查,不落门。普查已交付,三条候选门各自会 403 掉谁的实测对照在上一条评论里。本席不代裁门,等维护者定向。
⚠️ 落门前请注意普查自陈的覆盖缺口:cloud仓未挂载(add_repo两次被拒),调用方普查不覆盖该仓 —— 任何门都可能 403 掉一个未被普查到的调用方。
Generated by Claude Code
os-project-manager commented
on Aug 9, 2026 CollaboratorAuthorMore actions认领(dev agent)
- session ID:
session_017uFVNMmTxLpmfQYiuKM1Yx - 分支:
claude/issue-7033-packages-authz - 本卡与主卡 finding:
/packages域整体没有授权判据 ——discard-drafts(破坏性)、export、GET /packages零能力调用方均实测 200,#7023 只是其中一条 #7033 同根因,合并为一个 PR 一并关闭(Fixes #7033且Fixes #7023)。落地维护者 2026-08-09 已裁的/packages域授权门。
Generated by Claude Code
- session ID:
os-project-manager commented
on Aug 9, 2026 CollaboratorAuthorMore actions认领 + 状态:本卡正在被处理,不是无人管
此前只跑了只读普查(评论
5231013640),普查按约定未认领 issue —— 所以它看起来无人认领。现补上认领并说明归属,消除误会。归属:#7023 与 #7033(
/packages域整体无授权)同根因、同文件,由一个 PR 一起关。该 PR 正在飞:- 分支
claude/issue-7033-packages-authz(checkpoint97c875715,PM 在首个 dev 撞账号 session 限额后打的 wip 备份并已推送) - 现有 dev 正从该 checkpoint 续做收尾,PR 将带
Fixes #7033且Fixes #7023 - session
session_017uFVNMmTxLpmfQYiuKM1Yx
#7023 对应的门(维护者已裁):
POST /packages/:id/publish-drafts及其破坏性同族discard-drafts要求has('manage_metadata');读路由(GET /packages、export)走域级shouldDenyAnonymous+ D4 读豁免常量。每个 transport 都上门(REST + dispatcher)。维护者现行裁定:保持 #7023 与 #7033 合并、整单顶到最高优先级(插队),绿了立即翻牌 —— 不拆。
草稿 PR 一开我会回帖贴号。
Generated by Claude Code
- 分支
- added 3 commits that reference this issue
on Aug 9, 2026 os-project-manager commented
on Aug 9, 2026 CollaboratorAuthorMore actions已随 PR #7083 合入,本卡关闭
/packages域授权门已落地 main(b295e4b36),#7033 与 #7023 由同一 PR 一起关。#7023 对应的落地事实(实测
origin/main):packages/runtime/src/domains/packages.ts现有 7 处manage_metadata/shouldDenyAnonymous命中,POST /packages/:id/publish-drafts及其破坏性同族discard-drafts要求manage_metadata,读路由走 D4 读集 + 全域shouldDenyAnonymous,两 transport(dispatcher + REST direct-mount)各一份门,门在协议/服务解析之前(拒绝不写不删、防指纹)。pin 已进packages-capability-gate.test.ts与package-envelope.conformance.test.ts。遗留盲区(已在 PR 正文与 changeset 写明):
cloud仓未挂载(add_repo两次被拒),调用方普查不覆盖该仓 —— 落地后若在cloud内发现今天不持manage_metadata/D4 读集的生产调用方,可能被此门 403,需补一次cloud普查复核。此外 #7020 记录的「门能力集 ≠ ADR-0106 D4 掩码豁免集」对齐方向仍归维护者。
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
在 #6599 的消费方普查里撞到的(那张卡问的是「谁在调
/meta/_drafts」;顺着usePublishAllDrafts的 Publish 动作走到了这条写面)。未修,按 PD #10 立卡。实测:一个零能力的已认证调用方能做什么
不是「没看到能力门禁」这种静态观察 —— 直接驱动 dispatcher 量的。调用方上下文
{ userId: 'u_portal', systemPermissions: [] },一个会话、一条能力都没有:同一个调用方,打隔壁那条同族路由
POST /metadata/_migrate-stored:同一个调用方、同一次运行、同一类操作 —— 一条 200 并真的把整包 draft 转正,一条 403 且被调函数一次都没进。
代码面
POST /packages/:id/publish-drafts→packages/runtime/src/domains/packages.ts:160。整个
packages.ts(739 行)里systemPermissions/isSystem/manage_metadata/FORBIDDEN/403的命中数是 0。挂载侧mountPackagesRoute(packages/runtime/src/dispatcher-plugin.ts:984)也只是dispatcher.dispatch(...)的转发,没有包任何门禁。所以这条路由之上除全局requireAuth外没有任何判据。对照物就在同一个 dispatcher 里:
_migrate-stored(domains/meta.ts)显式判ec?.isSystem || systemPermissions.has('manage_metadata'),并且在解析 protocol 之前判,理由写在注释里(不让调用方拿 501/200 的差别去探测)。这条路由做的事
publishPackageDrafts把该 package 名下每一条 pending DRAFT 行提升为active;seed类型的 draft 被发布即意味着装载数据行(路由里applyPublishedSeeds那段);随后还会清掉 ADR-0045 的发布门 —— #4829 / PR #6942 刚把它挪到机器管理键app._unpublished,由 publish-drafts 置false。也就是说这一次调用同时是「schema 生效」「数据落库」「半成品应用对真实用户可见」。ADR-0045 自己写过这个门的失败方向判断:门失败开放会把半成品应用静默暴露给真实用户,比过度限制严格更糟(docs/adr/0045-...md:192)。为什么它值得单独一张卡
它和 #6603 是同一形状 —— 而 #6603 刚被维护者裁为需要
manage_metadata,判词是「能写 schema 的人就该是能看见完整 schema 的人」(评论5225531464)。#6603 管的是单条PUT /meta/:type/:name;这条是整包批量转正,按同一条判据赌注更大,却是两条里没有门禁的那条。同族参照:#6920(匿名 mass assignment)、#6599(
_drafts读面,其所述字段级泄露已实测证伪,见 PR #7014)。不在本卡里替维护者选修法
至少有「照 #6603 判
manage_metadata」和「按 package 所有权/作者身份判」两种读法,成本与语义不同;而且/packages域下同样裸奔的还有discard-drafts/revert/rollback/enable/disable,要不要一起收口是域级决定,不是这条路由的局部决定。留给分诊与维护者。复现
驱动
HttpDispatcher.handlePackages('/app.hr/publish-drafts', 'POST', {}, {}, { request: {}, executionContext: { userId: 'u_portal', systemPermissions: [] } }),protocol 侧放一个publishPackageDrafts双档记录被提升的条目;同一上下文再驱动handleMetadata('_migrate-stored', ctx, 'POST', {}, {})作对照。上面两段输出即为该探针的原始 stdout。立卡时的查重说明
开卡前的 issue 关键词查重被账号级 REST 速率限制挡住(本班次共享身份,多次退避重试仍
API rate limit already exceeded)。改用本地信号做的查重:git log -i --grep=publish-drafts与全仓publish-drafts+ 权限关键词交叉搜索,命中的都是发布语义(ADR-0045 可见性、seed 装载、org 作用域、端点发布门),没有一条关于这条路由的授权。若分诊发现已有同族卡,按重复合并即可。