Skip to content
2 changes: 1 addition & 1 deletion .changeset/18783-server-can-option-visibility.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/objectql': minor
'@objectstack/plugin-security': minor
Expand Down Expand Up @@ -34,6 +34,6 @@
- If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants".
- With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam.

**Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map still differs from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted, and an entry reached through a super-user wildcard carries no `transfer`.
**Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map still differs from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted. Its missing `transfer` is closed in this same release (`.changeset/20134-super-user-entries-every-bit.md`).

**No spec key, route or config key is added or removed.**
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 now applies annotate's own predicate**: resolve with the export slot annotate will read for the entry being seeded, and skip only an object that is unrestricted **and** keeps `export`. A seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser` does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to the same wildcard bit the seed read — the two passes cannot diverge again.
- **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 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 is still skipped, 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.

⚠️ **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.
17 changes: 17 additions & 0 deletions .changeset/20134-super-user-entries-every-bit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/core': minor
---

fix(core): an effective-map entry reached through a super-user `'*'` carries every bit the server grants, so `current_user.can(object, 'transfer')` agrees with `checkObjectPermission` for a platform admin (#20134)

`buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. For a subject holding a super-user wildcard — a `'*'` carrying `viewAllRecords` or `modifyAllRecords`, as `admin_full_access` and `organization_admin` do — its entries diverged from `PermissionEvaluator.checkObjectPermission` in three places, each in the refuse direction:

- **`transfer`.** The fold put only read, create, edit and delete on an entry. `modifyAllRecords` also grants `transfer` on the server, so `can(object, 'transfer')` answered `false` for `admin_full_access` on every object, and for the walled `organization_admin` on every object its own set does not name.
- **A super-read wildcard's own bits.** A `'*'` carrying `viewAllRecords` beside plain bits (`allowEdit`, `allowTransfer`, …) put only the read on an entry, so `can(object, 'edit')` answered `false` where the server edits.
- **A super-user wildcard carrying `allowExport`.** The super-user seed skipped every unrestricted object whose export stays allowed, because it needs no `apiOperations`. That left no entry at all, and `can()` reads an absent entry as "no grant", so every verb answered `false` on those objects.

**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`.

**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.
87 changes: 85 additions & 2 deletions packages/core/src/security/effective-object-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,11 @@ describe('[#20083] plain wildcard coverage', () => {
[{ objects: { '*': { ...WILD, viewAllRecords: true, modifyAllRecords: true } } }],
source,
);
// Seeded all-false, then folded: the super-user entry shape, not the wildcard's own bits.
expect(Object.keys(map.crm_account)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'apiOperations']);
// Seeded all-false, then folded: the super-user entry shape, not the wildcard's own key
// order — [#20134] with `allowTransfer`, which `modifyAllRecords` grants, appended by the
// per-set fold (the wildcard's own `allowTransfer: false` grants nothing and is not copied).
expect(Object.keys(map.crm_account)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'allowTransfer', 'apiOperations']);
expect(map.crm_account.allowTransfer).toBe(true);
// …and the private object is the seed's, as before.
expect(map.crm_secret).toMatchObject({ allowRead: true, allowEdit: true });
});
Expand All @@ -200,3 +203,83 @@ describe('[#20083] plain wildcard coverage', () => {
expect(map).toEqual({ '*': WILD });
});
});

/**
* [#20134] A SUPER-USER `'*'` — one carrying `viewAllRecords` or
* `modifyAllRecords` — puts on the map every bit it grants, per set, the way
* `PermissionEvaluator.checkObjectPermission` resolves it: the seed places an
* entry for every registered object the merge left absent, and the per-set
* fold reads each set's wildcard through the spec's `objectPermissionGrants`.
* The map used to hold only the four bits the merged fold pulls, so
* `current_user.can(object, 'transfer')` answered `false` for a platform admin
* the server lets transfer, a super-read wildcard lost its own plain bits, and
* a super-user wildcard carrying `allowExport` left every unrestricted object
* with no entry at all.
*
* The enforcement-side half — every verb, the shipped super-user sets, the
* real `can()` against the real evaluator — is pinned table-driven in
* plugin-security's `get-effective-object-permissions.test.ts`.
*/
describe('[#20134] super-user wildcard: every bit it grants, per set', () => {
const SCHEMAS: Record<string, any> = {
crm_account: { name: 'crm_account' },
crm_lead: { name: 'crm_lead', enable: { apiMethods: ['get', 'list'] } },
crm_secret: { name: 'crm_secret', access: { default: 'private' } },
};
const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] };

it('the write bypass carries `transfer` onto every entry, a private object\'s included', () => {
const map: any = buildEffectiveObjectPermissions(
[{ objects: { '*': { allowRead: true, allowCreate: true, modifyAllRecords: true } } }],
source,
);
for (const name of Object.keys(SCHEMAS)) {
expect(map[name], name).toMatchObject({ allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true });
}
});

it('a super-read wildcard keeps its own plain bits — and nothing it does not grant', () => {
const map: any = buildEffectiveObjectPermissions(
[{ objects: { '*': { viewAllRecords: true, allowEdit: true } } }],
source,
);
for (const name of Object.keys(SCHEMAS)) {
expect(map[name], name).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: false, allowDelete: false });
expect(map[name], name).not.toHaveProperty('allowTransfer');
}
});

it('a set that names the object contributes its explicit entry, never its own wildcard\'s grants', () => {
const map: any = buildEffectiveObjectPermissions(
[{ objects: { '*': { viewAllRecords: true, allowTransfer: true }, crm_account: { allowRead: true } } }],
source,
);
expect(map.crm_account).not.toHaveProperty('allowTransfer');
expect(map.crm_lead).toMatchObject({ allowRead: true, allowTransfer: true });
});

it('ANOTHER set\'s super-user wildcard widens a present entry, bit by bit', () => {
const map: any = buildEffectiveObjectPermissions(
[
{ objects: { crm_account: { allowRead: true } } },
{ objects: { '*': { viewAllRecords: true, allowTransfer: true, allowExport: true } } },
],
source,
);
// Unrestricted and export-allowed, so annotate has nothing to add to it.
expect(map.crm_account).toEqual({ allowRead: true, allowTransfer: true, allowExport: true });
});

it('a super-user wildcard carrying `allowExport` seeds every registered object — annotate keeps its own skip', () => {
const map: any = buildEffectiveObjectPermissions(
[{ objects: { '*': { allowRead: true, allowEdit: true, modifyAllRecords: true, allowExport: true } } }],
source,
);
expect(Object.keys(map)).toEqual(['*', 'crm_account', 'crm_lead', 'crm_secret']);
expect(map.crm_account).toMatchObject({ allowRead: true, allowEdit: true, allowTransfer: true, allowExport: true });
// An unrestricted object whose export stays allowed: an entry, and no operation set on it.
expect(map.crm_account).not.toHaveProperty('apiOperations');
// A narrowed one is annotated exactly as before, `export` kept.
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
});
});
Loading
Loading