-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathsecurity-service.test.ts
More file actions
400 lines (360 loc) · 21.2 KB
/
Copy pathsecurity-service.test.ts
File metadata and controls
400 lines (360 loc) · 21.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect } from 'vitest';
import type {
ISecurityService,
AuthoredRowWriteVerdict,
AuthoredRowWriteOperation,
} from './security-service';
/**
* These tests pin the two things a consumer of the `security` service reasons
* about and that a refactor could silently change: the SHAPE of the surface
* (compile-time) and the MEANING of each "empty" answer (runtime). The second
* matters more than it looks — `undefined` and `[]` from getReadableFields are
* opposite instructions, and a consumer that conflates them either leaks a
* column set or blanks one out.
*/
/**
* The REQUIRED members of `ISecurityService`, kept exhaustive BY THE COMPILER
* rather than by whoever edits the interface next.
*
* Both directions are closed, and each closes a failure this file has actually
* suffered. `satisfies readonly RequiredMember[]` rejects an OPTIONAL member
* listed as required — the tempting way to silence a red without implementing
* anything. `UnlistedRequiredMember` below rejects a required member left OUT —
* the drift that let this list sit three members behind the interface, unseen,
* because the compile-time half that would have caught it was itself suppressed
* by a `test-typecheck-debt.json` entry.
*/
type RequiredMember = {
// `-?` strips optionality, then `object extends Pick<T, K>` is true exactly
// when K was optional — so the union is the required members.
[K in keyof ISecurityService]-?: object extends Pick<ISecurityService, K> ? never : K;
}[keyof ISecurityService];
const REQUIRED_MEMBERS = [
'getReadFilter',
'getReadableFields',
'canExport',
'hasWriteBypass',
'resolveWriteScope',
'resolvePermissionSetNames',
'explain',
'describeDelegableScope',
'listAudienceBindingSuggestions',
'confirmAudienceBindingSuggestion',
'dismissAudienceBindingSuggestion',
] as const satisfies readonly RequiredMember[];
/** Empty exactly when every required member is listed above. */
type UnlistedRequiredMember = Exclude<RequiredMember, (typeof REQUIRED_MEMBERS)[number]>;
/**
* Minimal stub implementing the full surface.
*
* The literal is the OTHER exhaustiveness gate, and the load-bearing one: a
* required member added to `ISecurityService` and not added here makes the
* spread type carry it as optional, which does not satisfy the annotated return
* type (TS2322). Every value below is the method's own documented fail-closed
* answer, so the stub is a contract-honest minimal implementation rather than a
* cast — a reader consulting this file for the surface gets the safe default of
* each method, not a placeholder that would be wrong in production.
*/
function makeService(overrides: Partial<ISecurityService> = {}): ISecurityService {
return {
getReadFilter: async () => undefined,
getReadableFields: async () => [],
canExport: async () => true,
// [ADR-0111 D2] Fails CLOSED. This is the EXPLICIT `modifyAllRecords` bit
// only, and a stub resolving no permission set holds no such bit — the same
// `false` plugin-security returns for a principal-less context, an
// on-behalf-of context, and any resolution failure.
hasWriteBypass: async () => false,
// [ADR-0111 D1 DEPTH] Fails CLOSED to the NARROWEST scope. Deliberately not
// `'org'`: there `'org'` means EITHER a genuine Modify-All holder OR the
// fail-OPEN "no permission set mentions this object" default, so it is the
// one value the contract says a caller may not trust on its own — a stub
// answering it would hand every reader of this file that ambiguity as the
// apparent default.
resolveWriteScope: async () => 'own',
resolvePermissionSetNames: async () => [],
explain: async () => ({}) as any,
// [ADR-0090 D12 / ADR-0105 D8] Fails CLOSED, and "no delegated authority"
// is a REAL answer rather than a missing one: `isTenantAdmin: false` plus
// three empty lists — byte-for-byte the literal plugin-security returns
// when no delegated-admin gate is wired, so a consumer renders an empty
// picker instead of a permissive one.
describeDelegableScope: async () => ({
isTenantAdmin: false,
scopes: [],
placeableBusinessUnitIds: [],
assignablePositions: [],
}),
listAudienceBindingSuggestions: async () => ({
suggestions: [],
synced: { created: 0, confirmedObserved: 0, pruned: 0 },
}),
confirmAudienceBindingSuggestion: async () => ({ suggestion: {}, bindingCreated: true }),
dismissAudienceBindingSuggestion: async () => ({ suggestion: {} }),
...overrides,
};
}
describe('Security Service Contract', () => {
it('a full implementation satisfies the surface — and the member list is exhaustive in BOTH directions', () => {
const service = makeService();
for (const m of REQUIRED_MEMBERS) {
expect(typeof service[m]).toBe('function');
}
// The half a runtime loop over a hand-written array cannot state: the array
// is not missing anything. A required member added to `ISecurityService`
// and left out of `REQUIRED_MEMBERS` makes `UnlistedRequiredMember`
// non-empty and this line stops compiling. Suppressing it is not a way out
// — this file carries no entry in `test-typecheck-debt.json`, so its budget
// is zero and the gate goes red on the first error.
const everyRequiredMemberIsListed: [UnlistedRequiredMember] extends [never] ? true : never = true;
expect(everyRequiredMemberIsListed).toBe(true);
// …and the opposite direction, which is the cheap way to make a red go away
// without implementing anything: an OPTIONAL member is not a required one
// and may not be listed as though it were. Never invoked — its only job is
// to make the COMPILER prove the point.
// @ts-expect-error `checkAuthoredRowWrite` is OPTIONAL — not a required member
const notRequired: RequiredMember = 'checkAuthoredRowWrite';
expect(notRequired).toBe('checkAuthoredRowWrite');
});
it('canExport: an access-narrowing answer — false denies, and absence is feature-detectable', async () => {
// [#3544] The bulk-egress question. It fails CLOSED, so a consumer must
// never read a `false` (or a throw) as "no restriction" the way it may
// read getReadFilter's `undefined`.
const denied = makeService({ canExport: async () => false });
await expect(denied.canExport('deal', { userId: 'u1' })).resolves.toBe(false);
const allowed = makeService({ canExport: async () => true });
await expect(allowed.canExport('deal', { userId: 'u1' })).resolves.toBe(true);
// A system context bypasses, mirroring the engine middleware's isSystem skip.
const service = makeService({ canExport: async (_object, context) => context?.isSystem === true });
await expect(service.canExport('deal', { isSystem: true })).resolves.toBe(true);
await expect(service.canExport('deal', { userId: 'u1' })).resolves.toBe(false);
});
it('getReadFilter: undefined means NO row restriction — the only thing it may mean', async () => {
// A deny is expressed as a filter that matches nothing, never as `undefined`,
// so a consumer can safely read `undefined` as "apply no filter".
const open = makeService({ getReadFilter: async () => undefined });
await expect(open.getReadFilter('deal', { userId: 'u1' })).resolves.toBeUndefined();
const denied = makeService({ getReadFilter: async () => ({ id: { $eq: null } }) as any });
await expect(denied.getReadFilter('deal', { userId: 'u1' })).resolves.toBeDefined();
});
it('getReadableFields: undefined (no answer) and [] (nothing readable) are opposite answers', async () => {
const noAnswer = makeService({ getReadableFields: async () => undefined });
// `undefined` → the caller must fall back to its own projection…
await expect(noAnswer.getReadableFields('deal', { userId: 'u1' })).resolves.toBeUndefined();
const nothingReadable = makeService({ getReadableFields: async () => [] });
// …whereas `[]` is authoritative: expose no columns at all.
await expect(nothingReadable.getReadableFields('deal', { userId: 'u1' })).resolves.toEqual([]);
});
it('a system context is a full field-level bypass', async () => {
const service = makeService({
getReadableFields: async (_object, context) =>
context?.isSystem ? ['id', 'name', 'secret'] : ['id', 'name'],
});
await expect(service.getReadableFields('deal', { isSystem: true }))
.resolves.toEqual(['id', 'name', 'secret']);
await expect(service.getReadableFields('deal', { userId: 'u1' }))
.resolves.toEqual(['id', 'name']);
});
it('[ADR-0106 D7] getMetadataReadableFields is OPTIONAL — absence degrades to getReadableFields', async () => {
// THE structural pin behind "a deployment whose security service predates
// ADR-0106 keeps its pre-ADR behaviour". Optional is what makes that a
// property of the TYPE: such a service still satisfies the contract, and a
// consumer cannot reach the metadata-plane answer without first handling
// the absent case.
const withoutIt: ISecurityService = makeService({ getReadableFields: async () => ['id', 'name'] });
expect(typeof withoutIt.getMetadataReadableFields).toBe('undefined');
// The unguarded call does not compile. Never invoked — its only job is to
// make the COMPILER prove the point (invoking it would merely prove that
// JavaScript throws on `undefined()`, which is the runtime symptom this
// declaration exists to prevent).
const mustNotCompileWithoutAGuard = () =>
// @ts-expect-error possibly undefined — a consumer must feature-detect first
withoutIt.getMetadataReadableFields('deal', { userId: 'u1' });
expect(typeof mustNotCompileWithoutAGuard).toBe('function');
// The shape consumers actually write (`metadata-core`'s object-schema mask
// resolver): prefer the metadata-plane answer, fall back to the data-plane
// one. The fallback is never NARROWER than the metadata-plane answer, which
// is why degrading here cannot hide columns a caller may see.
const ask = typeof withoutIt.getMetadataReadableFields === 'function'
? withoutIt.getMetadataReadableFields.bind(withoutIt)
: withoutIt.getReadableFields.bind(withoutIt);
await expect(ask('deal', { userId: 'u1' })).resolves.toEqual(['id', 'name']);
});
it('[ADR-0106 D7] the metadata plane narrows where the data plane falls open', async () => {
// The one respect in which the two methods differ, and the reason a second
// method exists at all: a caller resolving to ZERO permission sets falls
// OPEN on the data plane (mirroring the middleware, which skips its field
// gate entirely) and resolves the fallback set on the metadata plane, so a
// guest deployment's schema exposure is a deliberate permission-set
// decision rather than an accidental everything-default.
const service = makeService({
getReadableFields: async () => ['id', 'name', 'secret'],
getMetadataReadableFields: async (_object, context) =>
context?.isSystem ? ['id', 'name', 'secret'] : ['id'],
});
await expect(service.getReadableFields('deal', {})).resolves.toEqual(['id', 'name', 'secret']);
await expect(service.getMetadataReadableFields?.('deal', {})).resolves.toEqual(['id']);
// A system context bypasses on BOTH planes.
await expect(service.getMetadataReadableFields?.('deal', { isSystem: true }))
.resolves.toEqual(['id', 'name', 'secret']);
// …and it inherits getReadableFields' two distinct empty answers, unchanged:
// `undefined` = no answer (fall back to your own projection), `[]` = the real
// answer that no field may be disclosed.
const noAnswer = makeService({ getMetadataReadableFields: async () => undefined });
await expect(noAnswer.getMetadataReadableFields?.('deal', { userId: 'u1' })).resolves.toBeUndefined();
const nothingDisclosable = makeService({ getMetadataReadableFields: async () => [] });
await expect(nothingDisclosable.getMetadataReadableFields?.('deal', { userId: 'u1' })).resolves.toEqual([]);
});
it('[#7616] resolvePermissionSetsForContext is OPTIONAL — absence keeps the consumer on its own resolution (compile-time)', () => {
// THE structural pin behind "a consumer must keep its local resolution as
// the fallback until a floor version carrying this method can be assumed".
// Optional is what makes that a property of the TYPE: a security service
// that predates the method still satisfies the contract, and the unguarded
// call does not compile, so the fallback branch cannot be dropped by
// accident on the way to a delegation that a deployment may not support.
const withoutIt: ISecurityService = makeService();
expect(typeof withoutIt.resolvePermissionSetsForContext).toBe('undefined');
// Never invoked — its only job is to make the COMPILER prove the point.
const mustNotCompileWithoutAGuard = () =>
// @ts-expect-error possibly undefined — a consumer must feature-detect first
withoutIt.resolvePermissionSetsForContext({ userId: 'u1' });
expect(typeof mustNotCompileWithoutAGuard).toBe('function');
// The names-only sibling is NOT optional and stays reachable unguarded —
// the two are different questions, not two spellings of one, so a service
// carrying only the older method is a complete implementation.
expect(typeof withoutIt.resolvePermissionSetNames).toBe('function');
});
it('[#7616] the sets carry the four columns the names cannot: objects, fields, systemPermissions, tabPermissions', async () => {
// Why the method exists at all. `resolvePermissionSetNames` answers an
// AUDIENCE question ("does this caller hold `sales_manager`?"); a consumer
// that must MERGE the caller's grants — the object/field map
// `/auth/me/permissions` serves, the capability + tab surface `/me/apps`
// filters with — cannot reach any of these four from a name, which is
// exactly why those two endpoints re-implement set resolution locally.
const service = makeService({
resolvePermissionSetNames: async () => ['member_default', 'sales_manager'],
resolvePermissionSetsForContext: async () => [
{
name: 'member_default',
objects: { deal: { allowRead: true } },
fields: { 'deal.amount': { readable: true, editable: false } },
systemPermissions: [],
tabPermissions: { app_crm: 'default_on' },
},
{
name: 'sales_manager',
objects: { deal: { allowRead: true, allowEdit: true } },
fields: { 'deal.amount': { readable: true, editable: true } },
systemPermissions: ['setup.access'],
tabPermissions: { app_crm: 'visible' },
},
] as any,
});
const sets = await service.resolvePermissionSetsForContext?.({ userId: 'u1' });
expect(sets?.map((s) => s.name)).toEqual(['member_default', 'sales_manager']);
// The names surface answers the audience question over the SAME resolution…
await expect(service.resolvePermissionSetNames({ userId: 'u1' }))
.resolves.toEqual(['member_default', 'sales_manager']);
// …and nothing else. Every column below is unreachable from that list.
expect(sets?.[1]?.objects).toBeDefined();
expect(sets?.[1]?.fields).toBeDefined();
expect(sets?.[1]?.systemPermissions).toEqual(['setup.access']);
expect(sets?.[1]?.tabPermissions).toEqual({ app_crm: 'visible' });
// The merge stays with the CALLER — this contract hands over the INPUT to
// it, unmerged and in resolution order, because two consumers legitimately
// project different subsets of the same sets. Folding a merge in here would
// make the method a fourth copy of the rule rather than the one source of
// its input.
expect(sets).toHaveLength(2);
});
it('a partial implementation is feature-detectable rather than wrong', () => {
// Consumers probe (`typeof svc.getReadableFields === 'function'`) so an
// implementation may omit a method it cannot honour and still be usable.
const partial: Partial<ISecurityService> = { getReadFilter: async () => undefined };
expect(typeof partial.getReadFilter).toBe('function');
expect(typeof partial.getReadableFields).toBe('undefined');
});
it('[#5493] AuthoredRowWriteVerdict names exactly admit / abstain (compile-time)', () => {
const everyVerdict: AuthoredRowWriteVerdict[] = ['admit', 'abstain'];
// Deliberately NO `deny`. This surface is evidence, not a gate: the caller
// already holds a refusal and asks only whether a declared widener speaks
// for the row. "No evidence" and "evidence against" are the same
// instruction to that caller — keep refusing — so a third state would be
// one nobody could act on differently.
// @ts-expect-error `deny` is not a state this contract defines
const notAVerdict: AuthoredRowWriteVerdict = 'deny';
expect(everyVerdict).toHaveLength(2);
expect(notAVerdict).toBe('deny');
// The operation axis is the RLS WRITE vocabulary, not the engine verb list:
// a caller maps `purge`/`transfer`/`restore` onto its nearest write class
// itself, so a new lifecycle verb cannot acquire a widening path here just
// by being spelled into a wider union.
const everyOperation: AuthoredRowWriteOperation[] = ['update', 'delete'];
// @ts-expect-error `select` is a read class — this surface answers writes only
const notAWriteOperation: AuthoredRowWriteOperation = 'select';
expect(everyOperation).toHaveLength(2);
expect(notAWriteOperation).toBe('select');
});
it('[#5493] checkAuthoredRowWrite is OPTIONAL — absence is the fail-closed default (compile-time)', () => {
// THE structural pin behind "a deployment without this method behaves
// byte-for-byte as today". Declaring it optional is what makes that a
// property of the TYPE rather than a promise in prose: a security service
// that predates the method still satisfies the contract, and TypeScript
// forces every consumer to handle the absent case instead of calling into
// `undefined` at runtime.
const withoutIt: ISecurityService = makeService();
expect(typeof withoutIt.checkAuthoredRowWrite).toBe('undefined');
// …and a consumer cannot forget: the unguarded call does not compile.
// Never invoked — its only job is to make the COMPILER prove the point
// (invoking it would merely prove that JavaScript throws on `undefined()`,
// which is the runtime symptom this declaration exists to prevent).
const mustNotCompileWithoutAGuard = () =>
// @ts-expect-error possibly undefined — a consumer must feature-detect first
withoutIt.checkAuthoredRowWrite('deal', 'r1', 'update', {});
expect(typeof mustNotCompileWithoutAGuard).toBe('function');
// The guarded form is the one that compiles, and it degrades to `undefined`
// — which the caller reads as `abstain` (see the case below).
expect(withoutIt.checkAuthoredRowWrite?.('deal', 'r1', 'update', {})).toBeUndefined();
});
it('[#5493] admit is a positive measurement; every other outcome is abstain', async () => {
// `admit` means "an app-authored, non-floor policy matches this row for
// this operation" — it never means "the write is permitted" (CRUD, the
// tenant wall, sharing and the post-image check all still apply), and it is
// never reported for a reason the implementation did not measure.
const admitting = makeService({
checkAuthoredRowWrite: async (_object, recordId) =>
recordId === 'r_open' ? 'admit' : 'abstain',
});
await expect(admitting.checkAuthoredRowWrite?.('deal', 'r_open', 'update', { userId: 'u1' }))
.resolves.toBe('admit');
// The #5493 probe E-A shape: a row a platform-floor policy would admit, but
// no authored policy names. The verdict is `abstain`, never `admit`.
await expect(admitting.checkAuthoredRowWrite?.('deal', 'r_transferred', 'update', { userId: 'u1' }))
.resolves.toBe('abstain');
// Fail-closed, and it is the INVERSE of SharingWriteVerdict's: there a
// failed lookup must be `deny` because `abstain` hands the decision on;
// here the caller uses `admit` to WIDEN, so the answer that changes nothing
// is `abstain`. The method returns a verdict rather than throwing.
const failing = makeService({ checkAuthoredRowWrite: async () => 'abstain' });
await expect(failing.checkAuthoredRowWrite?.('deal', 'r_open', 'update', { userId: 'u1' }))
.resolves.toBe('abstain');
// Absence and `abstain` are ONE instruction to the caller, which is what
// lets a consumer collapse feature detection and the verdict into a single
// non-widening branch.
const absent = makeService();
const verdict = (await absent.checkAuthoredRowWrite?.('deal', 'r_open', 'update', {})) ?? 'abstain';
expect(verdict).toBe('abstain');
});
it('explain accepts a record-scoped request and an explicit target user', async () => {
const seen: unknown[] = [];
const service = makeService({
explain: async (request) => { seen.push(request); return {} as any; },
});
await service.explain(
{ object: 'deal', operation: 'update', userId: 'u2', recordId: 'r1' },
{ userId: 'admin' },
);
expect(seen[0]).toEqual({ object: 'deal', operation: 'update', userId: 'u2', recordId: 'r1' });
});
});