Skip to content

Commit 7a70962

Browse files
committed
fix(plugin-sharing): a missing required capability is a hard stop the owner and Modify-All mint alternatives do not pass
canMintWithoutVisibility answers false when the ADR-0066 D3 capability AND-gate refuses the caller a read, read from the declared required_permissions layer of ISecurityService.explain; createLink then re-throws the capability refusal as it came. Positive evidence is needed to admit; a probe without explain, a throw or a report without the layer stop. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41f8cf3 commit 7a70962

3 files changed

Lines changed: 191 additions & 9 deletions

File tree

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

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,6 +1193,8 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
11931193
const ADMIN = 'u_admin';
11941194
const MANAGER = 'u_manager';
11951195
const READER = 'u_reader';
1196+
/** Owns a record on the capability-gated object AND holds the capability. */
1197+
const CAPABLE_OWNER = 'u_capable_owner';
11961198

11971199
const SCHEMAS = {
11981200
sys_share_link: { name: 'sys_share_link', fields: {} },
@@ -1211,18 +1213,52 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
12111213
},
12121214
// Never opted in.
12131215
notes: { name: 'notes', access: { default: 'private' }, fields: { id: {}, owner_id: {} } },
1216+
// Owner-private AND capability-gated (ADR-0066 D3): a read needs `view_vault`.
1217+
vault: {
1218+
name: 'vault',
1219+
access: { default: 'private' },
1220+
requiredPermissions: ['view_vault'],
1221+
publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] },
1222+
fields: { id: {}, owner_id: {} },
1223+
},
12141224
};
1215-
const PRIVATE_OBJECTS = new Set(['conversations', 'briefs', 'notes']);
1225+
const PRIVATE_OBJECTS = new Set(['conversations', 'briefs', 'notes', 'vault']);
1226+
/** The capabilities each principal holds; the doubles below read only this. */
1227+
const HELD_CAPABILITIES: Record<string, string[]> = { [CAPABLE_OWNER]: ['view_vault'] };
1228+
const requiredOf = (object: string): string[] =>
1229+
((SCHEMAS as Record<string, any>)[object]?.requiredPermissions as string[] | undefined) ?? [];
1230+
const lacksCapability = (object: string, userId: unknown): boolean =>
1231+
requiredOf(object).some((c) => !(HELD_CAPABILITIES[String(userId)] ?? []).includes(c));
12161232

12171233
const denial = () =>
12181234
Object.assign(new Error('You do not have permission to perform this action.'), {
12191235
code: 'PERMISSION_DENIED',
12201236
statusCode: 403,
12211237
});
1238+
/** The capability AND-gate's refusal: the same class, carrying what was missing. */
1239+
const capabilityDenial = (object: string) =>
1240+
Object.assign(new Error('You do not have permission to perform this action.'), {
1241+
code: 'PERMISSION_DENIED',
1242+
statusCode: 403,
1243+
details: { object, requiredPermissions: requiredOf(object), missingPermissions: requiredOf(object) },
1244+
});
1245+
/**
1246+
* The explain report's `required_permissions` layer, computed from the same
1247+
* `HELD_CAPABILITIES` the read double refuses with — so the two agree by
1248+
* construction, as the real engine and middleware do.
1249+
*/
1250+
const explainDouble = async (request: { object: string }, ctx: any) => {
1251+
explainCalls += 1;
1252+
const verdict = requiredOf(request.object).length === 0
1253+
? 'not_applicable'
1254+
: lacksCapability(request.object, ctx?.userId) ? 'denies' : 'neutral';
1255+
return { layers: [{ layer: 'required_permissions', verdict, detail: 'double' }] } as any;
1256+
};
12221257

12231258
let engine: ReturnType<typeof makeFakeEngine>;
12241259
let posture: string | undefined;
12251260
let writeScopeCalls: number;
1261+
let explainCalls: number;
12261262
let sharing: SharingService;
12271263
let service: ShareLinkService;
12281264
let mintProbeCalls: Array<[string, string, string | undefined]>;
@@ -1241,15 +1277,24 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
12411277
{ id: 'b_pub', title: 'Published', status: 'published', owner_id: OWNER },
12421278
];
12431279
engine._tables.notes = [{ id: 'n1', owner_id: OWNER }];
1280+
engine._tables.vault = [
1281+
{ id: 'v_owner', owner_id: OWNER },
1282+
{ id: 'v_capable', owner_id: CAPABLE_OWNER },
1283+
];
12441284
const plainFind = engine.find.bind(engine);
12451285
engine.find = async (object: string, options?: any) => {
12461286
const ctx = options?.context ?? {};
1247-
if (PRIVATE_OBJECTS.has(object) && ctx.isSystem !== true && ctx.userId !== READER) throw denial();
1287+
if (PRIVATE_OBJECTS.has(object) && ctx.isSystem !== true) {
1288+
// The capability AND-gate runs BEFORE the CRUD grant (ADR-0066 D3).
1289+
if (lacksCapability(object, ctx.userId)) throw capabilityDenial(object);
1290+
if (ctx.userId !== READER) throw denial();
1291+
}
12481292
return plainFind(object, options);
12491293
};
12501294

12511295
posture = 'single';
12521296
writeScopeCalls = 0;
1297+
explainCalls = 0;
12531298
mintProbeCalls = [];
12541299
sharing = new SharingService({
12551300
engine: engine as any,
@@ -1259,6 +1304,7 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
12591304
writeScopeCalls += 1;
12601305
return ctx?.userId === MANAGER ? 'unit' : 'own';
12611306
},
1307+
explain: explainDouble,
12621308
}),
12631309
// The enterprise seam: the manager's `unit` covers the owner.
12641310
hierarchyResolver: () =>
@@ -1340,6 +1386,65 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
13401386
await expect(service.createLink(conversation(), as(OWNER))).resolves.toMatchObject({ created_by: OWNER });
13411387
});
13421388

1389+
describe('the capability hard stop (ADR-0066 D3): no alternative applies past a missing required capability', () => {
1390+
it('an owner lacking the capability is refused with the capability refusal itself, and nothing lands', async () => {
1391+
const refusal = await service.createLink(mintIn('vault', 'v_owner'), as(OWNER)).then(
1392+
() => { throw new Error('the owner minted past the capability gate'); },
1393+
(err) => err,
1394+
);
1395+
expect(refusal).toMatchObject({
1396+
code: 'PERMISSION_DENIED',
1397+
statusCode: 403,
1398+
details: { missingPermissions: ['view_vault'] },
1399+
});
1400+
expect(minted()).toEqual([]);
1401+
// The owner alternative itself was admitted; the stop is what refused.
1402+
expect(await sharing.canManageShares('vault', 'v_owner', as(OWNER))).toBe(true);
1403+
});
1404+
1405+
it('a Modify-All holder lacking the capability is refused the same way', async () => {
1406+
await expect(service.createLink(mintIn('vault', 'v_owner'), as(ADMIN)))
1407+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', details: { missingPermissions: ['view_vault'] } });
1408+
expect(minted()).toEqual([]);
1409+
});
1410+
1411+
it('control: an owner who HOLDS the capability mints on the same object (refused only by the CRUD grant)', async () => {
1412+
await expect(engine.find('vault', { where: { id: 'v_capable' }, context: as(CAPABLE_OWNER) }))
1413+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
1414+
await expect(service.createLink(mintIn('vault', 'v_capable'), as(CAPABLE_OWNER)))
1415+
.resolves.toMatchObject({ record_id: 'v_capable', created_by: CAPABLE_OWNER });
1416+
});
1417+
1418+
it('control: the owner of an object that requires no capability still mints', async () => {
1419+
await expect(service.createLink(conversation(), as(OWNER))).resolves.toMatchObject({ created_by: OWNER });
1420+
});
1421+
1422+
it('a refused stranger never pays for the explain walk', async () => {
1423+
await expect(service.createLink(mintIn('vault', 'v_owner'), as(STRANGER))).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
1424+
await expect(service.createLink(conversation(), as(STRANGER))).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
1425+
expect(explainCalls).toBe(0);
1426+
});
1427+
1428+
it.each([
1429+
['a security service without explain', { hasWriteBypass: async () => false }],
1430+
['an explain that throws', { explain: async () => { throw new Error('explain down'); } }],
1431+
['a report without the layer', { explain: async () => ({ layers: [] }) }],
1432+
['a verdict outside admits', { explain: async () => ({ layers: [{ layer: 'required_permissions', verdict: 'narrows', detail: 'x' }] }) }],
1433+
])('fails closed: %s refuses the owner', async (_name, probe) => {
1434+
const closed = new SharingService({
1435+
engine: engine as any,
1436+
securityService: () => probe as any,
1437+
tenancy: () => ({ posture: 'single' }),
1438+
});
1439+
expect(await closed.canMintWithoutVisibility('conversations', 'c1', as(OWNER))).toBe(false);
1440+
});
1441+
1442+
it('a deployment with no security service at all enforces no capability gate, so the owner alternative stands', async () => {
1443+
const open = new SharingService({ engine: engine as any, tenancy: () => ({ posture: 'single' }) });
1444+
expect(await open.canMintWithoutVisibility('conversations', 'c1', as(OWNER))).toBe(true);
1445+
});
1446+
});
1447+
13431448
describe('the order: opt-in, then authority, then eligibility', () => {
13441449
it('the opt-in comes first — the owner of a record on an object that never opted in is refused 422', async () => {
13451450
await expect(service.createLink(mintIn('notes', 'n1'), as(OWNER)))

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,10 @@ export interface ShareLinkServiceOptions {
437437
* mint on a record their visibility read refused, because they are its
438438
* OWNER or hold the explicit Modify-All bypass? `createLink` asks it only
439439
* after that read refused. It answers with `canManageShares`' own owner and
440-
* bypass branches and never with its hierarchy-depth branch, and it is
441-
* withheld where an organization wall is in force.
440+
* bypass branches and never with its hierarchy-depth branch. It is withheld
441+
* where an organization wall is in force, and it answers `false` when the
442+
* refusal was the object's capability AND-gate (`requiredPermissions`,
443+
* ADR-0066 D3), which is a hard stop the alternatives do not pass.
442444
*
443445
* Absent → visibility alone admits (the pre-A′ rule), so a deployment
444446
* without the sharing service never widens who may mint. A throwing probe
@@ -606,10 +608,13 @@ export class ShareLinkService implements IShareLinkService {
606608
// once visibility has refused. The probe is the sharing service's
607609
// `canMintWithoutVisibility`: `canManageShares`' owner and bypass branches
608610
// WITHOUT its hierarchy-depth branch — a hierarchy manager still needs
609-
// visibility to mint — and withheld under an organization wall. Admitted,
610-
// the record is read under the system context: the caller's authority is
611-
// established, and the eligibility gate below must judge the row the
612-
// anonymous holder will be served (`resolveToken` reads it the same way).
611+
// visibility to mint — withheld under an organization wall, and never past
612+
// a capability the object requires (ADR-0066 D3): when the read was refused
613+
// for a missing `requiredPermissions` capability, the probe answers `false`
614+
// and that refusal is re-thrown as it came. Admitted, the record is read
615+
// under the system context: the caller's authority is established, and the
616+
// eligibility gate below must judge the row the anonymous holder will be
617+
// served (`resolveToken` reads it the same way).
613618
//
614619
// A system caller reaches the probe only when its own system-context read
615620
// found nothing or failed, and the probe grants it nothing it lacks: it

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type {
44
AuthoredRowWriteOperation,
55
AuthoredRowWriteVerdict,
6+
ExplainAccessRequest,
67
ISharingService,
78
IHierarchyScopeResolver,
89
RecordShare,
@@ -13,6 +14,7 @@ import type {
1314
import {
1415
normalizeTenancyPosture,
1516
postureEnforcesWall,
17+
type ExplainDecision,
1618
type TenancyPosture,
1719
} from '@objectstack/spec/security';
1820
// [#7136] Every enforcement method below takes the FULL `resolveAuthzContext`
@@ -287,6 +289,20 @@ export interface SharingSecurityProbe {
287289
object: string,
288290
context: unknown,
289291
): Promise<'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org'>;
292+
/**
293+
* [ADR-0111 D8 rule 1 — the capability hard stop] `ISecurityService.explain`,
294+
* a declared (not optional) contract method, read here for ONE layer of its
295+
* declared report: `required_permissions`, the ADR-0066 D3 capability
296+
* AND-gate. The explain engine computes that layer with the middleware's own
297+
* capability fold, so its `denies` is the refusal the read gate throws.
298+
* Used by {@link SharingService.canMintWithoutVisibility} only. Absent while
299+
* the service is present, a throw, or a report that does not carry the layer
300+
* answers as a refusal.
301+
*/
302+
explain?(
303+
request: ExplainAccessRequest,
304+
callerContext?: unknown,
305+
): Promise<Pick<ExplainDecision, 'layers'>>;
290306
}
291307

292308
/** [#5103] The table whose orphans this service owns. */
@@ -1026,6 +1042,16 @@ export class SharingService implements ISharingService {
10261042
* {@link organizationScopeRequired} reads, fail-closed: an unresolvable
10271043
* posture counts as walled.
10281044
*
1045+
* **A capability the object requires.** When the caller lacks a capability
1046+
* the object's `requiredPermissions` demands for a read (ADR-0066 D3), the
1047+
* visibility read was refused by that AND-gate, and the gate is a hard stop:
1048+
* neither alternative applies past it. The owner exception is about the
1049+
* record ROW (the owner's own record is the thing shared), not about a
1050+
* capability an administrator withheld from the caller for the whole object;
1051+
* and a Modify-All holder who lacks it does not "already read everything".
1052+
* The verdict is the declared `required_permissions` layer of
1053+
* `ISecurityService.explain` (`capabilityGateRefusesRead`).
1054+
*
10291055
* Everything else fails CLOSED to `false`: a missing record, a
10301056
* principal-less context, a failed read or probe.
10311057
*/
@@ -1035,7 +1061,53 @@ export class SharingService implements ISharingService {
10351061
context: ExecutionContext,
10361062
): Promise<boolean> {
10371063
if (this.organizationScopeRequired()) return false;
1038-
return (await this.ownerOrBypass(object, recordId, context)).kind === 'admit';
1064+
if ((await this.ownerOrBypass(object, recordId, context)).kind !== 'admit') return false;
1065+
// Asked last, and only of a principal an alternative admitted, so a
1066+
// refused stranger never pays for the explain walk.
1067+
return !(await this.capabilityGateRefusesRead(object, context));
1068+
}
1069+
1070+
/**
1071+
* [ADR-0111 D8 rule 1 — the capability hard stop] Does the ADR-0066 D3
1072+
* capability AND-gate refuse `context` a READ of `object`?
1073+
*
1074+
* Answered by `ISecurityService.explain`, a declared contract method, from
1075+
* the one layer of its declared report that IS that gate:
1076+
* `required_permissions`. The explain engine computes the layer with the
1077+
* read middleware's own capability fold (the same `requiredPermissions`
1078+
* normalisation, the same held-capability union, the same ADR-0090 D10
1079+
* delegator intersection), so `denies` there is the refusal the visibility
1080+
* read threw. It is NOT the owner-private CRUD refusal, which the same report
1081+
* attributes to `object_crud` with this layer `not_applicable` — which is
1082+
* what lets the hard stop leave the owner alternative standing on an object
1083+
* that requires no capability.
1084+
*
1085+
* `false` (the gate admits) needs POSITIVE evidence: the layer present with
1086+
* `neutral` (capabilities held) or `not_applicable` (none required). A
1087+
* security service without `explain`, a throw, a report missing the layer or
1088+
* carrying any other verdict answers `true` — a stop. The one exception is a
1089+
* deployment with NO security service at all: nothing there enforces a
1090+
* capability gate, so no read was refused by one.
1091+
*/
1092+
private async capabilityGateRefusesRead(
1093+
object: string,
1094+
context: ExecutionContext,
1095+
): Promise<boolean> {
1096+
let probe: SharingSecurityProbe | null | undefined;
1097+
try {
1098+
probe = this.securityService?.();
1099+
} catch {
1100+
return true;
1101+
}
1102+
if (!probe) return false;
1103+
if (typeof probe.explain !== 'function') return true;
1104+
try {
1105+
const decision = await probe.explain({ object, operation: 'read' }, context);
1106+
const gate = decision?.layers?.find((layer) => layer?.layer === 'required_permissions');
1107+
return !(gate && (gate.verdict === 'neutral' || gate.verdict === 'not_applicable'));
1108+
} catch {
1109+
return true;
1110+
}
10391111
}
10401112

10411113
/**

0 commit comments

Comments
 (0)