Skip to content

Commit e2c4e12

Browse files
fix(core): the effective map folds a super-user wildcard per set only — no cell checkObjectPermission refuses (#20136) (#20165)
Fixes #20136 Clause-②: no The effective object-permission map (`buildEffectiveObjectPermissions` in `@objectstack/core`: the `objects` slot of `GET /auth/me/permissions`, and what `ISecurityService.getEffectiveObjectPermissions` hands to `current_user.can()` on the write path) no longer grants any cell that `PermissionEvaluator.checkObjectPermission` refuses, for every super-user shape measured. This completes the family's closing assertion: 0 over-granted and 0 under-granted cells over every super-user subject in the parity table. ## What was wrong Before its per-set super-user fold, the builder ran `foldWildcardSuperUser` over the MERGED map. That pass put the merged bypass bits on every entry: - into an entry the super-user set names ITSELF, where `resolveObjectPermission` answers that set with its explicit entry (the walled `organization_admin`'s read-only RBAC rows and write-denied identity tables, an explicit `{}` entry, an export-only entry); - `allowCreate` on `modifyAllRecords` alone, which the spec's `objectPermissionGrants` gives no create cell. Mechanism hypotheses H3 are both CONFIRMED, by reading and by measurement: removing that pass alone takes every row below to 0, and adds 0 under-granted cells anywhere. ## What changes `packages/core/src/security/effective-object-permissions.ts`: - `buildEffectiveObjectPermissions` no longer calls `foldWildcardSuperUser`. The super-user fold is `foldSuperUserWildcardGrants` alone (added by #20134): per set, onto the entries that set does not name, exactly the bits `objectPermissionGrants` says that set's wildcard grants. Another set's super-user wildcard still widens an entry bit by bit, as `checkObjectPermission` combines sets. The seed, plain-wildcard coverage, clamp and annotation are untouched. - `foldWildcardSuperUser` keeps its name, signature and body. It is a released export of `@objectstack/plugin-hono-server` (re-exported from core). Its docblock now says it is not a step of the map, and names where it is broader (own narrower entry, create) and narrower (`transfer`, a super-user wildcard's own plain bits) than the server. Retiring it is an export removal, so it is left as an open question below rather than done here. - Docblocks that named the merged fold as a map step (`wildcardGrantsSuperRead`, the seed, plain coverage, the clamp, the builder) now name the per-set fold. Tests: - `plugin-security` `get-effective-object-permissions.test.ts`: `KNOWN_OVER_GRANT`, its flip-trigger assertion and its "names a row" test are DELETED rather than emptied. The table now holds every subject EXACT in both directions, with the managed-write clamp's narrowing as its only allowance. Each enumeration shape is a named subject, tagged with its row (`[#20136 row a]` to `[#20136 row h]`, plus the existing one-set row), so a regression names its row. Rows b, d, e, f, g and h are new. The `apiOperations` column runs over them too, which is where row e's offered-but-refused `export` is held. One spelled-out case: the walled org admin may not create, edit or delete `sys_position`, and `can()` says so, with and without `member_default`; `sys_user` edit without it. - `core` `effective-object-permissions.test.ts`: a `[#20136]` block (own read-only entry, `{}` entry, export-only entry under a super-read wildcard, `modifyAllRecords` without create, another set's wildcard still widening). Two composition fixtures that relied on the over-grant are re-spelled: the clamp and fold cases now put the narrower entry in a SECOND set, so they still exercise the fold. - `plugin-hono-server`: the endpoint fixture's `sys_member` deny moves to the second set, so its "clamp over the fold" assertion keeps exercising the fold. There is a new endpoint case, a super-user set's own narrower entry served as that set's answer, byte-equal to the builder. `fold-wildcard-superuser.test.ts` gets a note that it pins the standalone released helper. Changesets: `.changeset/20136-super-user-fold-per-set.md` (`@objectstack/core` patch), plus three one-clause DELIBERATE CORRECTIONS of pending notes, listed below. ## Measurement The map answer is the real formula `current_user.can()` over `toEvalPermissions(map)`. It is compared with `PermissionEvaluator.checkObjectPermission` on all 10 verbs. `over` means the map grants a cell the evaluator refuses, and `under` the reverse. As in the existing pin, create, edit and delete refused on a guarded managed object is the managed-write clamp and is not counted. The fixture has 61 registered objects: every object schema exported by `@objectstack/platform-objects` and by plugin-security's `objects`, plus 4 app objects (public, `apiMethods`-restricted, private, and `apiEnabled: false`). - Base: `49144fccc8`, with core's `dist/` built from it. - Head: `6527062a22`. - A second base leg (the producer file restored from base, core rebuilt, the marker proven present in `dist/`) is byte-identical (sha256) to true base on all 27 subjects. ### Enumeration (over-granted cells, base to head) | row | shape | base | head | |---|---|---|---| | a | walled `organization_admin` + `member_default` | 38 | **0** | | b | walled `organization_admin` alone | 44 | **0** | | c | `'*': { modifyAllRecords: true }` alone | 32 | **0** | | d | `{}` explicit entry inside a super-user set | 8 | **0** | | e | `'*': { viewAllRecords }` over its own `{ allowExport: true }` entry | 2 (+1 `export` offered and refused) | **0** (+0) | | f (new) | `'*': { viewAllRecords }` over its own narrower entry | 1 | **0** | | g (new) | `modifyAllRecords`-only `'*'` beside a set naming the object narrower | 2 | **0** | | h (new, control) | `admin_full_access` + `organization_admin` + `member_default` | 0 | 0 | | existing | one set: a super-user `'*'` and a narrower `crm_account` | 7 | **0** | - Rows a, b and d reproduce the card's 38, 44 and 8 exactly. - Row c is 32 here and 36 on the card: it is `create` + `import` on every object the clamp does not narrow, 16 on this fixture and 18 on the card's 63-object fixture. - Row f: the super-read path over the set's own entry, with no `modifyAllRecords` and no export. - Row g: the create lift arriving from a SECOND set's wildcard, over an entry the first set names. - Row h: the control row, where another set's super-user wildcard legitimately widens a narrower entry. ### Parity (all 27 subjects) The 18 subjects of the #20083/#20134/#20135 table, rows a to h, a super-read wildcard alone, `admin_full_access` alone, and a super-read wildcard beside a plain one: - **over**: 8 subjects were non-zero at base (the table above). Every subject is **0** at head. - **under**: **0** at base and at head, in every subject. No cell `checkObjectPermission` grants became refused in the map. - **`apiOperations` column** (offered and refused, and served but hidden): row e was 1 offered-and-refused at base (`crm_account` `export`). It is **0** at head, and every other cell is 0 at both. ### Reach, at a public door (H2) The write path is PR #20079's `ObjectQL` write path: the real engine, with the real `SecurityPlugin` resolver (the function it registers on the engine) as its resolver. `crm_case.stage` has options gated on `current_user.can(…)`. | subject | gate | base | head | `checkObjectPermission` | |---|---|---|---|---| | walled org admin + `member_default` | `can('sys_position','edit')` | **ADMITTED** | refused `VALIDATION_FAILED` / `invalid_option` | false | | walled org admin + `member_default` | `can('sys_position','create')` | **ADMITTED** | refused | false | | walled org admin alone | `can('sys_position','edit')`, `can('sys_position','create')` | **ADMITTED** | refused | false | | walled org admin alone | `can('sys_user','edit')` | **ADMITTED** | refused | false | | `'*': { modifyAllRecords }` | `can('sys_position','create')`, `can('crm_account','create')` | **ADMITTED** | refused | false | | the other 18 cells of the 25: the controls `admin_full_access` + `member_default` and `member_default`, and every cell the server grants | | unchanged | unchanged | agrees | `GET /api/v1/auth/me/permissions` was served by the real `registerCurrentUserEndpoints` on a Hono app over the same resolved sets and registry. | subject | `objects.sys_position` create / edit / delete | response bytes | `objects` sha256 | |---|---|---|---| | walled org admin + `member_default` | true / true / true → **false / false / false** | 13187 → 13203 | `7d78b7069a14` → `7c41ba445efa` | | walled org admin alone | true / true / true → **false / false / false** | 12789 → 12807 | `6cf54b268f58` → `65a3ee709036` | | `'*': { modifyAllRecords }` | true / true / true → **false** / true / true | 11122 → 11138 | `87f9b1e48f79` → `e5a31ecba130` | | `admin_full_access` + `member_default` | unchanged | 12987 → 12987 | identical | | `member_default` | (no entry) | 6704 → 6704 | identical | ### Collateral (H4) - **19 of 27 subjects are byte-identical (sha256)**: every subject holding no super-user wildcard, and every super-user subject with no own narrower entry and no create-less `modifyAllRecords`. That includes `admin_full_access` alone and beside `member_default`, `organization_admin_no_bypass`, and row h. - **The 8 changed subjects**: no entry is added or removed, and no bit turns `false` to `true`. Only `true` to `false`: | subject | bits turned `true` → `false` | |---|---| | row a | `allowEdit` ×6, `allowCreate` ×5, `allowDelete` ×5 | | row b | `allowEdit` ×8, `allowCreate` ×5, `allowDelete` ×5 | | row c | `allowCreate` ×16 | | row d | `allowRead`, `allowCreate`, `allowEdit`, `allowDelete` ×1 each | | row e | `allowRead` ×1 | | row f | `allowRead` ×1 | | row g | `allowCreate` ×1 | | one-set row | `allowCreate`, `allowEdit`, `allowDelete` ×1 each | - **One `apiOperations` change**: row e's `crm_account` goes from no annotation (default-allow, which offered `export`) to the closure without `export`. - **Enforcement untouched**: `permission-evaluator.ts` and all of plugin-security's non-test source have 0 changed lines, so no `checkObjectPermission` answer changes. ### Reverse verification The fix was committed first. The mutate leg restored the producer file from base (`git restore --source=49144fccc8`), rebuilt core, and proved the marker live with `ablation-dist-preflight` (present in 2 built files). On that tree: - core went red on 7 of 32: the 5 new `[#20136]` cases and the 2 re-spelled composition cases; - plugin-security went red on 10 of 64: rows a, b, c, d, e, f, g and the one-set row, the spelled-out case, and row e's `apiOperations` column; - hono-server went red on 1 of 47: the new endpoint case. Row h and every other subject stayed green. The direction is the expected one (to red). The restore used `git checkout HEAD --`, proven by the file's hash equalling its HEAD blob and by an empty `git diff HEAD`. Core was then rebuilt, with the marker proven absent from `dist/`. ## Tests and gates, at `6527062a22` - `pnpm --filter @objectstack/core test`: 53 files, 1358 passed. `test:repo`: 3 files, 48 passed. - `pnpm --filter @objectstack/plugin-security test`: 135 files, 2730 passed. - `pnpm --filter @objectstack/plugin-hono-server test`: 27 files, 324 passed. - `typecheck` for core, plugin-security and plugin-hono-server (`tsc`, the scripts/typecheck projects, `check:test-typecheck` over the test layer): exit 0. - Access-security dogfood, over the dogfood closure built at head: the 11 `access-security.json` checklist dogfood files plus `organization-update-door.dogfood.test.ts`, 12 files, 165 passed. - `node scripts/pm/dispatch-gates.mjs --commands` derived 64 commands from this diff. All 64 were run, and `--ran` reconciles 64 of 64 with 0 NOT-MEASURED. - 63 exit 0. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (8 unrelated packages had no `dist/`). After building them it exited 0. - **`check-empty-changeset --base origin/main` exits 1, by design**: it is the DELIBERATE CORRECTION class below, and the gate stays red until a person confirms. - `node scripts/check-issue-citations.mjs --base 49144fc`: exit 0, 8 citations resolve. - `eslint --no-inline-config` on the 5 changed TS files: 0 errors, 0 warnings, 5 files counted from `--format json`. All 5 are in the config's population (`--print-config` resolves each). The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. ## Deliberate corrections of pending release notes, for confirmation `check-empty-changeset` is red on these three by design. Each is one clause, and each sentence was made false by this PR: - `.changeset/18783-server-can-option-visibility.md`: "The map still differs from `checkObjectPermission` … an entry the super-user set itself names narrower can read as granted." now reads: "The map also differed … read as granted, which is closed in this same release (`.changeset/20136-super-user-fold-per-set.md`)." - `.changeset/20134-super-user-entries-every-bit.md`: "Both over-grants are left exactly as they were." now reads: "…left exactly as they were by this change, and both are closed in this same release (`.changeset/20136-…`)." - `.changeset/18931-me-permissions-unrestricted-export-annotation.md`: "Its CRUD bits are folded `true`" now reads: "Its CRUD bits are folded to what its wildcard grants (all four for the built-in admin sets)". The note's population includes a bare `'*': { modifyAllRecords }`, which no longer reads `create`. No released note was touched. ## Cross-lane For super-user subjects, two things change: the bytes `/auth/me/permissions` serves, and the write path's `current_user.can()` answer. Both change only on the cells listed above, and both move toward what the server enforces. For the seat to relay to `domain:cli` and `domain:services` at landing. The REST door, `checkObjectPermission` and `annotateEffectiveApiOperations` are untouched. ## Open question `foldWildcardSuperUser` is still exported, but nothing in this repository now calls it outside its own pins. Over a merged map it cannot be made to match the server, because the map has lost which set named which object. Retiring it removes a released export of `@objectstack/plugin-hono-server`, which is a breaking change (`Clause-②: yes (narrowing)`). That is a decision for the seat or the maintainer, not a rider on this fix. Recommendation: retire it in its own change, because an exported helper documented as the map's fold is how the merged reading could come back. ## Acceptance notes - H1: every row a to e was re-measured and reproduced (row c's count differs only by fixture size, as explained above). Three rows were added: f, g, and h (control). No other super-user shape measured diverges at base. - H5: no released behaviour becomes refused. The write-path `can()` gate is PR #20079's, whose changeset is still pending. On `/auth/me/permissions` only map bits narrow, and the server's answer to every request is unchanged. - The suggested route turned `KNOWN_OVER_GRANT` into an empty set. This PR deletes it instead: an empty allowance ledger is a mechanism for re-admitting a divergence, and the family's assertion is exact. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 369bcbe commit e2c4e12

9 files changed

Lines changed: 258 additions & 99 deletions

‎.changeset/18783-server-can-option-visibility.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ stage: Field.select({
3434
- 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".
3535
- 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.
3636

37-
**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`).
37+
**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`).
3838

3939
**No spec key, route or config key is added or removed.**

‎.changeset/18931-me-permissions-unrestricted-export-annotation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ For that principal an unrestricted object got no entry, so annotate never saw it
1212

1313
- **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.
1414
- **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.
15-
- **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.
15+
- **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.
1616
- **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.

‎.changeset/20134-super-user-entries-every-bit.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ fix(core): an effective-map entry reached through a super-user `'*'` carries eve
1414

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

17-
**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.
17+
**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`).
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
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)
6+
7+
`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:
8+
9+
- **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`.
10+
- **`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.
11+
12+
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.
13+
14+
Clause-②: no
15+
16+
**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.
17+
18+
**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.
19+
20+
**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`.

‎packages/core/src/security/effective-object-permissions.test.ts‎

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
* `effective-api-operations.test.ts`, which exercise them through that
1010
* package's unchanged re-exports). What is pinned HERE is the composition:
1111
* the merge rule, the order, the guards, and that the result aliases nothing.
12+
* [#20136] `foldWildcardSuperUser` is no longer one of the steps composed:
13+
* the super-user fold is per set (the `[#20136]` block below).
1214
*/
1315

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

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

9098
it('with no schema source at all it is the bare merge plus the fold', () => {
9199
const map: any = buildEffectiveObjectPermissions([
92-
{ objects: { '*': { modifyAllRecords: true }, deal: { allowRead: false } } },
100+
{ objects: { '*': { modifyAllRecords: true } } },
101+
{ objects: { deal: { allowRead: false } } },
93102
]);
94-
expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true });
103+
expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true });
104+
expect(map.deal.allowCreate).not.toBe(true);
95105
expect(map.deal.apiOperations).toBeUndefined();
96106
});
97107
});
@@ -284,6 +294,83 @@ describe('[#20134] super-user wildcard: every bit it grants, per set', () => {
284294
});
285295
});
286296

297+
/**
298+
* [#20136] The super-user fold is per set, and it is the only one: an entry a
299+
* super-user set names ITSELF is that set's whole answer for the object
300+
* (`resolveObjectPermission`), and a wildcard lends only the bits the spec's
301+
* `objectPermissionGrants` says it grants — `modifyAllRecords` grants no
302+
* create. The merged-bypass fold that used to run first granted both, so the
303+
* map answered `true` where `PermissionEvaluator.checkObjectPermission`
304+
* refuses. Each case reads the entry the way `current_user.can()` does.
305+
*
306+
* The enumeration over every super-user shape — shipped sets and authored
307+
* rows, every verb, the real `can()` against the real evaluator — is pinned
308+
* in plugin-security's `get-effective-object-permissions.test.ts`.
309+
*/
310+
describe('[#20136] a super-user set\'s own explicit entry is its whole answer', () => {
311+
const SCHEMAS: Record<string, any> = {
312+
crm_account: { name: 'crm_account' },
313+
crm_lead: { name: 'crm_lead' },
314+
sys_position: { name: 'sys_position' },
315+
};
316+
const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] };
317+
const SUPER = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true };
318+
const grants = (entry: unknown) =>
319+
(['allowRead', 'allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer', 'allowExport'] as const)
320+
.filter((bit) => objectPermissionGrants(entry as any, bit));
321+
322+
it('the walled org admin shape: a read-only entry in the super-user set stays read-only', () => {
323+
const map: any = buildEffectiveObjectPermissions(
324+
[{ objects: { '*': SUPER, sys_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } } }],
325+
source,
326+
);
327+
expect(grants(map.sys_position)).toEqual(['allowRead']);
328+
// The objects the set does NOT name still take its wildcard, every bit it grants.
329+
expect(grants(map.crm_account)).toEqual(['allowRead', 'allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer']);
330+
});
331+
332+
it('an EMPTY entry `{}` in the super-user set grants nothing — read included', () => {
333+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': SUPER, crm_account: {} } }], source);
334+
expect(grants(map.crm_account)).toEqual([]);
335+
});
336+
337+
it('a super-read wildcard over its own export-only entry: no read, so no export, and `apiOperations` withholds it', () => {
338+
const map: any = buildEffectiveObjectPermissions(
339+
[{ objects: { '*': { viewAllRecords: true }, crm_account: { allowExport: true } } }],
340+
source,
341+
);
342+
expect(grants(map.crm_account)).toEqual([]);
343+
expect(map.crm_account.apiOperations).not.toContain('export');
344+
});
345+
346+
it('`modifyAllRecords` alone grants no create — on a seeded entry, and on one another set names', () => {
347+
const map: any = buildEffectiveObjectPermissions(
348+
[
349+
{ objects: { '*': { modifyAllRecords: true } } },
350+
{ objects: { crm_lead: { allowRead: true } } },
351+
],
352+
source,
353+
);
354+
for (const name of Object.keys(SCHEMAS)) {
355+
expect(grants(map[name]), name).toEqual(['allowRead', 'allowEdit', 'allowDelete', 'allowTransfer']);
356+
}
357+
});
358+
359+
it('ANOTHER set\'s super-user wildcard still widens that entry — create only where its own wildcard grants it', () => {
360+
const map: any = buildEffectiveObjectPermissions(
361+
[
362+
{ objects: { '*': SUPER, crm_account: { allowRead: true } } },
363+
{ objects: { '*': { modifyAllRecords: true } } },
364+
],
365+
source,
366+
);
367+
// The second set does not name `crm_account`: its write bypass reaches it, and grants no create.
368+
expect(grants(map.crm_account)).toEqual(['allowRead', 'allowEdit', 'allowDelete', 'allowTransfer']);
369+
// Where the first set's wildcard reaches, its own `allowCreate` does.
370+
expect(grants(map.crm_lead)).toContain('allowCreate');
371+
});
372+
});
373+
287374
/**
288375
* [#20135] `apiOperations` is what the REST door SERVES this subject — the
289376
* door's own two questions, asked per entry:

0 commit comments

Comments
 (0)