Skip to content

Commit 7e0e587

Browse files
committed
test: pin super-user map entries against checkObjectPermission, every verb
The parity table gains one row per super-user wildcard shape and holds each at 0 under-granted cells; the fold's two known over-grants are pre-registered cell for cell with a flip trigger. The seed pins that asserted no entry for an unrestricted, export-allowed object now assert an entry with no apiOperations. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent e239dab commit 7e0e587

4 files changed

Lines changed: 234 additions & 23 deletions

File tree

‎packages/core/src/security/effective-object-permissions.test.ts‎

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,11 @@ describe('[#20083] plain wildcard coverage', () => {
187187
[{ objects: { '*': { ...WILD, viewAllRecords: true, modifyAllRecords: true } } }],
188188
source,
189189
);
190-
// Seeded all-false, then folded: the super-user entry shape, not the wildcard's own bits.
191-
expect(Object.keys(map.crm_account)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'apiOperations']);
190+
// Seeded all-false, then folded: the super-user entry shape, not the wildcard's own key
191+
// order — [#20134] with `allowTransfer`, which `modifyAllRecords` grants, appended by the
192+
// per-set fold (the wildcard's own `allowTransfer: false` grants nothing and is not copied).
193+
expect(Object.keys(map.crm_account)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'allowTransfer', 'apiOperations']);
194+
expect(map.crm_account.allowTransfer).toBe(true);
192195
// …and the private object is the seed's, as before.
193196
expect(map.crm_secret).toMatchObject({ allowRead: true, allowEdit: true });
194197
});
@@ -200,3 +203,83 @@ describe('[#20083] plain wildcard coverage', () => {
200203
expect(map).toEqual({ '*': WILD });
201204
});
202205
});
206+
207+
/**
208+
* [#20134] A SUPER-USER `'*'` — one carrying `viewAllRecords` or
209+
* `modifyAllRecords` — puts on the map every bit it grants, per set, the way
210+
* `PermissionEvaluator.checkObjectPermission` resolves it: the seed places an
211+
* entry for every registered object the merge left absent, and the per-set
212+
* fold reads each set's wildcard through the spec's `objectPermissionGrants`.
213+
* The map used to hold only the four bits the merged fold pulls, so
214+
* `current_user.can(object, 'transfer')` answered `false` for a platform admin
215+
* the server lets transfer, a super-read wildcard lost its own plain bits, and
216+
* a super-user wildcard carrying `allowExport` left every unrestricted object
217+
* with no entry at all.
218+
*
219+
* The enforcement-side half — every verb, the shipped super-user sets, the
220+
* real `can()` against the real evaluator — is pinned table-driven in
221+
* plugin-security's `get-effective-object-permissions.test.ts`.
222+
*/
223+
describe('[#20134] super-user wildcard: every bit it grants, per set', () => {
224+
const SCHEMAS: Record<string, any> = {
225+
crm_account: { name: 'crm_account' },
226+
crm_lead: { name: 'crm_lead', enable: { apiMethods: ['get', 'list'] } },
227+
crm_secret: { name: 'crm_secret', access: { default: 'private' } },
228+
};
229+
const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] };
230+
231+
it('the write bypass carries `transfer` onto every entry, a private object\'s included', () => {
232+
const map: any = buildEffectiveObjectPermissions(
233+
[{ objects: { '*': { allowRead: true, allowCreate: true, modifyAllRecords: true } } }],
234+
source,
235+
);
236+
for (const name of Object.keys(SCHEMAS)) {
237+
expect(map[name], name).toMatchObject({ allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true });
238+
}
239+
});
240+
241+
it('a super-read wildcard keeps its own plain bits — and nothing it does not grant', () => {
242+
const map: any = buildEffectiveObjectPermissions(
243+
[{ objects: { '*': { viewAllRecords: true, allowEdit: true } } }],
244+
source,
245+
);
246+
for (const name of Object.keys(SCHEMAS)) {
247+
expect(map[name], name).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: false, allowDelete: false });
248+
expect(map[name], name).not.toHaveProperty('allowTransfer');
249+
}
250+
});
251+
252+
it('a set that names the object contributes its explicit entry, never its own wildcard\'s grants', () => {
253+
const map: any = buildEffectiveObjectPermissions(
254+
[{ objects: { '*': { viewAllRecords: true, allowTransfer: true }, crm_account: { allowRead: true } } }],
255+
source,
256+
);
257+
expect(map.crm_account).not.toHaveProperty('allowTransfer');
258+
expect(map.crm_lead).toMatchObject({ allowRead: true, allowTransfer: true });
259+
});
260+
261+
it('ANOTHER set\'s super-user wildcard widens a present entry, bit by bit', () => {
262+
const map: any = buildEffectiveObjectPermissions(
263+
[
264+
{ objects: { crm_account: { allowRead: true } } },
265+
{ objects: { '*': { viewAllRecords: true, allowTransfer: true, allowExport: true } } },
266+
],
267+
source,
268+
);
269+
// Unrestricted and export-allowed, so annotate has nothing to add to it.
270+
expect(map.crm_account).toEqual({ allowRead: true, allowTransfer: true, allowExport: true });
271+
});
272+
273+
it('a super-user wildcard carrying `allowExport` seeds every registered object — annotate keeps its own skip', () => {
274+
const map: any = buildEffectiveObjectPermissions(
275+
[{ objects: { '*': { allowRead: true, allowEdit: true, modifyAllRecords: true, allowExport: true } } }],
276+
source,
277+
);
278+
expect(Object.keys(map)).toEqual(['*', 'crm_account', 'crm_lead', 'crm_secret']);
279+
expect(map.crm_account).toMatchObject({ allowRead: true, allowEdit: true, allowTransfer: true, allowExport: true });
280+
// An unrestricted object whose export stays allowed: an entry, and no operation set on it.
281+
expect(map.crm_account).not.toHaveProperty('apiOperations');
282+
// A narrowed one is annotated exactly as before, `export` kept.
283+
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
284+
});
285+
});

‎packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ describe('[#18783] /auth/me/permissions `objects` is the one effective-map funct
9191
expect(body.objects.sys_member).toMatchObject({ allowEdit: false }); // clamp over the fold
9292
expect(body.objects.report).toMatchObject({ allowRead: true }); // seed for a super-user
9393
expect(Array.isArray(body.objects.report.apiOperations)).toBe(true); // annotation
94+
// [#20134] the per-set super-user fold: `modifyAllRecords` grants transfer, on a seeded
95+
// entry and on one another set names narrower alike.
96+
expect(body.objects.report).toMatchObject({ allowTransfer: true });
97+
expect(body.objects.deal).toMatchObject({ allowTransfer: true });
9498
});
9599

96100
it('[#20083] a PLAIN wildcard reaches every registered public object it covers — the same bytes', async () => {

‎packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts‎

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -173,21 +173,24 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => {
173173
{ name: 'locked', enable: { apiMethods: [] } }, // deny-all (restricting)
174174
];
175175

176-
it('for a modify-all super-user, seeds false-init entries for restricting objects only', () => {
177-
// [#18931] `allowExport: true` is what keeps this case about the `apiMethods`
178-
// DERIVATION — the same reason the annotate block above grants it. Without
179-
// it the export axis also withholds `export`, which is its own reason to
180-
// seed `open_obj`, and this assertion would be reading that instead. The
181-
// withheld-export case is pinned separately below.
176+
// [#20134] INVERTED on purpose. This read "seeds false-init entries for
177+
// restricting objects only" and asserted `open_obj` undefined: an unrestricted
178+
// object whose export stays allowed needs no `apiOperations`, so the seed
179+
// skipped it. But `current_user.can()` reads the entry, and an absent one as
180+
// "no grant", so every such object answered `false` for every verb this
181+
// principal holds. The #18990 ruling's own headline — every object a
182+
// principal can reach gets an entry — now holds for it too; the annotation
183+
// keeps its skip, pinned in the #18931 block below.
184+
it('for a modify-all super-user, seeds a false-init entry for every registered object', () => {
185+
// [#18931] `allowExport: true` is what keeps `open_obj` the export-allowed,
186+
// unrestricted case — the one the seed used to skip.
182187
const objects: Record<string, any> = {
183188
'*': { modifyAllRecords: true, viewAllRecords: true, allowExport: true },
184189
};
185190
seedSuperUserRestrictedObjects(objects, schemas);
186191
expect(objects.widget).toEqual({ allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false });
187192
expect(objects.locked).toBeDefined();
188-
// an unrestricted object that keeps its FULL closure is NOT seeded — for it
189-
// the client's default-allow path is already the right answer
190-
expect(objects.open_obj).toBeUndefined();
193+
expect(objects.open_obj).toEqual({ allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false });
191194
});
192195

193196
// [#18990] INVERTED on purpose. This used to read "does not seed for a
@@ -287,15 +290,20 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => {
287290
);
288291
});
289292

290-
it('stays silent for the same object once the principal really may export', () => {
293+
// [#20134] INVERTED on purpose: this read "stays silent" and asserted no
294+
// entry at all. The OPERATION channel still says nothing — the control
295+
// below — but the entry exists, because `current_user.can()` reads it and
296+
// reads an absent one as "no grant" for every verb.
297+
it('once the principal really may export: an entry, and no operation set on it', () => {
291298
// The control: same schema, same super-user bits, `allowExport` granted.
292-
// Nothing is withheld, so there is nothing to say and the client's
293-
// default-allow path is correct — no entry, exactly as before #18931.
299+
// Nothing is withheld, so there is no `apiOperations` to say and the
300+
// client's default-allow path for the operation set is correct.
294301
const objects: Record<string, any> = wildcardOnlyAdmin();
295302
objects['*'].allowExport = true;
296303
seedSuperUserRestrictedObjects(objects, unrestricted);
297304
annotateEffectiveApiOperations(objects, (name) => unrestricted.find((s) => s.name === name));
298-
expect(objects.crm_lead).toBeUndefined();
305+
expect(objects.crm_lead).toBeDefined();
306+
expect(objects.crm_lead).not.toHaveProperty('apiOperations');
299307
});
300308

301309
// [#18990] INVERTED on purpose, under the ruling on #18990 (batch #159
@@ -326,15 +334,17 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => {
326334
);
327335
});
328336

329-
it('stays silent for a viewAll-only principal once it really may export', () => {
337+
it('a viewAll-only principal once it really may export: an entry read true, and no operation set on it', () => {
330338
// The ruling's own carve-out — "skip only an unrestricted object whose
331-
// export stays allowed" — is the SAME skip the modify-all control above
332-
// pins, reached through the same predicate and not a second one.
339+
// export stays allowed" — is annotate's skip, and it is the SAME skip the
340+
// modify-all control above pins; [#20134] the entry itself is no longer
341+
// skipped, so `can()` reads the read the server grants.
333342
const objects: Record<string, any> = { '*': { viewAllRecords: true, allowExport: true } };
334343
seedSuperUserRestrictedObjects(objects, unrestricted);
335344
foldWildcardSuperUser(objects);
336345
annotateEffectiveApiOperations(objects, (name) => unrestricted.find((s) => s.name === name));
337-
expect(objects.crm_lead).toBeUndefined();
346+
expect(objects.crm_lead).toMatchObject({ allowRead: true, allowEdit: false });
347+
expect(objects.crm_lead).not.toHaveProperty('apiOperations');
338348
});
339349
});
340350
});

0 commit comments

Comments
 (0)