diff --git a/.changeset/18931-me-permissions-unrestricted-export-annotation.md b/.changeset/18931-me-permissions-unrestricted-export-annotation.md index 6eac37164a9..d49f56e33e1 100644 --- a/.changeset/18931-me-permissions-unrestricted-export-annotation.md +++ b/.changeset/18931-me-permissions-unrestricted-export-annotation.md @@ -10,7 +10,7 @@ The endpoint builds its per-object map in four passes — seed, fold, clamp, ann For that principal an unrestricted object got no entry, so annotate never saw it and the response said nothing about it at all. The client reads `apiOperations: undefined`, takes the default-allow path #3391 gave it, renders **Export**, and the click is refused. A sibling object declaring `apiMethods` got an entry, an `apiOperations` without `export`, and no button — the same principal, the same session, two answers. -- **The seed and annotate cannot diverge again.** Since #20134 the seed places an entry for every registered object a super-user wildcard reaches that the merge left without one, and `annotateEffectiveApiOperations` alone decides which entries carry `apiOperations`: an object that is unrestricted **and** keeps `export` gets none. +- **The seed and annotate cannot diverge again.** Since #20134 the seed places an entry for every registered object a super-user wildcard reaches that the merge left without one, and `annotateEffectiveApiOperations` alone decides which entries carry `apiOperations`: an object that is unrestricted **and** keeps `export` gets none — unless its `enable.apiEnabled` is `false`, which is annotated `[]` since #20135 because the REST door answers 404 for every verb on it. - **The export axis is the only axis this reaches.** Measured across the `enable` shapes an unrestricted object can carry: withholding `export` subtracts `export` and nothing else, and `mode` stays `unrestricted` either way — which is why the old `mode`-only guard could not tell the two cases apart. The CRUD axis needed no annotation and still gets none. - **What the response gains**: for such a principal, one entry per unrestricted object, each the full closure minus `export`. Its CRUD bits are folded `true` — the same answer the client already computed by falling back to `'*'`, now stated explicitly rather than inherited. - **Denial is unchanged.** `enforceExportPermission` → `security.canExport` still answers `403 EXPORT_NOT_PERMITTED`, and no request that was refused is now accepted. This is the affordance half: the channel that is supposed to tell the client now does. diff --git a/.changeset/18990-viewall-only-permissions-seed.md b/.changeset/18990-viewall-only-permissions-seed.md index 261e412fe05..727fbffa2a5 100644 --- a/.changeset/18990-viewall-only-permissions-seed.md +++ b/.changeset/18990-viewall-only-permissions-seed.md @@ -9,6 +9,6 @@ - **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. - **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. - **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. -- **`apiOperations` is attached through the predicate already shared with the modify-all class** — an unrestricted object whose export stays allowed still gets no `apiOperations` (the entry itself is seeded since #20134), because for it the client's default-allow path is already right. +- **`apiOperations` is attached through the predicate already shared with the modify-all class** — an unrestricted object whose export stays allowed still gets no `apiOperations` (the entry itself is seeded since #20134), because for it the client's default-allow path is already right — except an object with `enable.apiEnabled: false`, annotated `[]` since #20135 because the REST door refuses every verb on it. ⚠️ **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. diff --git a/.changeset/20134-super-user-entries-every-bit.md b/.changeset/20134-super-user-entries-every-bit.md index a113da40a18..d2ee6a58648 100644 --- a/.changeset/20134-super-user-entries-every-bit.md +++ b/.changeset/20134-super-user-entries-every-bit.md @@ -12,6 +12,6 @@ fix(core): an effective-map entry reached through a super-user `'*'` carries eve **What changes.** The seed now places an entry for every registered object the merged map does not already carry. A new step after the fold then applies each set's super-user `'*'` to every entry that set does not name, and sets every bit the spec's `objectPermissionGrants` says that wildcard grants: `transfer` through `modifyAllRecords`, the wildcard's own plain bits, and its `allowExport`. This is the per-set reading `checkObjectPermission` applies. A set that names an object keeps its explicit entry as its whole answer for that object, and a private object is covered, as on the server. Only `true` bits are set. The step runs before the managed-write clamp, which still narrows create, edit and delete on a guarded managed object. No exported name or type changes. -**What a reader of `/auth/me/permissions` sees.** For a subject holding a super-user wildcard, entries gain `true` bits (`allowTransfer`, and the wildcard's own plain and export bits). Where that subject's wildcards also grant `allowExport`, the map gains an entry for each registered unrestricted object that had none. That entry carries no `apiOperations`, exactly as the operation channel said nothing about the object before, so a client's default-allow path for the operation set is unchanged. Nothing is removed and no `true` bit turns `false`. The response is byte-identical for a subject holding no super-user wildcard: `member_default` alone, and `viewer_readonly` or `organization_admin_no_bypass` beside it. The response shape, its keys and the route are unchanged. On the write path, a `can(object, 'transfer')`-gated option or default is now admitted for these subjects wherever the server grants `transfer`. +**What a reader of `/auth/me/permissions` sees.** For a subject holding a super-user wildcard, entries gain `true` bits (`allowTransfer`, and the wildcard's own plain and export bits). Where that subject's wildcards also grant `allowExport`, the map gains an entry for each registered unrestricted object that had none. That entry carries no `apiOperations` — unless the object declares `enable.apiEnabled: false`, which is annotated `[]` since #20135 — so for every other such object the operation channel says what it said before and a client's default-allow path is unchanged. Nothing is removed and no `true` bit turns `false`. The response is byte-identical for a subject holding no super-user wildcard: `member_default` alone, and `viewer_readonly` or `organization_admin_no_bypass` beside it. The response shape, its keys and the route are unchanged. On the write path, a `can(object, 'transfer')`-gated option or default is now admitted for these subjects wherever the server grants `transfer`. **Still broader than the server, unchanged here.** The fold still folds the merged super-user bits into an entry the super-user set itself names narrower. It also still pulls `allowCreate` on `modifyAllRecords` alone, which the server does not grant. Both over-grants are left exactly as they were. diff --git a/.changeset/20135-api-operations-rest-door-parity.md b/.changeset/20135-api-operations-rest-door-parity.md new file mode 100644 index 00000000000..fdd872dfc3d --- /dev/null +++ b/.changeset/20135-api-operations-rest-door-parity.md @@ -0,0 +1,23 @@ +--- +'@objectstack/core': patch +--- + +fix(core): the `apiOperations` of `/auth/me/permissions` offers only what the REST door serves — nothing on an object with `enable.apiEnabled: false`, and `export` only where the export door admits it (#20135) + +`buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`). Its last pass, `annotateEffectiveApiOperations`, attaches each entry's `apiOperations`: the operation set a client renders, where an absent annotation means default-allow. That set disagreed with the REST door in two places, both in the direction of offering an operation the door refuses: + +- **`enable.apiEnabled: false`.** The door answers `404 OBJECT_API_DISABLED` for every verb on such an object, whatever `apiMethods` says. The annotation ignored the switch. It carried the object's whole closure, or, for a subject whose export stays allowed on an otherwise unrestricted object, no annotation at all, which a client reads as default-allow. +- **The export slot.** It fell back to the merged `'*'` export bit whenever an entry carried no `allowExport` of its own. The merged bit cannot say which set's wildcard reaches which object, so `export` was offered on a private object that only a plain `'*': { allowExport: true }` reached (a plain wildcard never covers a private object), and on an object that the exporting set itself names without the grant. The export door answers both `403 EXPORT_NOT_PERMITTED`. + +Clause-②: no + +**What changes.** The annotation now asks the door's own two questions, entry by entry: + +- the object half is the spec's `canServeApiOperation`, the boolean face of `apiExposureDenialReason`, which `enforceApiAccess` in `@objectstack/rest` turns into its 404 and 405. An object with `enable.apiEnabled: false` is annotated `apiOperations: []`; +- the export half is the entry's own grant as the export door reads it: read and `allowExport`, through the spec's `objectPermissionGrants`. The coverage passes that run first have already put each set's `'*'` on exactly the entries that set reaches, per posture. + +The entry of an API-disabled object stays in the map with its grants: `apiEnabled` closes the API, not the data, and `current_user.can()` reads those grants on the server. Which entries carry an annotation keeps its rule: an unrestricted object whose every operation is still served gets none. + +**What a reader of `/auth/me/permissions` sees.** An object declaring `enable.apiEnabled: false` now reads `apiOperations: []` in every entry. That includes an unrestricted object whose export stays allowed, which used to carry no annotation at all; this release's notes for #18931, #18990 and #20134 name that exception. `export` leaves the annotation of a private object reached only through a plain wildcard export grant, and of an object named without the grant by the set whose wildcard carries it. A private, unrestricted object that lost its `export` this way is now annotated with its closure minus `export`. Nothing else moves: no entry is added or removed, no `allow*` bit changes, and no annotation gains an operation. The response shape, its keys and the route are unchanged. The REST door is unchanged, so no request changes its answer. + +**For a caller of the exported helper.** `annotateEffectiveApiOperations` keeps its signature. It no longer reads the map's `'*'` entry: it reads each entry's own grants, which `buildEffectiveObjectPermissions` puts there. A map composed some other way should be built with `buildEffectiveObjectPermissions`. diff --git a/packages/core/src/security/effective-object-permissions.test.ts b/packages/core/src/security/effective-object-permissions.test.ts index cfe74b39a0c..cd0367c6311 100644 --- a/packages/core/src/security/effective-object-permissions.test.ts +++ b/packages/core/src/security/effective-object-permissions.test.ts @@ -283,3 +283,100 @@ describe('[#20134] super-user wildcard: every bit it grants, per set', () => { expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); }); }); + +/** + * [#20135] `apiOperations` is what the REST door SERVES this subject — the + * door's own two questions, asked per entry: + * + * - the object half, `canServeApiOperation` (the spec's + * `apiExposureDenialReason`, which `enforceApiAccess` turns into its 404 / + * 405): `enable.apiEnabled: false` refuses every operation, whatever + * `apiMethods` says, so the entry is annotated `[]` — never left bare, which + * a client reads as default-allow; + * - the user half, the export door's `read ∧ allowExport` on the entry + * itself — per set and posture, because the coverage passes put each set's + * `'*'` only where that set reaches — never the MERGED `'*'` export bit. + * + * Which entries carry the annotation is unchanged in rule: an unrestricted + * object whose every operation is still served gets none. The route- and + * member-level parity against the door is pinned in plugin-security's + * `get-effective-object-permissions.test.ts`. + */ +describe('[#20135] apiOperations says what the REST door serves', () => { + const SCHEMAS: Record = { + crm_account: { name: 'crm_account' }, + crm_exposed: { name: 'crm_exposed', enable: { apiEnabled: true } }, + crm_lead: { name: 'crm_lead', enable: { apiMethods: ['get', 'list'] } }, + crm_hidden: { name: 'crm_hidden', enable: { apiEnabled: false } }, + crm_hidden_lead: { name: 'crm_hidden_lead', enable: { apiEnabled: false, apiMethods: ['get', 'list'] } }, + crm_secret: { name: 'crm_secret', access: { default: 'private' } }, + }; + const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] }; + const EVERY = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; + /** `admin_full_access`' wildcard shape: both bypass bits, and (#8681) no `allowExport`. */ + const SUPER = { ...EVERY, viewAllRecords: true, modifyAllRecords: true }; + + it('an API-disabled object is annotated `[]` — for a subject that may export, which used to get no annotation at all', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { '*': { ...EVERY, allowExport: true } } }], source); + expect(map.crm_hidden.apiOperations).toEqual([]); + expect(map.crm_hidden_lead.apiOperations).toEqual([]); + // The controls: unrestricted and exposed, every operation still served — no annotation. + expect(map.crm_account).not.toHaveProperty('apiOperations'); + expect(map.crm_exposed).not.toHaveProperty('apiOperations'); + // …and an `apiMethods` subset says exactly what it said before. + expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); + }); + + it('an API-disabled object is annotated `[]` — for a subject the export axis narrows, which used to get the full closure', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { '*': SUPER } }], source); + expect(map.crm_hidden.apiOperations).toEqual([]); + expect(map.crm_hidden_lead.apiOperations).toEqual([]); + // The controls: the export axis alone, which takes `export` and nothing else. + expect(map.crm_account.apiOperations).toEqual( + ['get', 'list', 'create', 'update', 'delete', 'upsert', 'bulk', 'aggregate', 'search', 'import'], + ); + expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']); + }); + + it('the entry itself stays: `apiEnabled` closes the API, not the data the server lets this subject write', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { '*': SUPER } }], source); + expect(map.crm_hidden).toMatchObject({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true }); + }); + + it('a private object reached only through a PLAIN `*` export grant is not annotated `export`', () => { + // The platform admin beside a plain export-only wildcard: the plain `'*'` never covers a private + // object, and the super-user `'*'` carries no `allowExport`, so the export door refuses there. + const map: any = buildEffectiveObjectPermissions( + [{ objects: { '*': SUPER } }, { objects: { '*': { allowExport: true } } }], + source, + ); + expect(map.crm_secret.apiOperations).toEqual( + ['get', 'list', 'create', 'update', 'delete', 'upsert', 'bulk', 'aggregate', 'search', 'import'], + ); + // The public objects the plain wildcard DOES cover keep `export` — an unrestricted one keeps its whole closure. + expect(map.crm_account).not.toHaveProperty('apiOperations'); + expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); + }); + + it('an object the exporting set itself names without the grant is not annotated `export`', () => { + // For that set its explicit entry is its whole answer, and no other set grants export. + const map: any = buildEffectiveObjectPermissions( + [{ objects: { '*': { allowRead: true, allowExport: true }, crm_lead: { allowRead: true } } }], + source, + ); + expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']); + }); + + it('an export grant without read is not annotated `export`: the export door is read ∧ grant', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { crm_lead: { allowExport: true } } }], source); + expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']); + }); + + it('read and export arriving from two different sets still annotate `export`, as the export door admits', () => { + const map: any = buildEffectiveObjectPermissions( + [{ objects: { crm_lead: { allowRead: true } } }, { objects: { '*': { allowExport: true } } }], + source, + ); + expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); + }); +}); diff --git a/packages/core/src/security/effective-object-permissions.ts b/packages/core/src/security/effective-object-permissions.ts index 0955a452451..da81b3192bb 100644 --- a/packages/core/src/security/effective-object-permissions.ts +++ b/packages/core/src/security/effective-object-permissions.ts @@ -35,6 +35,7 @@ import { resolveEffectiveApiMethods, effectiveOperationsArray, + canServeApiOperation, type EnableLike, } from '@objectstack/spec/data'; import { objectPermissionGrants, type EffectiveObjectPermission } from '@objectstack/spec/security'; @@ -435,38 +436,63 @@ export function seedSuperUserRestrictedObjects( * fold + clamp so the annotation sits alongside the final CRUD affordances, and * {@link seedSuperUserRestrictedObjects} applies the same predicate so a * wildcard-only principal has an entry here to annotate ([#18931]). + * + * [#20135] The set is what the REST door SERVES this subject, read through the + * door's own two questions rather than a second spelling of either: + * + * - the OBJECT half is `canServeApiOperation` — the boolean face of the + * spec's `apiExposureDenialReason`, the one function + * `@objectstack/rest`'s `enforceApiAccess` turns into its 404 / 405. It + * judges `enable.apiEnabled === false` FIRST and for every operation, so an + * API-disabled object is annotated `[]`: the door answers + * `404 OBJECT_API_DISABLED` for every verb, whatever `apiMethods` says, and + * an entry left without an annotation would send the client down its + * default-allow path — every operation offered, every one refused. The + * entry itself stays: `apiEnabled` closes the API, not data access, and the + * map's CRUD bits are what `current_user.can()` reads on the server; + * - the USER half is the export door's own conjunction on this entry, + * `objectPermissionGrants(entry, 'allowExport')` — read ∧ an opt-in export + * grant. By the time this pass runs every set's `'*'` has been put on the + * entries it covers for THAT set and posture (plain coverage, then the + * per-set super-user fold), so the entry already is + * `PermissionEvaluator.checkObjectPermission('export', …, { isPrivate })`'s + * answer. It used to fall back to the MERGED `'*'` export bit, which covers + * what no single set covers: a private object reached only through a plain + * `'*': { allowExport: true }` (a plain wildcard never covers a private + * object), and an object the exporting set itself names without the grant. + * Both were annotated `export` and answered `403 EXPORT_NOT_PERMITTED`. + * + * Which entries carry the annotation is unchanged in rule: only an + * unrestricted object that keeps its whole closure — every operation served, + * `export` included — gets none. */ export function annotateEffectiveApiOperations( objects: Record, schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined, ): void { - // [#3544] The `'*'` entry's export grant is the FALLBACK for objects that do - // not carry one of their own. The merge keeps `'*'` and named objects as - // independent keys, but the server evaluator does not: its - // `resolveObjectPermission` falls back to the wildcard whenever a set has no - // explicit entry for the object, so an admin set granting export wholesale - // via `'*': { allowExport: true }` really does grant it per-object. Reading - // the wildcard here keeps the button the client shows and the request the - // server accepts in agreement — the same class of client/server divergence - // `foldWildcardSuperUser` exists to close, on the export axis. - const wildExport = objects?.['*']?.allowExport; for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { if (obj === '*' || !acc) continue; const schema = schemaOf(obj); if (!schema) continue; // schema missing → no annotation (client falls back) + const enable = schema.enable ?? undefined; // [#3544] User-level export axis: `export` derives from `list ∧ this - // grant`. OPT-IN — only an explicit `true` (on the object entry, else - // inherited from `'*'`) allows export; unset and `false` both withhold - // it, and the super-user bits do NOT imply it. - const exportBit = acc.allowExport ?? wildExport; - const userExportAllowed = exportBit === true; - const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); - // Annotate when the object tightens via `apiMethods`, OR when the export + // grant`. OPT-IN — only an explicit `true` allows export; unset and + // `false` both withhold it, and the super-user bits do NOT imply it. + // [#20135] Read off THIS entry, as the export door reads it (see above). + const userExportAllowed = objectPermissionGrants(acc as EffectiveObjectPermission, 'allowExport'); + const eff = resolveEffectiveApiMethods(enable, { userExportAllowed }); + const closure = effectiveOperationsArray(eff); + // [#20135] Only what the door itself admits: `apiEnabled: false` empties + // the set; any other `enable` leaves the closure as it is. + const served = closure.filter((operation) => canServeApiOperation(enable, operation)); + // Annotate when the object tightens via `apiMethods`, when the export // axis removes `export` from an otherwise-open object (so the client - // hides the Export button). An unrestricted object with export still - // allowed needs no annotation — the client keeps its default-allow path. - if (eff.mode === 'unrestricted' && userExportAllowed) continue; - acc.apiOperations = effectiveOperationsArray(eff); + // hides the Export button), OR when the door serves less than the closure + // (an API-disabled object). An unrestricted object with every operation + // still served needs no annotation — the client keeps its default-allow + // path. + if (eff.mode === 'unrestricted' && userExportAllowed && served.length === closure.length) continue; + acc.apiOperations = served; } } diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts index e1e7ea6a8f9..ab113b718ac 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts @@ -36,11 +36,16 @@ const RESOLVED = [ { name: 'sales', objects: { deal: { allowRead: true, allowEdit: false } }, fields: {} }, ]; -/** Registered schemas: plain, better-auth-managed, and one whose `apiMethods` tighten exposure. */ +/** + * Registered schemas: plain, better-auth-managed, one whose `apiMethods` tighten exposure, + * [#20135] one with its API switched off, and one private. + */ const SCHEMAS: Record = { deal: { name: 'deal' }, sys_member: { name: 'sys_member', managedBy: 'better-auth' }, report: { name: 'report', enable: { apiMethods: ['get', 'list'] } }, + hidden: { name: 'hidden', enable: { apiEnabled: false } }, + vault: { name: 'vault', access: { default: 'private' } }, }; const ql = { @@ -116,6 +121,31 @@ describe('[#18783] /auth/me/permissions `objects` is the one effective-map funct expect(body.objects.sys_member).toMatchObject({ allowRead: true, allowEdit: false }); // the clamp still has the last word }); + it('[#20135] apiOperations offers what the REST door serves — the same bytes', async () => { + // The platform admin's super-user wildcard beside a plain export-only one. + const resolved = [ + RESOLVED[0], + { name: 'exporter', objects: { '*': { allowExport: true } }, fields: {} }, + ]; + const body: any = await (await mount(resolved).request(`http://localhost${ME_PERMISSIONS}`)).json(); + const expected = buildEffectiveObjectPermissions(resolved, { + allSchemas: () => ql.registry.getAllObjects(), + schemaOf: (name) => ql.getSchema(name), + }); + expect(JSON.stringify(body.objects)).toBe(JSON.stringify(expected)); + // `enable.apiEnabled: false`: the door answers 404 OBJECT_API_DISABLED for every verb, so + // nothing is offered — while the entry keeps the grants the data plane honours. + expect(body.objects.hidden.apiOperations).toEqual([]); + expect(body.objects.hidden).toMatchObject({ allowRead: true, allowEdit: true }); + // A private object: the plain `'*'` does not reach it and the super-user `'*'` grants no + // export, so the export door answers 403 EXPORT_NOT_PERMITTED — and export is not offered… + expect(body.objects.vault.apiOperations).toBeDefined(); + expect(body.objects.vault.apiOperations).not.toContain('export'); + // …while a public object the plain `'*'` covers keeps its whole closure, export included. + expect(body.objects.deal).not.toHaveProperty('apiOperations'); + expect(body.objects.report.apiOperations).toContain('export'); + }); + it('keeps the rest of the envelope on its own merges', async () => { const body: any = await (await mount().request(`http://localhost${ME_PERMISSIONS}`)).json(); expect(body.permissionSets).toEqual(['ops_admin', 'sales']); diff --git a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts index 1a29ebde30a..070606df33e 100644 --- a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts +++ b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { buildEffectiveObjectPermissions } from '@objectstack/core'; import { annotateEffectiveApiOperations, foldWildcardSuperUser, @@ -45,6 +46,44 @@ describe('annotateEffectiveApiOperations (#3391)', () => { expect(objects.locked.apiOperations).toEqual([]); }); + // [#20135] The REST door answers `404 OBJECT_API_DISABLED` for every verb on + // an object with `enable.apiEnabled: false`, whatever `apiMethods` says and + // whatever the caller may export — the spec's `apiExposureDenialReason` + // judges it first. An entry left bare reads as default-allow to the client. + describe('an API-disabled object (#20135)', () => { + it('is annotated `[]` even when it is otherwise unrestricted and export is granted', () => { + const objects: Record = { hidden: { allowRead: true, allowExport: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + hidden: { name: 'hidden', enable: { apiEnabled: false } }, + })); + expect(objects.hidden.apiOperations).toEqual([]); + }); + + it('is annotated `[]` when the export axis narrows it, not the closure minus export', () => { + const objects: Record = { hidden: { allowRead: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + hidden: { name: 'hidden', enable: { apiEnabled: false } }, + })); + expect(objects.hidden.apiOperations).toEqual([]); + }); + + it('is annotated `[]` whatever its `apiMethods` subset says', () => { + const objects: Record = { hidden: { allowRead: true, allowExport: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + hidden: { name: 'hidden', enable: { apiEnabled: false, apiMethods: ['get', 'list'] } }, + })); + expect(objects.hidden.apiOperations).toEqual([]); + }); + + it('`apiEnabled: true` is the control: nothing withheld, no annotation', () => { + const objects: Record = { shown: { allowRead: true, allowExport: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + shown: { name: 'shown', enable: { apiEnabled: true } }, + })); + expect('apiOperations' in objects.shown).toBe(false); + }); + }); + it('skips the wildcard entry', () => { const objects: Record = { '*': { modifyAllRecords: true }, widget: { allowRead: true } }; annotateEffectiveApiOperations(objects, schemaOf({ @@ -123,13 +162,16 @@ describe('annotateEffectiveApiOperations (#3391)', () => { expect('apiOperations' in objects.deal).toBe(false); }); - // The merge keeps `'*'` and named objects as independent keys, but the - // SERVER evaluator does not — `resolveObjectPermission` falls back to the - // wildcard for any object a set has no explicit entry for. Reading it here - // too is what keeps the shown button and the accepted request the same - // decision; without it an admin's `'*': {allowExport:true}` would have its - // Export button hidden on every object it never names explicitly. - it("inherits the '*' export grant when the object entry declares none", () => { + // [#20135] INVERTED on purpose. This read "inherits the '*' export grant + // when the object entry declares none" and asserted `export` here. The + // merged `'*'` cannot say WHICH set's wildcard reaches WHICH object: the + // server's `resolveObjectPermission` answers per set — a set's explicit + // entry is its whole answer, and a plain wildcard never covers a private + // object — so the fallback annotated `export` on objects the export door + // answers `403 EXPORT_NOT_PERMITTED`. Each set's `'*'` now reaches the + // entries it covers upstream, in `buildEffectiveObjectPermissions`' + // coverage passes (the next case), and this pass reads the entry alone. + it("reads the entry's own export grant, never the map's '*'", () => { const objects: Record = { '*': { allowRead: true, allowExport: true }, deal: { allowRead: true }, // no allowExport of its own @@ -137,7 +179,26 @@ describe('annotateEffectiveApiOperations (#3391)', () => { annotateEffectiveApiOperations(objects, schemaOf({ deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } }, })); - expect(objects.deal.apiOperations).toContain('export'); + expect(objects.deal.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']); + }); + + it("a '*' export grant reaches an object through the composition, exactly where the server resolves it", () => { + const schemas: Record = { + deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } }, + }; + const source = { allSchemas: () => Object.values(schemas), schemaOf: (n: string) => schemas[n] }; + // ANOTHER set's wildcard: it covers `deal` for that set, so the export door admits. + const other: any = buildEffectiveObjectPermissions( + [{ objects: { '*': { allowRead: true, allowExport: true } } }, { objects: { deal: { allowRead: true } } }], + source, + ); + expect(other.deal.apiOperations).toContain('export'); + // The SAME set names `deal` without the grant: its explicit entry is its whole answer there. + const same: any = buildEffectiveObjectPermissions( + [{ objects: { '*': { allowRead: true, allowExport: true }, deal: { allowRead: true } } }], + source, + ); + expect(same.deal.apiOperations).not.toContain('export'); }); it("an explicit per-object allowExport:false overrides a '*' grant", () => { @@ -244,10 +305,13 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => { expect(objects.locked.apiOperations).toEqual([]); }); + // [#20135] Through the composition: the wildcard's `allowExport` reaches the + // seeded entry in the per-set super-user fold, and annotate reads the entry. it('end-to-end: a super-user wildcard CARRYING the export grant keeps export', () => { - const objects: Record = { '*': { modifyAllRecords: true, allowExport: true } }; - seedSuperUserRestrictedObjects(objects, schemas); - annotateEffectiveApiOperations(objects, (name) => schemas.find((s) => s.name === name)); + const objects: any = buildEffectiveObjectPermissions([{ objects: { '*': { modifyAllRecords: true, allowExport: true } } }], { + allSchemas: () => schemas, + schemaOf: (name) => schemas.find((s) => s.name === name), + }); expect(objects.widget.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); }); @@ -298,10 +362,14 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => { // The control: same schema, same super-user bits, `allowExport` granted. // Nothing is withheld, so there is no `apiOperations` to say and the // client's default-allow path for the operation set is correct. - const objects: Record = wildcardOnlyAdmin(); - objects['*'].allowExport = true; - seedSuperUserRestrictedObjects(objects, unrestricted); - annotateEffectiveApiOperations(objects, (name) => unrestricted.find((s) => s.name === name)); + // [#20135] Through the composition: the grant reaches the entry in the + // per-set super-user fold, and annotate reads the entry. + const wildcard: Record = wildcardOnlyAdmin(); + wildcard['*'].allowExport = true; + const objects: any = buildEffectiveObjectPermissions([{ objects: wildcard }], { + allSchemas: () => unrestricted, + schemaOf: (name) => unrestricted.find((s) => s.name === name), + }); expect(objects.crm_lead).toBeDefined(); expect(objects.crm_lead).not.toHaveProperty('apiOperations'); }); @@ -339,10 +407,11 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => { // export stays allowed" — is annotate's skip, and it is the SAME skip the // modify-all control above pins; [#20134] the entry itself is no longer // skipped, so `can()` reads the read the server grants. - const objects: Record = { '*': { viewAllRecords: true, allowExport: true } }; - seedSuperUserRestrictedObjects(objects, unrestricted); - foldWildcardSuperUser(objects); - annotateEffectiveApiOperations(objects, (name) => unrestricted.find((s) => s.name === name)); + // [#20135] Through the composition, as the case above. + const objects: any = buildEffectiveObjectPermissions([{ objects: { '*': { viewAllRecords: true, allowExport: true } } }], { + allSchemas: () => unrestricted, + schemaOf: (name) => unrestricted.find((s) => s.name === name), + }); expect(objects.crm_lead).toMatchObject({ allowRead: true, allowEdit: false }); expect(objects.crm_lead).not.toHaveProperty('apiOperations'); }); diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index b3e9cdc71e2..0ea90cbfed6 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -33,6 +33,7 @@ import { buildEffectiveObjectPermissions } from '@objectstack/core'; import { ExpressionEngine, toEvalPermissions } from '@objectstack/formula'; import { SysAttachment, SysMember, SysSecret, SysUser, SysUserPreference } from '@objectstack/platform-objects'; import type { ISecurityService } from '@objectstack/spec/contracts'; +import { canServeApiOperation } from '@objectstack/spec/data'; import { OBJECT_PERMISSION_VERB_NAMES, PermissionSetSchema, @@ -386,6 +387,11 @@ describe('[#20083] parity: can() over the member\'s map answers what checkObject crm_account: { allowRead: true }, }), ], + // [#20135] The export slot's own shapes. + 'platform admin beside a plain export-only wildcard': [shipped('admin_full_access'), authored('exporter', { '*': { allowExport: true } })], + 'one set: an exporting wildcard AND an explicit entry without the grant': [ + authored('same_export', { '*': { allowRead: true, allowExport: true }, crm_lead: { allowRead: true } }), + ], }; /** @@ -512,6 +518,87 @@ describe('[#20083] parity: can() over the member\'s map answers what checkObject expect(map.crm_lead.apiOperations).toEqual(expect.arrayContaining(['get', 'list', 'export'])); }); + /** + * [#20135] The `apiOperations` COLUMN — the map's operation set, read the way + * a client reads it (absent = default-allow), against what the REST door + * serves, for every subject above × every registered object its map carries + * an entry for × every operation the door gates by name. The door is asked + * its own two questions: + * + * - the object half, `canServeApiOperation` — the boolean face of the spec's + * `apiExposureDenialReason`, which `@objectstack/rest`'s `enforceApiAccess` + * turns into `404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED` + * (this package takes no dependency on the transport, so the door's decision + * function is asked, not its envelope); + * - the user half on `export`, the security member's own `canExport` — what + * `enforceExportPermission` asks before its `403 EXPORT_NOT_PERMITTED`. + * + * `offeredRefused` is the security direction — an operation the client offers + * and the door refuses — and `servedHidden` its converse. Both must be empty. + * An object with no entry is left out: whether the map carries an entry at all + * is the seed's question, not this column's. + * + * It used to fail two ways: an `enable.apiEnabled: false` object was annotated + * with its whole closure, or not at all, while the door answers 404 for every + * verb; and the export slot fell back to the MERGED `'*'` export bit, which + * offered `export` on a private object only a plain wildcard reached, and on + * an object the exporting set itself names without the grant. + */ + const DOOR_OPERATIONS = ['get', 'list', 'create', 'update', 'delete', 'bulk', 'import', 'export'] as const; + + for (const [label, sets] of Object.entries(SUBJECTS)) { + it(`[#20135] ${label}: apiOperations offers exactly what the REST door serves`, async () => { + const { svc, plugin } = await locate({ schemas: REGISTERED }); + vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets); + const context = { userId: USER.id }; + const map: any = await svc.getEffectiveObjectPermissions!(context); + + const offeredRefused: string[] = []; + const servedHidden: string[] = []; + let cells = 0; + for (const schema of Object.values(REGISTERED)) { + const entry = map[schema.name]; + if (!entry) continue; + for (const operation of DOOR_OPERATIONS) { + const served = canServeApiOperation(schema.enable, operation) + && (operation !== 'export' || (await svc.canExport!(schema.name, context))); + const offered = entry.apiOperations === undefined || entry.apiOperations.includes(operation); + cells += 1; + if (offered && !served) offeredRefused.push(`${schema.name}.${operation}`); + if (served && !offered) servedHidden.push(`${schema.name}.${operation}`); + } + } + // Every registered entry the map carries was scored (a subject granted nothing carries none). + expect(cells).toBe(Object.keys(map).filter((name) => name in REGISTERED).length * DOOR_OPERATIONS.length); + expect({ offeredRefused, servedHidden }).toEqual({ offeredRefused: [], servedHidden: [] }); + }); + } + + it('[#20135] the reported cases, spelled out', async () => { + const context = { userId: USER.id }; + // An API-disabled object: the platform admin's entry stays, and offers nothing. + const admin = SUBJECTS['platform admin']; + const a = await locate({ schemas: REGISTERED }); + vi.spyOn(a.plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(admin); + const adminMap: any = await a.svc.getEffectiveObjectPermissions!(context); + for (const operation of DOOR_OPERATIONS) expect(canServeApiOperation(REGISTERED.crm_hidden.enable, operation), operation).toBe(false); + expect(adminMap.crm_hidden.apiOperations).toEqual([]); + expect(adminMap.crm_hidden).toMatchObject({ allowRead: true, allowEdit: true }); + + // The private export: `admin_full_access` beside a plain `'*': { allowExport: true }`. + const sets = SUBJECTS['platform admin beside a plain export-only wildcard']; + const b = await locate({ schemas: REGISTERED }); + vi.spyOn(b.plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets); + const map: any = await b.svc.getEffectiveObjectPermissions!(context); + expect(evaluator.checkObjectPermission('export', SysSecret.name, sets, { isPrivate: true })).toBe(false); + expect(await b.svc.canExport!(SysSecret.name, context)).toBe(false); + expect(map[SysSecret.name].apiOperations).not.toContain('export'); + expect(map.crm_secret.apiOperations).not.toContain('export'); + // …and a public object the plain wildcard covers keeps it, on both sides. + expect(await b.svc.canExport!('crm_lead', context)).toBe(true); + expect(map.crm_lead.apiOperations).toContain('export'); + }); + it('the wall-less org admin\'s reported case, spelled out: edit on an app object reached only through `*`', async () => { const sets = SUBJECTS['wall-less org admin']; const { svc, plugin } = await locate({ schemas: REGISTERED });