Skip to content

Commit ef70521

Browse files
os-zhuangclaude
andauthored
fix(plugin-security): 堵跨租户 UPDATE 写 + org_admin private 对象越墙(security) (#2946)
修复 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。 Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
1 parent 698454e commit ef70521

5 files changed

Lines changed: 257 additions & 51 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security): 堵跨租户 UPDATE 写 + org_admin 越 private 租户对象墙(security)
6+
7+
**安全修复 + 行为变更(release-notes callout)。** 修复 security review 确认的两个租户墙授权漏洞,两者同在 `security-plugin.ts` / `tenant-layer.ts` 的写侧热路径。多组织模式(`tenancy.mode='multi'` + `@objectstack/organizations`)下生效。
8+
9+
**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)、FLS、服务端未强制的 `readonly`、Layer 0 pre-image(只校验旧 org)、显式 RLS check 全部漏过。修法(Option B,最小面 + 与 insert 对称):把 step 3.7 的 Layer 0 post-image 检查扩到 update,复用**同一套** Layer 0 决策(`computeWriteTenantCheckFilter` → `computeLayeredRlsFilter` 的 `layer0`)。一个**显式提供**的 `organization_id` 必须过 Layer 0(== 调用者活动组织),否则 fail-closed 拒绝——这令非平台用户上下文里 `organization_id` **事实不可变**(只有活动组织值能过,而 pre-image 已把目标锁在活动组织内,故重指到任何**其他**租户被拒)。缺省(不碰 org_id)的 update 不受影响;bulk update 的跨租户 change-set 也被堵。
10+
11+
**Finding 2 [HIGH] — org_admin 在 private 租户对象上越租户墙。** Layer 0 跨租户豁免门此前用「持有 `viewAllRecords`/`modifyAllRecords`」判定。`organization_admin`(自动授给每个 org owner/admin)经其 `'*'` 通配持有这两个超级位,于是在 `access.default:'private'`**租户业务对象**上触发豁免 → 零过滤 → 读写所有租户的行。修法:把 Layer 0 豁免门从「超级位」收窄为**真正的平台管理员判定**`hasPlatformAdminPosture`:持有平台专属能力 `manage_metadata`/`manage_platform_settings`/`studio.access`/`manage_users`,即 `admin_full_access` 携带而 `organization_admin` 刻意不给的那组)。超级位继续只驱动 Layer 1 业务 RLS 短路(TENANT_ADMIN 组织内见全行、无所有权收窄)。因新豁免是旧门的严格子集,只会**收窄**、绝不放宽(fail-safe)。
12+
13+
**行为收窄(预期的安全收窄,需注意):** org admin 不再在 private/platform-global/better-auth 的**租户**对象上越租户墙——它现在被 Layer 0 墙到自己的 org。真·平台管理员(`admin_full_access` + 平台 systemPermissions)仍豁免;better-auth 托管身份表 carve-out 不受影响(无 `organization_id` 列,Layer 0 本就 inert)。系统上下文(`isSystem`,含 import/迁移/seed 的合法跨组织移动)在中间件入口即短路,完全不受影响。
14+
15+
**为何不用 `ctx.posture` 作豁免门:** B2 已把 `PLATFORM_ADMIN` posture 落进 `resolve-authz-context.ts``ctx.posture`,但该字段**未被 plumb 进** enforcement 中间件收到的 ExecutionContext(rest-server 与 runtime dispatcher 都丢弃了它),直接消费会静默 no-op。改用平台专属能力探针,读的是 enforcement 已用的同一套 permission sets,覆盖所有入口,且天然 fail-safe。
16+
17+
矩阵门:`authz-matrix-gate.test.ts` 更新 `private_obj.org_admin` 格(read `null``{organization_id:'org-1'}`)并新增 `[Finding 1 …]`(8 格:成员重指异租户→拒、同租户→通过、不碰 org_id→放行、无活动组织→拒、org_admin 重指→拒、platform-admin private 对象→放行、public 对象→拒、单组织→不检查)与 `[Finding 2 …]`(5 格:org_admin private 对象读/写墙到本租户、真平台管理员仍豁免、org_admin public 对象回归、better-auth carve-out 不受影响)。授权一致性 ledger 更新 `multi-tenant-write-postimage`(覆盖 insert+update)并新增 `multi-tenant-exemption-posture`。关联 objectstack-ai/framework#2920

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
4242
enforcement: 'plugin-security/security-plugin.ts computeControlledByParentFilter + assertControlledByParentWrite', proof: 'controlled-by-parent.dogfood.test.ts' },
4343
{ id: 'multi-tenant', summary: 'organization isolation', state: 'enforced',
4444
enforcement: '@objectstack/organizations (enterprise) + Layer 0 tenant wall (plugin-security/tenant-layer.ts, AND-composed ahead of business RLS — ADR-0095 D1)', proof: 'rls-multitenant.dogfood.test.ts' },
45-
{ id: 'multi-tenant-insert-postimage', summary: 'Layer 0 tenant post-image check on INSERT (#2937 — forged organization_id cannot cross the tenant wall)', state: 'enforced',
46-
enforcement: 'plugin-security/security-plugin.ts step 3.7 — computeInsertTenantCheckFilter (reuses computeLayeredRlsFilter\'s Layer 0) matched against the insert post-image (fail-closed); enterprise auto-stamp authoritatively overwrites a user-context organization_id (@objectstack/organizations Middleware A)',
47-
note: 'insert has no pre-image, so the AND-composed Layer 0 wall (rls-read/rls-by-id-write) never reached it; a supplied cross-tenant organization_id is now DENIED (platform-admin posture + single-mode exempt, same rule as the read side). Unit-proven in plugin-security/authz-matrix-gate.test.ts ([#2937] Layer 0 insert post-image tenant guard). Multi-org is enterprise-only so it is not in the open-core dogfood boot; see ADR-0095 D1.' },
45+
{ id: 'multi-tenant-write-postimage', summary: 'Layer 0 tenant post-image check on INSERT + UPDATE (#2937 / Finding 1 — a forged OR re-pointed organization_id cannot cross the tenant wall)', state: 'enforced',
46+
enforcement: 'plugin-security/security-plugin.ts step 3.7 — computeWriteTenantCheckFilter (reuses computeLayeredRlsFilter\'s Layer 0) matched against the write post-image (fail-closed) for BOTH insert and update; enterprise auto-stamp authoritatively overwrites a user-context organization_id (@objectstack/organizations Middleware A)',
47+
note: 'INSERT has no pre-image and UPDATE\'s pre-image (step 2.7) validates only the OLD organization_id, so the AND-composed Layer 0 wall never inspected the NEW value: a member could INSERT a forged cross-tenant organization_id (#2937) or UPDATE a row to RE-POINT it into a victim tenant (Finding 1, BLOCKER). A supplied cross-tenant organization_id is now DENIED on both paths — organization_id is effectively immutable in non-platform user contexts (platform-admin posture on a posture-permitting object + single-mode exempt, same rule as the read side). Unit-proven in plugin-security/authz-matrix-gate.test.ts ([#2937] insert + [Finding 1 / #2937] update post-image tenant guard). Multi-org is enterprise-only so it is not in the open-core dogfood boot; see ADR-0095 D1.' },
48+
{ id: 'multi-tenant-exemption-posture', summary: 'Layer 0 cross-tenant exemption requires the PLATFORM_ADMIN posture (Finding 2 — org_admin does not cross the wall)', state: 'enforced',
49+
enforcement: 'plugin-security/security-plugin.ts hasPlatformAdminPosture (platform-exclusive systemPermissions) gates the tenant-layer.ts Layer 0 exemption; the superuser bit (viewAllRecords/modifyAllRecords) governs only the Layer 1 business-RLS short-circuit',
50+
note: 'An organization_admin holds the superuser bit via its `*` wildcard, so it used to also get the Layer 0 exemption and read/write EVERY tenant\'s rows on private tenant objects. The exemption now requires a platform-exclusive capability (manage_metadata/manage_platform_settings/studio.access/manage_users), which org_admin deliberately lacks — a SECURITY NARROWING: org admin is walled to its own org, a true platform admin still crosses, the better-auth carve-out is untouched. Unit-proven in plugin-security/authz-matrix-gate.test.ts ([Finding 2 / #2937] Layer 0 cross-tenant exemption requires the platform posture).' },
4851
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
4952
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
5053
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',

packages/plugins/plugin-security/src/authz-matrix-gate.test.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,27 @@ async function insertOrg(cell: any, roleCtx: any, orgId: unknown): Promise<unkno
143143
return 'organization_id' in opCtx.data ? opCtx.data.organization_id : '<<absent>>';
144144
}
145145

146+
/**
147+
* [Finding 1 / #2937] Effective by-id UPDATE outcome for a supplied
148+
* `organization_id`. Drives the REAL update path; the pre-image re-read (step 2.7)
149+
* is stubbed to return a matching row so the test isolates the Layer 0 post-image
150+
* tenant guard (step 3.7). `CRUD_DENY:*` = blocked (pre-image RLS, or the Layer 0
151+
* post-image re-point guard); otherwise the `organization_id` the row would keep.
152+
*/
153+
async function updateOrg(cell: any, roleCtx: any, orgId: unknown): Promise<unknown> {
154+
const plugin = new SecurityPlugin();
155+
const preImage = { id: 'r1', organization_id: roleCtx.tenantId ?? 'org-1', created_by: roleCtx.userId, name: 'old' };
156+
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true, findOneImpl: () => preImage });
157+
await plugin.init(h.ctx); await plugin.start(h.ctx);
158+
const opCtx: any = {
159+
object: cell.objectName, operation: 'update',
160+
data: { id: 'r1', name: 'x', ...(orgId === undefined ? {} : { organization_id: orgId }) },
161+
options: { where: { id: 'r1' } }, context: roleCtx,
162+
};
163+
try { await h.run(opCtx); } catch (e: any) { return `CRUD_DENY:${e?.name ?? 'err'}`; }
164+
return 'organization_id' in opCtx.data ? opCtx.data.organization_id : '<<absent>>';
165+
}
166+
146167
// ── Axes ─────────────────────────────────────────────────────────────────────
147168
const OBJECTS = {
148169
// Ordinary tenant business object: has organization_id, public posture.
@@ -190,9 +211,20 @@ const EXPECTED_MATRIX: Record<string, Record<string, { read: unknown; write: unk
190211
no_org_member: { read: { id: DENY }, write: [{ $and: [{ id: DENY }, { created_by: 'u2' }] }] },
191212
},
192213
private_obj: {
193-
// [W2] Layer 0 exemption + Layer 1 short-circuit both fire for superuser on a private object → null.
214+
// [W2] TRUE platform admin: Layer 0 exemption (platform posture) + Layer 1
215+
// short-circuit (superuser bit) both fire on a private object → null.
194216
platform_admin: { read: null, write: 'BYPASS(no-write-filter)' },
195-
org_admin: { read: null, write: 'BYPASS(no-write-filter)' },
217+
// [Finding 2 / #2937 — SECURITY NARROWING] An `organization_admin` holds the
218+
// superuser bit via its `'*'` wildcard, so it used to ALSO get the Layer 0
219+
// exemption and read/write EVERY tenant's rows on this private TENANT object
220+
// (a cross-tenant wall breach). It is NOT a platform admin (no platform-
221+
// exclusive capability), so the exemption is now WITHHELD: Layer 1 is still
222+
// short-circuited by the superuser bit (TENANT_ADMIN sees all rows in-org, no
223+
// ownership narrowing), but Layer 0 walls it to its own org.
224+
org_admin: {
225+
read: { organization_id: 'org-1' },
226+
write: [{ organization_id: 'org-1' }],
227+
},
196228
// A member's plain wildcard grant does not cover a private object → denied at the CRUD gate, before RLS.
197229
member: { read: 'CRUD_DENY:PermissionDeniedError', write: 'CRUD_DENY:PermissionDeniedError' },
198230
no_org_member: { read: 'CRUD_DENY:PermissionDeniedError', write: 'CRUD_DENY:PermissionDeniedError' },
@@ -341,6 +373,67 @@ describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => {
341373
});
342374
});
343375

376+
// ── [Finding 1 / #2937 BLOCKER] Layer 0 UPDATE post-image tenant guard ──────
377+
// The insert post-image guard has a symmetric UPDATE twin: a member owning a
378+
// row in org A could `update` it with `{organization_id: victim org B}` and
379+
// MOVE the row into another tenant (auto-stamp is insert-only, FLS/readonly
380+
// don't protect it, the pre-image check validates only the OLD org). The Layer
381+
// 0 post-image check makes `organization_id` immutable in non-platform user
382+
// contexts — the only value that passes is the caller's active org.
383+
describe('[Finding 1 / #2937] Layer 0 update post-image tenant guard (cross-tenant re-point)', () => {
384+
it('member RE-POINTING organization_id to another tenant is DENIED', async () => {
385+
expect(await updateOrg(OBJECTS.task, ROLES.member, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
386+
});
387+
it('member updating with the SAME (active-org) organization_id is allowed', async () => {
388+
expect(await updateOrg(OBJECTS.task, ROLES.member, 'org-1')).toBe('org-1');
389+
});
390+
it('member update that does NOT touch organization_id is unaffected', async () => {
391+
expect(await updateOrg(OBJECTS.task, ROLES.member, undefined)).toBe('<<absent>>');
392+
});
393+
it('member with NO active org supplying ANY organization_id is fail-closed DENIED', async () => {
394+
expect(await updateOrg(OBJECTS.task, ROLES.no_org_member, 'org-1')).toBe('CRUD_DENY:PermissionDeniedError');
395+
});
396+
it('org_admin RE-POINTING organization_id to another tenant is DENIED (not a platform admin)', async () => {
397+
// org_admin holds the superuser bit but not the platform posture, so it is
398+
// Layer-0-walled to its own org on the public business object too.
399+
expect(await updateOrg(OBJECTS.task, ROLES.org_admin, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
400+
});
401+
it('platform admin may re-point organization_id on a PRIVATE object (posture exemption)', async () => {
402+
expect(await updateOrg(OBJECTS.private_obj, ROLES.platform_admin, 'org-2')).toBe('org-2');
403+
});
404+
it('platform admin stays org-scoped on a PUBLIC business object (re-point DENIED)', async () => {
405+
expect(await updateOrg(OBJECTS.task, ROLES.platform_admin, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
406+
});
407+
it('single-org mode: Layer 0 inert, a supplied organization_id is NOT checked on update', async () => {
408+
const single = { ...OBJECTS.task, orgScoping: false };
409+
expect(await updateOrg(single, ROLES.member, 'org-2')).toBe('org-2');
410+
});
411+
});
412+
413+
// ── [Finding 2 / #2937] org_admin does NOT cross the Layer 0 wall ───────────
414+
// The Layer 0 cross-tenant exemption is gated on the TRUE PLATFORM_ADMIN posture
415+
// (a platform-exclusive capability), not the raw superuser bit an
416+
// `organization_admin` also holds. So an org admin is walled to its own tenant
417+
// on PRIVATE tenant objects, while a real platform admin still crosses it, and
418+
// the better-auth carve-out is untouched.
419+
describe('[Finding 2 / #2937] Layer 0 cross-tenant exemption requires the platform posture', () => {
420+
it('org_admin on a PRIVATE tenant object is Layer-0-walled to its own org (read)', async () => {
421+
expect(await readFilter(OBJECTS.private_obj, ROLES.org_admin)).toEqual({ organization_id: 'org-1' });
422+
});
423+
it('org_admin on a PRIVATE tenant object is Layer-0-walled to its own org (write pre-image)', async () => {
424+
expect(await writeFilter(OBJECTS.private_obj, ROLES.org_admin)).toEqual([{ organization_id: 'org-1' }]);
425+
});
426+
it('TRUE platform admin still crosses the wall on a PRIVATE object (read null)', async () => {
427+
expect(await readFilter(OBJECTS.private_obj, ROLES.platform_admin)).toBeNull();
428+
});
429+
it('org_admin stays walled on a PUBLIC tenant object too (regression)', async () => {
430+
expect(await readFilter(OBJECTS.task, ROLES.org_admin)).toEqual({ organization_id: 'org-1' });
431+
});
432+
it('better-auth-managed identity table carve-out is unaffected for org_admin (self-only, no org filter)', async () => {
433+
expect(await readFilter(OBJECTS.better_auth, ROLES.org_admin)).toEqual({ $or: [{ id: 'oadmin' }, { id: 'oadmin' }] });
434+
});
435+
});
436+
344437
// ── Single-org mode: Layer 0 is inert; tenant policy stripped (parity today) ─
345438
// With org-scoping absent, collectRLSPolicies strips the wildcard tenant policy
346439
// entirely, so a member's read carries NO tenant where and the write keeps only

0 commit comments

Comments
 (0)