Skip to content

Commit ad0e5d3

Browse files
committed
feat(plugin-sharing): 姿态感知的组织门 —— 有墙姿态下缺权威组织即拒绝展开 DEPTH (#5859)
#5859 裁决(C)追加:`SharingService` 新增 late-bound `tenancy` 姿态探针(读法与 SecurityPlugin 为 Layer-0 墙读 `tenancy` 服务一致,由 SharingServicePlugin 接线), 按 ADR-0105 D1 的既有分叉决定「没有活动组织」意味着什么: - `single`(纯单租户,无组织):行为不变,DEPTH 照常 —— 那是唯一隐含租户,不是「所有组织」; - `group` / `isolated`:权威组织缺失/空白 → 不咨询 resolver,回落 owner-only, warn 点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 的 fail-closed 契约义务; - 姿态解析不出(未接线/抛错/词表外)→ 按有墙处理,未知姿态不是 single 的证据。 测试两个方向都钉:single+无组织仍 widened(先绿保持绿)、walled+无组织拒绝(先红后绿)、 姿态不可解析拒绝、legacy `isolationActive:false` 视为无墙、空白组织在两侧各自的表现。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
1 parent 2c334be commit ad0e5d3

4 files changed

Lines changed: 225 additions & 25 deletions

File tree

.changeset/sharing-hierarchy-org-authority.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,21 @@ RLS 的部署,跨组织 edit/delete 同样放行**。
3434
- resolver 抛错的静默回退改为**留声**(`logger.warn`):此前「resolver 炸了」和
3535
「层级里确实没有别人」在外部完全同形,这也是本缺陷长期不可见的原因之一。
3636

37-
安全收紧:按组织收窄 owner 集合的 resolver(企业版即是)从此真正拿到组织,
38-
跨组织的 share 管理 / edit / delete / 批量写全部按组织边界闭合。
37+
## 姿态感知的组织门(user-visible 行为变化)
38+
39+
`SharingService` 新增一个 late-bound 的 `tenancy` 姿态探针(读法与 `SecurityPlugin`
40+
为 Layer-0 墙读 `tenancy` 服务的完全一致,由 `SharingServicePlugin` 自动接线),
41+
**ADR-0105 D1** 的既有分叉决定「没有活动组织」意味着什么 —— 与
42+
`computeTenantLayer0Filter` 对同一问题给出的答案逐条同形:
43+
44+
- **`single`**(纯单租户,无组织):**行为不变**,DEPTH 照常widened。此处「没有组织」
45+
是那一个隐含租户,不是「所有组织」。
46+
- **`group` / `isolated`**(有墙):权威组织缺失/空白 → **拒绝**,根本不咨询 resolver,
47+
回落 owner-only 并打一条点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 契约义务的 `warn`
48+
即:有墙部署里,缺组织的 owner-scope 解析从「按无租户约束展开」变为「拒绝展开」。
49+
- **姿态解析不出**(未接线 / 探针抛错 / 词表外的值)→ 按**有墙**处理。未知姿态不是
50+
`single` 的证据,否则恰恰在配置已经可疑的部署上恢复了展开。
51+
52+
对已有部署的影响:`single` 部署零变化;`group` / `isolated` 部署中,一个**没有活动
53+
组织**的调用方将不再通过 DEPTH 拿到跨组织的 owner 集合(共享管理 / edit / delete /
54+
批量写四条路径同时闭合)。

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,16 @@ export class SharingServicePlugin implements Plugin {
449449
try { return ctx.getService<any>('security'); }
450450
catch { return null; }
451451
},
452+
// [ADR-0105 D1 / #5859] Late-bound tenancy posture — read exactly the
453+
// way SecurityPlugin reads it for the Layer 0 wall, so the two layers
454+
// can never disagree about whether an organization wall is in force.
455+
// Absent (no plugin-auth) → the org gate assumes WALLED and refuses to
456+
// widen a hierarchy scope that carries no organization; an unresolvable
457+
// posture is not evidence of `single`.
458+
tenancy: () => {
459+
try { return ctx.getService<any>('tenancy'); }
460+
catch { return null; }
461+
},
452462
});
453463
ctx.registerService('sharing', this.service);
454464

packages/plugins/plugin-sharing/src/sharing-service.test.ts

Lines changed: 109 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { describe, it, expect, beforeEach, vi } from 'vitest';
44
import { assertEngineDeleteDispatch } from '@objectstack/objectql';
5-
import { SharingService } from './sharing-service.js';
5+
import { SharingService, type SharingServiceOptions } from './sharing-service.js';
66
import { buildSharingMiddleware } from './sharing-plugin.js';
77
import { bootRequestContext } from './exec-context-seam.testkit.js';
88

@@ -1206,6 +1206,15 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
12061206
});
12071207
}
12081208

1209+
/**
1210+
* [ADR-0105 D1] The deployment posture, stated the way the `tenancy` service
1211+
* states it. `single` = no organization wall (the pure single-tenant end of
1212+
* the spectrum the ADR-0057 D1 proofs boot); `group` / `isolated` = a wall is
1213+
* in force. Every fixture below says which deployment it is talking about,
1214+
* because after #5859 the answer to "no active organization" depends on it.
1215+
*/
1216+
const posture = (p: string) => () => ({ posture: p });
1217+
12091218
let engine: ReturnType<typeof makeFakeEngine>;
12101219
beforeEach(() => {
12111220
engine = makeFakeEngine({ account: ACCOUNT_SCHEMA, sys_record_share: {} });
@@ -1233,6 +1242,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
12331242
engine,
12341243
securityService: () => MANAGER_PROBE,
12351244
hierarchyResolver: orgScopedResolver(seen),
1245+
tenancy: posture('isolated'),
12361246
});
12371247
const bob = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' });
12381248

@@ -1252,6 +1262,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
12521262
engine,
12531263
securityService: () => MANAGER_PROBE,
12541264
hierarchyResolver: orgScopedResolver(seen),
1265+
tenancy: posture('group'),
12551266
});
12561267
const bob = await bootRequestContext({
12571268
userId: 'bob',
@@ -1275,6 +1286,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
12751286
const svc = new SharingService({
12761287
engine,
12771288
hierarchyResolver: orgScopedResolver(seen),
1289+
tenancy: posture('group'),
12781290
// NO securityService: no `modifyAllRecords` bypass, and no owner-only RLS
12791291
// anywhere — the sharing service is the ONLY gate in this fixture, which
12801292
// is the deployment shape #5852 named as unprotected (the probe app was
@@ -1296,31 +1308,99 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
12961308
expect(filter).toEqual({ owner_id: { $in: ['alice', 'bob'] } });
12971309
});
12981310

1299-
it('no active organization is reported as an HONEST null — never the deprecated alias, never a stand-in', async () => {
1311+
// ── [ADR-0105 D1] The posture fork on "no active organization" ────────
1312+
// Same caller, same absent org, two deployments, two answers — the same
1313+
// fork Layer 0 already makes (`computeTenantLayer0Filter`: `single` inert,
1314+
// walled postures deny). Both directions are pinned; neither is a default.
1315+
1316+
it('single posture: no organization at all → DEPTH still widens, and the null is HONEST', async () => {
13001317
const seen: any[] = [];
13011318
const svc = new SharingService({
13021319
engine,
13031320
securityService: () => MANAGER_PROBE,
13041321
hierarchyResolver: orgScopedResolver(seen),
1322+
// The pure single-tenant end of the spectrum — the shape the verify
1323+
// harness boots deliberately (`autoDefaultOrganization: false`) and the
1324+
// ADR-0057 D1 dogfood proofs run in. "No org" here is the one implicit
1325+
// tenant, not "every org", so refusing would retire DEPTH for every
1326+
// org-less deployment.
1327+
tenancy: posture('single'),
13051328
});
1306-
// A session with no active organization — the supported pure-single-tenant
1307-
// shape (the verify harness boots it deliberately: `autoDefaultOrganization:
1308-
// false`), not an anomaly this layer invents a verdict for.
13091329
const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null });
13101330
expect((orgless as any).tenantId).toBeUndefined();
13111331

1312-
await svc.canManageShares('account', 'a1', orgless);
1332+
expect(await svc.canManageShares('account', 'a1', orgless)).toBe(true);
13131333
expect(seen).toHaveLength(1);
1314-
// `string | null` per the contract: the producer states the absence rather
1315-
// than omitting the key (which is what let #5852's resolver read
1316-
// `undefined` and query unscoped without anyone noticing).
1334+
// `string | null` per the contract: the producer STATES the absence rather
1335+
// than omitting the key (omission is what let a resolver read `undefined`
1336+
// and query unscoped without anyone noticing). What a resolver must then do
1337+
// with that null is its own obligation — cloud#1148's half.
13171338
expect(seen[0]).toHaveProperty('organizationId');
13181339
expect(seen[0].organizationId).toBeNull();
1319-
// What the resolver must DO with that null is its own contract obligation
1320-
// ("Fail CLOSED on a missing organization … 'no org' is not 'every org'",
1321-
// IHierarchyScopeResolver.resolveOwnerIds) — cloud#1148's half. Whether the
1322-
// OPEN edition should additionally refuse to consult it is the open
1323-
// tenancy-posture question on #5859; deliberately not decided here.
1340+
});
1341+
1342+
it.each(['group', 'isolated'])(
1343+
'%s posture: no active organization → the resolver is NOT consulted, loudly',
1344+
async (p) => {
1345+
const seen: any[] = [];
1346+
const warn = vi.fn();
1347+
const svc = new SharingService({
1348+
engine,
1349+
securityService: () => MANAGER_PROBE,
1350+
hierarchyResolver: orgScopedResolver(seen),
1351+
tenancy: posture(p),
1352+
logger: { warn },
1353+
});
1354+
const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null });
1355+
1356+
// A wall is in force and the caller carries no organization to scope by:
1357+
// owner-only, never widened — and the resolver is not even asked, so an
1358+
// out-of-tree implementation cannot answer for every org on its own.
1359+
expect(await svc.canManageShares('account', 'a1', orgless)).toBe(false);
1360+
expect(await svc.canEdit('account', 'b1', { ...(orgless as any), __writeScope: 'unit' })).toBe(false);
1361+
expect(seen).toHaveLength(0);
1362+
expect(warn).toHaveBeenCalled();
1363+
const [message, meta] = warn.mock.calls[0];
1364+
expect(String(message)).toContain('organization wall is in force');
1365+
expect(String(message)).toContain('ADR-0095 D1 / ADR-0105 D1');
1366+
expect(meta).toMatchObject({ userId: 'bob' });
1367+
},
1368+
);
1369+
1370+
it('an UNRESOLVABLE posture is not evidence of `single` — it refuses too', async () => {
1371+
const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null });
1372+
const probes: Array<SharingServiceOptions['tenancy']> = [
1373+
undefined, // no `tenancy` wired at all
1374+
() => null, // service not registered
1375+
() => { throw new Error('tenancy unavailable'); },
1376+
() => ({ posture: 'not-a-posture' }), // outside the vocabulary
1377+
];
1378+
for (const tenancy of probes) {
1379+
const seen: any[] = [];
1380+
const svc = new SharingService({
1381+
engine,
1382+
securityService: () => MANAGER_PROBE,
1383+
hierarchyResolver: orgScopedResolver(seen),
1384+
tenancy,
1385+
});
1386+
expect(await svc.canManageShares('account', 'a1', orgless)).toBe(false);
1387+
expect(seen).toHaveLength(0);
1388+
}
1389+
});
1390+
1391+
it('the legacy `isolationActive: false` shape still states "no wall" (single)', async () => {
1392+
const seen: any[] = [];
1393+
const svc = new SharingService({
1394+
engine,
1395+
securityService: () => MANAGER_PROBE,
1396+
hierarchyResolver: orgScopedResolver(seen),
1397+
// Pre-ADR-0105 `tenancy` shape — a POSITIVE statement that no wall is
1398+
// enforced, unlike a missing/unknown posture.
1399+
tenancy: () => ({ isolationActive: false }),
1400+
});
1401+
const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null });
1402+
expect(await svc.canManageShares('account', 'a1', orgless)).toBe(true);
1403+
expect(seen).toHaveLength(1);
13241404
});
13251405

13261406
it('fail closed: a THROWING resolver falls back to owner-only and SAYS so', async () => {
@@ -1331,6 +1411,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
13311411
hierarchyResolver: () => ({
13321412
async resolveOwnerIds(): Promise<string[]> { throw new Error('resolver exploded'); },
13331413
}),
1414+
tenancy: posture('isolated'),
13341415
logger: { warn },
13351416
});
13361417
const bob = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' });
@@ -1348,6 +1429,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
13481429
engine,
13491430
securityService: () => MANAGER_PROBE,
13501431
hierarchyResolver: orgScopedResolver(seen),
1432+
tenancy: posture('single'),
13511433
});
13521434
const blank = await bootRequestContext({ userId: 'bob', activeOrganizationId: ' ' });
13531435
await svc.canManageShares('account', 'a1', blank);
@@ -1356,4 +1438,17 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
13561438
// a literal that silently matches no rows and reads as "scoped" in a log.
13571439
expect(seen[0].organizationId).toBeNull();
13581440
});
1441+
1442+
it('a blank organization is ALSO an absent one under a wall (same normalization, refusing side)', async () => {
1443+
const seen: any[] = [];
1444+
const svc = new SharingService({
1445+
engine,
1446+
securityService: () => MANAGER_PROBE,
1447+
hierarchyResolver: orgScopedResolver(seen),
1448+
tenancy: posture('isolated'),
1449+
});
1450+
const blank = await bootRequestContext({ userId: 'bob', activeOrganizationId: ' ' });
1451+
expect(await svc.canManageShares('account', 'a1', blank)).toBe(false);
1452+
expect(seen).toHaveLength(0);
1453+
});
13591454
});

packages/plugins/plugin-sharing/src/sharing-service.ts

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import type {
88
SharingExecutionContext,
99
ShareAccessLevel,
1010
} from '@objectstack/spec/contracts';
11+
import {
12+
normalizeTenancyPosture,
13+
postureEnforcesWall,
14+
type TenancyPosture,
15+
} from '@objectstack/spec/security';
1116
import { WRITE_ACCESS_LEVELS, normalizeAccessLevel } from './access-level.js';
1217
import {
1318
deleteRowsForDeletedRecords,
@@ -156,6 +161,20 @@ const RECORD_SHARE_SWEEP_SUBJECT = {
156161
issue: '#5103',
157162
} as const;
158163

164+
/**
165+
* [ADR-0105 D1 / #5859] The narrow slice of the `tenancy` service the
166+
* organization gate needs — the deployment's posture, i.e. whether an
167+
* organization wall is enforced at all. Kept structural (and identical in shape
168+
* to what `SecurityPlugin` reads) so a stack without `@objectstack/plugin-auth`
169+
* needs no adapter, and so a unit test can state a posture without a kernel.
170+
*/
171+
export interface SharingTenancyProbe {
172+
/** `single` | `group` | `isolated` (the legacy `multi` spelling normalizes). */
173+
readonly posture?: TenancyPosture | string;
174+
/** Pre-ADR-0105 shape: "is the hard organization wall on?" */
175+
readonly isolationActive?: boolean;
176+
}
177+
159178
export interface SharingServiceOptions {
160179
engine: SharingEngine;
161180
/** Object names that bypass sharing — typically platform internals. */
@@ -172,6 +191,16 @@ export interface SharingServiceOptions {
172191
* null → management authority fails CLOSED to owner-only.
173192
*/
174193
securityService?: () => SharingSecurityProbe | null | undefined;
194+
/**
195+
* [ADR-0105 D1 / #5859] Late-bound lookup for the `tenancy` service — the
196+
* single source of truth for which posture is IN FORCE. Read ONLY to decide
197+
* whether a missing authoritative organization must refuse a hierarchy scope
198+
* (see {@link SharingService.organizationScopeRequired}).
199+
*
200+
* Absent / throwing / posture-less → the gate assumes a WALLED deployment and
201+
* refuses: an unresolvable posture must not be read as "no wall, carry on".
202+
*/
203+
tenancy?: () => SharingTenancyProbe | null | undefined;
175204
/** [#5103] Optional logger for the record-delete cascade / orphan sweep. */
176205
logger?: { info?: Function; warn?: Function; error?: Function; debug?: Function };
177206
}
@@ -189,12 +218,14 @@ export class SharingService implements ISharingService {
189218
private readonly bypassObjects: Set<string>;
190219
private readonly hierarchyResolver?: () => IHierarchyScopeResolver | null | undefined;
191220
private readonly securityService?: () => SharingSecurityProbe | null | undefined;
221+
private readonly tenancy?: () => SharingTenancyProbe | null | undefined;
192222
private readonly logger?: SharingServiceOptions['logger'];
193223

194224
constructor(options: SharingServiceOptions) {
195225
this.engine = options.engine;
196226
this.hierarchyResolver = options.hierarchyResolver;
197227
this.securityService = options.securityService;
228+
this.tenancy = options.tenancy;
198229
this.logger = options.logger;
199230
this.bypassObjects = new Set([
200231
'sys_record_share',
@@ -911,15 +942,11 @@ export class SharingService implements ISharingService {
911942
* organization** … 'no org' is not 'every org'. Return owner-only (or throw,
912943
* which the sharing layer treats the same way); never widen."
913944
*
914-
* An additional REFUSAL here (not consulting the resolver at all on a null
915-
* org) is deliberately NOT implemented yet: "no active organization" is the
916-
* normal state of the supported pure-single-tenant deployment — the verify
917-
* harness boots exactly that shape on purpose (`autoDefaultOrganization:
918-
* false`) and the ADR-0057 D1 dogfood proofs pin hierarchy DEPTH working in
919-
* it. Whether the open edition should refuse there is a tenancy-posture
920-
* question (ADR-0105 D1: `single` → no wall; `isolated`/`group` → a missing
921-
* org denies, cf. `computeTenantLayer0Filter`), and the sharing service holds
922-
* no posture today — see #5859 for the open decision.
945+
* On top of that, a WALLED deployment refuses outright: when an organization
946+
* wall is in force and the authoritative org is missing, the resolver is not
947+
* consulted at all and the caller falls back to owner-only, loudly — see
948+
* {@link SharingService.organizationScopeRequired} for why the refusal is
949+
* posture-scoped rather than unconditional.
923950
*/
924951
private async resolveOwnerScopeIds(
925952
context: SharingExecutionContext,
@@ -931,6 +958,17 @@ export class SharingService implements ISharingService {
931958
if (!resolver) return [me];
932959

933960
const organizationId = activeOrganizationId(context);
961+
if (organizationId === null && this.organizationScopeRequired()) {
962+
this.logger?.warn?.(
963+
'[sharing] hierarchy scope NOT widened: an organization wall is in force but the caller ' +
964+
'context carries no active organization — failing closed to owner-only. ' +
965+
'"No org" is not "every org" (IHierarchyScopeResolver.resolveOwnerIds, #5973); ' +
966+
'the same rule walls Layer 0 (ADR-0095 D1 / ADR-0105 D1).',
967+
{ userId: me, scope },
968+
);
969+
return [me];
970+
}
971+
934972
try {
935973
const ids = await resolver.resolveOwnerIds(
936974
{
@@ -959,6 +997,47 @@ export class SharingService implements ISharingService {
959997
}
960998
}
961999

1000+
/**
1001+
* [ADR-0105 D1 / #5859] Must a hierarchy scope be refused when the caller
1002+
* carries no authoritative organization?
1003+
*
1004+
* The answer is the deployment's TENANCY POSTURE, not a constant — which is
1005+
* the same answer Layer 0 already gives to the same question
1006+
* (`computeTenantLayer0Filter`, ADR-0095 D1 / ADR-0105 D1):
1007+
*
1008+
* - `single` → **no**. There is no organization dimension at all; "no org"
1009+
* there means "the one implicit tenant", not "every org", and hierarchy
1010+
* DEPTH is pinned working in exactly that shape (the ADR-0057 D1 proofs
1011+
* boot a pure single-tenant stack on purpose — `@objectstack/verify`'s
1012+
* harness sets `autoDefaultOrganization: false` to model it). Refusing
1013+
* here would retire DEPTH for every org-less deployment.
1014+
* - `group` / `isolated` → **yes**. A wall is in force, so a caller with no
1015+
* active organization has no tenancy constraint to scope an owner set by,
1016+
* and widening one would hand out exactly the cross-organization reach
1017+
* #5852 measured. Layer 0 denies in the same situation; this is that rule,
1018+
* applied one layer up where the sharing gates read.
1019+
*
1020+
* Fails CLOSED on an unresolvable posture (no `tenancy` probe wired, a
1021+
* throwing probe, or a value outside the vocabulary): an unknown posture is
1022+
* NOT evidence of `single`, and reading it as such would restore the widening
1023+
* on precisely the deployments whose configuration is already suspect.
1024+
*/
1025+
private organizationScopeRequired(): boolean {
1026+
let probe: SharingTenancyProbe | null | undefined;
1027+
try {
1028+
probe = this.tenancy?.();
1029+
} catch {
1030+
return true; // unresolvable → assume walled
1031+
}
1032+
if (!probe) return true;
1033+
const posture = normalizeTenancyPosture(probe.posture);
1034+
if (posture) return postureEnforcesWall(posture);
1035+
// Pre-ADR-0105 shape: only `isolationActive === false` is a positive
1036+
// statement that no wall is enforced. `undefined` stays unresolved.
1037+
if (probe.isolationActive === false) return false;
1038+
return true;
1039+
}
1040+
9621041
private shouldBypass(object: string, context: SharingExecutionContext): boolean {
9631042
if (context?.isSystem) return true;
9641043
if (this.bypassObjects.has(object)) return true;

0 commit comments

Comments
 (0)