Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/authz-tenant-write-wall-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-security': minor
---

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

**安全修复 + 行为变更(release-notes callout)。** 修复 security review 确认的两个租户墙授权漏洞,两者同在 `security-plugin.ts` / `tenant-layer.ts` 的写侧热路径。多组织模式(`tenancy.mode='multi'` + `@objectstack/organizations`)下生效。

**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 也被堵。

**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)。

**行为收窄(预期的安全收窄,需注意):** 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 的合法跨组织移动)在中间件入口即短路,完全不受影响。

**为何不用 `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。

矩阵门:`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。
9 changes: 6 additions & 3 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
enforcement: 'plugin-security/security-plugin.ts computeControlledByParentFilter + assertControlledByParentWrite', proof: 'controlled-by-parent.dogfood.test.ts' },
{ id: 'multi-tenant', summary: 'organization isolation', state: 'enforced',
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' },
{ 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',
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)',
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.' },
{ 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',
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)',
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.' },
{ 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',
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',
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).' },
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
Expand Down
97 changes: 95 additions & 2 deletions packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,27 @@ async function insertOrg(cell: any, roleCtx: any, orgId: unknown): Promise<unkno
return 'organization_id' in opCtx.data ? opCtx.data.organization_id : '<<absent>>';
}

/**
* [Finding 1 / #2937] Effective by-id UPDATE outcome for a supplied
* `organization_id`. Drives the REAL update path; the pre-image re-read (step 2.7)
* is stubbed to return a matching row so the test isolates the Layer 0 post-image
* tenant guard (step 3.7). `CRUD_DENY:*` = blocked (pre-image RLS, or the Layer 0
* post-image re-point guard); otherwise the `organization_id` the row would keep.
*/
async function updateOrg(cell: any, roleCtx: any, orgId: unknown): Promise<unknown> {
const plugin = new SecurityPlugin();
const preImage = { id: 'r1', organization_id: roleCtx.tenantId ?? 'org-1', created_by: roleCtx.userId, name: 'old' };
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true, findOneImpl: () => preImage });
await plugin.init(h.ctx); await plugin.start(h.ctx);
const opCtx: any = {
object: cell.objectName, operation: 'update',
data: { id: 'r1', name: 'x', ...(orgId === undefined ? {} : { organization_id: orgId }) },
options: { where: { id: 'r1' } }, context: roleCtx,
};
try { await h.run(opCtx); } catch (e: any) { return `CRUD_DENY:${e?.name ?? 'err'}`; }
return 'organization_id' in opCtx.data ? opCtx.data.organization_id : '<<absent>>';
}

// ── Axes ─────────────────────────────────────────────────────────────────────
const OBJECTS = {
// Ordinary tenant business object: has organization_id, public posture.
Expand Down Expand Up @@ -190,9 +211,20 @@ const EXPECTED_MATRIX: Record<string, Record<string, { read: unknown; write: unk
no_org_member: { read: { id: DENY }, write: [{ $and: [{ id: DENY }, { created_by: 'u2' }] }] },
},
private_obj: {
// [W2] Layer 0 exemption + Layer 1 short-circuit both fire for superuser on a private object → null.
// [W2] TRUE platform admin: Layer 0 exemption (platform posture) + Layer 1
// short-circuit (superuser bit) both fire on a private object → null.
platform_admin: { read: null, write: 'BYPASS(no-write-filter)' },
org_admin: { read: null, write: 'BYPASS(no-write-filter)' },
// [Finding 2 / #2937 — SECURITY NARROWING] 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 this private TENANT object
// (a cross-tenant wall breach). It is NOT a platform admin (no platform-
// exclusive capability), so the exemption is now WITHHELD: Layer 1 is still
// short-circuited by the superuser bit (TENANT_ADMIN sees all rows in-org, no
// ownership narrowing), but Layer 0 walls it to its own org.
org_admin: {
read: { organization_id: 'org-1' },
write: [{ organization_id: 'org-1' }],
},
// A member's plain wildcard grant does not cover a private object → denied at the CRUD gate, before RLS.
member: { read: 'CRUD_DENY:PermissionDeniedError', write: 'CRUD_DENY:PermissionDeniedError' },
no_org_member: { read: 'CRUD_DENY:PermissionDeniedError', write: 'CRUD_DENY:PermissionDeniedError' },
Expand Down Expand Up @@ -341,6 +373,67 @@ describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => {
});
});

// ── [Finding 1 / #2937 BLOCKER] Layer 0 UPDATE post-image tenant guard ──────
// The insert post-image guard has a symmetric UPDATE twin: a member owning a
// row in org A could `update` it with `{organization_id: victim org B}` and
// MOVE the row into another tenant (auto-stamp is insert-only, FLS/readonly
// don't protect it, the pre-image check validates only the OLD org). The Layer
// 0 post-image check makes `organization_id` immutable in non-platform user
// contexts — the only value that passes is the caller's active org.
describe('[Finding 1 / #2937] Layer 0 update post-image tenant guard (cross-tenant re-point)', () => {
it('member RE-POINTING organization_id to another tenant is DENIED', async () => {
expect(await updateOrg(OBJECTS.task, ROLES.member, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
});
it('member updating with the SAME (active-org) organization_id is allowed', async () => {
expect(await updateOrg(OBJECTS.task, ROLES.member, 'org-1')).toBe('org-1');
});
it('member update that does NOT touch organization_id is unaffected', async () => {
expect(await updateOrg(OBJECTS.task, ROLES.member, undefined)).toBe('<<absent>>');
});
it('member with NO active org supplying ANY organization_id is fail-closed DENIED', async () => {
expect(await updateOrg(OBJECTS.task, ROLES.no_org_member, 'org-1')).toBe('CRUD_DENY:PermissionDeniedError');
});
it('org_admin RE-POINTING organization_id to another tenant is DENIED (not a platform admin)', async () => {
// org_admin holds the superuser bit but not the platform posture, so it is
// Layer-0-walled to its own org on the public business object too.
expect(await updateOrg(OBJECTS.task, ROLES.org_admin, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
});
it('platform admin may re-point organization_id on a PRIVATE object (posture exemption)', async () => {
expect(await updateOrg(OBJECTS.private_obj, ROLES.platform_admin, 'org-2')).toBe('org-2');
});
it('platform admin stays org-scoped on a PUBLIC business object (re-point DENIED)', async () => {
expect(await updateOrg(OBJECTS.task, ROLES.platform_admin, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
});
it('single-org mode: Layer 0 inert, a supplied organization_id is NOT checked on update', async () => {
const single = { ...OBJECTS.task, orgScoping: false };
expect(await updateOrg(single, ROLES.member, 'org-2')).toBe('org-2');
});
});

// ── [Finding 2 / #2937] org_admin does NOT cross the Layer 0 wall ───────────
// The Layer 0 cross-tenant exemption is gated on the TRUE PLATFORM_ADMIN posture
// (a platform-exclusive capability), not the raw superuser bit an
// `organization_admin` also holds. So an org admin is walled to its own tenant
// on PRIVATE tenant objects, while a real platform admin still crosses it, and
// the better-auth carve-out is untouched.
describe('[Finding 2 / #2937] Layer 0 cross-tenant exemption requires the platform posture', () => {
it('org_admin on a PRIVATE tenant object is Layer-0-walled to its own org (read)', async () => {
expect(await readFilter(OBJECTS.private_obj, ROLES.org_admin)).toEqual({ organization_id: 'org-1' });
});
it('org_admin on a PRIVATE tenant object is Layer-0-walled to its own org (write pre-image)', async () => {
expect(await writeFilter(OBJECTS.private_obj, ROLES.org_admin)).toEqual([{ organization_id: 'org-1' }]);
});
it('TRUE platform admin still crosses the wall on a PRIVATE object (read null)', async () => {
expect(await readFilter(OBJECTS.private_obj, ROLES.platform_admin)).toBeNull();
});
it('org_admin stays walled on a PUBLIC tenant object too (regression)', async () => {
expect(await readFilter(OBJECTS.task, ROLES.org_admin)).toEqual({ organization_id: 'org-1' });
});
it('better-auth-managed identity table carve-out is unaffected for org_admin (self-only, no org filter)', async () => {
expect(await readFilter(OBJECTS.better_auth, ROLES.org_admin)).toEqual({ $or: [{ id: 'oadmin' }, { id: 'oadmin' }] });
});
});

// ── Single-org mode: Layer 0 is inert; tenant policy stripped (parity today) ─
// With org-scoping absent, collectRLSPolicies strips the wildcard tenant policy
// entirely, so a member's read carries NO tenant where and the write keeps only
Expand Down
Loading