fix(plugin-security): 堵跨租户 UPDATE 写 + org_admin private 对象越墙(security) - #2946
Merged
Conversation
修复 security review 确认的两个租户墙授权漏洞(写侧热路径,
security-plugin.ts / tenant-layer.ts)。
Finding 1 [BLOCKER] — 经 UPDATE 重指 organization_id 的跨租户写。
Layer 0 insert post-image 检查(step 3.7)只管 insert;对称的 update
路径无人管,成员可对自有 org A 记录发 update 带 {organization_id: org B}
把行移动进任意租户(auto-stamp insert-only、FLS、服务端未强制的
readonly、Layer 0 pre-image 只校验旧 org、显式 RLS check 全部漏过)。
修法(Option B,与 insert 对称、最小面):把 step 3.7 的 Layer 0
post-image 检查扩到 update,复用同一 computeWriteTenantCheckFilter →
computeLayeredRlsFilter 的 layer0;显式提供的 organization_id 必须过
Layer 0(== 活动组织)否则 fail-closed 拒 —— 令非平台用户上下文里
organization_id 事实不可变。缺省不碰 org_id 的 update 不受影响;bulk
update 跨租户 change-set 也被堵。
Finding 2 [HIGH] — org_admin 在 private 租户对象上越租户墙。
Layer 0 跨租户豁免门此前用「持有 viewAllRecords/modifyAllRecords」判定,
organization_admin 经其 '*' 通配持有超级位 → 在 private 租户业务对象上
触发豁免 → 零过滤 → 读写所有租户。修法:把豁免门收窄为真正的平台管理员
判定(hasPlatformAdminPosture:持有平台专属能力 manage_metadata/
manage_platform_settings/studio.access/manage_users)。超级位继续只驱动
Layer 1 业务 RLS 短路。新豁免是旧门严格子集,只收窄不放宽(fail-safe)。
未用 ctx.posture 作豁免门:posture 未被 plumb 进 enforcement 的
ExecutionContext(rest/runtime 都丢弃),直接消费会静默 no-op。
行为收窄(预期):org admin 不再越 private/platform-global/better-auth
租户对象的墙;真·平台管理员仍豁免;better-auth carve-out 不受影响;
系统上下文(isSystem,含合法跨组织移动)完全不受影响。
验证:authz-matrix-gate.test.ts 更新 private_obj.org_admin 格 + 新增
[Finding 1](8 格)/[Finding 2](5 格);plugin-security 全绿(405);
tsc --noEmit 通过;build 通过;dogfood authz-conformance ledger 绿
(multi-tenant-write-postimage 覆盖 insert+update、新增
multi-tenant-exemption-posture)。关联 #2920。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 15, 2026 03:57
This was referenced Jul 15, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 15, 2026
#2948) (#2957) A field's static `readonly: true` was never enforced server-side on update: the record validator only skipped read-only columns from validation, and only the conditional `readonlyWhen` variant was stripped from the write payload. A non-system update could therefore overwrite any readonly column — audit stamps, provenance, or other system-computed values. (#2946 already closed the cross-tenant organization_id face; this is the broader in-tenant integrity face.) engine.update now strips caller-supplied writes to statically-readonly fields for non-system contexts, on both the single-id and multi-row paths (strips, does not reject — symmetric with readonlyWhen). Two guards keep legit writes intact: - caller-supplied only: the strip runs against a snapshot of the keys the caller sent BEFORE hooks/middleware ran, so audit-hook stamps (updated_by/ updated_at) and write-middleware stamps survive; only a client that forged a readonly field has it dropped. - system-context exempt: isSystem writes (import, seed replay, approvals, lifecycle) legitimately set readonly columns and skip the strip. Adds a stripReadonlyFields helper + unit tests and an engine-level integration test (user forge stripped, server stamp survives, system write allowed). objectql suite: 852 passed. Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Jul 15, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 17, 2026
…nt path, no explicit deny (#3109) Follow-through on ADR-0095: the derived posture rung is carried on the ExecutionContext since #2956 but no enforcement decision reads it — each tier decision re-derives principal tier from capability bits at its own site, the divergence class behind #2946 Finding 2 (org admin crossed the tenant wall) and #2949 (explain/enforcement posture split). - D1: posture becomes the single tier-adjudication input (Layer 0 exemption, Layer 1 short-circuit tier, explain); capability probe demoted to narrowing fallback; behavior-preserving behind an equivalence gate in the authz matrix. - D2: normative posture → layer action map with six invariants (TENANT_ADMIN never crosses Layer 0; enforcement and explain read the same carried value; posture selects tier, never side; never bypasses system-only write guards). - D3: EXTERNAL rung activation chain (audience:'external' → derivePosture → explicit-shares-only injection), matrix cells land now, implementation demand-gated on an external principal type (portal track). - D4: explicit deny/muting rejected as an adjudication primitive; needs mapped to capability gates / FLS / admission / validity windows; assembly-time muting reserved to a future permission-set-groups ADR. Composes with ADR-0096 (admission vs tiering — orthogonal axes). Number 0099 verified free of open-PR claims before push. Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Jul 18, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 18, 2026
…ture rung (#3211 M2) (#3226) The Layer 0 cross-tenant exemption gate in computeLayeredRlsFilter now reads the carried ctx.posture rung (#2956) as authoritative: crossing the tenant wall requires PLATFORM_ADMIN. The hasPlatformAdminPosture capability probe demotes to a fallback for resolver-less contexts (delegated-admin bridge, sharing service, getReadFilter consumers), where behavior is byte-for-byte unchanged. Read and write (insert/update post-image) tenant checks share the one decision, so they cannot drift. A probe/rung disagreement logs an [authz/ADR-0099] defect breadcrumb and enforces the narrower rung verdict. Retires the stale 'posture not plumbed' comment. Security narrowing (multi-org / @objectstack/organizations only, the G1- adjudicated deltas): a scoped admin_full_access grant (a) and a piecemeal platform-capability grant (b) resolve below PLATFORM_ADMIN and are now walled to their own org on private / platform-global / better-auth objects, where the posture-blind probe used to exempt them. Fail-safe (rung is a strict subset of the probe, ADR-0099 I3); recover with an unscoped admin_full_access grant. Single-org / env-per-database unaffected. authz-matrix-gate.test.ts: new ADR-0099 P1 describe (narrowing read/write, PLATFORM_ADMIN stays exempt, TENANT_ADMIN stays walled per I1, resolver-less fallback preserved, defect-breadcrumb on disagreement / silence on agreement). dogfood multi-tenant-exemption-posture ledger note updated. Docs: authorization.mdx records the unscoped-grant rule. Verified: plugin-security 503 passed, dogfood 296 passed, build 71/71, tsc + check:role-word + check:doc-authoring clean. Refs #3211 · ADR-0099 / #3109 · #2946 Finding 2 · #2956 Claude-Session: https://claude.ai/code/session_01DAHp4K7FvyMPBY1DPNkmRu Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Jul 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
修复 security review 确认的两个租户墙授权漏洞(其一是发布 blocker)。两者同在
security-plugin.ts/tenant-layer.ts的写侧授权热路径,同批修。多组织模式(tenancy.mode='multi'+@objectstack/organizations)下生效。关联 #2920。Finding 1 [BLOCKER] — 经 UPDATE 重指
organization_id的跨租户写漏洞: #2937 的 Layer 0 insert post-image 检查(中间件 step 3.7)只管 insert。对称的 update 路径无人管:成员拥有 org A 的记录 R,对 R 发 by-id 或 bulk
update带{organization_id: 受害者 org B},即可把行移动进任意租户。所有既有防线全漏过:organization_id;readonly— 服务端 update 未强制(record-validator.ts仅跳过、engine.ts只剥readonlyWhen);check的策略生效。行落到 org B、对受害租户可见 —— 任意成员可把行注入任意租户。
修法(Option B — 与 insert 对称、最小面): 把 step 3.7 的 Layer 0 post-image 检查从
insert扩到insert || update,复用同一套 Layer 0 决策(computeInsertTenantCheckFilter泛化为computeWriteTenantCheckFilter(…, operation, …)→computeLayeredRlsFilter的layer0,单一真相源,绝不与读侧漂移)。一个显式提供的organization_id必须过 Layer 0(== 调用者活动组织),否则 fail-closed 拒。因 pre-image 已把目标行锁在活动组织内,唯一能过的新值就是活动组织本身 → 重指到任何其他租户被拒,即organization_id在非平台用户上下文里事实不可变。为何选 B 而非 A(immutability via pre-image 比对): B 复用与 insert 完全相同的 Layer 0 机器(符合 ADR-0095 D1「一处决策」的精神)、无需额外 pre-image 读、platform-admin 豁免与 system 直通免费继承,且天然覆盖 bulk update(只看 change-set 里的 org_id 值,无需重建 per-row post-image;A 的 pre-image 方案对 bulk 只能放弃)。
Finding 2 [HIGH] — org_admin 在 private 租户对象上越租户墙
漏洞: Layer 0 跨租户豁免门(
tenant-layer.ts:isPlatformAdmin && posturePermitsCrossTenant)此前用「持有viewAllRecords/modifyAllRecords」判定。organization_admin(default-permission-sets.ts的'*': {viewAllRecords, modifyAllRecords},自动授给每个 org owner/admin)经通配持有这两个超级位。于是在access.default:'private'的租户业务对象上,org admin 触发豁免 →computeTenantLayer0Filter返回null+ Layer 1 短路 → 零过滤 → 读写所有租户的行。豁免门分不清平台管理员与租户 org admin。修法: 把 Layer 0 豁免门从「持有超级位」收窄为真正的平台管理员判定 —— 新增
hasPlatformAdminPosture(permissionSets):持有平台专属能力(manage_metadata/manage_platform_settings/studio.access/manage_users——admin_full_access携带而organization_admin刻意不给的那组)。超级位(viewAllRecords/modifyAllRecords)继续只驱动 Layer 1 业务 RLS 短路(TENANT_ADMIN 组织内见全行、无所有权/depth/sharing 收窄)。因新豁免(超级位 AND 平台能力)是旧门(仅超级位)的严格子集,只会收窄、绝不放宽(fail-safe)。为何不用
ctx.posture === PLATFORM_ADMIN作豁免门: B2 已把PLATFORM_ADMINposture 落进resolve-authz-context.ts的ctx.posture,但实测确认该字段未被 plumb 进 enforcement 中间件收到的ExecutionContext——rest-server.ts(L1197-1215)与runtime/resolve-execution-context.ts(L143-204)构建执行上下文时都丢弃了authz.posture。直接在插件里读context.posture会静默 no-op(永远 undefined → 永不豁免),对真·平台管理员是功能回归。改用平台专属能力探针:读的是 enforcement 已用的同一套 permission sets,覆盖所有入口(REST/runtime/MCP),无需跨传输层 plumbing,且天然 fail-safe。这是最小 blast radius 的正确落点。行为收窄(预期的安全收窄)
admin_full_access+ 平台 systemPermissions)在 private/platform-global/better-auth 对象上仍豁免。organization_id列,Layer 0 本就 inert)。isSystem,含 import 引擎 / 迁移 / seed replay 的SYSTEM_CTX,以及合法的跨组织记录迁移)在中间件入口即短路,完全不受影响。测试矩阵(实测,全绿)
authz-matrix-gate.test.ts(真 SecurityPlugin 中间件 + 真 seeded 权限集,27 格全绿):private_obj.org_admin主矩阵格null→{organization_id:'org-1'};writeBYPASS→[{organization_id:'org-1'}]<<absent>>pnpm --filter @objectstack/plugin-security test— 405 全绿tsc --noEmit(plugin-security)— 通过pnpm build— 70/70 通过multi-tenant-write-postimage覆盖 insert+update、新增multi-tenant-exemption-posture)Reviewer checklist
organization_idimmutability 不误伤合法的用户上下文 org 变更路径(已核查:无此路径,跨组织迁移走isSystem短路);缺省 update 不受影响;bulk update 跨租户 change-set 被堵。manage_metadata/manage_platform_settings/studio.access/manage_users)确为 platform-only(org_admin 只有manage_org_users/setup.access/setup.write);setup.access虽 spec scope=platform但 org_admin 持有,故刻意排除。hasPlatformAdminPosture是唯一新增消费「平台管理员判定」的 enforcement 点(未做更大范围 enforcement 重写)。存疑点 / 后续
ctx.posture未被 plumb 进 enforcement context 是一个更广的架构缺口(posture 已算出却被传输层丢弃);本 PR 用能力探针绕开,未做 plumbing。若后续要让 enforcement 真正消费 posture,需在 rest-server + runtime dispatcher 两处补posture: authz.posture(out of scope,可另开 issue)。readonly字段更新未被强制是一个比 org_id 更广的洞(本 PR 只堵了 org_id 的跨租户面);其余 readonly 字段仍可被 update 覆盖 —— 建议另开 issue 跟踪。🤖 Generated with Claude Code
https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
Generated by Claude Code