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
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. Its missing `transfer` is closed in this same release (`.changeset/20134-super-user-entries-every-bit.md`).
**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 also differed from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower read as granted, which is closed in this same release (`.changeset/20136-super-user-fold-per-set.md`). 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 @@ -12,5 +12,5 @@

- **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.
- **What the response gains**: for such a principal, one entry per unrestricted object, each the full closure minus `export`. Its CRUD bits are folded to what its wildcard grants (all four for the built-in admin sets) — 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/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 @@ -14,4 +14,4 @@

**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.
**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 by this change, and both are closed in this same release (`.changeset/20136-super-user-fold-per-set.md`).
20 changes: 20 additions & 0 deletions .changeset/20136-super-user-fold-per-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/core': patch
---

fix(core): the effective object-permission map grants no cell the server refuses for a super-user subject — a super-user set's own narrower entry is that set's answer, and `modifyAllRecords` alone grants no create (#20136)

`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` — the map granted cells that `PermissionEvaluator.checkObjectPermission` refuses. Before building each set's own contribution, it ran a fold over the MERGED map that put the merged bypass bits on every entry:

- **Into an entry the super-user set names itself.** The server answers each set with its explicit entry for the object when it has one, so the set's wildcard never reaches that object. The walled `organization_admin` names `sys_position`, `sys_permission_set`, `sys_position_permission_set`, `sys_user_permission_set` and `sys_user_position` read-only, and the identity tables write-denied; the map granted create, edit and delete on the first five anyway, and edit on `sys_organization`. With `member_default` that was 38 cells, and 44 without it (edit on `sys_user` and `sys_api_key` as well). An explicit `{}` entry read as readable and writable. An explicit entry granting `allowExport` without read read as readable and exportable, so `apiOperations` offered `export` where the export door answers `403 EXPORT_NOT_PERMITTED`.
- **`allowCreate` on `modifyAllRecords` alone.** The spec's `objectPermissionGrants` gives the write bypass no create cell, and the server grants none. A `'*': { modifyAllRecords: true }` without `allowCreate` read `create` and `import` as granted on every object the managed-write clamp does not cover.

On the write path this failed OPEN: an option gated on `current_user.can('sys_position', 'edit')` was admitted for the walled `organization_admin`, whom the server refuses that edit.

Clause-②: no

**What changes.** The merged fold is no longer a step of `buildEffectiveObjectPermissions`. The super-user fold is the per-set one alone: each set's super-user `'*'` puts on every entry that set does not name exactly the bits the spec's `objectPermissionGrants` says that wildcard grants. A set that names an object keeps its explicit entry as its whole answer for that object, and another set's super-user wildcard still widens that entry bit by bit, as `checkObjectPermission` combines sets. The seed, the plain-wildcard coverage, the managed-write clamp and the `apiOperations` annotation are unchanged. `checkObjectPermission` and every route are unchanged, so no request changes its answer on the server.

**What a reader of `/auth/me/permissions` sees.** For a subject whose super-user set names an object narrower than its wildcard, or whose only create grant was `modifyAllRecords`, the entry reads what the server enforces: `allowCreate`, `allowEdit`, `allowDelete` or `allowRead` turn from `true` to `false` on those cells, and an entry whose export no longer holds gains an `apiOperations` list without `export`. No entry is added or removed and no bit turns from `false` to `true`. The response is byte-identical for every subject whose super-user sets name no object narrower and grant `allowCreate` wherever they carry `modifyAllRecords` — `admin_full_access` alone or beside `member_default` — and for every subject holding no super-user wildcard. The response shape, its keys and the route are unchanged. On the write path, a `can()`-gated option for those cells is now refused and a `can()` default reads `false`, as the server refuses the write they describe.

**For a caller of the exported helper.** `foldWildcardSuperUser` keeps its name, signature and body, and `@objectstack/plugin-hono-server` still re-exports it. It is no longer what the map is built from: over a merged map it cannot tell a set's own entry from another set's. Build the map with `buildEffectiveObjectPermissions`.
97 changes: 92 additions & 5 deletions packages/core/src/security/effective-object-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
* `effective-api-operations.test.ts`, which exercise them through that
* package's unchanged re-exports). What is pinned HERE is the composition:
* the merge rule, the order, the guards, and that the result aliases nothing.
* [#20136] `foldWildcardSuperUser` is no longer one of the steps composed:
* the super-user fold is per set (the `[#20136]` block below).
*/

import { describe, it, expect } from 'vitest';
import { objectPermissionGrants } from '@objectstack/spec/security';
import { buildEffectiveObjectPermissions } from './effective-object-permissions.js';

describe('buildEffectiveObjectPermissions', () => {
Expand Down Expand Up @@ -42,11 +45,16 @@ describe('buildEffectiveObjectPermissions', () => {
sys_member: { name: 'sys_member', managedBy: 'better-auth' },
};
const map: any = buildEffectiveObjectPermissions(
[{ objects: { '*': { viewAllRecords: true, modifyAllRecords: true }, sys_member: { allowRead: true } } }],
[
{ objects: { '*': { viewAllRecords: true, modifyAllRecords: true } } },
// [#20136] Named by ANOTHER set, so the super-user wildcard reaches it and the clamp has something to narrow.
{ objects: { sys_member: { allowRead: true } } },
],
{ allSchemas: () => Object.values(schemas), schemaOf: (n) => schemas[n] },
);
// Seed → fold: an entry nobody named, pulled true by the super-user bits.
expect(map.report).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true });
// Seed → fold: an entry nobody named, pulled true by the super-user bits —
// [#20136] every bit they grant and no other: `modifyAllRecords` grants no create.
expect(map.report).toMatchObject({ allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true, allowCreate: false });
// Fold → clamp: the guard has the last word on a managed object's writes.
expect(map.sys_member).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false });
// Annotate runs last, over the final entries.
Expand Down Expand Up @@ -89,9 +97,11 @@ describe('buildEffectiveObjectPermissions', () => {

it('with no schema source at all it is the bare merge plus the fold', () => {
const map: any = buildEffectiveObjectPermissions([
{ objects: { '*': { modifyAllRecords: true }, deal: { allowRead: false } } },
{ objects: { '*': { modifyAllRecords: true } } },
{ objects: { deal: { allowRead: false } } },
]);
expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true });
expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true });
expect(map.deal.allowCreate).not.toBe(true);
expect(map.deal.apiOperations).toBeUndefined();
});
});
Expand Down Expand Up @@ -284,6 +294,83 @@ describe('[#20134] super-user wildcard: every bit it grants, per set', () => {
});
});

/**
* [#20136] The super-user fold is per set, and it is the only one: an entry a
* super-user set names ITSELF is that set's whole answer for the object
* (`resolveObjectPermission`), and a wildcard lends only the bits the spec's
* `objectPermissionGrants` says it grants — `modifyAllRecords` grants no
* create. The merged-bypass fold that used to run first granted both, so the
* map answered `true` where `PermissionEvaluator.checkObjectPermission`
* refuses. Each case reads the entry the way `current_user.can()` does.
*
* The enumeration over every super-user shape — shipped sets and authored
* rows, every verb, the real `can()` against the real evaluator — is pinned
* in plugin-security's `get-effective-object-permissions.test.ts`.
*/
describe('[#20136] a super-user set\'s own explicit entry is its whole answer', () => {
const SCHEMAS: Record<string, any> = {
crm_account: { name: 'crm_account' },
crm_lead: { name: 'crm_lead' },
sys_position: { name: 'sys_position' },
};
const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] };
const SUPER = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true };
const grants = (entry: unknown) =>
(['allowRead', 'allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer', 'allowExport'] as const)
.filter((bit) => objectPermissionGrants(entry as any, bit));

it('the walled org admin shape: a read-only entry in the super-user set stays read-only', () => {
const map: any = buildEffectiveObjectPermissions(
[{ objects: { '*': SUPER, sys_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } } }],
source,
);
expect(grants(map.sys_position)).toEqual(['allowRead']);
// The objects the set does NOT name still take its wildcard, every bit it grants.
expect(grants(map.crm_account)).toEqual(['allowRead', 'allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer']);
});

it('an EMPTY entry `{}` in the super-user set grants nothing — read included', () => {
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': SUPER, crm_account: {} } }], source);
expect(grants(map.crm_account)).toEqual([]);
});

it('a super-read wildcard over its own export-only entry: no read, so no export, and `apiOperations` withholds it', () => {
const map: any = buildEffectiveObjectPermissions(
[{ objects: { '*': { viewAllRecords: true }, crm_account: { allowExport: true } } }],
source,
);
expect(grants(map.crm_account)).toEqual([]);
expect(map.crm_account.apiOperations).not.toContain('export');
});

it('`modifyAllRecords` alone grants no create — on a seeded entry, and on one another set names', () => {
const map: any = buildEffectiveObjectPermissions(
[
{ objects: { '*': { modifyAllRecords: true } } },
{ objects: { crm_lead: { allowRead: true } } },
],
source,
);
for (const name of Object.keys(SCHEMAS)) {
expect(grants(map[name]), name).toEqual(['allowRead', 'allowEdit', 'allowDelete', 'allowTransfer']);
}
});

it('ANOTHER set\'s super-user wildcard still widens that entry — create only where its own wildcard grants it', () => {
const map: any = buildEffectiveObjectPermissions(
[
{ objects: { '*': SUPER, crm_account: { allowRead: true } } },
{ objects: { '*': { modifyAllRecords: true } } },
],
source,
);
// The second set does not name `crm_account`: its write bypass reaches it, and grants no create.
expect(grants(map.crm_account)).toEqual(['allowRead', 'allowEdit', 'allowDelete', 'allowTransfer']);
// Where the first set's wildcard reaches, its own `allowCreate` does.
expect(grants(map.crm_lead)).toContain('allowCreate');
});
});

/**
* [#20135] `apiOperations` is what the REST door SERVES this subject — the
* door's own two questions, asked per entry:
Expand Down
Loading
Loading