Skip to content

Commit 437bb0d

Browse files
fix(core): effective-map entries reached through a super-user * carry every bit the server grants, so can(object, 'transfer') agrees with checkObjectPermission (#20134) (#20145)
Fixes #20134 Clause-②: yes (widening) The effective object-permission map (the `objects` slot of `GET /auth/me/permissions`, and what `ISecurityService.getEffectiveObjectPermissions` hands to `current_user.can()`) now carries every bit a super-user `'*'` grants on the server. A super-user wildcard is one carrying `viewAllRecords` or `modifyAllRecords`. Before this change, its entries diverged from `PermissionEvaluator.checkObjectPermission` in three places, all in the refuse direction: - **`transfer`.** The fold put only read, create, edit and delete on an entry, so `can(X, 'transfer')` was `false` for `admin_full_access` on every object, and for the walled `organization_admin` on every object its own set does not name. The server grants `transfer` through `modifyAllRecords`. - **A super-read wildcard's own plain bits.** `'*': { viewAllRecords, allowEdit }` put only the read on an entry. - **A super-user wildcard carrying `allowExport`.** It skipped the super-user seed for every unrestricted object. This is the third location of the family recorded on #20136, measured at 214 cells by the reviewer of #20132. The fix is at the producer, `@objectstack/core` `buildEffectiveObjectPermissions`, as triage directed (「seed and fold every bit `checkObjectPermission` reads」). `checkObjectPermission`, formula's `can()`, `annotateEffectiveApiOperations` and every route and plugin source are untouched. ## What changes `packages/core/src/security/effective-object-permissions.ts`: - **`seedSuperUserRestrictedObjects`** places an entry for every registered object the merged map lacks, whenever the merged `'*'` carries a super-user bit. It used to skip an unrestricted object whose export stays allowed, because that object needs no `apiOperations`. The seed no longer resolves the operation set at all. Its signature and its `{allow*: false}` initial shape are unchanged. - **`foldSuperUserWildcardGrants`** is new and module-private. It runs right after `foldWildcardSuperUser` and before the managed-write clamp. For each set whose `'*'` is a super-user wildcard, it walks every entry that set does not name. On each such entry it sets every grant bit the spec's `objectPermissionGrants` says that wildcard grants: the one fold `checkObjectPermission` itself asks. This is `resolveObjectPermission`'s per-set reading: - a set that names an object keeps its explicit entry as its whole answer for that object; - a private object is covered, as a super-user wildcard covers it on the server; - only `true` bits are set. For a super-user wildcard the read cell is always true, so its export cell is exactly its own `allowExport`, the grant half the evaluator's export conjunction reads. The bits are derived, not listed. - **`foldWildcardSuperUser`** has an unchanged body. Its docblock now says what the merged reading cannot carry, and names its two known over-grants (below). The old line "exactly as broad as real enforcement — never broader" was false for those cells. No exported name or type changes. Both helpers keep their signatures, and the new step is module-private. ## Measurement The map answer is the real formula `current_user.can()` over `toEvalPermissions(map)`. It is compared with `PermissionEvaluator.checkObjectPermission` on all 10 verbs the vocabulary accepts. `under` means the map refuses a verb the evaluator grants, and `over` the reverse. As in the #20083 pin, create/edit/delete refused on a guarded managed object is the managed-write clamp and is not counted. **Unit fixture: 63 registered objects × 10 verbs.** The objects are 52 from `@objectstack/platform-objects`, 7 plugin-security objects, and 4 app objects: public, `apiMethods`-restricted, private, and `apiEnabled: false`. Base is `a08e059c61`, with core's `dist/` built from base source. Head is `071e3f49fb`. | subject (resolved sets) | entries | under | over | map bytes | |---|---|---|---|---| | `admin_full_access` | 64 → 64 | 63 → **0** | 0 → 0 | changed | | `admin_full_access` + `member_default` | 66 → 66 | 63 → **0** | 0 → 0 | changed | | `admin_full_access` + `organization_admin_no_bypass` + `member_default` | 66 → 66 | 63 → **0** | 0 → 0 | changed | | walled `organization_admin` + `member_default` | 66 → 66 | 30 → **0** | 38 → 38 (same cells) | changed | | `'*': { modifyAllRecords }` | 64 → 64 | 63 → **0** | 36 → 36 (same cells) | changed | | `'*': { modifyAllRecords, allowCreate, allowRead }` | 64 → 64 | 63 → **0** | 0 → 0 | changed | | `'*': { viewAllRecords, allowEdit }` | 64 → 64 | 54 → **0** | 0 → 0 | changed | | `'*': { viewAllRecords, allowEdit, allowTransfer, allowDelete }` | 64 → 64 | 153 → **0** | 0 → 0 | changed | | `'*': { viewAllRecords }` | 64 → 64 | 0 → 0 | 0 → 0 | identical | | `'*'`: admin bits + `allowExport` | 53 → 64 | **214 → 0** | 0 → 0 | changed | | `'*': { viewAllRecords, allowExport }` | 53 → 64 | 74 → **0** | 0 → 0 | changed | | `'*'`: admin bits + `allowExport`, + `member_default` | 56 → 66 | 206 → **0** | 0 → 0 | changed | | explicit `crm_account: read` beside another set's super-user `'*'` | 54 → 64 | 136 → **0** | 0 → 0 | changed | | ONE set: super-user `'*'` and a narrower explicit `crm_account` | 64 → 64 | 62 → **0** | 7 → 7 (same cells) | changed | | super-read `'*'` beside a plain `'*'` | 64 → 64 | 0 → 0 | 0 → 0 | identical | | `admin_full_access` beside a plain export-only `'*'` | 53 → 64 | 161 → **0** | 0 → 0 | changed | | `member_default` | 31 → 31 | 0 → 0 | 0 → 0 | identical | | `organization_admin_no_bypass` + `member_default` | 64 → 64 | 0 → 0 | 0 → 0 | identical | | `viewer_readonly` + `member_default` | 64 → 64 | 0 → 0 | 0 → 0 | identical | | the four #20083 plain-wildcard shapes | — | 0 → 0 | 0 → 0 | identical | - **No narrowing, measured entry by entry, base against head over all 23 subjects.** No entry is removed, no `true` bit turns `false`, and no `apiOperations` list loses or gains an operation. The head adds 53 entries (the `allowExport` shapes' unrestricted objects) and `true` bits: `allowTransfer` × 688, `allowExport` × 212, `allowEdit` × 42, `allowDelete` × 18. - **The head adds ZERO over-granted cells in any subject.** The three pre-existing over-grant sets are the same cells at base and head. **Public door on a booted showcase.** `bootStack(showcase)` in its default composition, with the seeded platform admin (positions `platform_admin`, `everyone`; sets `showcase_member_default` + `admin_full_access` + `member_default`). The answer read is the response bytes of `GET /auth/me/permissions` itself. The comparison covers 78 registered objects × 10 verbs against `checkObjectPermission` over the same resolved sets. | | base | head | |---|---|---| | under | 78 (all `transfer`) | **0** | | over | 0 | 0 | | entries / `objects` bytes | 81 / 16695 | 81 / 17385 | | `showcase_account` | `allowTransfer: false`, `can(…, 'transfer')` false; server true | `allowTransfer: true`, `can()` true | That is the card's 63 fixture cells and 78 showcase cells, reproduced exactly. The base leg of this table and of the write-path table is this head with both changes ablated and core's `dist/` rebuilt. On the fixture, that leg's maps are byte-identical (sha256) to true base `a08e059c61`'s, with the same under and over counts, in all 23 subjects. **Write path.** The real `ObjectQL` engine, with the real `SecurityPlugin` member registered as its resolver. `crm_case.stage` has options gated on `current_user.can('crm_account', …)`, and a boolean field has CEL `defaultValue` `current_user.can('crm_account', 'transfer')`. | subject | option `transfer` | option `edit` | option `read` | `defaultValue` | |---|---|---|---|---| | `admin_full_access` + `member_default` | refused `VALIDATION_FAILED`/`invalid_option` → **admitted** | admitted → admitted | admitted → admitted | false → **true** | | walled `organization_admin` + `member_default` | refused → **admitted** | admitted → admitted | admitted → admitted | false → **true** | | `'*': { viewAllRecords, allowEdit }` | refused → refused (server refuses) | refused → **admitted** | admitted → admitted | false → false | | `'*'`: admin bits + `allowExport` | refused → **admitted** | refused → **admitted** | refused → **admitted** | — → **true** | | `member_default` | refused → refused | refused → refused | refused → refused | — | | `organization_admin_no_bypass` + `member_default` | refused → refused | admitted → admitted | admitted → admitted | false → false | | `viewer_readonly` + `member_default` | refused → refused | refused → refused | admitted → admitted | false → false | Every head cell agrees with `checkObjectPermission`, and every control cell is unchanged. ## The #18990 ruling and the seed's skip The pins this PR inverts in `plugin-hono-server`'s `effective-api-operations.test.ts` quote the #18990 ruling (batch #159 item 1, letter A). The ruling's headline is 「`/auth/me/permissions` owes every object a principal can reach an explicit entry」. The carve-out the pins quote, 「skip only an unrestricted object whose export stays allowed」, is written about `annotateEffectiveApiOperations` attaching `apiOperations`. This PR keeps that carve-out exactly. An entry seeded for an unrestricted, export-allowed object carries no `apiOperations`, and `annotateEffectiveApiOperations` is untouched. What changes is that the entry now exists, which is the ruling's headline. `current_user.can()` reads the entry and reads an absent one as 「no grant」, a reader the ruling predates. **Ruled: Q2 → A** (seat amendment 5831894689 on #20134). The #18990 ruling line (5729478681), quoted verbatim: > Ruling: batch #159 item 1 · letter A (`/auth/me/permissions` owes every object a principal can reach an explicit entry: the seed runs for a wildcard-only `viewAllRecords` principal too — `allowRead` folded true, the write bits false, `apiOperations` annotated by the same predicate as #18931; PR #18984's `toBeUndefined` pin flips on purpose) · maintainer 「同意」 2026-09-18T11:42Z The entry is owed. The #18931 skip predicate governs the `apiOperations` annotation, which this PR keeps byte-identical (0 `apiOperations` lost or gained across 23 subjects). Seeding the entry for an unrestricted, export-allowed object therefore carries out the ruling's letter; it does not reopen it. The three `effective-api-operations.test.ts` seed pins that quoted the annotate skip as a seed skip are inverted on purpose, and the amendment adds that file to the claimed surface. ## Known divergence, pre-registered and unchanged (#20136) The fold is broader than the evaluator in two places. Both are OVER-grants and belong to #20136, the family's over-grant card, which remains open. This PR leaves both byte-identical. - **The merged bypass folded into an entry the super-user set itself names narrower.** On the 63-object fixture this is the walled `organization_admin`'s 38 cells (create, edit, delete and import on its five read-only RBAC objects, and edit on `sys_organization`). A one-set authored shape shows 7. - **`allowCreate` pulled on `modifyAllRecords` alone.** The spec's `objectPermissionGrants` gives this no create cell. A bare `'*': { modifyAllRecords: true }` shows 36 create/import cells. No shipped set has this shape, since both shipped super-user wildcards carry `allowCreate: true`. This second location is not in #20136's body. The seat has routed it to #20136's enumeration (amendment 5831894689). `KNOWN_OVER_GRANT` in the parity pin lists both, cell for cell, and the pin asserts the observed set EXACTLY. That is the flip trigger: when #20136 lands, those rows go red and their entries are deleted, never widened. ## For `domain:cli` (the route) and `domain:services` (plugin-security) No code in `plugin-hono-server` or `plugin-security` changes, only their pins. What their readers see: - **`GET /auth/me/permissions` → `objects`.** 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, and that entry carries no `apiOperations`. Nothing is removed, and no `apiOperations` list changes. Shape, keys and route are unchanged. - **`ISecurityService.getEffectiveObjectPermissions`.** The same map, since it is the same function. On the write path, a `can()`-gated option or `defaultValue` is admitted for these subjects wherever the server grants. - **Byte-identical** for every subject holding no super-user wildcard (the plain-wildcard and member rows above). There is no non-test requester of `/auth/me/permissions` in this repo. objectui's `can()` is **UNMEASURED**: the sibling repo is not in this container. ## Clause-② - **The behavioural accept set widens.** `can()` answers widen to match enforcement, and a `can()`-gated write for these subjects is now admitted. Nothing narrows (see the entry-level diff above), so `@objectstack/core` is `minor`, following the #20083 and #18783 precedents. - **Public type:** no exported name or type changes. - `check-changeset-no-major --base origin/main` and `check-adr-0087-registration --base origin/main` both exit 0. ## DELIBERATE CORRECTION: three pending changesets Each correction changes ONE clause or bullet of a pending release note that this PR makes false or inexact, following the #20083 precedent. Every other line of each file is byte-identical. - **Q1 → A** (seat amendment 5831894689 on #20134) covers the #18783 clause. - The contract review FAIL 5832348775 (prose only) names the #18931 and #18990 bullets, and seat amendment 2 (5832355884) rules them corrected in this PR. ### 1. `.changeset/18783-server-can-option-visibility.md`, line 37 (one clause) The pending `current_user.can()` write-path changeset ends its **Plain-wildcard coverage, closed in this release.** paragraph (the paragraph #20132 corrected) with a clause this PR makes FALSE. - **Before:** 「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`.」 - **After:** 「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`).」 **Byte identity.** `git diff --numstat` gives `1 1` on the file, 39 lines before and after. With line 37 removed, the before and after files are identical (`diff` exit 0). Line 37 is identical up to and including 「…names narrower can read as granted」, and only its tail changes. The blob goes from `f357292a` to `29a6ba2e`. **The new sentences are TRUE at this head** (`c63f61522c`; its code and test blobs are identical to `071e3f49fb`, where the measurements above were taken): - **「an entry the super-user set itself names narrower can read as granted」** holds. These are #20136's cells: 38 for the walled `organization_admin` on the 63-object fixture, and 7 for the one-set authored shape. Both are byte-identical at base and head and pre-registered in `KNOWN_OVER_GRANT`. - **「Its missing `transfer` is closed in this same release」** holds. `transfer` under-grants are 0 in every super-user row of the fixture table and 78 → 0 on the booted showcase. - **The `create`-on-`modifyAllRecords`-alone cells are not named there, and do not need to be.** The sentence does not claim to list every divergence. That over-grant is disclosed in this release's own `.changeset/20134-super-user-entries-every-bit.md` (「Still broader than the server, unchanged here」), and no shipped set has the shape. Naming it in the #18783 entry would widen the correction beyond the one clause Q1 authorizes. ### 2. `.changeset/18931-me-permissions-unrestricted-export-annotation.md`, line 13 (one bullet) - **Before:** 「**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.」 - **After:** 「**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.」 **Why the old bullet is FALSE at this head.** The seed skips no registered object for a super-user wildcard any more. Also, the per-set super-user fold puts `allowExport` on a seeded entry whenever a set's super-user wildcard grants it, so 「a seeded entry carries no `allowExport` of its own」 no longer holds. **Why the new bullet is TRUE.** - `seedSuperUserRestrictedObjects` gives an entry to every registered object the merged map lacks, whenever the merged `'*'` carries a super-user bit. That is what 「…that the merge left without one」 says. The seat's suggested text read 「places an entry for every registered object a super-user wildcard reaches」; an object the merge already carries keeps its own entry, so the clause is narrowed to what the seed actually writes. - `annotateEffectiveApiOperations` is untouched, and it is the only pass that writes `apiOperations`. It skips exactly an object that is unrestricted and whose export bit is true. - Measured: 0 `apiOperations` lost or gained across 23 fixture subjects and on the booted showcase. **Byte identity.** `git diff --numstat` gives `1 1`, 16 lines before and after. With line 13 removed, the files are identical (`diff` exit 0). The blob goes from `38bfb50f` to `6eac3716`. ### 3. `.changeset/18990-viewall-only-permissions-seed.md`, line 12 (one bullet) - **Before:** 「**`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.」 - **After:** 「**`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.」 **Why.** 「is still skipped」 was true of `apiOperations` and is false of the entry, which the seed now places. The corrected text states both halves. The ruling's carve-out governs `apiOperations` only, which this PR keeps byte-identical. **Byte identity.** `git diff --numstat` gives `1 1`, 14 lines before and after. With line 12 removed, the files are identical (`diff` exit 0). The blob goes from `3ed4a3fe` to `261e412f`. **`Check Changeset` stays red on this PR by design.** `check-empty-changeset` names exactly these three files, because it treats an edit to another card's pending changeset as a foreign-changeset edit. `pr-automation.yml` runs it on `pull_request` only, never on `merge_group`, as with #20083. Three named files add no new class of red. **Changeset gates at `c63f61522c`**, run against merge base `a08e059c61`. `check-changeset-no-major` read this body as its `--event` payload. ```text $ node scripts/check-empty-changeset.mjs --base a08e059 -> exit 1 (expected) ✓ No empty-frontmatter changeset introduced by this diff (4 declaring changeset(s) added). This PR changes a changeset it did not add: .changeset/18783-server-can-option-visibility.md present on the merge base and CHANGED by this PR -- this is somebody else's release note .changeset/18931-me-permissions-unrestricted-export-annotation.md present on the merge base and CHANGED by this PR -- this is somebody else's release note .changeset/18990-viewall-only-permissions-seed.md present on the merge base and CHANGED by this PR -- this is somebody else's release note $ node scripts/check-changeset-no-major.mjs --base a08e059 --event (this body) -> exit 0 ✓ This diff introduces no `major` bump. ✓ LEVEL AXIS: this PR declares clause-② `yes (widening)`, and no package whose `packages/**/src/**` it moves is graded `patch`. $ node scripts/check-adr-0087-registration.mjs --base a08e059 -> exit 0 ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (4 non-breaking changeset(s) seen). $ node scripts/check-issue-citations.mjs --base a08e059 -> exit 0 citations judged: 11 across 1 file(s) ✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference). ``` ## Tests All at `071e3f49fb`, `dist/` of the closure rebuilt, each run under the shared verify lock. The head is now `c63f61522c`, which adds only the three changeset corrections above; every code and test blob is identical to `071e3f49fb`: - `pnpm --filter @objectstack/core test`: 53 files / 1346 passed. `typecheck` exit 0 (`check:test-typecheck` OK, 4 pinned signatures held). - `pnpm --filter @objectstack/plugin-security test`: 135 files / 2697 passed. `typecheck` exit 0. - `pnpm --filter @objectstack/plugin-hono-server test`: 27 files / 317 passed. `typecheck` exit 0. - **Access-security dogfood** (the 11 files the checklist area names, plus every dogfood file reading the effective map or `current_user.can`, plus #20083's permission set): 18 files / 261 passed, 1 skipped. - `shared-showcase`: `showcase-anonymous-deny-surfaces`, `showcase-permission-zoo`, `showcase-private-owd`, `two-doors-permission`: 4 / 66. - `isolated`: `me-apps-and-everyone-baseline`, `owner-anchor-and-bulk-writes`, `showcase-client-liaison-fixtures`, `showcase-crud-persona-matrix`, `showcase-fls-read-mask-strip`, `showcase-scope-depth-fallback`, `showcase-scope-depth-write`, `showcase-scope-depth`, `organization-update-door`, `showcase-permission-projection`, `showcase-permission-seeding`, `comments-permission-matrix`, `attachments-permission-matrix`, `authz-conformance`: 14 / 195 + 1 skipped. **New and changed pins:** - plugin-security `get-effective-object-permissions.test.ts`: the #20083 parity table gains 9 super-user rows, plus 2 spelled-out cases and a registration-integrity case (31 tests). - core `effective-object-permissions.test.ts`: a 5-case `[#20134]` battery, and one updated key-order assertion (20 tests). - plugin-hono-server: 3 seed pins inverted, and 2 route assertions added. **Ablation**, from the committed head through `scripts/ablation-replace.mjs`, two nested WRAP legs: - The per-set call was replaced by a runtime marker: anchor 1 → 0, blob `0955a452` → `c8367daf`. - The seed's old skip was re-inserted with its own marker: anchor 1 → 0, blob `c8367daf` → `eb95e485`. - `ablation-dist-preflight` found both markers in 2 core `dist/` files. - Observed direction: red. - plugin-security 11 failed / 20 passed: every super-user row and both spelled-out cases. The 7 plain rows stayed green. - core 6 failed / 14 passed. - hono 4 failed / 36 passed. - Restore: blob == HEAD and `git diff HEAD` empty on both legs. After the rebuild, `--absent` passes for both markers over 14 dist files, the whole tree is clean, and the pins are green again (31 / 20 / 40). - The first attempt was refused by the tool before any command ran: the inner replacement contained its own anchor. Nothing was measured on it, and both legs restored and proved clean. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derives 64 commands at `071e3f49fb`; all 64 exit 0. - `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3), because 8 unrelated packages had no `dist/`. That was NOT MEASURED, not red. After building them it exits 0 (105 entries / 67 packages / 660 CJS files). - `--ran` reads 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. - `node scripts/check-issue-citations.mjs --base a08e059` exits 0, with 11 citations resolving. - **Lint, as a proven narrowing at `071e3f49fb`:** 1. eslint's own config admits all 5 changed `.ts` files (`isPathIgnored` false). 2. `--format json` counts 5 files, 0 errors, 0 warnings. 3. The config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **The three pending release notes this PR made false or inexact** (`18783`, `18931`, `18990`) are corrected here, one clause or bullet each (see **DELIBERATE CORRECTION** above). - **`transfer` on guarded managed objects.** The managed-write clamp narrows create, edit and delete, not `transfer`. The map therefore answers `transfer` as `checkObjectPermission` does on a guarded object too. The 63-object fixture registry holds 45 guarded objects: 44 from `@objectstack/platform-objects`, plus plugin-security's engine-owned `sys_audience_binding_suggestion`. An earlier count of 44 read the platform objects alone. Of the 45, only `sys_metadata` has an owner field. Whether its engine-owned guard refuses the owner update that a transfer rides is UNMEASURED here. - **Branch base.** The branch sits on `a08e059c61`. `origin/main` has moved one commit since (`f09d4122bc`, `driver-sql` / `driver-turso` only), which touches none of this diff's packages. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 949e99b commit 437bb0d

9 files changed

Lines changed: 370 additions & 57 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, and an entry reached through a super-user wildcard carries no `transfer`.
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`).
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
@@ -10,7 +10,7 @@ The endpoint builds its per-object map in four passes — seed, fold, clamp, ann
1010

1111
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.
1212

13-
- **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.
13+
- **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.
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.
1515
- **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.
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/18990-viewall-only-permissions-seed.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
- **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.
1010
- **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.
1111
- **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.
12-
- **`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.
12+
- **`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.
1313

1414
⚠️ **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.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
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)
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`, as `admin_full_access` and `organization_admin` do — its entries diverged from `PermissionEvaluator.checkObjectPermission` in three places, each in the refuse direction:
8+
9+
- **`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.
10+
- **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.
11+
- **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.
12+
13+
**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.
14+
15+
**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`.
16+
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.

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

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,11 @@ describe('[#20083] plain wildcard coverage', () => {
187187
[{ objects: { '*': { ...WILD, viewAllRecords: true, modifyAllRecords: true } } }],
188188
source,
189189
);
190-
// Seeded all-false, then folded: the super-user entry shape, not the wildcard's own bits.
191-
expect(Object.keys(map.crm_account)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'apiOperations']);
190+
// Seeded all-false, then folded: the super-user entry shape, not the wildcard's own key
191+
// order — [#20134] with `allowTransfer`, which `modifyAllRecords` grants, appended by the
192+
// per-set fold (the wildcard's own `allowTransfer: false` grants nothing and is not copied).
193+
expect(Object.keys(map.crm_account)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'allowTransfer', 'apiOperations']);
194+
expect(map.crm_account.allowTransfer).toBe(true);
192195
// …and the private object is the seed's, as before.
193196
expect(map.crm_secret).toMatchObject({ allowRead: true, allowEdit: true });
194197
});
@@ -200,3 +203,83 @@ describe('[#20083] plain wildcard coverage', () => {
200203
expect(map).toEqual({ '*': WILD });
201204
});
202205
});
206+
207+
/**
208+
* [#20134] A SUPER-USER `'*'` — one carrying `viewAllRecords` or
209+
* `modifyAllRecords` — puts on the map every bit it grants, per set, the way
210+
* `PermissionEvaluator.checkObjectPermission` resolves it: the seed places an
211+
* entry for every registered object the merge left absent, and the per-set
212+
* fold reads each set's wildcard through the spec's `objectPermissionGrants`.
213+
* The map used to hold only the four bits the merged fold pulls, so
214+
* `current_user.can(object, 'transfer')` answered `false` for a platform admin
215+
* the server lets transfer, a super-read wildcard lost its own plain bits, and
216+
* a super-user wildcard carrying `allowExport` left every unrestricted object
217+
* with no entry at all.
218+
*
219+
* The enforcement-side half — every verb, the shipped super-user sets, the
220+
* real `can()` against the real evaluator — is pinned table-driven in
221+
* plugin-security's `get-effective-object-permissions.test.ts`.
222+
*/
223+
describe('[#20134] super-user wildcard: every bit it grants, per set', () => {
224+
const SCHEMAS: Record<string, any> = {
225+
crm_account: { name: 'crm_account' },
226+
crm_lead: { name: 'crm_lead', enable: { apiMethods: ['get', 'list'] } },
227+
crm_secret: { name: 'crm_secret', access: { default: 'private' } },
228+
};
229+
const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] };
230+
231+
it('the write bypass carries `transfer` onto every entry, a private object\'s included', () => {
232+
const map: any = buildEffectiveObjectPermissions(
233+
[{ objects: { '*': { allowRead: true, allowCreate: true, modifyAllRecords: true } } }],
234+
source,
235+
);
236+
for (const name of Object.keys(SCHEMAS)) {
237+
expect(map[name], name).toMatchObject({ allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true });
238+
}
239+
});
240+
241+
it('a super-read wildcard keeps its own plain bits — and nothing it does not grant', () => {
242+
const map: any = buildEffectiveObjectPermissions(
243+
[{ objects: { '*': { viewAllRecords: true, allowEdit: true } } }],
244+
source,
245+
);
246+
for (const name of Object.keys(SCHEMAS)) {
247+
expect(map[name], name).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: false, allowDelete: false });
248+
expect(map[name], name).not.toHaveProperty('allowTransfer');
249+
}
250+
});
251+
252+
it('a set that names the object contributes its explicit entry, never its own wildcard\'s grants', () => {
253+
const map: any = buildEffectiveObjectPermissions(
254+
[{ objects: { '*': { viewAllRecords: true, allowTransfer: true }, crm_account: { allowRead: true } } }],
255+
source,
256+
);
257+
expect(map.crm_account).not.toHaveProperty('allowTransfer');
258+
expect(map.crm_lead).toMatchObject({ allowRead: true, allowTransfer: true });
259+
});
260+
261+
it('ANOTHER set\'s super-user wildcard widens a present entry, bit by bit', () => {
262+
const map: any = buildEffectiveObjectPermissions(
263+
[
264+
{ objects: { crm_account: { allowRead: true } } },
265+
{ objects: { '*': { viewAllRecords: true, allowTransfer: true, allowExport: true } } },
266+
],
267+
source,
268+
);
269+
// Unrestricted and export-allowed, so annotate has nothing to add to it.
270+
expect(map.crm_account).toEqual({ allowRead: true, allowTransfer: true, allowExport: true });
271+
});
272+
273+
it('a super-user wildcard carrying `allowExport` seeds every registered object — annotate keeps its own skip', () => {
274+
const map: any = buildEffectiveObjectPermissions(
275+
[{ objects: { '*': { allowRead: true, allowEdit: true, modifyAllRecords: true, allowExport: true } } }],
276+
source,
277+
);
278+
expect(Object.keys(map)).toEqual(['*', 'crm_account', 'crm_lead', 'crm_secret']);
279+
expect(map.crm_account).toMatchObject({ allowRead: true, allowEdit: true, allowTransfer: true, allowExport: true });
280+
// An unrestricted object whose export stays allowed: an entry, and no operation set on it.
281+
expect(map.crm_account).not.toHaveProperty('apiOperations');
282+
// A narrowed one is annotated exactly as before, `export` kept.
283+
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
284+
});
285+
});

0 commit comments

Comments
 (0)