Skip to content

feat(plugin-security): 内置行写护栏 — sys_position / sys_capability 平台/应用托管行不可删改 - #2930

Merged
os-zhuang merged 1 commit into
mainfrom
claude/plugin-security-guardrails-fwvxhe
Jul 14, 2026
Merged

feat(plugin-security): 内置行写护栏 — sys_position / sys_capability 平台/应用托管行不可删改#2930
os-zhuang merged 1 commit into
mainfrom
claude/plugin-security-guardrails-fwvxhe

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#2918

问题

平台/应用发布的系统行(sys_positionmanaged_by = system/configsys_capabilitymanaged_by = platform/package可被客户管理员直接 delete / update 直达驱动——运行时没有内置写护栏。sys_permission_set 早有两道门护栏 assertPackageManagedWriteGate,这两个对象却缺失,管理员误删/改写一个平台岗位或应用发的岗位/能力会静默破坏该应用的授权基线(ADR-0049:provenance 字段存在却无强制 = 正是要补的 enforcement gap)。

改动

新增 assertSystemRowWriteGatepackages/plugins/plugin-security/src/security-plugin.ts),接线在 data-write hook 中紧邻 package 门,对 sys_position / sys_capability 两个资产对象施加一道无条件数据层边界

  • 禁止伪造托管来源 — 管理员门的 insert / update 载荷(单对象或数组)不得把 managed_by 盖成平台/应用值;只有携带 isSystem 的平台 seeder / 包发布路径可写。同时封堵 update-to-forge(把自建行改 badge 成托管行)。
  • 拒绝改删托管行 — 对 managed_by 已是平台/应用值的行,delete / update / transfer / restore / purge 一律拒绝。与 sys_permission_set 不同,这两个对象没有 ADR-0094 overlay write-through,故写护栏必须在此层直接拒绝,而非下放给下游翻译。
  • 管理员自建行不受限managed_byuser/∅(sys_position)或 admin(sys_capability)的行完全归管理员所有(含委派管理员在自己 subtree 内的自建行)。

护栏 fail-closed 且不依赖调用方授权——持 modifyAllRecords 的超管也无法删除平台岗位。两对象的 managed_by 词表不同,网关按对象分别判定(module-level SYSTEM_ROW_PROVENANCE 规格表)。错误信息仅含业务文案("此岗位/能力由 平台|应用包 提供,不可删除/修改")。

与委派管理边界的关系

delegated-admin-gate.tsGOVERNED_OBJECTS 本就不含 sys_position / sys_capability——委派管理治理的是 RBAC 链接表(assignments / bindings),而非定义对象。新护栏只拦截平台/应用托管行,管理员/委派管理员的自建行照常放行,二者不冲突。

测试

security-plugin.test.ts 新增 system-row write gate describe 块(15 用例):

  • 非 admin/admin × 托管行/自建行 × delete/update 矩阵;
  • platform/config 托管行删改被拒断言;platform/package 能力删改被拒断言;
  • admin 自建行(user / ∅ / admin)不受影响断言;
  • 伪造托管来源(insert / bulk-array / update-to-forge)被拒;
  • filter 写探测命中托管行则拒、仅命中自建行则放行;
  • isSystem seeder bypass 放行。

全量 pnpm vitest run(15 文件 / 349 用例)通过,tsc --noEmit 干净。

关联

🤖 Generated with Claude Code


Generated by Claude Code

…/ sys_capability managed rows
Platform/application-published system rows (sys_position `managed_by`
system/config, sys_capability `managed_by` platform/package) could be
deleted or rewritten directly by a customer admin — the write went
straight to the driver with no guardrail, silently breaking an app's
authorization baseline. sys_permission_set already had the two-doors
`assertPackageManagedWriteGate`; sys_position / sys_capability had no
equivalent (ADR-0049: a provenance attribute that exists but is never
enforced is exactly the gap to close).
Add `assertSystemRowWriteGate`, wired into the data-write hook next to
the package gate, as an unconditional data-layer boundary over both
asset objects:
- Refuse forging platform/package `managed_by` through the admin door
(insert or update, single object or array) — closes update-to-forge.
- Refuse delete / update / transfer / restore / purge on rows whose
existing `managed_by` is platform/package-owned. Unlike
sys_permission_set these objects have no ADR-0094 overlay
write-through, so the refusal holds here rather than deferring
downstream.
- Admin-authored rows (`user`/∅ on sys_position, `admin` on
sys_capability) are untouched — the admin fully owns those, incl. a
delegate's rows in their own subtree.
The gate fails closed and never depends on the caller's grants, so a
superuser with modifyAllRecords cannot delete a platform position. The
two objects' `managed_by` vocabularies differ, so the gate keys off a
per-object provenance spec. Deny text is business-message only. No
conflict with the delegated-admin gate — GOVERNED_OBJECTS excludes both
objects, which govern the RBAC link tables, not the definitions.
Tests: non-admin/admin × managed/admin-authored × delete/update matrix,
forge + update-to-forge + bulk-array cases, filter-write probe, and the
isSystem seeder bypass.
Closes#2918
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5TEu5LzF1aZdE9rRaF6tV
@vercel

vercelBot commented Jul 14, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 14, 2026 3:57pm

Request Review

@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Jul 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

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

  • content/docs/getting-started/cli.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-security)
  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/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.

@os-zhuang
os-zhuang marked this pull request as ready for review July 14, 2026 16:15
@os-zhuang
os-zhuang merged commit 5774a75 into mainJul 14, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/plugin-security-guardrails-fwvxhe branch July 14, 2026 16:15
os-zhuang pushed a commit that referenced this pull request Jul 15, 2026
…ows (#2926 ①)
The system-row write gate (#2918/#2930) keyed sys_position provenance on the
legacy managed_by values (system/config), but the A4 vocab unification (#2934)
stamps and boot-normalizes rows to platform/package — so the gate silently
stopped firing for positions, letting admins physically delete the everyone/
guest audience anchors once their bindings were removed. Guard both the
canonical and legacy vocabularies, update the gate tests to the canonical
values, add legacy-row regressions, and correct the stale 'no runtime path
branches on legacy values' safety comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
os-zhuang added a commit that referenced this pull request Jul 15, 2026
)
* fix(plugin-security): protect platform/package-stamped sys_position rows (#2926 ①)
The system-row write gate (#2918/#2930) keyed sys_position provenance on the
legacy managed_by values (system/config), but the A4 vocab unification (#2934)
stamps and boot-normalizes rows to platform/package — so the gate silently
stopped firing for positions, letting admins physically delete the everyone/
guest audience anchors once their bindings were removed. Guard both the
canonical and legacy vocabularies, update the gate tests to the canonical
values, add legacy-row regressions, and correct the stale 'no runtime path
branches on legacy values' safety comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* fix(rest): pass through explicit 4xx status+code in mapDataError (#2926 ⑦)
Record-scope authorization denials (plugin-sharing throws status=403,
code=FORBIDDEN) degraded to a bare 400 with no code because the generic data
routes call mapDataError directly, bypassing sendError's status passthrough.
Add a guarded 4xx passthrough after the structured-code branches (409
envelopes keep their rich fields; 5xx still goes through the sanitizing
heuristics; oversized messages fall back to generic text), and stop logging
expected statuses as unhandled on the list route.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* feat(metadata-protocol): reject unknown $-prefixed query params with 400 (#2926 ⑩)
Unsupported $ parameters (e.g. $foo, $inlinecount) used to fall into the
implicit-filter bucket and silently match zero rows — and before the $filter
alias existed, were dropped entirely, returning the unfiltered first page to
callers that believed they had a filtered result set. All supported aliases
are consumed before the guard, so anything $-prefixed that remains is a hard
400 UNSUPPORTED_QUERY_PARAM naming the offending keys and the supported list.
Bare-key implicit equality filters are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* feat(plugin-sharing): backfill rule grants at boot (#2926 ③)
Sharing-rule grants are materialized by write hooks that deliberately skip
isSystem writes, so seed-loader records never got sys_record_share rows —
demo data with matching rules was broken on a fresh deploy until each record
was touched at runtime. Reconcile every active rule once per boot (idempotent,
best-effort per rule) right after the rule hooks bind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* feat(plugin-sharing): sys_sharing_rule provenance + seed-not-clobber (#2909 P0/T1)
sys_sharing_rule is record-authoritative (ADR-0094 addendum): declared rules
are a boot seed, the row is the authority — but every boot re-ran a clobbering
upsert, so an admin's active:false on an over-sharing rule was silently
resurrected on redeploy. Add readonly managed_by (A4 tri-state) + customized
provenance columns, put defineRule in seed mode when the bootstrap passes
managedBy:'package' (pristine/legacy rows adopted and updated; admin-authored
or customized rows untouched), and stamp customized via a beforeUpdate hook on
any non-system edit of a seeded rule. No write gate on purpose — sharing rules
stay a first-class admin authoring surface; edits are remembered, not blocked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* test(plugin-security): lock bootstrap-declared-positions seed semantics (#2909 T2)
sys_position is record-authoritative — the declared seeder refreshes only
label/description and must never touch bindings, active, is_default,
delegatable, or managed_by. That behavior predates these tests but was never
locked; regressions would silently clobber admin state at every boot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* fix(plugin-security): stop clobbering admin-edited capability scope (#2909 T3)
scope is an admin-editable classification select on sys_capability, but the
curated seeder refreshed it every boot — silently reverting admin
reclassifications. Make it seed-once (insert only); label/description remain
platform-owned and keep refreshing. Lock both sides with tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* docs(adr): resolve 0094 addendum audit items; record sharing-rule seed-not-clobber tradeoff (#2909 T4)
The three 'Audit that…' per-type rows are now shipped decisions:
sys_sharing_rule = record-authoritative with provenance + seed-not-clobber;
sys_position = seed-only identity/display, locked by test; sys_capability =
seed-not-clobber with scope seed-once. Record why sharing rules got no write
gate (admin tuning surface), the customized-stamp boundaries, and the scope
migration cost.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* feat(app-showcase): bind persona positions on kernel:listening + announcements; document isDefault dual-track (#2926 ②⑤)
A fresh deploy booted with ZERO position↔permission-set bindings — every
persona silently degraded to the everyone baseline. Bindings are
record-authoritative (ADR-0090/0094) and cannot be a declarative seed (the
seed loader runs before the security bootstrap creates sys_position /
sys_permission_set, so name references can't resolve). They are ensured
imperatively by registerShowcasePositionBindings on kernel:listening — the
phase that fires only after every kernel:ready handler (incl. the security
bootstrap) has settled, so the referenced rows exist. everyone→member_default
is bound here too: the framework only auto-binds an app's isDefault set to
everyone when it is application-owned; the showcase ships as a package, so its
default lands in sys_audience_binding_suggestion (pending admin confirmation)
and is not live until confirmed.
Also seeds showcase_announcement demo rows and documents the isDefault
dual-track (app-level auto-bind vs package-level suggestion) in the spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* fix(plugin-sharing): defer rule backfill to kernel:listening so seed rows materialize (#2926 ③)
The boot backfill ran inside a kernel:ready handler, but SeedLoader also
seeds on kernel:ready (raced against a budget, in a different AppPlugin
handler). Since kernel:ready handlers fire sequentially in registration
order, the backfill could run before the seed records exist and materialize
nothing. Move the reconcile to kernel:listening (Phase 4), which the kernel
fires only after every kernel:ready handler has settled — so the seeded rows
are present. Verified end-to-end: a seeded red project now yields a
sys_record_share to the exec recipient at boot, no runtime touch required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
---------
Co-authored-by: Claude <noreply@anthropic.com>
xuyushun441-sys pushed a commit that referenced this pull request Jul 17, 2026
…ole coverage
The previous revision under-covered the line in three ways, now fixed:
1. 15.0.0 was missing everything outside the ADR-0095 theme from the
14.8.0..15.0.0 range: the strict view/page schema BREAKING change
(ADR-0089 D3a, #2943) with its migration, the per-row import automation
chain + skipAutomations + runAutomations-default-ON behavior change
(#2922), the sys_position/sys_capability system-row write guardrail
(#2930), the bidirectional visibility lint (#2931), and the explain
posture-label alignment (#2949).
2. 15.0.0 had NO Console section at all, despite bundling the objectui 14.0
major (13.2.0→14.0.0, ~73 commits — first release-page disclosure since
much of it rode unversioned in the 14.8.0 pin): the ADR-0057 ChatDock
consolidation (including its breaking cleanup #2475), the Gantt batch,
lists/forms/auth/Studio/i18n enhancements, two security fixes
(#2485/#2410), and the six early-14.1 commits the 15.0.0 pin picked up.
3. The 15.1.0 sections were over-compressed; every domain is now expanded to
its full changeset detail, and the Console 14.1 section covers all eight
areas of the 94-commit range instead of seven bullets.
465 → ~1000 lines; structure mirrors v14.mdx (per-minor annotations inside
one major page, per docs/releases-maintenance.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 17, 2026
…ole coverage (#3082)
* docs(releases): expand v15 page to full 14.8→15.0→15.1 backend + Console coverage
The previous revision under-covered the line in three ways, now fixed:
1. 15.0.0 was missing everything outside the ADR-0095 theme from the
14.8.0..15.0.0 range: the strict view/page schema BREAKING change
(ADR-0089 D3a, #2943) with its migration, the per-row import automation
chain + skipAutomations + runAutomations-default-ON behavior change
(#2922), the sys_position/sys_capability system-row write guardrail
(#2930), the bidirectional visibility lint (#2931), and the explain
posture-label alignment (#2949).
2. 15.0.0 had NO Console section at all, despite bundling the objectui 14.0
major (13.2.0→14.0.0, ~73 commits — first release-page disclosure since
much of it rode unversioned in the 14.8.0 pin): the ADR-0057 ChatDock
consolidation (including its breaking cleanup #2475), the Gantt batch,
lists/forms/auth/Studio/i18n enhancements, two security fixes
(#2485/#2410), and the six early-14.1 commits the 15.0.0 pin picked up.
3. The 15.1.0 sections were over-compressed; every domain is now expanded to
its full changeset detail, and the Console 14.1 section covers all eight
areas of the 94-commit range instead of seven bullets.
465 → ~1000 lines; structure mirrors v14.mdx (per-minor annotations inside
one major page, per docs/releases-maintenance.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(releases): note post-14.1 fixes carried by the final Console pin (#2615/#2617/#2619/#2620/#2621/#2623)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(releases): add v15 to the releases index; mark v14 line final at 14.8.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(plugin-security): 内置行写护栏 — sys_position / sys_capability 平台/应用托管行可被删改

2 participants

@os-zhuang@claude