Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/plugin-hono-server": patch
---
Expand All @@ -10,7 +10,7 @@

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.
2 changes: 1 addition & 1 deletion .changeset/18990-viewall-only-permissions-seed.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/plugin-hono-server': patch
---
Expand All @@ -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.
2 changes: 1 addition & 1 deletion .changeset/20134-super-user-entries-every-bit.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/core': minor
---
Expand All @@ -12,6 +12,6 @@

**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.
23 changes: 23 additions & 0 deletions .changeset/20135-api-operations-rest-door-parity.md
Original file line number Diff line number Diff line change
@@ -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`.
97 changes: 97 additions & 0 deletions packages/core/src/security/effective-object-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {
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']);
});
});
Loading
Loading