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

vercel Bot commented Jul 14, 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 14, 2026 3:57pm

Request Review

@github-actions github-actions Bot 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 main Jul 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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants