Skip to content

feat(security): 权限集「两道门」分离 — 包门发布物化 + 管理门写门 (ADR-0086 P2)#2573

Merged
os-zhuang merged 1 commit into
mainfrom
claude/production-launch-priorities-ps8hk3
Jul 4, 2026
Merged

feat(security): 权限集「两道门」分离 — 包门发布物化 + 管理门写门 (ADR-0086 P2)#2573
os-zhuang merged 1 commit into
mainfrom
claude/production-launch-priorities-ps8hk3

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

概述

落地 ADR-0086 P2「两道门分离」 的 framework 侧,把"谁能改一个权限集"拆成两扇互不重叠、且在数据层强制的门(不再靠约定):

  • 包门(package door):权限集在包里声明/编辑 → 存成 permission 元数据草稿 → 发布时物化进 sys_permission_set
  • 管理门(admin door):环境管理员经通用数据面 CRUD 改自己的权限集,但不能碰包管理的行

本 PR 只做框架侧(块1+块2)。objectui 包门接线(把实时保存改走 mode:'draft' + 批量发布)是后续独立改动。

块1 — 包门:发布时物化(路线①,复用既有元数据流水线)

变更
metadata-protocol/protocol.ts 新增通用发布期物化器注册表 registerPublishMaterializer(type, fn);publishMetaItem 在 promote 后调用对应物化器,把结果作为 materializeApplied 挂在响应上(best-effort、绝不 throw——与 seedApplied 同契约)。publishPackageDrafts 批量路径聚合 materializeApplied,让批量发布里被拒的集不再被静默吞掉
metadata-protocol/sys-metadata-repository.ts promoteDraft 现在返回草稿的 packageId,物化器据此盖上归属包
plugin-security 注册 permission 物化器:把已发布集 upsert 进 sys_permission_set(managed_by:'package' + package_id)。单集 upsert(upsertPackagePermissionSet)与 bootstrapDeclaredPermissions 共用,所以开机播种与运行时发布走同一套 own-row / 外包拒绝 / env 自建永不覆盖 规则。物化落空(无归属包 / 名字被别处占用)时报 success:false

permission 类型本就 supportsOverlay/allowRuntimeCreate/allowOrgOverride/supportsVersioning 全开,REST /meta/*?mode=draft + /meta/*/publish 已就绪——几乎零框架新增,只加了"发布→物化"这座桥。

❌ 未走路线②(给 sys_permission_set 数据行自建平行 draft):ADR-0016/0010 已多次否决,等于重复开发。

块2 — 管理门:数据层写门(硬拒绝)

  • 安全中间件拒绝任何管理门对 managed_by:'package'sys_permission_set 行的写(update/delete/transfer/restore/purge),并拒绝任何伪造 managed_by:'package' 的负载(insert 或 update、单对象或数组)。
  • 放在空主体 fall-open 与 CRUD 检查之前,是一道真正无条件的数据层边界——对无主体上下文、对带 modifyAllRecords 的超级用户都成立。
  • 多行/带过滤的写,仅当过滤真正命中某个包管理行时才拒绝,所以只改 env 自建集的批量编辑照常通过(无过度封锁)。
  • 系统/开机写带 isSystem,在中间件最前端短路——播种器与物化器自身的写不会被自己的门挡住。

验证

  • 单测:物化语义(归属包盖章 / 幂等 / env 与外包不覆盖 / 无归属包跳过);写门(单/数组伪造、update 改badge 伪造、无主体上下文、精确批量命中)。plugin-security 173 passed、metadata-protocol 8 passed、objectql publish-package-drafts 10 passed。
  • dogfood(真实 showcase 内核):发布一个包权限草稿 → 断言物化出带 managed_by:'package'+package_id 的行;再经 REST 驱动管理门——改包行 403、改 env 行 200、insert 伪造 provenance 403。4 passed。
  • 全量构建绿(70/70),改动文件 lint 干净。

自审

已用多角度自审(逐行 + removed-behavior + cross-file),发现并修复:数组 insert 绕过伪造门、update 改badge 伪造、无归属包误报 success、批量发布丢弃物化结果、写门在 fall-open 之后不够无条件、批量写门过度封锁——均已修并补测。

关联

  • ADR-0086(P1 已落 package_id/managed_by/bootstrapDeclaredPermissions;本 PR 为 P2)
  • ADR-0078(inert-metadata:包集终于进管理面)、ADR-0010(admin read-mostly 现在落到数据层强制)、ADR-0049(honesty:物化落空如实上报)
  • changeset:@objectstack/metadata-protocol minor、@objectstack/plugin-security minor

🤖 Generated with Claude Code

https://claude.ai/code/session_014y5kiH3aPLWtRRRGcVrXcT


Generated by Claude Code

@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 4, 2026 12:15pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Jul 4, 2026
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/dogfood, @objectstack/metadata-protocol, @objectstack/plugin-security.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/authorization.mdx (via packages/dogfood, packages/plugins/plugin-security)
  • content/docs/concepts/implementation-status.mdx (via @objectstack/plugin-security)
  • content/docs/concepts/packages.mdx (via @objectstack/plugin-security)
  • content/docs/getting-started/cli.mdx (via @objectstack/plugin-security)
  • content/docs/guides/cheatsheets/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/guides/metadata/dashboard.mdx (via @objectstack/plugin-security)
  • content/docs/guides/packages.mdx (via @objectstack/plugin-security)
  • content/docs/guides/plugins.mdx (via @objectstack/plugin-security)
  • content/docs/guides/security.mdx (via @objectstack/plugin-security)
  • content/docs/guides/solutions/data-automation-interface-access.mdx (via packages/plugins/plugin-security)
  • content/docs/guides/solutions/role-based-interfaces.mdx (via packages/plugins/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Splits who may change a permission set into two non-overlapping doors,
enforced at the data layer instead of by convention.

块1 — package door (publish-time materialization):
- metadata-protocol: generic publish-time materializer registry
  (registerPublishMaterializer); publishMetaItem projects a published body
  into its data-plane row and surfaces materializeApplied (best-effort,
  never thrown — same contract as seedApplied). publishPackageDrafts
  aggregates it across the batch so a refused set is surfaced, not swallowed.
- promoteDraft now returns the draft's packageId so the materializer stamps
  the owning package.
- plugin-security registers a `permission` materializer that upserts the
  published set into sys_permission_set with managed_by:'package' +
  package_id. The single-set upsert (upsertPackagePermissionSet) is shared
  with bootstrapDeclaredPermissions, so boot and publish apply identical
  own-row / foreign-package / env-authored rules. A publish that materializes
  nothing (no owning package / name owned elsewhere) reports success:false.

块2 — admin door (data-layer write gate):
- The security middleware refuses any admin-door write to a
  sys_permission_set row with managed_by:'package', and refuses a payload
  (insert OR update, single object OR array) that forges managed_by:'package'.
- Placed before the empty-principal fall-open and the CRUD check, so it is a
  real, unconditional boundary — it holds for a principal-less context and for
  a superuser with modifyAllRecords. A multi-row/filter write is denied only
  when a package-owned row actually falls within the write's own filter, so
  env-only bulk edits still succeed. System/boot writes carry isSystem and
  bypass the middleware, so the seeder and materializer are unaffected.

Tested: unit (materialize semantics, gate incl. array-forge / update-to-forge
/ principal-less / precise-bulk) + a dogfood test booting the real showcase
stack that publishes a package permission draft and drives the admin door
against the seeded package row over REST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y5kiH3aPLWtRRRGcVrXcT
@os-zhuang
os-zhuang force-pushed the claude/production-launch-priorities-ps8hk3 branch from 8a106ad to b625994 Compare July 4, 2026 12:09
@os-zhuang
os-zhuang marked this pull request as ready for review July 4, 2026 12:38
@os-zhuang
os-zhuang merged commit 9796e7c into main Jul 4, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/production-launch-priorities-ps8hk3 branch July 4, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants