Skip to content

Commit 215840f

Browse files
fix(hono-server): seed /me/permissions for a viewAll-only wildcard principal too (#19242)
Fixes #18990 Clause-②: no `/auth/me/permissions` stayed completely silent about every object a principal could reach when that principal's only grant was a wildcard carrying `viewAllRecords`. `seedSuperUserRestrictedObjects` returned early unless the wildcard carried `modifyAllRecords`, so the object got no entry, the client fell back to its default-allow path, and it rendered write and Export affordances the server answers `403 EXPORT_NOT_PERMITTED`. Same silence and same consequence as the class #18931 closed, for a different principal class. Implements the ruling on this card — batch #159 item 1, **letter A**, maintainer 「同意」 2026-09-18T11:42Z, recorded at comment `5729478681`. ## What changed **One predicate admits both principal classes.** The seed now asks the wildcard READ bypass rather than the modify bit alone. That question already existed twice in this file's neighbourhood — `foldWildcardSuperUser` asks it to decide whose `allowRead` it pulls true, and `PermissionEvaluator.checkObjectPermission` applies the same bypass server-side (`viewAllRecords || modifyAllRecords` short-circuits read; only the modify bit reaches the write axis). It is now a single module-local reading that the fold and the seed share, so the seed structurally cannot materialise an entry for a principal the fold leaves entirely false. ``` seed -> fold -> clamp -> annotate (unchanged order, unchanged call site) {allow*: false} allowRead: true writes stay false apiOperations attached ``` **The export axis needed no new branch.** This was the premise worth falsifying, since adding a second predicate for one question is the exact defect #18931 was about. It holds: the seed resolves `resolveEffectiveApiMethods` with `userExportAllowed` read off the wildcard's `allowExport`, annotate resolves it with `acc.allowExport ?? wildExport`, and a seeded entry carries no `allowExport` of its own while the fold adds none — so the two resolve to the same bit, and both skip on the same `unrestricted && userExportAllowed`. The ruling's carve-out ("skip only an unrestricted object whose export stays allowed") is therefore reached through the predicate that was already there, not a copy of it. **A plain wildcard grant carrying neither bypass bit is still not seeded**, and that is what makes the admission the read bypass rather than "any wildcard": the fold pulls nothing true for such a principal, so a seeded entry would be an all-false claim with no server behaviour behind it. Pinned as its own case. ## ⭐ Two `toBeUndefined` pins are INVERTED on purpose A reviewer should not have to guess whether a flipped pin was intentional. Both flips are the ruling being carried out, not collateral: 1. `does not reach a viewAll-only principal (the seed guard is unchanged)` — the pin PR #18984 added, and the one the ruling names by its text. That PR pinned this boundary deliberately while saying in the same breath that the pin was **not** a ruling that the silence was correct. The ruling on this card is what now says otherwise. 2. `does not seed for a viewAll-only wildcard (avoids flipping check() to explicit deny)` — the older #3391-era pin on the same principal class. The ruling names only the first; this one asserts the identical thing about the identical class, so it moves with it. Flagging it explicitly because the dispatch order named one pin and the diff flips two. The behaviour change they pinned is real and deliberate: for a viewAll-only principal, a client that read "no entry" as default-allow now reads an explicit `allowEdit: false`. That is the truth about it — the seed only ever touches objects with **no explicit entry** (`if (objects[name]) continue`), and on those a viewAll-only principal genuinely can only read, while the silence it replaces was not true about anything. ## Verification Reverse verification, run from the committed fix and restored byte-exact (`git hash-object` equal to the HEAD blob, `git diff HEAD` empty). **Expected direction chosen before the run: the two new POSITIVE pins go red; the two negative/control pins cannot distinguish and stay green.** That is what happened — replacing the shared predicate with the old `modifyAllRecords`-only guard: `Tests 2 failed | 22 passed`, failing exactly `seeds for a viewAll-only wildcard too` and `reaches a viewAll-only principal: read folded true, writes explicitly false`. The two absence-asserting cases stayed green under the old guard, correctly: absence held there too, so they are controls on the predicate's other half and not evidence for the fix. - `pnpm --filter @objectstack/plugin-hono-server test` :: exit 0 — 26 files, 311 passed, 1 todo - `pnpm --filter @objectstack/plugin-hono-server typecheck` :: exit 0 — 0 errors, 0 pinned signatures - `pnpm lint` (the full repo union, `eslint . --no-inline-config`) :: exit 0, at `0625da76a` - 59 derived gate families via `dispatch-gates --commands --repo objectstack-ai/objectstack`, reconciled with `--ran` carrying every exit code: **56 run green, 3 NOT MEASURED, 0 UNRUN** The three NOT MEASURED are `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. Each exits **3** (PREREQUISITE NOT MET, which is neither a pass nor a failure) because it reads built output across the whole workspace and this tree has only the `plugin-hono-server` dependency closure built. CI builds the workspace before them. A declared narrowing, not a skipped gate. `check-widening-tells --declaration no` exits 0 but reports all 3 files NOT MEASURED — no declared surface covers `packages/plugins/**` — so the `Clause-②: no` above is a structural measurement, not that gate's clearance: the diff adds no exported symbol (`wildcardGrantsSuperRead` is module-local), no key on the published payload (the `objects` map gains **entries**, not keys), no registration and no error code. `EXPORT_NOT_PERMITTED` appears in this diff only as prose citing the existing code. ## Acceptance notes - **Changeset: `patch` on `@objectstack/plugin-hono-server`.** A behaviour fix in a released package. The body carries the deliberate-behaviour-change warning for whoever compiles the release notes, since a consumer reading "no entry" as default-allow sees a different answer after this. - Nothing here reaches `packages/spec` — `resolveEffectiveApiMethods` is imported, not edited. The declared file surface `packages/plugins/plugin-hono-server/src/` was not breached; the only file outside it is the changeset this package owes. - The re-lane the ruling flagged as possible does not materialise: all three symbols resolve out of `plugin-hono-server`, confirmed again at this branch's base. --- _Generated by [Claude Code](https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7ec8534 commit 215840f

3 files changed

Lines changed: 110 additions & 18 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
`/auth/me/permissions` now answers a wildcard-only `viewAllRecords` principal instead of staying silent about every object it can reach.
6+
7+
`seedSuperUserRestrictedObjects` was guarded to `modifyAllRecords` super-users alone. A principal that reaches an object only through a wildcard `viewAllRecords` grant therefore got **no entry at all**: the client fell back to its default-allow path and rendered write and Export affordances the server answers `403 EXPORT_NOT_PERMITTED`. Same silence, same consequence, different principal class from the one framework#18931 closed.
8+
9+
- **One predicate admits both classes.** The seed now asks the wildcard READ bypass — `viewAllRecords || modifyAllRecords` — which is the same question `foldWildcardSuperUser` already asks to decide whose `allowRead` it pulls true, and the same one `PermissionEvaluator` applies server-side. It is now a single module-local reading both call sites share, so the seed can never materialise an entry for a principal the fold leaves entirely false.
10+
- **A plain wildcard grant carrying neither bypass bit is still not seeded.** That is what makes the admission the read bypass rather than "any wildcard": the fold pulls nothing true for it, so a seeded entry would be an all-false claim with no server behaviour behind it.
11+
- **The seeded entry is the truth, not an overreach.** It starts `{allow*: false}`, the fold pulls `allowRead` true, and the write bits stay false. The seed only ever touches objects with **no explicit entry**, and on those a viewAll-only principal really can only read — so "explicit false" for edit is what is true about it, where the silence it replaces was not.
12+
- **`apiOperations` is attached through the predicate already shared with the modify-all class** — an unrestricted object whose export stays allowed is still skipped, because for it the client's default-allow path is already right.
13+
14+
⚠️ **This is a deliberate behaviour change on an existing published channel, ruled rather than inferred.** For a viewAll-only principal a client that reads "no entry" as default-allow now reads an explicit `allowEdit: false` instead. Two pins asserting the old silence (`toBeUndefined` for the viewAll-only principal, one of them added by the framework#18931 PR that pinned this boundary while saying the pin was not a ruling that the silence was correct) are inverted on purpose under that ruling. Payload growth is the same one-entry-per-object framework#18931 accepted, now also for viewAll principals.

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

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,23 @@ function allPathsMounted(rawApp: any, paths: readonly string[]): boolean {
237237
);
238238
}
239239

240+
/**
241+
* Does the `'*'` entry carry the super-user READ bypass?
242+
*
243+
* ONE reading of that question for this whole file — {@link foldWildcardSuperUser}
244+
* asks it to decide whose `allowRead` it pulls true, and
245+
* {@link seedSuperUserRestrictedObjects} asks it to decide whom it seeds for, so
246+
* the seed can never materialise an entry for a principal the fold leaves false.
247+
* It is the same bypass the server itself applies: `PermissionEvaluator`'s
248+
* `wildcardSuperUser()` treats `viewAllRecords` and `modifyAllRecords` alike for
249+
* read (`allowRead` short-circuits on either), and only the modify bit reaches
250+
* the write axis.
251+
*/
252+
function wildcardGrantsSuperRead(objects: Record<string, any>): boolean {
253+
const wild = objects?.['*'];
254+
return wild?.viewAllRecords === true || wild?.modifyAllRecords === true;
255+
}
256+
240257
/**
241258
* Fold the `'*'` wildcard super-user grant into every per-object entry of a
242259
* `/me/permissions` `objects` map, mutating it in place.
@@ -263,7 +280,7 @@ function allPathsMounted(rawApp: any, paths: readonly string[]): boolean {
263280
export function foldWildcardSuperUser(objects: Record<string, any>): void {
264281
const wild = objects?.['*'];
265282
if (!wild) return;
266-
const superRead = wild.viewAllRecords === true || wild.modifyAllRecords === true;
283+
const superRead = wildcardGrantsSuperRead(objects);
267284
const superWrite = wild.modifyAllRecords === true;
268285
if (!superRead && !superWrite) return;
269286
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
@@ -443,7 +460,7 @@ export interface ApiExposureSchemaLike {
443460
}
444461

445462
/**
446-
* [#3391] Seed false-initialized per-object entries for a MODIFY-ALL super-user,
463+
* [#3391] Seed false-initialized per-object entries for a wildcard SUPER-USER,
447464
* for every registered object whose `apiMethods` whitelist tightens exposure.
448465
*
449466
* A super-user's grant is usually the `'*'` wildcard, not explicit per-object
@@ -453,11 +470,20 @@ export interface ApiExposureSchemaLike {
453470
* everything) and lets {@link annotateEffectiveApiOperations} attach the effective
454471
* set. Runs BEFORE fold.
455472
*
456-
* Guarded to `modifyAllRecords` super-users ONLY: for a viewAll-only caller,
457-
* materializing a `false` entry would flip the client's `check('edit')` from
458-
* "undefined → default-allow" to "explicit false → deny" — a scope-exceeding
459-
* behavior change. A modify-all caller is folded to `true` anyway, so seeding is
460-
* harmless there.
473+
* [#18990] Admitted by {@link wildcardGrantsSuperRead} — the READ bypass, so
474+
* BOTH super-user classes are seeded, and a plain wildcard grant carrying
475+
* neither bypass bit still is not. This pass used to be guarded to
476+
* `modifyAllRecords` alone, on the reading that materializing a `false` entry
477+
* for a viewAll-only caller would flip the client's `check('edit')` from
478+
* "undefined → default-allow" to "explicit false → deny". It does flip it, and
479+
* that flip is the POINT: the seed only ever touches objects with no explicit
480+
* entry (`objects[name]` below), and on those a viewAll-only principal really
481+
* can only read — so "explicit false" for edit is what is TRUE about it, while
482+
* the silence it replaces left the client rendering write and Export
483+
* affordances the server answers `403`. `allowRead` is pulled true by the same
484+
* fold for the same reason: `viewAllRecords` is a read bypass server-side too
485+
* (`PermissionEvaluator.checkObjectPermission`), so the entry is exactly as
486+
* broad as real enforcement, never broader.
461487
*
462488
* [#18931] A schema is skipped only when it needs NO annotation, which is the
463489
* predicate {@link annotateEffectiveApiOperations} itself applies: unrestricted
@@ -473,7 +499,7 @@ export function seedSuperUserRestrictedObjects(
473499
objects: Record<string, any>,
474500
allSchemas: readonly ApiExposureSchemaLike[],
475501
): void {
476-
if (objects?.['*']?.modifyAllRecords !== true) return;
502+
if (!wildcardGrantsSuperRead(objects)) return;
477503
// [#18931] The export slot annotate will read for an entry seeded here. A
478504
// seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser`
479505
// does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to
@@ -1075,10 +1101,11 @@ export function registerCurrentUserEndpoints(
10751101
// (sys_user → edit). Together these remove both the false-negative
10761102
// (admin sees sys_user editable) and the false-positive (admin does
10771103
// NOT see sys_member editable, matching the guard).
1078-
// [#3391] For a modify-all super-user, seed restricting objects
1079-
// absent from the merged map so fold pulls them true and annotate
1080-
// can attach their effective apiOperations. Guarded — a failure
1081-
// here must never drop the whole response.
1104+
// [#3391] For a wildcard super-user — [#18990] either bypass bit,
1105+
// not modify-all alone — seed restricting objects absent from the
1106+
// merged map so fold pulls what it pulls and annotate can attach
1107+
// their effective apiOperations. Guarded — a failure here must
1108+
// never drop the whole response.
10821109
try {
10831110
// The contract's registry view returns `unknown[]` (schema
10841111
// shape is engine-local); narrow to the slice this seeding

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

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { describe, it, expect } from 'vitest';
44
import {
55
annotateEffectiveApiOperations,
6+
foldWildcardSuperUser,
67
seedSuperUserRestrictedObjects,
78
type ApiExposureSchemaLike,
89
} from './current-user-endpoints.js';
@@ -189,10 +190,30 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => {
189190
expect(objects.open_obj).toBeUndefined();
190191
});
191192

192-
it('does not seed for a viewAll-only wildcard (avoids flipping check() to explicit deny)', () => {
193+
// [#18990] INVERTED on purpose. This used to read "does not seed for a
194+
// viewAll-only wildcard (avoids flipping check() to explicit deny)". The
195+
// ruling on #18990 (batch #159 item 1, letter A) settles that the flip is the
196+
// truth for this class, not an overreach: the seed only touches objects with
197+
// no explicit entry, and there a viewAll-only principal really can only read.
198+
it('seeds for a viewAll-only wildcard too — the read bypass is what admits a principal', () => {
193199
const objects: Record<string, any> = { '*': { viewAllRecords: true } }; // no modifyAllRecords
194200
seedSuperUserRestrictedObjects(objects, schemas);
201+
expect(objects.widget).toEqual({ allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false });
202+
expect(objects.locked).toBeDefined();
203+
// the export axis withholds `export` here (no `allowExport` on the
204+
// wildcard), which is its own reason to seed the unrestricted object
205+
expect(objects.open_obj).toBeDefined();
206+
});
207+
208+
// The other half of that predicate, and the reason it is the READ BYPASS
209+
// rather than "any wildcard": a plain wildcard grant carries no bypass bit,
210+
// `foldWildcardSuperUser` pulls nothing true for it, and a seeded entry would
211+
// therefore be an all-false claim with no server behaviour behind it.
212+
it('does not seed for a wildcard carrying neither bypass bit', () => {
213+
const objects: Record<string, any> = { '*': { allowRead: true, allowEdit: true } };
214+
seedSuperUserRestrictedObjects(objects, schemas);
195215
expect(objects.widget).toBeUndefined();
216+
expect(objects.locked).toBeUndefined();
196217
});
197218

198219
it('does not clobber an object already present in the map', () => {
@@ -277,12 +298,42 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => {
277298
expect(objects.crm_lead).toBeUndefined();
278299
});
279300

280-
it('does not reach a viewAll-only principal (the seed guard is unchanged)', () => {
281-
// Materialising a `false` entry for a caller the fold does NOT pull true
282-
// would flip the client's `check()` from default-allow to explicit deny.
283-
// #18931 widens WHICH schemas are considered, never WHICH principals.
284-
const objects: Record<string, any> = { '*': { viewAllRecords: true } };
301+
// [#18990] INVERTED on purpose, under the ruling on #18990 (batch #159
302+
// item 1, letter A). This case read "does not reach a viewAll-only
303+
// principal (the seed guard is unchanged)" and asserted `toBeUndefined`;
304+
// #18931 deliberately widened WHICH schemas are considered and left WHICH
305+
// principals alone, pinning that boundary while saying in the same breath
306+
// that the pin was not a ruling that the silence was correct. It is that
307+
// ruling that now says otherwise, so the pin moves with it.
308+
it('reaches a viewAll-only principal: read folded true, writes explicitly false', () => {
309+
const objects: Record<string, any> = { '*': { viewAllRecords: true } }; // no modifyAllRecords
310+
seedSuperUserRestrictedObjects(objects, unrestricted);
311+
foldWildcardSuperUser(objects);
312+
annotateEffectiveApiOperations(objects, (name) => unrestricted.find((s) => s.name === name));
313+
314+
// The four affordances, each pinned — the entry's VALUE is the whole
315+
// point of materialising it, so "an entry exists" is not the assertion.
316+
expect(objects.crm_lead).toBeDefined();
317+
expect(objects.crm_lead.allowRead).toBe(true); // the wildcard read bypass, folded
318+
expect(objects.crm_lead.allowEdit).toBe(false); // …and the write bits the fold leaves alone
319+
expect(objects.crm_lead.allowCreate).toBe(false);
320+
expect(objects.crm_lead.allowDelete).toBe(false);
321+
// The export axis withholds `export` for this principal exactly as it
322+
// does for the modify-all class above — one predicate, both classes.
323+
expect(objects.crm_lead.apiOperations).not.toContain('export');
324+
expect(objects.crm_lead.apiOperations).toEqual(
325+
['get', 'list', 'create', 'update', 'delete', 'upsert', 'bulk', 'aggregate', 'search', 'import'],
326+
);
327+
});
328+
329+
it('stays silent for a viewAll-only principal once it really may export', () => {
330+
// 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.
333+
const objects: Record<string, any> = { '*': { viewAllRecords: true, allowExport: true } };
285334
seedSuperUserRestrictedObjects(objects, unrestricted);
335+
foldWildcardSuperUser(objects);
336+
annotateEffectiveApiOperations(objects, (name) => unrestricted.find((s) => s.name === name));
286337
expect(objects.crm_lead).toBeUndefined();
287338
});
288339
});

0 commit comments

Comments
 (0)