Skip to content

Commit 88cfaf9

Browse files
committed
fix(core): materialise plain '*' coverage in the effective object-permission map
buildEffectiveObjectPermissions merged each set's explicit entries and kept '*' as a key of its own, so an object covered only by a plain wildcard (no super-user bit) had no entry, and current_user.can() read it as no grant where PermissionEvaluator.checkObjectPermission allows. A new pass puts each set's plain '*' grants on the registered public objects that set does not name, after the super-user seed and before the fold. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b27bd0 commit 88cfaf9

1 file changed

Lines changed: 127 additions & 11 deletions

File tree

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

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
effectiveOperationsArray,
3838
type EnableLike,
3939
} from '@objectstack/spec/data';
40-
import type { EffectiveObjectPermission } from '@objectstack/spec/security';
40+
import { objectPermissionGrants, type EffectiveObjectPermission } from '@objectstack/spec/security';
4141

4242
/**
4343
* Does the `'*'` entry carry the super-user READ bypass?
@@ -175,6 +175,103 @@ export interface ApiExposureSchemaLike {
175175
enable?: EnableLike | null;
176176
}
177177

178+
/**
179+
* The posture slice of a registered object schema: whether a plain `'*'` grant
180+
* covers it at all. `access.default === 'private'` keeps a wildcard carrying no
181+
* super-user bypass bit off the object — ADR-0066 D2, read exactly as
182+
* `PermissionEvaluator` reads it (`access?.default === 'private'`; anything else
183+
* is public).
184+
*/
185+
export interface ObjectAccessPostureLike {
186+
name?: string;
187+
access?: { default?: unknown } | null;
188+
}
189+
190+
/** The `allow*` bits {@link objectPermissionGrants} reads, i.e. every bit a `can()` verb resolves to. */
191+
const GRANT_BITS = ['allowRead', 'allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer', 'allowExport'] as const;
192+
const GRANT_BIT_SET: ReadonlySet<string> = new Set(GRANT_BITS);
193+
194+
/** Does the entry grant any verb on its own? (`objectPermissionGrants` over every verb target.) */
195+
function grantsAnyVerb(entry: Record<string, unknown>): boolean {
196+
return GRANT_BITS.some((bit) => objectPermissionGrants(entry as EffectiveObjectPermission, bit));
197+
}
198+
199+
/**
200+
* [#20083] Materialise every PLAIN `'*'` grant — a wildcard carrying neither
201+
* super-user bypass bit — onto the registered objects it covers, mutating the
202+
* map in place. Runs after the merge and the super-user seed (an entry the
203+
* seed placed only gains grants here, and keeps its place), BEFORE the fold.
204+
*
205+
* The merge above folds each set's EXPLICIT entries and keeps `'*'` as a key of
206+
* its own, but the server does not stop there: `PermissionEvaluator`'s
207+
* `resolveObjectPermission` answers, PER SET, with that set's explicit entry
208+
* for the object when it has one, and otherwise with its `'*'` — for a public
209+
* object always, for a private one only when the wildcard is a super-user
210+
* grant. So a set whose plain wildcard covers an object contributes that
211+
* wildcard to the object, and `checkObjectPermission` allows as soon as ANY
212+
* set's contribution grants. Without this pass the map held no entry for an
213+
* object reached only that way, and `current_user.can()` — which reads an absent
214+
* entry as "no grant" — answered `false` for a wall-less org admin
215+
* (`organization_admin_no_bypass`) on every app object the server lets them
216+
* write; and it held a narrower entry wherever one set named the object and
217+
* another covered it by its wildcard.
218+
*
219+
* Exactly as broad as that resolution, never broader:
220+
* - only REGISTERED objects are covered — the server refuses an object whose
221+
* posture it cannot resolve, whatever the wildcard says;
222+
* - a set that names the object explicitly contributes nothing here: for
223+
* that set the explicit entry is the whole answer, and the merge already
224+
* carries it;
225+
* - a `private` object takes nothing from a plain wildcard;
226+
* - only `true` bits are merged — the grants every `can()` verb reads. A
227+
* wildcard's `false` or unset bit grants nothing, and depth keys
228+
* (`readScope`/`writeScope`) are not grants;
229+
* - an object the pass would ADD but whose entry grants no verb on its own is
230+
* left out: an absent entry and an all-`false` one read the same.
231+
*
232+
* A super-user wildcard is NOT materialised here: {@link seedSuperUserRestrictedObjects}
233+
* and {@link foldWildcardSuperUser} carry it, and this pass leaves their answer
234+
* byte-for-byte as it was for every subject holding no plain wildcard.
235+
*/
236+
function materializePlainWildcardCoverage(
237+
objects: Record<string, any>,
238+
sets: ReadonlyArray<EffectiveObjectPermissionsInputSet | null | undefined>,
239+
allSchemas: readonly (ApiExposureSchemaLike & ObjectAccessPostureLike)[],
240+
): void {
241+
const plainWildcards: Array<{ named: Record<string, unknown>; wild: Record<string, unknown> }> = [];
242+
for (const ps of sets) {
243+
const named = ps?.objects as Record<string, unknown> | null | undefined;
244+
const wild = named?.['*'] as Record<string, unknown> | null | undefined;
245+
if (!named || !wild || typeof wild !== 'object') continue;
246+
if (wild.viewAllRecords === true || wild.modifyAllRecords === true) continue;
247+
if (!GRANT_BITS.some((bit) => wild[bit] === true)) continue;
248+
plainWildcards.push({ named, wild });
249+
}
250+
if (plainWildcards.length === 0) return;
251+
for (const schema of allSchemas) {
252+
const name = schema?.name;
253+
if (!name || name === '*') continue;
254+
if (schema.access?.default === 'private') continue;
255+
const had = Object.prototype.hasOwnProperty.call(objects, name);
256+
const acc: Record<string, unknown> = had ? objects[name] : {};
257+
let touched = false;
258+
for (const { named, wild } of plainWildcards) {
259+
// `resolveObjectPermission`'s own test: a set's explicit entry, when it
260+
// has one, is that set's whole answer for the object.
261+
if (named[name]) continue;
262+
// The wildcard's own key order, so an entry this pass adds reads like
263+
// every other entry the map carries.
264+
for (const [bit, value] of Object.entries(wild)) {
265+
if (value === true && GRANT_BIT_SET.has(bit) && acc[bit] !== true) {
266+
acc[bit] = true;
267+
touched = true;
268+
}
269+
}
270+
}
271+
if (!had && touched && grantsAnyVerb(acc)) objects[name] = acc;
272+
}
273+
}
274+
178275
/**
179276
* [#3391] Seed false-initialized per-object entries for a wildcard SUPER-USER,
180277
* for every registered object whose `apiMethods` whitelist tightens exposure.
@@ -188,7 +285,9 @@ export interface ApiExposureSchemaLike {
188285
*
189286
* [#18990] Admitted by {@link wildcardGrantsSuperRead} — the READ bypass, so
190287
* BOTH super-user classes are seeded, and a plain wildcard grant carrying
191-
* neither bypass bit still is not. This pass used to be guarded to
288+
* neither bypass bit still is not — [#20083] its coverage is
289+
* {@link materializePlainWildcardCoverage}'s, which puts the wildcard's own
290+
* grants on the objects it covers. This pass used to be guarded to
192291
* `modifyAllRecords` alone, on the reading that materializing a `false` entry
193292
* for a viewAll-only caller would flip the client's `check('edit')` from
194293
* "undefined → default-allow" to "explicit false → deny". It does flip it, and
@@ -289,8 +388,13 @@ export function annotateEffectiveApiOperations(
289388
* (no seed, no clamp, no `apiOperations`) — it never drops the map.
290389
*/
291390
export interface EffectiveObjectPermissionsSchemaSource {
292-
/** Every registered object schema — read by the super-user seed. */
293-
allSchemas?: () => readonly ApiExposureSchemaLike[] | null | undefined;
391+
/**
392+
* Every registered object schema — read by the plain-wildcard coverage
393+
* (`name` and `access.default`, [#20083]) and by the super-user seed (`name`
394+
* and `enable`). Hand over the registered schemas themselves: an entry whose
395+
* `access` is missing reads as public, exactly as it does to the server.
396+
*/
397+
allSchemas?: () => readonly (ApiExposureSchemaLike & ObjectAccessPostureLike)[] | null | undefined;
294398
/** One object's schema — read by the managed-write clamp and the `apiOperations` annotation. */
295399
schemaOf?: (objectName: string) => (ManagedSchemaLike & ApiExposureSchemaLike) | null | undefined;
296400
/** Where a failed seed / annotation pass is reported. */
@@ -307,16 +411,19 @@ export interface EffectiveObjectPermissionsInputSet {
307411
* sets are `sets` — the `objects` slot of `/auth/me/permissions` and the answer
308412
* of `ISecurityService.getEffectiveObjectPermissions`, from this ONE function.
309413
*
310-
* In order, exactly as the endpoint has always composed it:
414+
* In order:
311415
*
312416
* 1. the most-permissive merge of every set's explicit `objects` entries —
313417
* same semantics as `PermissionEvaluator.getFieldPermissions`, for ALL
314418
* objects in one pass (`'*'` is merged as an ordinary key);
315419
* 2. {@link seedSuperUserRestrictedObjects} — guarded: a failure is reported
316420
* and the map is kept;
317-
* 3. {@link foldWildcardSuperUser};
318-
* 4. {@link clampManagedObjectWrites};
319-
* 5. {@link annotateEffectiveApiOperations} — guarded like (2).
421+
* 3. {@link materializePlainWildcardCoverage} — [#20083] each set's plain
422+
* `'*'` onto the registered objects it covers for that set, so the map is
423+
* as broad as `checkObjectPermission` there; guarded like (2);
424+
* 4. {@link foldWildcardSuperUser};
425+
* 5. {@link clampManagedObjectWrites};
426+
* 6. {@link annotateEffectiveApiOperations} — guarded like (2).
320427
*
321428
* Every entry is a FRESH object: nothing in the returned map aliases a
322429
* permission set, so a caller may freeze or serialise it freely.
@@ -344,18 +451,27 @@ export function buildEffectiveObjectPermissions(
344451
const schemaOf = (name: string): (ManagedSchemaLike & ApiExposureSchemaLike) | undefined => {
345452
try { return source.schemaOf?.(name) ?? undefined; } catch { return undefined; }
346453
};
454+
const allSchemas = (() => {
455+
try { return source.allSchemas?.() ?? []; } catch { return [] as ApiExposureSchemaLike[]; }
456+
})();
347457
// [#3391] For a wildcard super-user — [#18990] either bypass bit, not
348458
// modify-all alone — seed restricting objects absent from the merged map so
349459
// fold pulls what it pulls and annotate can attach their effective
350460
// apiOperations. Guarded — a failure here must never drop the whole map.
351461
try {
352-
const allSchemas = (() => {
353-
try { return source.allSchemas?.() ?? []; } catch { return [] as ApiExposureSchemaLike[]; }
354-
})();
355462
seedSuperUserRestrictedObjects(objects, allSchemas);
356463
} catch (e: any) {
357464
source.logger?.warn?.('[effective-permissions] apiOperations seed failed', { err: e?.message });
358465
}
466+
// [#20083] A plain `'*'` covers every registered public object its set does
467+
// not name — per set, as the server resolves it. After the seed, so an entry
468+
// the seed already placed keeps its place in the map and only gains grants.
469+
// Guarded like the seed.
470+
try {
471+
materializePlainWildcardCoverage(objects, sets, allSchemas);
472+
} catch (e: any) {
473+
source.logger?.warn?.('[effective-permissions] plain-wildcard coverage failed', { err: e?.message });
474+
}
359475
// Make the per-object map reflect the server's ACTUAL effective enforcement
360476
// = permission-set grant ∩ identity write guard (ADR-0057 D10, cited as an
361477
// attribution, #9628): (1) fold the `'*'` super-user grant into every object

0 commit comments

Comments
 (0)