Skip to content

Commit 3fdfdeb

Browse files
committed
test: pin plain-wildcard coverage and can()/checkObjectPermission parity
core: the coverage pass's rules (registered public objects only, a set's explicit entry excludes its own wildcard, another set's wildcard widens a present entry, true bits only, no no-verb entries, super-user wildcards left to the seed and fold, seed-before-cover ordering). plugin-security: a table-driven parity pin — shipped and authored plain wildcard subjects x registered objects x every can() verb, the real can() over the member's map against PermissionEvaluator.checkObjectPermission; the member and route byte-equality pins now exercise the new pass. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88cfaf9 commit 3fdfdeb

3 files changed

Lines changed: 325 additions & 13 deletions

File tree

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,27 @@ describe('buildEffectiveObjectPermissions', () => {
6666
expect(map).toEqual({ deal: { allowRead: true } });
6767
});
6868

69+
it('seeds before it covers: an entry the seed placed keeps its place and only gains grants', () => {
70+
const schemas: Record<string, any> = {
71+
a_open: { name: 'a_open' },
72+
b_private: { name: 'b_private', access: { default: 'private' } },
73+
c_open: { name: 'c_open' },
74+
};
75+
const map: any = buildEffectiveObjectPermissions(
76+
[
77+
{ objects: { '*': { allowRead: true, viewAllRecords: true } } },
78+
{ objects: { '*': { allowCreate: true, allowRead: true, allowEdit: true } } },
79+
],
80+
{ allSchemas: () => Object.values(schemas), schemaOf: (n) => schemas[n] },
81+
);
82+
// Registration order, not "covered first, seeded after".
83+
expect(Object.keys(map)).toEqual(['*', 'a_open', 'b_private', 'c_open']);
84+
expect(Object.keys(map.a_open)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'apiOperations']);
85+
expect(map.a_open).toMatchObject({ allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false });
86+
// The private object takes nothing from the plain wildcard; the read bypass still reads it.
87+
expect(map.b_private).toMatchObject({ allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false });
88+
});
89+
6990
it('with no schema source at all it is the bare merge plus the fold', () => {
7091
const map: any = buildEffectiveObjectPermissions([
7192
{ objects: { '*': { modifyAllRecords: true }, deal: { allowRead: false } } },
@@ -74,3 +95,108 @@ describe('buildEffectiveObjectPermissions', () => {
7495
expect(map.deal.apiOperations).toBeUndefined();
7596
});
7697
});
98+
99+
/**
100+
* [#20083] A plain `'*'` — a wildcard carrying neither super-user bit — is
101+
* materialised onto the registered objects it covers, per set, the way
102+
* `PermissionEvaluator.checkObjectPermission` resolves it: a set's explicit
103+
* entry is that set's whole answer for the object; otherwise its wildcard
104+
* applies to a public object and never to a private one. Without it the map
105+
* held no entry for an object reached only through such a wildcard, and
106+
* `current_user.can()` read "no grant" where the server allows.
107+
*
108+
* The enforcement-side half of this parity — every verb, shipped sets, the
109+
* real `can()` against the real evaluator — is pinned table-driven in
110+
* plugin-security's `get-effective-object-permissions.test.ts`, the one
111+
* package that holds both functions.
112+
*/
113+
describe('[#20083] plain wildcard coverage', () => {
114+
const SCHEMAS: Record<string, any> = {
115+
crm_account: { name: 'crm_account' },
116+
crm_lead: { name: 'crm_lead', enable: { apiMethods: ['get', 'list'] } },
117+
crm_secret: { name: 'crm_secret', access: { default: 'private' } },
118+
crm_note: { name: 'crm_note', access: { default: 'public' } },
119+
};
120+
const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] };
121+
const WILD = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: false, viewAllRecords: false, modifyAllRecords: false };
122+
123+
it('puts a plain wildcard\'s grants on every registered public object no set names', () => {
124+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': WILD } }], source);
125+
for (const name of ['crm_account', 'crm_lead', 'crm_note']) {
126+
expect(map[name], name).toMatchObject({ allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true });
127+
}
128+
// Only `true` bits travel: a wildcard's `false` grants nothing and is not copied.
129+
expect(map.crm_account).not.toHaveProperty('allowTransfer');
130+
expect(map.crm_account).not.toHaveProperty('modifyAllRecords');
131+
// The later passes still run over the new entries.
132+
expect(map.crm_lead.apiOperations).toEqual(expect.arrayContaining(['get', 'list']));
133+
expect(map.crm_lead.apiOperations).not.toContain('update');
134+
});
135+
136+
it('never covers a private object', () => {
137+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': WILD } }], source);
138+
expect(map).not.toHaveProperty('crm_secret');
139+
});
140+
141+
it('never covers an object the registry does not hold', () => {
142+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': WILD } }], source);
143+
expect(Object.keys(map).sort()).toEqual(['*', 'crm_account', 'crm_lead', 'crm_note']);
144+
});
145+
146+
it('a set that names the object contributes its explicit entry, never its own wildcard', () => {
147+
const map: any = buildEffectiveObjectPermissions(
148+
[{ objects: { '*': WILD, crm_account: { allowRead: true, allowEdit: false } } }],
149+
source,
150+
);
151+
expect(map.crm_account).toMatchObject({ allowRead: true, allowEdit: false });
152+
expect(map.crm_account).not.toHaveProperty('allowDelete');
153+
expect(map.crm_note).toMatchObject({ allowEdit: true, allowDelete: true });
154+
});
155+
156+
it('ANOTHER set\'s plain wildcard widens a present entry, bit by bit', () => {
157+
const map: any = buildEffectiveObjectPermissions(
158+
[
159+
{ objects: { crm_account: { allowRead: true, allowEdit: false } } },
160+
{ objects: { '*': { allowRead: true, allowEdit: true, allowExport: true } } },
161+
],
162+
source,
163+
);
164+
expect(map.crm_account).toEqual({ allowRead: true, allowEdit: true, allowExport: true });
165+
});
166+
167+
it('an export-only wildcard lends its bit to a present entry, and adds no entry of its own', () => {
168+
const map: any = buildEffectiveObjectPermissions(
169+
[
170+
{ objects: { crm_account: { allowRead: true } } },
171+
{ objects: { '*': { allowExport: true } } },
172+
],
173+
source,
174+
);
175+
expect(map.crm_account).toEqual({ allowRead: true, allowExport: true });
176+
// Export is `grant ∧ read`: alone it grants no verb, so nothing is added for it.
177+
expect(Object.keys(map).sort()).toEqual(['*', 'crm_account']);
178+
});
179+
180+
it('a wildcard that grants nothing adds nothing', () => {
181+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': { allowRead: false } } }], source);
182+
expect(map).toEqual({ '*': { allowRead: false } });
183+
});
184+
185+
it('leaves a super-user wildcard to the seed and the fold', () => {
186+
const map: any = buildEffectiveObjectPermissions(
187+
[{ objects: { '*': { ...WILD, viewAllRecords: true, modifyAllRecords: true } } }],
188+
source,
189+
);
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']);
192+
// …and the private object is the seed's, as before.
193+
expect(map.crm_secret).toMatchObject({ allowRead: true, allowEdit: true });
194+
});
195+
196+
it('a throwing registry leaves the merge standing and covers nothing', () => {
197+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': WILD } }], {
198+
allSchemas: () => { throw new Error('registry down'); },
199+
});
200+
expect(map).toEqual({ '*': WILD });
201+
});
202+
});

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
// reintroduced here — the second copy that would let the console and the
1111
// server's own `current_user.can()` disagree — turns this red.
1212
//
13-
// The fixture makes every step of the composition fire (merge, super-user
14-
// seed, fold, managed-write clamp, apiOperations annotation), because an
15-
// equality over a map none of them touched would pin nothing.
13+
// The fixtures make every step of the composition fire (merge, super-user
14+
// seed, plain-wildcard coverage, fold, managed-write clamp, apiOperations
15+
// annotation), because an equality over a map none of them touched would pin
16+
// nothing.
1617

1718
import { describe, it, expect } from 'vitest';
1819
import { Hono } from 'hono';
@@ -48,7 +49,7 @@ const ql = {
4849
getSchema: (name: string) => SCHEMAS[name],
4950
};
5051

51-
function mount() {
52+
function mount(resolved: unknown[] = RESOLVED) {
5253
const services: Record<string, unknown> = {
5354
auth: {
5455
api: {
@@ -60,7 +61,7 @@ function mount() {
6061
},
6162
objectql: ql,
6263
metadata: { list: async () => [] as unknown[] },
63-
security: { resolvePermissionSetsForContext: async () => RESOLVED },
64+
security: { resolvePermissionSetsForContext: async () => resolved },
6465
};
6566
const app = new Hono();
6667
registerCurrentUserEndpoints({
@@ -92,6 +93,25 @@ describe('[#18783] /auth/me/permissions `objects` is the one effective-map funct
9293
expect(Array.isArray(body.objects.report.apiOperations)).toBe(true); // annotation
9394
});
9495

96+
it('[#20083] a PLAIN wildcard reaches every registered public object it covers — the same bytes', async () => {
97+
// The wall-less org admin's shape: a `'*'` with no super-user bit, and a
98+
// second set naming one object explicitly. The server lets this subject
99+
// write `report` and `deal` through the wildcard, so the map carries them.
100+
const plain = [
101+
{ name: 'ops_plain', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, fields: {} },
102+
{ name: 'sales', objects: { deal: { allowRead: true, allowEdit: false } }, fields: {} },
103+
];
104+
const body: any = await (await mount(plain).request(`http://localhost${ME_PERMISSIONS}`)).json();
105+
const expected = buildEffectiveObjectPermissions(plain, {
106+
allSchemas: () => ql.registry.getAllObjects(),
107+
schemaOf: (name) => ql.getSchema(name),
108+
});
109+
expect(JSON.stringify(body.objects)).toBe(JSON.stringify(expected));
110+
expect(body.objects.report).toMatchObject({ allowRead: true, allowEdit: true }); // named by no set
111+
expect(body.objects.deal).toMatchObject({ allowRead: true, allowEdit: true }); // another set's wildcard widens it
112+
expect(body.objects.sys_member).toMatchObject({ allowRead: true, allowEdit: false }); // the clamp still has the last word
113+
});
114+
95115
it('keeps the rest of the envelope on its own merges', async () => {
96116
const body: any = await (await mount().request(`http://localhost${ME_PERMISSIONS}`)).json();
97117
expect(body.permissionSets).toEqual(['ops_admin', 'sales']);

0 commit comments

Comments
 (0)