Skip to content

Commit 2ae78c6

Browse files
os-zhuangclaude
andauthored
fix(plugin-security): #2937 — Layer 0 insert post-image 租户检查 (#2940)
多组织模式下用户 insert 可伪造 organization_id 越租户墙 (ADR-0095 D1 读侧 W1 的写侧未竟部分)。Layer 0 的租户墙作用于读 + update/delete 的 pre-image, 但 insert 无 pre-image 且不带 AST,从未被门控。 新增中间件步骤 3.7:对 insert 的 post-image 复用读侧同一套 Layer 0 决策 (computeInsertTenantCheckFilter → computeLayeredRlsFilter 的 layer0)校验 —— 显式提供的 organization_id 必须等于调用者活动组织,否则 fail-closed 拒绝。 缺省值不受影响(由 @objectstack/organizations auto-stamp 补全,故与其注册顺序 无关);isSystem 上下文中间件入口短路,合法代客设 org_id(import/迁移/seed replay)完全不受影响。matrix gate + 授权一致性 ledger 覆盖。 Closes #2937 Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
1 parent a581a65 commit 2ae78c6

4 files changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security): #2937 — Layer 0 insert post-image 租户检查(伪造 organization_id 的用户 insert 现被拒)
6+
7+
**安全修复 + 行为变更(release-notes callout)。** 多组织模式(`tenancy.mode='multi'` +
8+
`@objectstack/organizations`)下,一个普通用户此前可以 `insert` 一条**伪造 `organization_id`**
9+
(指向别的租户)的业务记录并使其落进受害租户 —— Layer 0 的租户墙 AND-composed 到读 +
10+
update/delete 的 pre-image,但 insert 没有 pre-image、也不带 AST,从未被门控(ADR-0095 D1
11+
读侧 W1 的写侧未竟部分)。
12+
13+
新增 SecurityPlugin 中间件步骤 3.7:对 insert 的 **post-image** 复用读侧同一套 Layer 0 决策
14+
(`computeInsertTenantCheckFilter``computeLayeredRlsFilter``layer0`)做校验 ——
15+
一个**显式提供**`organization_id` 必须等于调用者的活动组织,否则 fail-closed 拒绝。规则与
16+
读侧完全一致:单组织/隔离未激活、非租户对象(无 `organization_id` 列或 `tenancy.enabled:false`)、
17+
platform-admin 姿态豁免的对象均不适用;无活动组织的用户提供任意 org_id → 拒(deny sentinel)。
18+
19+
**行为 delta(需注意):** 此前能成功的「带跨租户 `organization_id` 的用户级 insert」现被拒绝。
20+
**缺省(不提供 `organization_id`)的 insert 不受影响** —— 补全仍由 `@objectstack/organizations`
21+
的 auto-stamp 负责(职责分离,因此本检查与 auto-stamp 中间件的注册顺序无关)。系统上下文
22+
(`isSystem`,含 import 引擎 / 迁移 / 每-org seed replay·clone·orphan-claim 的 `SYSTEM_CTX`)
23+
在中间件入口即短路,合法的「代客设置 org_id」写路径**完全不受影响**
24+
25+
矩阵门:`authz-matrix-gate.test.ts` 新增 `[#2937] Layer 0 insert post-image tenant guard`
26+
八格(伪造异租户→拒、同租户→通过、缺省→放行、无活动组织→拒、platform-admin 私有对象豁免、
27+
public 业务对象不豁免、tenancy-disabled 对象不适用、单组织模式不检查)。授权一致性 ledger
28+
新增 `multi-tenant-insert-postimage` 行。配套 cloud `@objectstack/organizations` 的 auto-stamp
29+
权威覆盖(纵深防御)。Closes objectstack-ai/framework#2937

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ 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.' },
4548
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
4649
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
4750
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,25 @@ async function writeFilter(cell: any, roleCtx: any): Promise<unknown> {
124124
return h.findOne.mock.calls[0][1].where.$and.slice(1);
125125
}
126126

127+
/**
128+
* [#2937] Effective INSERT outcome for a supplied `organization_id`. Drives the
129+
* REAL insert path through the security CRUD middleware and reports either the
130+
* denial marker or the post-image `organization_id` the row would land with.
131+
* `CRUD_DENY:*` = blocked (CRUD gate or the Layer 0 tenant post-image check).
132+
*/
133+
async function insertOrg(cell: any, roleCtx: any, orgId: unknown): Promise<unknown> {
134+
const plugin = new SecurityPlugin();
135+
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true });
136+
await plugin.init(h.ctx); await plugin.start(h.ctx);
137+
const opCtx: any = {
138+
object: cell.objectName, operation: 'insert',
139+
data: { name: 'x', ...(orgId === undefined ? {} : { organization_id: orgId }) },
140+
context: roleCtx,
141+
};
142+
try { await h.run(opCtx); } catch (e: any) { return `CRUD_DENY:${e?.name ?? 'err'}`; }
143+
return 'organization_id' in opCtx.data ? opCtx.data.organization_id : '<<absent>>';
144+
}
145+
127146
// ── Axes ─────────────────────────────────────────────────────────────────────
128147
const OBJECTS = {
129148
// Ordinary tenant business object: has organization_id, public posture.
@@ -270,6 +289,47 @@ describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => {
270289
expect(await readFilter(OBJECTS.task, ROLES.no_org_member)).toEqual({ id: DENY });
271290
});
272291

292+
// ── #2937: Layer 0 INSERT post-image tenant check ─────────────────────────
293+
// insert has no pre-image, so the tenant wall never reached it: a member could
294+
// `insert` a row with a FORGED organization_id and land it in another tenant.
295+
// The Layer 0 insert post-image check closes this — a SUPPLIED organization_id
296+
// must equal the caller's active org; an absent value is left to the
297+
// enterprise auto-stamp (organizations plugin), and system/platform-admin/
298+
// single-mode paths are exempt exactly as on the read side.
299+
describe('[#2937] Layer 0 insert post-image tenant guard', () => {
300+
it('member inserting a FORGED cross-tenant organization_id is DENIED', async () => {
301+
expect(await insertOrg(OBJECTS.task, ROLES.member, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
302+
});
303+
it('member inserting the SAME-tenant organization_id is allowed (row keeps it)', async () => {
304+
expect(await insertOrg(OBJECTS.task, ROLES.member, 'org-1')).toBe('org-1');
305+
});
306+
it('member inserting with NO organization_id passes the wall (auto-stamp territory)', async () => {
307+
// plugin-security does not stamp; an absent value is the organizations
308+
// plugin's job. The Layer 0 check must NOT deny it (ordering-independent).
309+
expect(await insertOrg(OBJECTS.task, ROLES.member, undefined)).toBe('<<absent>>');
310+
});
311+
it('member with NO active org supplying ANY organization_id is fail-closed DENIED', async () => {
312+
expect(await insertOrg(OBJECTS.task, ROLES.no_org_member, 'org-1')).toBe('CRUD_DENY:PermissionDeniedError');
313+
});
314+
it('platform admin may insert a cross-org row on a PRIVATE object (posture exemption)', async () => {
315+
// private posture permits the platform-admin superuser bypass → Layer 0 null.
316+
expect(await insertOrg(OBJECTS.private_obj, ROLES.platform_admin, 'org-2')).toBe('org-2');
317+
});
318+
it('platform admin stays org-scoped on a PUBLIC business object (no exemption)', async () => {
319+
// public tenant business object → posture gate withholds the bypass → the
320+
// forged cross-org insert is DENIED even for a platform admin.
321+
expect(await insertOrg(OBJECTS.task, ROLES.platform_admin, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
322+
});
323+
it('platform-global (tenancy-disabled) object: supplied org value is untouched', async () => {
324+
// non-tenant object → Layer 0 contributes nothing → no insert check.
325+
expect(await insertOrg(OBJECTS.platform_global, ROLES.member, 'org-2')).toBe('org-2');
326+
});
327+
it('single-org mode: Layer 0 inert, a supplied organization_id is NOT checked', async () => {
328+
const single = { ...OBJECTS.task, orgScoping: false };
329+
expect(await insertOrg(single, ROLES.member, 'org-2')).toBe('org-2');
330+
});
331+
});
332+
273333
// ── Single-org mode: Layer 0 is inert; tenant policy stripped (parity today) ─
274334
// With org-scoping absent, collectRLSPolicies strips the wildcard tenant policy
275335
// entirely, so a member's read carries NO tenant where and the write keeps only

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,65 @@ export class SecurityPlugin implements Plugin {
10241024
}
10251025
}
10261026

1027+
// 3.7. [ADR-0095 D1 / #2937] Layer 0 tenant post-image check for INSERT.
1028+
//
1029+
// The tenant wall (Layer 0) is AND-composed onto reads and onto the
1030+
// update/delete PRE-image, but an insert has no pre-image and carries no
1031+
// AST, so the wall never reached it: a member could `insert` a row bearing
1032+
// a FORGED `organization_id` (pointing at another org) and land it in the
1033+
// victim tenant — the enterprise auto-stamp only fills a MISSING value, it
1034+
// never overwrites a supplied one. Close it here by validating the insert
1035+
// post-image against the SAME Layer 0 filter the read side uses (identical
1036+
// rule: isolation active, tenant object, platform-admin posture exemption,
1037+
// fail-closed on a missing active org).
1038+
//
1039+
// Scope: only a SUPPLIED (non-empty) `organization_id` is validated — an
1040+
// ABSENT value is the organizations-plugin auto-stamp's responsibility
1041+
// (separation of concerns; plugin-security no longer stamps org ids), and a
1042+
// pure plugin-security deployment has no isolation active (Layer 0 → null),
1043+
// so this is ordering-independent w.r.t. the auto-stamp middleware. System /
1044+
// boot writes carry `isSystem` and short-circuited the whole middleware
1045+
// above, so legitimate on-behalf `organization_id` writes (import engine,
1046+
// migrations, plugin SYSTEM_CTX) are unaffected.
1047+
if (
1048+
opCtx.operation === 'insert' &&
1049+
opCtx.data &&
1050+
typeof opCtx.data === 'object' &&
1051+
!Array.isArray(opCtx.data) &&
1052+
!!opCtx.context?.userId
1053+
) {
1054+
const data = opCtx.data as Record<string, unknown>;
1055+
const suppliedOrg = data.organization_id;
1056+
if (suppliedOrg != null && suppliedOrg !== '') {
1057+
const tenantCheck = await this.computeInsertTenantCheckFilter(
1058+
permissionSets,
1059+
opCtx.object,
1060+
opCtx.context,
1061+
);
1062+
// [ADR-0090 D10] The post-image must also satisfy the delegator's tenant
1063+
// wall — an on-behalf-of insert may not land a row in a tenant the
1064+
// delegator itself could not reach.
1065+
const delTenantCheck = delegatorSets
1066+
? await this.computeInsertTenantCheckFilter(delegatorSets, opCtx.object, delegatorContext)
1067+
: null;
1068+
const tenantParts = [tenantCheck, delTenantCheck].filter(Boolean) as Record<string, unknown>[];
1069+
if (
1070+
tenantParts.length > 0 &&
1071+
!tenantParts.every((f) => matchesFilterCondition(data as any, f as any))
1072+
) {
1073+
this.logger.warn?.(
1074+
`[Security] Layer 0 tenant CHECK FAILED on insert '${opCtx.object}' — write denied ` +
1075+
`(fail-closed); a supplied organization_id does not match the active organization`,
1076+
);
1077+
throw new PermissionDeniedError(
1078+
`[Security] Access denied: the insert would place '${opCtx.object}' in another tenant ` +
1079+
`(organization_id does not match the active organization)`,
1080+
{ operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets },
1081+
);
1082+
}
1083+
}
1084+
}
1085+
10271086
// 2.9. Field-level predicate guard (anti filter-oracle, objectui#2251).
10281087
// FieldMasker (step 4) only strips hidden fields from RESULTS — a
10291088
// caller could still probe a hidden field's value by filtering /
@@ -2068,6 +2127,26 @@ export class SecurityPlugin implements Plugin {
20682127
return this.rlsCompiler.compileFilter(withCheck, context, 'check');
20692128
}
20702129

2130+
/**
2131+
* [ADR-0095 D1 / #2937] Compute the Layer 0 (tenant) filter that an INSERT
2132+
* post-image must satisfy — the write-side twin of the read/update Layer 0
2133+
* wall. Reuses {@link computeLayeredRlsFilter} so the tenant decision is
2134+
* DERIVED FROM ONE PLACE and can never drift from the read side: same
2135+
* isolation probe, same "is this a tenant object?" field/posture test, same
2136+
* platform-admin posture exemption, same fail-closed deny sentinel when the
2137+
* context has no active organization. Only `layer0` is returned — business RLS
2138+
* (`layer1`) is NOT applied to the insert post-image (that path is governed by
2139+
* explicit `check` clauses via {@link computeWriteCheckFilter}).
2140+
*/
2141+
private async computeInsertTenantCheckFilter(
2142+
permissionSets: PermissionSet[],
2143+
object: string,
2144+
context: any,
2145+
): Promise<Record<string, unknown> | null> {
2146+
const { layer0 } = await this.computeLayeredRlsFilter(permissionSets, object, 'insert', context);
2147+
return layer0;
2148+
}
2149+
20712150
/**
20722151
* Resolve a controlled_by_parent object's master-detail relation (the FK field
20732152
* key + the master object name), or null. Prefers a required `master_detail`

0 commit comments

Comments
 (0)