Skip to content

Commit ca1e94e

Browse files
committed
test(plugin-sharing,plugin-security): pin the ruled mint matrix beside the service and the owner alternative at the organization wall
Beside the service: the owner mints on an owner-private object, a hierarchy manager without visibility is refused (and its write scope is never asked), a non-owner member is refused, Modify-All mints with visibility refused; the opt-in comes first and eligibility last. Through the real plugin wiring and the real Layer 0: a member who owns a record in an organization they are not in is refused under group and isolated. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50797ce commit ca1e94e

3 files changed

Lines changed: 293 additions & 7 deletions

File tree

‎packages/plugins/plugin-security/src/share-link-tenant-wall.test.ts‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,18 @@ interface MintOptions {
160160
memberOf: string[];
161161
/** The record's owning organization. */
162162
recordOrg?: string;
163+
/** The record's `owner_id`; absent ⇒ the row carries none. */
164+
recordOwner?: string;
163165
}
164166

165167
/**
166168
* Boot the real `SharingServicePlugin` and POST `/api/v1/share-links` for
167169
* `crm_account/acc_1` as a signed-in member — the exact call a user makes from
168170
* the record page's "share" button.
169171
*/
170-
async function groupPostureMint(opts: MintOptions): Promise<{ status: number; body: any }> {
172+
async function groupPostureMint(
173+
opts: MintOptions,
174+
): Promise<{ status: number; body: any; links: any[] }> {
171175
const userId = 'u_sharer';
172176
const activeOrg = opts.memberOf[0];
173177
const tables: Record<string, any[]> = {
@@ -181,7 +185,12 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
181185
sys_user_position: [],
182186
sys_user_permission_set: [],
183187
sys_permission_set: [],
184-
[OBJECT]: [{ id: RECORD, name: 'Acme', organization_id: opts.recordOrg ?? ORG_A }],
188+
[OBJECT]: [{
189+
id: RECORD,
190+
name: 'Acme',
191+
organization_id: opts.recordOrg ?? ORG_A,
192+
...(opts.recordOwner ? { owner_id: opts.recordOwner } : {}),
193+
}],
185194
sys_share_link: [],
186195
};
187196

@@ -194,6 +203,9 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
194203
getService: (name: string) => {
195204
if (name === 'objectql') return engine;
196205
if (name === 'http-server') return http;
206+
// The posture the sharing service reads (ADR-0105 D1) — the same one the
207+
// engine's Layer 0 applies.
208+
if (name === 'tenancy') return { posture: opts.posture };
197209
if (name === 'auth') {
198210
return {
199211
api: {
@@ -233,7 +245,8 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
233245
},
234246
res,
235247
);
236-
return captured;
248+
// The store, read back: a refusal is only a refusal if no row landed.
249+
return { ...captured, links: tables.sys_share_link ?? [] };
237250
}
238251

239252
describe('[#6206] share-link creation under the `group` tenancy posture', () => {
@@ -267,3 +280,49 @@ describe('[#6206] share-link creation under the `group` tenancy posture', () =>
267280
expect(res.status).toBe(201);
268281
});
269282
});
283+
284+
/**
285+
* [ADR-0111 D8 rule 1 — ruling 5950188467, A′] The owner may mint on a record
286+
* their visibility read refuses — but never across the organization wall.
287+
*
288+
* The owner alternative reads `owner_id`, and `owner_id` outlives a
289+
* membership: a member who left the record's organization still owns the rows
290+
* they created there. Under a walled posture the visibility read applies Layer
291+
* 0, and that refusal is the only thing standing between such a member and a
292+
* capability token on their former organization's record, which
293+
* `resolveToken` would then serve anonymously. So where a wall is in force the
294+
* owner and Modify-All alternatives are withheld and visibility alone admits.
295+
*
296+
* Real here, as above: the plugin's own wiring (the link service's
297+
* `canMintWithoutVisibility` probe is the one `SharingServicePlugin` composes)
298+
* and the real `computeTenantLayer0Filter`.
299+
*/
300+
describe('[ADR-0111 D8] the owner alternative does not cross the organization wall', () => {
301+
it.each(['group', 'isolated'] as const)(
302+
'%s: a member of plant B who OWNS a record in plant A is refused, and nothing lands',
303+
async (posture) => {
304+
const res = await groupPostureMint({
305+
posture,
306+
memberOf: [ORG_B],
307+
recordOrg: ORG_A,
308+
recordOwner: 'u_sharer',
309+
});
310+
311+
expect(res.status).toBe(403);
312+
expect(res.body).toMatchObject({ success: false, error: { code: 'FORBIDDEN' } });
313+
expect(res.links).toEqual([]);
314+
},
315+
);
316+
317+
it('control: the same owner, in the record\'s own organization, mints', async () => {
318+
const res = await groupPostureMint({
319+
posture: 'isolated',
320+
memberOf: [ORG_A],
321+
recordOrg: ORG_A,
322+
recordOwner: 'u_sharer',
323+
});
324+
expect(res.status).toBe(201);
325+
expect(res.body.data).toMatchObject({ object_name: OBJECT, record_id: RECORD, created_by: 'u_sharer' });
326+
expect(res.links.map((l) => l.created_by)).toEqual(['u_sharer']);
327+
});
328+
});

‎packages/plugins/plugin-sharing/src/share-link-service.test.ts‎

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, beforeEach } from 'vitest';
44
import { ShareLinkService } from './share-link-service.js';
5+
import { SharingService } from './sharing-service.js';
56

67
interface FakeRow { [k: string]: any }
78

@@ -1170,3 +1171,228 @@ describe('[#12981] a refused usage stamp is reported ONCE as a durability degrad
11701171
await expect(service.resolveToken(link.token)).resolves.not.toBeNull();
11711172
});
11721173
});
1174+
1175+
// ── [ADR-0111 D8 rule 1 — ruling 5950188467, A′] who may mint ────────────────
1176+
//
1177+
// "`createLink` admits `visibility OR owner OR Modify-All bypass`, still behind
1178+
// the `publicSharing` opt-in and before its `eligibility` check. … A hierarchy
1179+
// manager still needs visibility to mint." The owner and bypass halves are
1180+
// `canManageShares`' own first two branches, so the link service here is wired
1181+
// to a REAL `SharingService` exactly as `SharingServicePlugin` wires it; only
1182+
// its probes (the security service, the enterprise hierarchy resolver, the
1183+
// tenancy posture) are doubles.
1184+
//
1185+
// The object is OWNER-PRIVATE: the double below refuses every non-system read
1186+
// of it the way the CRUD gate does on an `access.default: 'private'` object
1187+
// (`PERMISSION_DENIED` with `statusCode` 403), except for `u_reader`, the one
1188+
// principal who can see it. So on the visibility rule alone nobody else —
1189+
// the owner included — could ever mint.
1190+
describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the record owner, or Modify-All', () => {
1191+
const OWNER = 'u_owner';
1192+
const STRANGER = 'u_stranger';
1193+
const ADMIN = 'u_admin';
1194+
const MANAGER = 'u_manager';
1195+
const READER = 'u_reader';
1196+
1197+
const SCHEMAS = {
1198+
sys_share_link: { name: 'sys_share_link', fields: {} },
1199+
conversations: {
1200+
name: 'conversations',
1201+
access: { default: 'private' },
1202+
publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] },
1203+
fields: { id: {}, title: {}, owner_id: {} },
1204+
},
1205+
// The same object shape with an eligibility predicate, for the ordering pins.
1206+
briefs: {
1207+
name: 'briefs',
1208+
access: { default: 'private' },
1209+
publicSharing: { enabled: true, eligibility: "record.status == 'published'" },
1210+
fields: { id: {}, title: {}, status: {}, owner_id: {} },
1211+
},
1212+
// Never opted in.
1213+
notes: { name: 'notes', access: { default: 'private' }, fields: { id: {}, owner_id: {} } },
1214+
};
1215+
const PRIVATE_OBJECTS = new Set(['conversations', 'briefs', 'notes']);
1216+
1217+
const denial = () =>
1218+
Object.assign(new Error('You do not have permission to perform this action.'), {
1219+
code: 'PERMISSION_DENIED',
1220+
statusCode: 403,
1221+
});
1222+
1223+
let engine: ReturnType<typeof makeFakeEngine>;
1224+
let posture: string | undefined;
1225+
let writeScopeCalls: number;
1226+
let sharing: SharingService;
1227+
let service: ShareLinkService;
1228+
let mintProbeCalls: Array<[string, string, string | undefined]>;
1229+
1230+
const mintIn = (object: string, recordId: string) =>
1231+
({ object, recordId, audience: 'link_only', permission: 'view' }) as const;
1232+
const conversation = () => mintIn('conversations', 'c1');
1233+
const as = (userId: string) => ({ userId }) as any;
1234+
const minted = () => (engine._tables.sys_share_link ?? []).map((r) => r.created_by);
1235+
1236+
beforeEach(() => {
1237+
engine = makeFakeEngine(SCHEMAS);
1238+
engine._tables.conversations = [{ id: 'c1', title: 'My chat', owner_id: OWNER }];
1239+
engine._tables.briefs = [
1240+
{ id: 'b_draft', title: 'Draft', status: 'draft', owner_id: OWNER },
1241+
{ id: 'b_pub', title: 'Published', status: 'published', owner_id: OWNER },
1242+
];
1243+
engine._tables.notes = [{ id: 'n1', owner_id: OWNER }];
1244+
const plainFind = engine.find.bind(engine);
1245+
engine.find = async (object: string, options?: any) => {
1246+
const ctx = options?.context ?? {};
1247+
if (PRIVATE_OBJECTS.has(object) && ctx.isSystem !== true && ctx.userId !== READER) throw denial();
1248+
return plainFind(object, options);
1249+
};
1250+
1251+
posture = 'single';
1252+
writeScopeCalls = 0;
1253+
mintProbeCalls = [];
1254+
sharing = new SharingService({
1255+
engine: engine as any,
1256+
securityService: () => ({
1257+
hasWriteBypass: async (_object: string, ctx: any) => ctx?.userId === ADMIN,
1258+
resolveWriteScope: async (_object: string, ctx: any) => {
1259+
writeScopeCalls += 1;
1260+
return ctx?.userId === MANAGER ? 'unit' : 'own';
1261+
},
1262+
}),
1263+
// The enterprise seam: the manager's `unit` covers the owner.
1264+
hierarchyResolver: () =>
1265+
({
1266+
resolveOwnerIds: async (ctx: any, scope: string) =>
1267+
ctx?.userId === MANAGER && scope === 'unit' ? [MANAGER, OWNER] : [String(ctx?.userId)],
1268+
}) as any,
1269+
tenancy: () => (posture === undefined ? null : { posture }),
1270+
});
1271+
// Wired the way `SharingServicePlugin` wires it.
1272+
service = new ShareLinkService({
1273+
engine: engine as any,
1274+
canManageShares: (o, r, c) => sharing.canManageShares(o, r, c),
1275+
canMintWithoutVisibility: (o, r, c) => {
1276+
mintProbeCalls.push([o, r, (c as any)?.userId]);
1277+
return sharing.canMintWithoutVisibility(o, r, c);
1278+
},
1279+
});
1280+
});
1281+
1282+
it('[persona] nobody but the reader sees the record: the owner\'s own read is refused', async () => {
1283+
await expect(engine.find('conversations', { where: { id: 'c1' }, context: as(OWNER) }))
1284+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
1285+
expect(await engine.find('conversations', { where: { id: 'c1' }, context: as(READER) })).toHaveLength(1);
1286+
});
1287+
1288+
it('the owner mints on an owner-private object, and the link resolves', async () => {
1289+
const link = await service.createLink(conversation(), as(OWNER));
1290+
expect(link).toMatchObject({ object_name: 'conversations', record_id: 'c1', created_by: OWNER });
1291+
expect(minted()).toEqual([OWNER]);
1292+
await expect(service.resolveToken(link.token)).resolves.toMatchObject({ link: { id: link.id } });
1293+
});
1294+
1295+
it('a hierarchy manager without visibility is refused, though they ARE a share-manager of the record', async () => {
1296+
// The control: ADR-0111 D1 DEPTH admits this principal to manage shares.
1297+
expect(await sharing.canManageShares('conversations', 'c1', as(MANAGER))).toBe(true);
1298+
const callsBefore = writeScopeCalls;
1299+
1300+
await expect(service.createLink(conversation(), as(MANAGER)))
1301+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
1302+
expect(minted()).toEqual([]);
1303+
// Minting never asked for the caller's write scope: the DEPTH branch is not
1304+
// part of mint authority, rather than a branch that happened to say no.
1305+
expect(writeScopeCalls, 'the mint consulted the hierarchy-depth probe').toBe(callsBefore);
1306+
});
1307+
1308+
it('a non-owner member is refused with the visibility read\'s own refusal', async () => {
1309+
await expect(service.createLink(conversation(), as(STRANGER)))
1310+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
1311+
expect(minted()).toEqual([]);
1312+
});
1313+
1314+
it('Modify-All mints, with the visibility read refused (the bypass branch alone admits)', async () => {
1315+
await expect(engine.find('conversations', { where: { id: 'c1' }, context: as(ADMIN) }))
1316+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
1317+
const link = await service.createLink(conversation(), as(ADMIN));
1318+
expect(link).toMatchObject({ record_id: 'c1', created_by: ADMIN });
1319+
});
1320+
1321+
it('visibility still admits on its own, without asking the owner/bypass probe', async () => {
1322+
const link = await service.createLink(conversation(), as(READER));
1323+
expect(link.created_by).toBe(READER);
1324+
expect(mintProbeCalls).toEqual([]);
1325+
});
1326+
1327+
it('an empty visibility read stays 403 FORBIDDEN for a caller no alternative admits', async () => {
1328+
// A row filter, not a CRUD refusal: the read answers nothing.
1329+
engine.find = (async (object: string, options?: any) =>
1330+
options?.context?.isSystem === true ? engine._tables[object] ?? [] : []) as any;
1331+
await expect(service.createLink(conversation(), as(STRANGER)))
1332+
.rejects.toMatchObject({ status: 403, code: 'FORBIDDEN' });
1333+
// ...and the owner of the same row mints past it.
1334+
await expect(service.createLink(conversation(), as(OWNER))).resolves.toMatchObject({ created_by: OWNER });
1335+
});
1336+
1337+
describe('the order: opt-in, then authority, then eligibility', () => {
1338+
it('the opt-in comes first — the owner of a record on an object that never opted in is refused 422', async () => {
1339+
await expect(service.createLink(mintIn('notes', 'n1'), as(OWNER)))
1340+
.rejects.toMatchObject({ status: 422, code: 'SHARING_NOT_ENABLED' });
1341+
expect(mintProbeCalls).toEqual([]);
1342+
});
1343+
1344+
it('eligibility is judged after the owner is admitted: an ineligible record is refused 422, an eligible one mints', async () => {
1345+
await expect(service.createLink(mintIn('briefs', 'b_draft'), as(OWNER)))
1346+
.rejects.toMatchObject({ status: 422, code: 'RECORD_NOT_ELIGIBLE' });
1347+
await expect(service.createLink(mintIn('briefs', 'b_pub'), as(OWNER)))
1348+
.resolves.toMatchObject({ record_id: 'b_pub', created_by: OWNER });
1349+
});
1350+
1351+
it('a caller with no authority is refused BEFORE eligibility, learning nothing about the record', async () => {
1352+
// The draft would draw RECORD_NOT_ELIGIBLE; the stranger never reaches it.
1353+
await expect(service.createLink(mintIn('briefs', 'b_draft'), as(STRANGER)))
1354+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
1355+
});
1356+
});
1357+
1358+
describe('where the alternatives are withheld', () => {
1359+
it.each(['isolated', 'group'])(
1360+
'an organization wall (%s): visibility alone admits, so the owner and Modify-All are refused',
1361+
async (walled) => {
1362+
posture = walled;
1363+
for (const who of [OWNER, ADMIN]) {
1364+
await expect(service.createLink(conversation(), as(who)))
1365+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
1366+
}
1367+
expect(minted()).toEqual([]);
1368+
// Revoke authority is untouched by the wall: the owner still manages the record.
1369+
expect(await sharing.canManageShares('conversations', 'c1', as(OWNER))).toBe(true);
1370+
},
1371+
);
1372+
1373+
it('an unresolvable posture counts as walled (fail closed)', async () => {
1374+
posture = undefined;
1375+
await expect(service.createLink(conversation(), as(OWNER)))
1376+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
1377+
});
1378+
1379+
it('a deployment without the probe keeps visibility alone (the pre-A′ rule)', async () => {
1380+
const probeless = new ShareLinkService({
1381+
engine: engine as any,
1382+
canManageShares: (o, r, c) => sharing.canManageShares(o, r, c),
1383+
});
1384+
await expect(probeless.createLink(conversation(), as(OWNER)))
1385+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
1386+
});
1387+
1388+
it('a throwing probe is a refusal, answered with the visibility read\'s own envelope', async () => {
1389+
const broken = new ShareLinkService({
1390+
engine: engine as any,
1391+
canMintWithoutVisibility: async () => { throw new Error('probe down'); },
1392+
});
1393+
await expect(broken.createLink(conversation(), as(OWNER)))
1394+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
1395+
expect(minted()).toEqual([]);
1396+
});
1397+
});
1398+
});

‎packages/runtime/src/domains/share-links-enforcement-context.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -951,11 +951,12 @@ describe('[#14637] the dispatcher probe reads the standing policy before it answ
951951
* not a hand-assembled copy of it. DOUBLES: storage (`makeEngine` above) and
952952
* the enterprise hierarchy resolver, which this open edition does not ship.
953953
*
954-
* ## Why the route door is not pinned here
954+
* ## The other door
955955
*
956-
* The plugin's own route door reads `err.status` alone, and the CRUD gate's
957-
* refusal carries `statusCode`, so on that door the refusals below answer 500
958-
* until #21405 lands. Its 201s are the same service call.
956+
* The plugin's own route door calls the same service, so every admission and
957+
* refusal below is the same verdict there. How that door maps the CRUD gate's
958+
* refusal to a status is a property of that door, pinned with it; this block
959+
* reads the status this door answers.
959960
*/
960961
describe('[#21329] mint authority on an owner-private object (dispatcher door, ruling A′)', () => {
961962
const CONV = 'ai_conversations';

0 commit comments

Comments
 (0)