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 重指异租户 update DENY
Finding 1:member 同租户 / 不碰 org_id 通过 / <<absent>>
Finding 1:无活动组织 member 供任意 org_id fail-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

vercel Bot commented Jul 15, 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 15, 2026 3:59am

Request Review

@github-actions github-actions Bot 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 main Jul 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>
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 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.

2 participants