Repository navigation
Expand file tree
/
Copy pathget-effective-object-permissions.test.ts
More file actions
692 lines (653 loc) · 38.1 KB
/
Copy pathget-effective-object-permissions.test.ts
File metadata and controls
692 lines (653 loc) · 38.1 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
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [#18783] `ISecurityService.getEffectiveObjectPermissions` — the effective
* object-permission map, and the one producer behind both `/auth/me/permissions`
* and the map `current_user.can()` reads on the write path.
*
* Every case resolves the service the way a cross-package consumer does — off
* the `ctx.registerService('security', …)` call, as a `Partial` (the contract's
* availability rule) — never off the plugin instance, so what is pinned is the
* member a consumer can actually REACH.
*
* What the contract member's docblock requires, clause by clause:
*
* - byte-for-byte the `objects` slot of `/auth/me/permissions` — pinned as
* equality with `buildEffectiveObjectPermissions` (`@objectstack/core`) over
* the same resolution and the same engine, the function the endpoint builds
* that slot with (the endpoint's own half is pinned in plugin-hono-server's
* `current-user-endpoints-effective-objects.test.ts`);
* - the WHOLE map — seeded, covered (a plain `'*'`, #20083), folded, clamped
* and annotated, not a slice — and, per cell, the map `current_user.can()`
* answers what `PermissionEvaluator.checkObjectPermission` answers (the
* parity table at the foot of this file);
* - it THROWS on resolution failure and ⛔ never answers `{}`;
* - request-scoped: resolved per ask, never cached across requests.
*
* And the wiring half: the SAME method is what the plugin registers on the
* engine, and an engine without the seam is reported, not silently skipped.
*/
import { describe, it, expect, vi } from 'vitest';
import { buildEffectiveObjectPermissions, engineExternalWriteForbidden, registryObjectPackageDisabled } from '@objectstack/core';
import { ExpressionEngine, toEvalPermissions } from '@objectstack/formula';
import { SysAttachment, SysMember, SysSecret, SysUser, SysUserPreference } from '@objectstack/platform-objects';
import type { ISecurityService } from '@objectstack/spec/contracts';
import { canServeApiOperation } from '@objectstack/spec/data';
import {
OBJECT_PERMISSION_VERB_NAMES,
PermissionSetSchema,
resolveObjectPermissionVerb,
type PermissionSet,
} from '@objectstack/spec/security';
import { SecurityPlugin } from './security-plugin.js';
import { PermissionEvaluator } from './permission-evaluator.js';
import { defaultPermissionSets } from './objects/default-permission-sets.js';
import { SysUserPosition, SysUserPermissionSet } from './objects/index.js';
/** The metadata-declared baseline every member resolves additively. */
const MEMBER_DEFAULT: PermissionSet = {
name: 'member_default',
label: 'Member',
objects: { deal: { allowRead: true }, sys_member: { allowRead: true } },
fields: {},
systemPermissions: [],
tabPermissions: {},
} as any;
/**
* A DB-authored super-user set: a `'*'` wildcard carrying both bypass bits
* (and, per #8681, no `allowExport`), plus an explicit write-deny on a
* better-auth object — the shape that makes all four folds fire.
*/
const OPS_ADMIN_ROW = {
name: 'ops_admin',
label: 'Ops Admin',
object_permissions: JSON.stringify({
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true },
sys_member: { allowRead: true, allowEdit: false },
}),
field_permissions: JSON.stringify({}),
system_permissions: JSON.stringify([]),
tab_permissions: JSON.stringify({}),
};
/** A DB-authored plain grant — no wildcard, no bypass. */
const SALES_ROW = {
name: 'sales',
label: 'Sales',
object_permissions: JSON.stringify({ deal: { allowRead: true, allowEdit: true } }),
field_permissions: JSON.stringify({}),
system_permissions: JSON.stringify([]),
tab_permissions: JSON.stringify({}),
};
/**
* [#20083] A DB-authored PLAIN wildcard — no bypass bit — the shape of the
* wall-less org admin (`organization_admin_no_bypass`). It names no object, so
* every entry it contributes is the plain-wildcard coverage pass's.
*/
const OPS_PLAIN_ROW = {
name: 'ops_plain',
label: 'Ops (no bypass)',
object_permissions: JSON.stringify({ '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }),
field_permissions: JSON.stringify({}),
system_permissions: JSON.stringify([]),
tab_permissions: JSON.stringify({}),
};
/** Registered schemas: one plain, one better-auth-managed, one whose `apiMethods` tighten exposure. */
const SCHEMAS: Record<string, any> = {
deal: { name: 'deal', label: 'Deal', fields: { id: { name: 'id' } } },
sys_member: { name: 'sys_member', label: 'Member', managedBy: 'better-auth', fields: { id: { name: 'id' } } },
report: { name: 'report', label: 'Report', enable: { apiMethods: ['get', 'list'] }, fields: { id: { name: 'id' } } },
};
function bootPlugin(
opts: {
dbRows?: Array<Record<string, unknown>>; engineSeam?: boolean; schemas?: Record<string, any>;
/** [#28877] Objects whose owning package the registry reports disabled. */
disabledObjects?: string[];
/** [#30370] Objects whose external write gate the engine reports closed. */
readOnlyExternal?: string[];
} = {},
) {
const dbRows = opts.dbRows ?? [];
const schemas = opts.schemas ?? SCHEMAS;
const permissionSetReads: string[][] = [];
const registered: Array<(context: unknown) => Promise<unknown>> = [];
// [ADR-0131 D3/D4] The sets are the security catalog's definitions — each
// row above as the registry holds it once saved through the metadata door —
// read live, so a set taken away is gone from the next resolution.
const definitionOf = (row: Record<string, unknown>) => ({
name: row.name,
label: row.label,
objects: JSON.parse(String(row.object_permissions ?? '{}')),
fields: JSON.parse(String(row.field_permissions ?? '{}')),
systemPermissions: JSON.parse(String(row.system_permissions ?? '[]')),
tabPermissions: JSON.parse(String(row.tab_permissions ?? '{}')),
});
const ql: any = {
registerMiddleware: () => {},
registry: {
getAllObjects: () => Object.values(schemas),
getItem: (type: string, name: string) => {
if (type !== 'permission') return undefined;
permissionSetReads.push([name]);
const row = dbRows.find((r) => r.name === name);
return row ? definitionOf(row) : undefined;
},
listItems: (type: string) => (type === 'permission' ? dbRows.map(definitionOf) : []),
isPackageDisabled: () => false,
isObjectPackageDisabled: (name: string) => (opts.disabledObjects ?? []).includes(name),
},
getSchema: (name: string) => schemas[name] ?? null,
isExternalWriteForbidden: (name: string) => (opts.readOnlyExternal ?? []).includes(name),
// No table answers here: every set is the catalog's (above).
find: async () => [],
};
if (opts.engineSeam !== false) {
ql.registerEffectiveObjectPermissionsResolver = (fn: (context: unknown) => Promise<unknown>) => registered.push(fn);
}
const metadata: any = {
get: async (_type: string, name: string) => schemas[name] ?? null,
list: async () => [MEMBER_DEFAULT],
};
const services: Record<string, any> = { manifest: { register: vi.fn() }, objectql: ql, metadata };
const ctx: any = {
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerService: vi.fn(),
getService: (name: string) => {
if (!(name in services)) throw new Error(`service not registered: ${name}`);
return services[name];
},
};
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any);
return { plugin, ctx, ql, permissionSetReads, registered };
}
async function locate(opts?: Parameters<typeof bootPlugin>[0]) {
const booted = bootPlugin(opts);
await booted.plugin.init(booted.ctx);
await booted.plugin.start(booted.ctx);
const svc = booted.ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial<ISecurityService>;
return { ...booted, svc };
}
/** The endpoint's computation, stated the way `/auth/me/permissions` states it: its sets, its engine. */
function endpointObjects(sets: unknown, ql: any) {
return buildEffectiveObjectPermissions(sets as any, {
allSchemas: () => ql.registry.getAllObjects(),
schemaOf: (name) => ql.getSchema(name),
isObjectPackageDisabled: registryObjectPackageDisabled(ql.registry),
isExternalWriteForbidden: engineExternalWriteForbidden(ql),
});
}
const ADMIN = { userId: 'u_admin', permissions: ['ops_admin'] } as any;
const REP = { userId: 'u_rep', permissions: ['sales'] } as any;
const PLAIN = { userId: 'u_plain', permissions: ['ops_plain'] } as any;
describe('[#18783] getEffectiveObjectPermissions — reachable, and the endpoint\'s answer', () => {
it('is exposed on the REGISTERED literal, where a cross-package consumer feature-detects it', async () => {
const { svc } = await locate();
expect(typeof svc.getEffectiveObjectPermissions).toBe('function');
});
it('is BYTE-EQUAL to the /auth/me/permissions computation over the same resolution', async () => {
const { svc, ql } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW, OPS_PLAIN_ROW] });
for (const context of [ADMIN, REP, PLAIN, { userId: 'u_member' }]) {
const sets = await svc.resolvePermissionSetsForContext!(context);
const member = await svc.getEffectiveObjectPermissions!(context);
expect(JSON.stringify(member), context.userId).toBe(JSON.stringify(endpointObjects(sets, ql)));
}
// [#20083] …with the plain-wildcard coverage pass firing for the no-bypass subject, so the
// equality covers it too: `report` is named by no set, and reaches the map through `'*'`.
const plain: any = await svc.getEffectiveObjectPermissions!(PLAIN);
expect(plain.report).toMatchObject({ allowRead: true, allowEdit: true });
});
it('is the WHOLE map — merged, seeded, folded, clamped and annotated, not a slice', async () => {
const { svc } = await locate({ dbRows: [OPS_ADMIN_ROW] });
const map: any = await svc.getEffectiveObjectPermissions!(ADMIN);
// Fold: the wildcard super-user reaches an object another set named explicitly.
expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowDelete: true });
// Seed: a restricting object no set names still gets an entry for a super-user…
expect(map.report).toMatchObject({ allowRead: true, allowEdit: true });
// …annotated with its effective operation set.
expect(Array.isArray(map.report.apiOperations)).toBe(true);
// Clamp: the better-auth guard wins over the fold on a write the object never opted in.
expect(map.sys_member).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false });
// The wildcard itself is carried, as the endpoint serves it.
expect(map['*']).toMatchObject({ modifyAllRecords: true });
});
it('[#28877] leaves out an object of a disabled package — the same bytes as the endpoint, and can() answers false', async () => {
const { svc, ql } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW], disabledObjects: ['report', 'deal'] });
for (const context of [ADMIN, REP]) {
const sets = await svc.resolvePermissionSetsForContext!(context);
const map: any = await svc.getEffectiveObjectPermissions!(context);
expect(JSON.stringify(map), context.userId).toBe(JSON.stringify(endpointObjects(sets, ql)));
// `report` reached the admin's map by the super-user seed, `deal` by an explicit grant.
expect(Object.keys(map), context.userId).not.toContain('report');
expect(Object.keys(map), context.userId).not.toContain('deal');
const answer = ExpressionEngine.evaluate<boolean>(
{ dialect: 'cel', source: "current_user.can('deal', 'read')" },
{ user: { id: context.userId }, permissions: toEvalPermissions(map) } as any,
);
expect(answer, context.userId).toMatchObject({ ok: true, value: false });
}
// The admin keeps every other object.
const admin: any = await svc.getEffectiveObjectPermissions!(ADMIN);
expect(admin.sys_member).toMatchObject({ allowRead: true });
});
it('[#30370] a read-only external object grants no write — the same bytes as the endpoint, and can() answers false', async () => {
const { svc, ql } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW], readOnlyExternal: ['deal'] });
for (const context of [ADMIN, REP]) {
const sets = await svc.resolvePermissionSetsForContext!(context);
const map: any = await svc.getEffectiveObjectPermissions!(context);
expect(JSON.stringify(map), context.userId).toBe(JSON.stringify(endpointObjects(sets, ql)));
for (const verb of ['create', 'edit', 'delete', 'transfer']) {
const answer = ExpressionEngine.evaluate<boolean>(
{ dialect: 'cel', source: `current_user.can('deal', '${verb}')` },
{ user: { id: context.userId }, permissions: toEvalPermissions(map) } as any,
);
expect(answer, `${context.userId} ${verb}`).toMatchObject({ ok: true, value: false });
}
for (const op of ['create', 'update', 'delete', 'upsert', 'bulk', 'import']) {
expect(map.deal.apiOperations, `${context.userId} ${op}`).not.toContain(op);
}
}
// Read stays; and an object on a managed datasource keeps its writes.
const admin: any = await svc.getEffectiveObjectPermissions!(ADMIN);
expect(admin.deal).toMatchObject({ allowRead: true });
expect(admin.report).toMatchObject({ allowEdit: true });
});
it('is frozen at the top level and aliases no permission set', async () => {
const { svc } = await locate({ dbRows: [SALES_ROW] });
const sets: any[] = await svc.resolvePermissionSetsForContext!(REP);
const map: any = await svc.getEffectiveObjectPermissions!(REP);
expect(Object.isFrozen(map)).toBe(true);
for (const set of sets) {
for (const entry of Object.values(set.objects ?? {})) {
expect(Object.values(map)).not.toContain(entry);
}
}
});
it('an EMPTY map is a real answer: a caller that resolves no set gets `{}`', async () => {
const { svc } = await locate();
// No principal ⇒ no additive baseline ⇒ nothing resolves (the middleware's own answer).
await expect(svc.getEffectiveObjectPermissions!({ positions: [], permissions: [] } as any)).resolves.toEqual({});
});
});
describe('[#18783] failure stance and scope', () => {
it('a resolution failure THROWS, untouched — it never degrades to `{}`', async () => {
const { svc, plugin } = await locate();
const boom = Object.assign(new Error('permission-set resolution failed'), { status: 503 });
vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockRejectedValueOnce(boom);
await expect(svc.getEffectiveObjectPermissions!(REP)).rejects.toBe(boom);
});
it('is request-scoped: one resolution per request context, a fresh one for the next request', async () => {
const { svc, permissionSetReads } = await locate({ dbRows: [SALES_ROW] });
// The loads that resolve THIS subject's grant (boot-time reads name nothing).
const salesLoads = () => permissionSetReads.filter((names) => names.includes('sales')).length;
const before = salesLoads();
const request1 = { ...REP };
await svc.getEffectiveObjectPermissions!(request1);
await svc.getEffectiveObjectPermissions!(request1);
// Within one request the plugin's per-context memo answers the second ask.
expect(salesLoads() - before).toBe(1);
// A new request is a new context object — resolved again, never served from the last one.
await svc.getEffectiveObjectPermissions!({ ...REP });
expect(salesLoads() - before).toBe(2);
});
it('a grant revoked between two requests is gone from the second map', async () => {
const rows: Array<Record<string, unknown>> = [SALES_ROW];
const { svc } = await locate({ dbRows: rows });
const before: any = await svc.getEffectiveObjectPermissions!({ ...REP });
expect(before.deal.allowEdit).toBe(true);
rows.length = 0; // the `sales` set is taken away
const after: any = await svc.getEffectiveObjectPermissions!({ ...REP });
expect(after.deal?.allowEdit).not.toBe(true);
});
});
describe('[#18783] the engine is handed the same producer', () => {
it('registers ONE resolver on the engine, and it answers exactly what the service answers', async () => {
const { svc, registered } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW] });
expect(registered).toHaveLength(1);
for (const context of [ADMIN, REP]) {
const viaEngine = await registered[0](context);
const viaService = await svc.getEffectiveObjectPermissions!(context);
expect(JSON.stringify(viaEngine)).toBe(JSON.stringify(viaService));
}
});
it('an engine without the seam is REPORTED at start — absence is loud, not silent', async () => {
const { ctx, registered } = await locate({ engineSeam: false });
expect(registered).toHaveLength(0);
const lines = ctx.logger.warn.mock.calls.map((c: any[]) => String(c[0]));
expect(lines.some((l: string) => l.includes('registerEffectiveObjectPermissionsResolver'))).toBe(true);
});
});
/**
* [#20083] PARITY — the map the member answers, read the way `current_user.can()`
* reads it, against the server's own verdict, `PermissionEvaluator.checkObjectPermission`,
* cell by cell: subjects x registered objects x every verb `can()` accepts.
*
* The map used to omit every object a subject reached only through a PLAIN `'*'`
* (one with no super-user bit), so the wall-less org admin's `can('crm_account',
* 'edit')` answered `false` where the server writes; and it kept a narrower entry
* wherever one set named the object and another covered it by its wildcard. Both
* directions are held here, over the shipped sets and the wildcard shapes an
* author can write:
*
* - EXACT, cell for cell — except that
* - on a guarded managed object (`better-auth` / `engine-owned` / `append-only`)
* the create / edit / delete verbs may read NARROWER than the evaluator: that
* is the managed-write clamp, the engine write guard the permission sets do not
* model. There the pin holds the refuse direction only — the map never grants
* what the evaluator refuses. [#30440] `transfer` is one of them: an owner
* change is an update, which the guard refuses unless the object opts `edit` in.
*
* [#20134] Subjects whose sets carry a SUPER-USER wildcard are held by the same
* table, one row per shape: the shipped platform admin and walled org admin, a
* bare `modifyAllRecords` wildcard, a super-read wildcard carrying plain bits, a
* super-user wildcard carrying `allowExport` (which the seed used to skip), and
* a super-user wildcard beside a set naming an object narrower. Their entries
* used to lack `transfer` (the write bypass grants it) and a super-read
* wildcard's own plain bits, and the `allowExport` shape had no entry at all for
* an unrestricted object; each row is now 0 under-granted.
*
* [#20136] …and 0 over-granted. The map used to be BROADER than the evaluator
* wherever a merged-bypass fold ran over it: into an entry the super-user set
* itself names narrower (the walled org admin's read-only RBAC rows, an empty
* `{}` entry, an export-only entry), and `allowCreate` pulled on
* `modifyAllRecords` alone, which the spec's `objectPermissionGrants` gives no
* create cell. Each shape that went wrong is a row of its own below, tagged
* with its enumeration letter, so a regression names its row. There is no
* allowance for a known divergence in either direction: the only cells the
* table forgives are the managed-write clamp's.
*/
describe('[#20083] parity: can() over the member\'s map answers what checkObjectPermission answers', () => {
const REGISTERED: Record<string, any> = {
// App objects: public, restricted exposure, private posture, API switched off.
crm_account: { name: 'crm_account', label: 'Account', fields: { name: { type: 'text' } } },
crm_lead: { name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text' } }, enable: { apiMethods: ['get', 'list'] } },
crm_secret: { name: 'crm_secret', label: 'Secret', fields: { name: { type: 'text' } }, access: { default: 'private' } },
crm_hidden: { name: 'crm_hidden', label: 'Hidden', fields: { name: { type: 'text' } }, enable: { apiEnabled: false } },
// Real platform objects, one per posture the shipped sets treat differently.
[SysAttachment.name]: SysAttachment, // public, named by no shipped set
[SysUserPreference.name]: SysUserPreference, // named by member_default, not by the org admin
[SysUser.name]: SysUser, // better-auth, opts `edit` in
[SysMember.name]: SysMember, // better-auth, write-denied blanket
[SysSecret.name]: SysSecret, // private, engine-owned, named by no shipped set
[SysUserPosition.name]: SysUserPosition, // the org admin's read-only RBAC rows
[SysUserPermissionSet.name]: SysUserPermissionSet,
};
const shipped = (name: string): PermissionSet => {
const set = defaultPermissionSets.find((ps) => ps.name === name);
if (!set) throw new Error(`no shipped permission set '${name}'`);
return set;
};
const authored = (name: string, objects: Record<string, unknown>): PermissionSet =>
PermissionSetSchema.parse({ name, label: name, objects });
const SUBJECTS: Record<string, PermissionSet[]> = {
'wall-less org admin': [shipped('organization_admin_no_bypass'), shipped('member_default')],
'member': [shipped('member_default')],
'viewer': [shipped('viewer_readonly'), shipped('member_default')],
'an explicit entry beside another set\'s plain wildcard': [
authored('reader', { crm_account: { allowRead: true } }),
authored('wild', { '*': { allowRead: true, allowEdit: true, allowExport: true } }),
],
'one set: a plain wildcard AND a narrower explicit entry': [
authored('same', { '*': { allowRead: true, allowEdit: true, allowDelete: true }, crm_account: { allowRead: true } }),
],
'an export-only wildcard beside a reader': [
authored('reader', { crm_account: { allowRead: true } }),
authored('exporter', { '*': { allowExport: true } }),
],
'a wildcard that grants nothing': [authored('none', { '*': {} })],
// [#20134] Super-user wildcards.
'platform admin': [shipped('admin_full_access'), shipped('member_default')],
'platform admin who is also a wall-less org admin': [
shipped('admin_full_access'), shipped('organization_admin_no_bypass'), shipped('member_default'),
],
'[#20136 row a] walled org admin': [shipped('organization_admin'), shipped('member_default')],
'[#20136 row c] a bare modify-all wildcard': [authored('modify_all', { '*': { modifyAllRecords: true } })],
'a super-read wildcard carrying plain bits': [
authored('view_all_editor', { '*': { viewAllRecords: true, allowEdit: true, allowTransfer: true } }),
],
'a super-user wildcard carrying the export grant': [
authored('exporting_admin', {
'*': {
allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true,
viewAllRecords: true, modifyAllRecords: true, allowExport: true,
},
}),
],
'a super-read wildcard carrying the export grant': [
authored('exporting_viewer', { '*': { viewAllRecords: true, allowExport: true } }),
],
'an explicit entry beside another set\'s super-user wildcard': [
authored('reader', { crm_account: { allowRead: true } }),
authored('super', { '*': { viewAllRecords: true, allowTransfer: true, allowExport: true } }),
],
'[#20136] one set: a super-user wildcard AND a narrower explicit entry': [
authored('same_super', {
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true },
crm_account: { allowRead: true },
}),
],
// [#20136] The rest of the over-grant enumeration, one row per shape.
'[#20136 row b] walled org admin WITHOUT member_default': [shipped('organization_admin')],
'[#20136 row d] an EMPTY explicit entry inside a super-user set': [
authored('empty_entry', {
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true },
crm_account: {},
}),
],
'[#20136 row e] a super-read wildcard over its own export-only entry': [
authored('export_no_read', { '*': { viewAllRecords: true }, crm_account: { allowExport: true } }),
],
'[#20136 row f] a super-read wildcard over its own narrower entry': [
// Built raw: the authoring schema refuses an explicit entry granting edit
// without read; a row stored before that refusal still carries it.
{ name: 'view_all_own', label: 'view_all_own', objects: { '*': { viewAllRecords: true }, crm_account: { allowEdit: true } } } as unknown as PermissionSet,
],
'[#20136 row g] a modify-all-only wildcard beside a set naming the object narrower': [
authored('named_narrow', {
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true },
crm_account: { allowRead: true },
}),
authored('modify_only', { '*': { modifyAllRecords: true } }),
],
'[#20136 row h] platform admin who is also a walled org admin': [
shipped('admin_full_access'), shipped('organization_admin'), shipped('member_default'),
],
// [#20135] The export slot's own shapes.
// [#30440] An explicit modify-all entry on guarded managed objects: one opting
// `edit` in (sys_user), one opting nothing in (sys_member), one private (sys_secret).
'[#30440] explicit modifyAllRecords entries on guarded managed objects': [
authored('managed_modify_all', {
[SysUser.name]: { modifyAllRecords: true },
[SysMember.name]: { modifyAllRecords: true },
[SysSecret.name]: { modifyAllRecords: true },
}),
],
'platform admin beside a plain export-only wildcard': [shipped('admin_full_access'), authored('exporter', { '*': { allowExport: true } })],
'one set: an exporting wildcard AND an explicit entry without the grant': [
authored('same_export', { '*': { allowRead: true, allowExport: true }, crm_lead: { allowRead: true } }),
],
};
const OPERATION: Record<string, string> = {
allowRead: 'find', allowCreate: 'insert', allowEdit: 'update', allowDelete: 'delete',
allowTransfer: 'transfer', allowExport: 'export',
};
const GUARDED = new Set(['better-auth', 'engine-owned', 'append-only']);
const CLAMPED = new Set(['allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer']);
const USER = { id: 'u_parity', positions: [] as string[] };
const evaluator = new PermissionEvaluator();
/** The real `can()`: formula's CEL binding over the published map shape. */
function can(permissions: ReturnType<typeof toEvalPermissions>, object: string, verb: string): boolean {
const res = ExpressionEngine.evaluate<boolean>(
{ dialect: 'cel', source: `current_user.can('${object}', '${verb}')` },
{ user: USER, permissions },
);
if (!res.ok) throw new Error(`can('${object}', '${verb}') did not evaluate: ${JSON.stringify(res.error)}`);
return res.value === true;
}
it('covers every verb the vocabulary accepts', () => {
expect([...OBJECT_PERMISSION_VERB_NAMES].sort()).toEqual(
['create', 'delete', 'edit', 'export', 'import', 'read', 'remove', 'transfer', 'update', 'write'],
);
});
for (const [label, sets] of Object.entries(SUBJECTS)) {
it(`${label}: no cell where the map and the evaluator disagree`, async () => {
const { svc, plugin } = await locate({ schemas: REGISTERED });
vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets);
const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!({ userId: USER.id }));
const wrong: string[] = [];
let cells = 0;
for (const schema of Object.values(REGISTERED)) {
const isPrivate = schema.access?.default === 'private';
const clamped = GUARDED.has(schema.managedBy);
for (const verb of OBJECT_PERMISSION_VERB_NAMES) {
const target = resolveObjectPermissionVerb(verb)!;
const map = can(permissions, schema.name, verb);
const server = evaluator.checkObjectPermission(OPERATION[target], schema.name, sets, { isPrivate });
cells += 1;
if (map === server) continue;
// The managed-write clamp may only NARROW, and only on its own verbs.
if (clamped && CLAMPED.has(target) && server && !map) continue;
wrong.push(`${schema.name}.${verb}: can()=${map} checkObjectPermission=${server}`);
}
}
expect(cells).toBe(Object.keys(REGISTERED).length * OBJECT_PERMISSION_VERB_NAMES.length);
expect(wrong).toEqual([]);
});
}
// [ADR-0090 D12, amended 2026-10-17] Since the organization boundary the
// walled org admin creates and removes assignment rows (the gate bounds
// which), and still never edits one — the object grant and can() agree.
it('[#20136] the reported case, spelled out: the walled org admin may not EDIT its RBAC rows, and can() says so', async () => {
const context = { userId: USER.id };
for (const label of ['[#20136 row a] walled org admin', '[#20136 row b] walled org admin WITHOUT member_default']) {
const sets = SUBJECTS[label];
const { svc, plugin } = await locate({ schemas: REGISTERED });
vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets);
const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!(context));
for (const [verb, operation, granted] of [['edit', 'update', false], ['create', 'insert', true], ['delete', 'delete', true]] as const) {
expect(evaluator.checkObjectPermission(operation, SysUserPosition.name, sets), `${label} ${verb}`).toBe(granted);
expect(can(permissions, SysUserPosition.name, verb), `${label} ${verb}`).toBe(granted);
}
// …and still reads them, on both sides.
expect(evaluator.checkObjectPermission('find', SysUserPosition.name, sets)).toBe(true);
expect(can(permissions, SysUserPosition.name, 'read')).toBe(true);
}
// `sys_user` edit: `member_default` grants it, `organization_admin` alone names it write-denied.
const alone = SUBJECTS['[#20136 row b] walled org admin WITHOUT member_default'];
const { svc, plugin } = await locate({ schemas: REGISTERED });
vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(alone);
const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!(context));
expect(evaluator.checkObjectPermission('update', SysUser.name, alone)).toBe(false);
expect(can(permissions, SysUser.name, 'edit')).toBe(false);
});
it('[#20134] the reported case, spelled out: the platform admin may transfer, and can() says so', async () => {
const sets = SUBJECTS['platform admin'];
const { svc, plugin } = await locate({ schemas: REGISTERED });
vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets);
const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!({ userId: USER.id }));
// `modifyAllRecords` is the write bypass, and `transfer` is in its class.
expect(evaluator.checkObjectPermission('transfer', 'crm_account', sets)).toBe(true);
expect(can(permissions, 'crm_account', 'transfer')).toBe(true);
// …on a private object too: a super-user wildcard covers it.
expect(evaluator.checkObjectPermission('transfer', 'crm_secret', sets, { isPrivate: true })).toBe(true);
expect(can(permissions, 'crm_secret', 'transfer')).toBe(true);
});
it('[#20134] a super-user wildcard carrying the export grant: an unrestricted object has an entry, and it answers', async () => {
const sets = SUBJECTS['a super-user wildcard carrying the export grant'];
const { svc, plugin } = await locate({ schemas: REGISTERED });
vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets);
const map: any = await svc.getEffectiveObjectPermissions!({ userId: USER.id });
const permissions = toEvalPermissions(map);
for (const [verb, operation] of [['read', 'find'], ['edit', 'update'], ['export', 'export']] as const) {
expect(evaluator.checkObjectPermission(operation, 'crm_account', sets)).toBe(true);
expect(can(permissions, 'crm_account', verb), verb).toBe(true);
}
// The operation channel still says nothing about an object that keeps its whole closure…
expect(map.crm_account).not.toHaveProperty('apiOperations');
// …and still speaks for one whose `apiMethods` narrow it, `export` included.
expect(map.crm_lead.apiOperations).toEqual(expect.arrayContaining(['get', 'list', 'export']));
});
/**
* [#20135] The `apiOperations` COLUMN — the map's operation set, read the way
* a client reads it (absent = default-allow), against what the REST door
* serves, for every subject above × every registered object its map carries
* an entry for × every operation the door gates by name. The door is asked
* its own two questions:
*
* - the object half, `canServeApiOperation` — the boolean face of the spec's
* `apiExposureDenialReason`, which `@objectstack/rest`'s `enforceApiAccess`
* turns into `404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`
* (this package takes no dependency on the transport, so the door's decision
* function is asked, not its envelope);
* - the user half on `export`, the security member's own `canExport` — what
* `enforceExportPermission` asks before its `403 EXPORT_NOT_PERMITTED`.
*
* `offeredRefused` is the security direction — an operation the client offers
* and the door refuses — and `servedHidden` its converse. Both must be empty.
* An object with no entry is left out: whether the map carries an entry at all
* is the seed's question, not this column's.
*
* It used to fail two ways: an `enable.apiEnabled: false` object was annotated
* with its whole closure, or not at all, while the door answers 404 for every
* verb; and the export slot fell back to the MERGED `'*'` export bit, which
* offered `export` on a private object only a plain wildcard reached, and on
* an object the exporting set itself names without the grant.
*/
const DOOR_OPERATIONS = ['get', 'list', 'create', 'update', 'delete', 'bulk', 'import', 'export'] as const;
for (const [label, sets] of Object.entries(SUBJECTS)) {
it(`[#20135] ${label}: apiOperations offers exactly what the REST door serves`, async () => {
const { svc, plugin } = await locate({ schemas: REGISTERED });
vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets);
const context = { userId: USER.id };
const map: any = await svc.getEffectiveObjectPermissions!(context);
const offeredRefused: string[] = [];
const servedHidden: string[] = [];
let cells = 0;
for (const schema of Object.values(REGISTERED)) {
const entry = map[schema.name];
if (!entry) continue;
for (const operation of DOOR_OPERATIONS) {
const served = canServeApiOperation(schema.enable, operation)
&& (operation !== 'export' || (await svc.canExport!(schema.name, context)));
const offered = entry.apiOperations === undefined || entry.apiOperations.includes(operation);
cells += 1;
if (offered && !served) offeredRefused.push(`${schema.name}.${operation}`);
if (served && !offered) servedHidden.push(`${schema.name}.${operation}`);
}
}
// Every registered entry the map carries was scored (a subject granted nothing carries none).
expect(cells).toBe(Object.keys(map).filter((name) => name in REGISTERED).length * DOOR_OPERATIONS.length);
expect({ offeredRefused, servedHidden }).toEqual({ offeredRefused: [], servedHidden: [] });
});
}
it('[#20135] the reported cases, spelled out', async () => {
const context = { userId: USER.id };
// An API-disabled object: the platform admin's entry stays, and offers nothing.
const admin = SUBJECTS['platform admin'];
const a = await locate({ schemas: REGISTERED });
vi.spyOn(a.plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(admin);
const adminMap: any = await a.svc.getEffectiveObjectPermissions!(context);
for (const operation of DOOR_OPERATIONS) expect(canServeApiOperation(REGISTERED.crm_hidden.enable, operation), operation).toBe(false);
expect(adminMap.crm_hidden.apiOperations).toEqual([]);
expect(adminMap.crm_hidden).toMatchObject({ allowRead: true, allowEdit: true });
// The private export: `admin_full_access` beside a plain `'*': { allowExport: true }`.
const sets = SUBJECTS['platform admin beside a plain export-only wildcard'];
const b = await locate({ schemas: REGISTERED });
vi.spyOn(b.plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets);
const map: any = await b.svc.getEffectiveObjectPermissions!(context);
expect(evaluator.checkObjectPermission('export', SysSecret.name, sets, { isPrivate: true })).toBe(false);
expect(await b.svc.canExport!(SysSecret.name, context)).toBe(false);
expect(map[SysSecret.name].apiOperations).not.toContain('export');
expect(map.crm_secret.apiOperations).not.toContain('export');
// …and a public object the plain wildcard covers keeps it, on both sides.
expect(await b.svc.canExport!('crm_lead', context)).toBe(true);
expect(map.crm_lead.apiOperations).toContain('export');
});
it('the wall-less org admin\'s reported case, spelled out: edit on an app object reached only through `*`', async () => {
const sets = SUBJECTS['wall-less org admin'];
const { svc, plugin } = await locate({ schemas: REGISTERED });
vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets);
const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!({ userId: USER.id }));
expect(evaluator.checkObjectPermission('update', 'crm_account', sets)).toBe(true);
expect(can(permissions, 'crm_account', 'edit')).toBe(true);
// …and the private object stays out of a plain wildcard's reach on both sides.
expect(evaluator.checkObjectPermission('find', 'crm_secret', sets, { isPrivate: true })).toBe(false);
expect(can(permissions, 'crm_secret', 'read')).toBe(false);
});
});