Skip to content

Commit e239dab

Browse files
committed
fix(core): super-user entries in the effective permission map carry every bit checkObjectPermission grants
The super-user seed now places an entry for every registered object a super-user wildcard reaches (it skipped an unrestricted object whose export stays allowed), and a per-set pass puts each set's super-user wildcard grants on the entries that set does not name: transfer through modifyAllRecords, and the wildcard's own plain bits. The merged fold is unchanged. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent a08e059 commit e239dab

1 file changed

Lines changed: 116 additions & 31 deletions

File tree

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

Lines changed: 116 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,24 @@ function wildcardGrantsSuperRead(objects: Record<string, any>): boolean {
7676
* the client is told must be derived from the server's actual effective
7777
* enforcement, never from an independent reading of the declarations.
7878
*
79-
* The super-user grant covers private/managed objects on the server, so folding
80-
* it here is exactly as broad as real enforcement — never broader.
79+
* The super-user grant covers private/managed objects on the server, so the
80+
* read and write bypass reach every entry here.
81+
*
82+
* [#20134] This reads the MERGED wildcard, so it knows only the two bypass
83+
* bits, and only four entry bits. The per-set half,
84+
* {@link foldSuperUserWildcardGrants}, carries what that reading cannot: the
85+
* `transfer` the write bypass grants, and a super-user wildcard's own plain
86+
* bits (`'*': { viewAllRecords, allowEdit }` edits). Two cells here are known
87+
* to be BROADER than enforcement, and are left exactly as they are for the
88+
* over-grant card of this family (#20136):
89+
* - the merged bypass is folded into an entry the super-user set itself names
90+
* narrower — `resolveObjectPermission` answers that set with its explicit
91+
* entry, so the walled `organization_admin` is granted edit on
92+
* `sys_position` here and refused it by the server;
93+
* - `allowCreate` is pulled on the write bypass, which the spec's
94+
* `objectPermissionGrants` deliberately gives no create cell: a
95+
* `'*': { modifyAllRecords: true }` without `allowCreate` is granted create
96+
* here and refused it by the server.
8197
*/
8298
export function foldWildcardSuperUser(objects: Record<string, any>): void {
8399
const wild = objects?.['*'];
@@ -96,6 +112,64 @@ export function foldWildcardSuperUser(objects: Record<string, any>): void {
96112
}
97113
}
98114

115+
/**
116+
* [#20134] The per-set half of the super-user fold: each set's SUPER-USER
117+
* `'*'` puts every bit it grants on each entry that set does not name, mutating
118+
* the map in place. Runs after the seed (so every registered object has an
119+
* entry) and BEFORE the managed-write clamp.
120+
*
121+
* Without it the map held only the four bits {@link foldWildcardSuperUser}
122+
* pulls, so `current_user.can(object, 'transfer')` answered `false` for
123+
* `admin_full_access` and the walled `organization_admin` on every object
124+
* where `PermissionEvaluator.checkObjectPermission('transfer', …)` answers
125+
* `true` through `modifyAllRecords`, and a super-read wildcard lost its own
126+
* plain bits (`'*': { viewAllRecords, allowEdit }` read only).
127+
*
128+
* The bits are DERIVED, not listed: for each grant bit, the spec's
129+
* `objectPermissionGrants` — the one fold `checkObjectPermission` itself asks
130+
* — is read on the set's wildcard. For a super-user wildcard its read cell is
131+
* always `true`, so its export cell is exactly the wildcard's own
132+
* `allowExport`, the grant half the evaluator's export conjunction reads.
133+
*
134+
* Exactly as broad as `resolveObjectPermission`, never broader:
135+
* - a set that names the object contributes nothing here: for that set the
136+
* explicit entry is the whole answer, and the merge already carries it;
137+
* - a super-user wildcard covers a private object as much as a public one,
138+
* so no posture is read;
139+
* - only `true` bits are set — a bit the wildcard does not grant is left
140+
* as the other passes left it.
141+
*
142+
* A plain wildcard is {@link materializePlainWildcardCoverage}'s, and a map
143+
* holding no super-user wildcard leaves this pass without a single write.
144+
*/
145+
function foldSuperUserWildcardGrants(
146+
objects: Record<string, any>,
147+
sets: ReadonlyArray<EffectiveObjectPermissionsInputSet | null | undefined>,
148+
): void {
149+
const superWildcards: Array<{ named: Record<string, unknown>; bits: readonly GrantBit[] }> = [];
150+
for (const ps of sets) {
151+
const named = ps?.objects as Record<string, unknown> | null | undefined;
152+
const wild = named?.['*'] as Record<string, unknown> | null | undefined;
153+
if (!named || !wild || typeof wild !== 'object') continue;
154+
// The file's one reading of "is this a super-user wildcard?", asked of the set.
155+
if (!wildcardGrantsSuperRead(named)) continue;
156+
const bits = GRANT_BITS.filter((bit) => objectPermissionGrants(wild as EffectiveObjectPermission, bit));
157+
superWildcards.push({ named, bits });
158+
}
159+
if (superWildcards.length === 0) return;
160+
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
161+
if (obj === '*' || !acc) continue;
162+
for (const { named, bits } of superWildcards) {
163+
// `resolveObjectPermission`'s own test: a set's explicit entry, when it
164+
// has one, is that set's whole answer for the object.
165+
if (named[obj]) continue;
166+
for (const bit of bits) {
167+
if (acc[bit] !== true) acc[bit] = true;
168+
}
169+
}
170+
}
171+
}
172+
99173
/** Minimal schema shape the managed-write clamp needs. */
100174
export interface ManagedSchemaLike {
101175
managedBy?: string;
@@ -200,6 +274,7 @@ function isPrivatePosture(schema: ObjectAccessPostureLike | null | undefined): b
200274

201275
/** The `allow*` bits {@link objectPermissionGrants} reads, i.e. every bit a `can()` verb resolves to. */
202276
const GRANT_BITS = ['allowRead', 'allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer', 'allowExport'] as const;
277+
type GrantBit = (typeof GRANT_BITS)[number];
203278
const GRANT_BIT_SET: ReadonlySet<string> = new Set(GRANT_BITS);
204279

205280
/** Does the entry grant any verb on its own? (`objectPermissionGrants` over every verb target.) */
@@ -285,14 +360,14 @@ function materializePlainWildcardCoverage(
285360

286361
/**
287362
* [#3391] Seed false-initialized per-object entries for a wildcard SUPER-USER,
288-
* for every registered object whose `apiMethods` whitelist tightens exposure.
363+
* for every registered object the merged map does not already carry.
289364
*
290365
* A super-user's grant is usually the `'*'` wildcard, not explicit per-object
291-
* entries — so restricting objects never appear in the merged `objects` map and
292-
* would miss their `apiOperations` annotation. Seeding a `{allow*: false}` entry
293-
* lets {@link foldWildcardSuperUser} pull it true (super-user reads/writes
294-
* everything) and lets {@link annotateEffectiveApiOperations} attach the effective
295-
* set. Runs BEFORE fold.
366+
* entries — so the objects it reaches never appear in the merged `objects` map.
367+
* Seeding a `{allow*: false}` entry lets {@link foldWildcardSuperUser} and
368+
* {@link foldSuperUserWildcardGrants} pull its grants true and lets
369+
* {@link annotateEffectiveApiOperations} attach the effective operation set
370+
* where the object narrows it. Runs BEFORE both folds.
296371
*
297372
* [#18990] Admitted by {@link wildcardGrantsSuperRead} — the READ bypass, so
298373
* BOTH super-user classes are seeded, and a plain wildcard grant carrying
@@ -311,33 +386,37 @@ function materializePlainWildcardCoverage(
311386
* (`PermissionEvaluator.checkObjectPermission`), so the entry is exactly as
312387
* broad as real enforcement, never broader.
313388
*
314-
* [#18931] A schema is skipped only when it needs NO annotation, which is the
315-
* predicate {@link annotateEffectiveApiOperations} itself applies: unrestricted
316-
* AND the export axis leaves `export` in place. Resolving WITHOUT the export
317-
* slot made this pass disagree with annotate's — an unrestricted object whose
318-
* `export` the axis withholds got no entry, annotate (which iterates existing
319-
* entries only) never saw it, and `/me/permissions` stayed silent for exactly
320-
* the population #8681 created: a wildcard-only admin holding no `allowExport`.
321-
* The client's `apiOperations` is then `undefined`, its default-allow path
322-
* renders an Export button, and the click is refused `403 EXPORT_NOT_PERMITTED`.
389+
* [#18931] An unrestricted object whose `export` the export axis withholds is
390+
* seeded too: annotate iterates existing entries only, so without an entry
391+
* `/me/permissions` stayed silent for exactly the population #8681 created — a
392+
* wildcard-only admin holding no `allowExport` — the client's `apiOperations`
393+
* was `undefined`, its default-allow path rendered an Export button, and the
394+
* click was refused `403 EXPORT_NOT_PERMITTED`.
395+
*
396+
* [#20134] And so is EVERY other registered object absent from the map — the
397+
* name keeps its #3391 origin, the scope does not. The pass used to skip an
398+
* unrestricted object whose `export` stays allowed, the one object that needs
399+
* no `apiOperations` annotation. But the entry is not only the annotation's
400+
* carrier: `current_user.can()` reads the map entry by entry and an absent one
401+
* as "no grant", so a super-user wildcard that also carries `allowExport` left
402+
* every such object reading `false` for every verb the server lets that
403+
* principal perform. The entry is the truth the #18990 ruling asks for — every
404+
* object a principal can reach gets one — and annotate keeps its own skip:
405+
* an entry seeded for an unrestricted, export-allowed object carries no
406+
* `apiOperations`, so the client's default-allow path for the operation set is
407+
* exactly as it was.
323408
*/
324409
export function seedSuperUserRestrictedObjects(
325410
objects: Record<string, any>,
326411
allSchemas: readonly ApiExposureSchemaLike[],
327412
): void {
328413
if (!wildcardGrantsSuperRead(objects)) return;
329-
// [#18931] The export slot annotate will read for an entry seeded here. A
330-
// seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser`
331-
// does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to
332-
// exactly this wildcard bit — the two passes cannot diverge again.
333-
const userExportAllowed = objects['*']?.allowExport === true;
414+
// A super-user wildcard covers every registered object, private ones
415+
// included (`PermissionEvaluator`'s `resolveObjectPermission`), so every
416+
// registered object the merge left absent gets an entry.
334417
for (const schema of allSchemas) {
335418
const name = schema?.name;
336419
if (!name || name === '*' || objects[name]) continue;
337-
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed });
338-
// Same skip predicate as annotate: there is nothing to say about an
339-
// unrestricted object that keeps its full operation closure.
340-
if (eff.mode === 'unrestricted' && userExportAllowed) continue;
341420
objects[name] = { allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false };
342421
}
343422
}
@@ -432,7 +511,9 @@ export interface EffectiveObjectPermissionsInputSet {
432511
* 3. {@link materializePlainWildcardCoverage} — [#20083] each set's plain
433512
* `'*'` onto the registered objects it covers for that set, so the map is
434513
* as broad as `checkObjectPermission` there; guarded like (2);
435-
* 4. {@link foldWildcardSuperUser};
514+
* 4. {@link foldWildcardSuperUser}, then {@link foldSuperUserWildcardGrants} —
515+
* [#20134] each set's super-user `'*'` onto the entries it covers for that
516+
* set, every bit it grants;
436517
* 5. {@link clampManagedObjectWrites};
437518
* 6. {@link annotateEffectiveApiOperations} — guarded like (2).
438519
*
@@ -466,9 +547,10 @@ export function buildEffectiveObjectPermissions(
466547
try { return source.allSchemas?.() ?? []; } catch { return [] as ApiExposureSchemaLike[]; }
467548
})();
468549
// [#3391] For a wildcard super-user — [#18990] either bypass bit, not
469-
// modify-all alone — seed restricting objects absent from the merged map so
470-
// fold pulls what it pulls and annotate can attach their effective
471-
// apiOperations. Guarded — a failure here must never drop the whole map.
550+
// modify-all alone — seed [#20134] every registered object absent from the
551+
// merged map, so the folds pull what they pull and annotate can attach the
552+
// effective apiOperations where the object narrows them. Guarded — a failure
553+
// here must never drop the whole map.
472554
try {
473555
seedSuperUserRestrictedObjects(objects, allSchemas);
474556
} catch (e: any) {
@@ -486,9 +568,12 @@ export function buildEffectiveObjectPermissions(
486568
// Make the per-object map reflect the server's ACTUAL effective enforcement
487569
// = permission-set grant ∩ identity write guard (ADR-0057 D10, cited as an
488570
// attribution, #9628): (1) fold the `'*'` super-user grant into every object
489-
// so an admin's wildcard is not shadowed by another set's explicit deny;
571+
// so an admin's wildcard is not shadowed by another set's explicit deny —
572+
// the merged bypass bits, then [#20134] every bit each set's super-user
573+
// wildcard grants, `transfer` and its own plain bits included;
490574
// (2) re-clamp guarded managed objects by their write affordance.
491575
foldWildcardSuperUser(objects);
576+
foldSuperUserWildcardGrants(objects, sets);
492577
clampManagedObjectWrites(objects, schemaOf);
493578
// [#3391] Annotate the per-object effective API operation set. Guarded: on
494579
// failure `apiOperations` is simply omitted and a client falls back to its

0 commit comments

Comments
 (0)