Skip to content

fix(plugin-security): 堵跨租户 UPDATE 写 + org_admin private 对象越墙(security) - #2946

Merged
os-zhuang merged 1 commit into
mainfrom
claude/authz-tenant-write-wall-fix
Jul 15, 2026
Merged

fix(plugin-security): 堵跨租户 UPDATE 写 + org_admin private 对象越墙(security)#2946
os-zhuang merged 1 commit into
mainfrom
claude/authz-tenant-write-wall-fix

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

修复 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},即可把行移动进任意租户。所有既有防线全漏过:

  • auto-stamp — insert-only,不碰 update;
  • FLS — 不保护 organization_id
  • readonly — 服务端 update 未强制(record-validator.ts 仅跳过、engine.ts 只剥 readonlyWhen);
  • Layer 0 pre-image(step 2.7)— 只校验 org=A 放行,看不到新值;
  • RLS check — 仅对显式声明 check 的策略生效。

行落到 org B、对受害租户可见 —— 任意成员可把行注入任意租户。

修法(Option B — 与 insert 对称、最小面): 把 step 3.7 的 Layer 0 post-image 检查从 insert 扩到 insert || update,复用同一套 Layer 0 决策(computeInsertTenantCheckFilter 泛化为 computeWriteTenantCheckFilter(…, operation, …)computeLayeredRlsFilterlayer0,单一真相源,绝不与读侧漂移)。一个显式提供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.tsisPlatformAdmin && posturePermitsCrossTenant)此前用「持有 viewAllRecords/modifyAllRecords」判定。organization_admindefault-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_ADMIN posture 落进 resolve-authz-context.tsctx.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 的正确落点。


行为收窄(预期的安全收窄)

  • org admin 不再越墙:在 private / platform-global / better-auth 的租户对象上,org admin 现被 Layer 0 墙到自己的 org(此前零过滤)。
  • 真·平台管理员(admin_full_access + 平台 systemPermissions)在 private/platform-global/better-auth 对象上仍豁免
  • better-auth 托管身份表 carve-out 不受影响(无 organization_id 列,Layer 0 本就 inert)。
  • 系统上下文isSystem,含 import 引擎 / 迁移 / seed replay 的 SYSTEM_CTX,以及合法的跨组织记录迁移)在中间件入口即短路,完全不受影响

测试矩阵(实测,全绿)

authz-matrix-gate.test.ts(真 SecurityPlugin 中间件 + 真 seeded 权限集,27 格全绿):

用例结果
更新的 private_obj.org_admin 主矩阵格read null{organization_id:'org-1'};write BYPASS[{organization_id:'org-1'}]
Finding 1:member 重指异租户 updateDENY
Finding 1:member 同租户 / 不碰 org_id通过 / <<absent>>
Finding 1:无活动组织 member 供任意 org_idfail-closed DENY
Finding 1:org_admin 重指异租户DENY(非平台管理员)
Finding 1:platform-admin 在 private 对象重指放行(豁免)
Finding 1:platform-admin 在 public 业务对象重指DENY
Finding 1:单组织模式不检查
Finding 2:org_admin private 租户对象 read/write墙到本租户(只见 org-1)
Finding 2:真平台管理员 private 对象仍豁免(read null)
Finding 2:org_admin public 租户对象(回归)仍墙住
Finding 2:better-auth 表 org_admin仅 self,无 org 过滤(carve-out 不受影响)
  • pnpm --filter @objectstack/plugin-security test405 全绿
  • tsc --noEmit(plugin-security)— 通过
  • pnpm build70/70 通过
  • dogfood authz-conformance ledger — 绿multi-tenant-write-postimage 覆盖 insert+update、新增 multi-tenant-exemption-posture

Reviewer checklist

  • Finding 1:确认 update 的 organization_id immutability 不误伤合法的用户上下文 org 变更路径(已核查:无此路径,跨组织迁移走 isSystem 短路);缺省 update 不受影响;bulk update 跨租户 change-set 被堵。
  • Finding 2:确认平台专属能力集(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 持有,故刻意排除
  • 确认收窄符合预期:org_admin 越 private 租户对象的墙被堵是预期安全收窄,非回归。
  • 确认 hasPlatformAdminPosture 是唯一新增消费「平台管理员判定」的 enforcement 点(未做更大范围 enforcement 重写)。
  • Layer 1 业务 RLS 短路仍由超级位驱动(TENANT_ADMIN 语义未变)。

存疑点 / 后续

  • 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

修复 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
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 15, 2026 3:59am

Request Review

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

12 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/dogfood, packages/plugins/plugin-security)
  • content/docs/permissions/delegated-administration.mdx(via packages/dogfood)
  • 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 15, 2026 03:57
@os-zhuang
os-zhuang merged commit ef70521 into mainJul 15, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/authz-tenant-write-wall-fix branch July 15, 2026 04:06
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>
@github-actionsgithub-actionsBot mentioned this pull request 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>
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>
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.

2 participants

@os-zhuang@claude