@@ -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 */
291390export 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