Skip to content

Commit 49144fc

Browse files
fix(core): apiOperations offers only what the REST door serves — nothing on apiEnabled: false, export only where the export door admits it (#20135) (#20151)
Fixes #20135 Clause-②: no `apiOperations`, the operation set `/auth/me/permissions` (and `ISecurityService.getEffectiveObjectPermissions`) attaches to each entry, now offers exactly what the REST door serves this subject. It used to disagree with the door in two places, both in the direction of offering an operation the door refuses: - **`enable.apiEnabled: false`.** The door answers `404 OBJECT_API_DISABLED` for every verb. The annotation ignored the switch: it carried the object's whole closure, or no annotation at all (read by a client as default-allow) when the subject's export stays allowed on an otherwise unrestricted object. - **The export slot** (seat pointer 5831905897). `annotateEffectiveApiOperations` fell back to the MERGED `'*'` export bit (`acc.allowExport ?? wildExport`). So a private object reached only through a plain `'*': { allowExport: true }` was annotated `export`, and so was an object the exporting set itself names without the grant. The export door answers both `403 EXPORT_NOT_PERMITTED`. The fix is in the producer, `annotateEffectiveApiOperations` in `@objectstack/core` (`packages/core/src/security/effective-object-permissions.ts`), as triage directed: 「read the same resolver `enforceApiAccess` reads, so the two cannot diverge」. The REST door, `packages/spec` (`resolveEffectiveApiMethods`, `effectiveOperationsArray`, `apiExposureDenialReason`), `checkObjectPermission`, the seed and the folds are all untouched. No exported name or type changes. ## What changes The annotation asks the door's own two questions, entry by entry: - **The object half** is `canServeApiOperation` (`@objectstack/spec/data`). It is the boolean face of `apiExposureDenialReason`, the function `apiAccessDenialFromEnable` (`enforceApiAccess`) turns into its 404 and 405. The served set is the closure filtered through it. It judges `apiEnabled === false` first and for every operation, so an API-disabled object is annotated `[]`. For any other `enable` the filter is the identity, because the closure already is what the door admits. - **The export half** is `objectPermissionGrants(entry, 'allowExport')`: read and `allowExport` on the entry itself. That is the export door's conjunction, and `PermissionEvaluator`'s `export` branch documents it as what the merged per-object entry answers. For the export bit, since #20083 and #20134 the coverage passes put each set's `'*'` grant only on the entries that set reaches, per posture, so the entry's `allowExport` already is the per-set, per-posture answer and the merged-bit fallback is gone. The read half of the conjunction is the entry's read bit, which still carries the merged fold's read over-grant pre-registered for #20136. It moved no cell of the parity column below. Which entries carry an annotation keeps the #18931 / #18990 rule: an unrestricted object whose every operation is still served, `export` included, gets none. What moved is the evaluation of "still served": an API-disabled object never qualifies. ## H4: `apiOperations: []`, and the entry stays - **What the client reads.** Nothing in this repo's SDK reads `apiOperations`. `git grep apiOperations -- packages/client packages/client-react` finds 0 lines; `@objectstack/client` only re-exports the `GetEffectivePermissionsResponse` type. The reader is objectui, which is **UNMEASURED** here because the sibling repo is not in this container. From the spec contract (`EffectiveObjectPermissionSchema.apiOperations`: "absent = default-allow") and the objectui code quoted on #18931 (`effectiveApiOps ? effectiveApiOps.includes('export') : true`), an array hides every operation it does not list and an absent one hides nothing. `[]` is also the shape a deny-all (`apiMethods: []`) object already carries, so the client meets no new shape. - **Why not drop the entry.** The entry carries the CRUD bits that `current_user.can()` reads on the server, and it reads an absent entry as "no grant". `apiEnabled` closes the API, not data access (`enforceApiAccess`' docblock: "`apiEnabled` controls automatic API exposure, not data access"). Dropping the entry would change a server-side `can()` answer, which H5 rules out, and would contradict the #18990 ruling that every reachable object gets an entry. ## Measurement **Door semantics used below.** An operation is "served" when neither door refuses it: the object door (`404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`) and, for `export`, the export door (`403 EXPORT_NOT_PERMITTED`). The operations measured are the ones the REST door gates by name: `get`, `list`, `create`, `update`, `delete`, `bulk`, `import`, `export`. `bulk` counts as served when any of `createMany` / `updateMany` / `deleteMany` passes the door. "Offered" means the entry's `apiOperations` lists the operation, or the entry has no annotation (default-allow). ### Real stack (H1, H2): the real `GET /auth/me/permissions` against real REST requests This used a throwaway dogfood probe that is **not committed**. It ran `bootStack` with the real `SecurityPlugin`, `orgContext: true`, 61 registered objects, and one authored app. The app holds `pq_open` (public), `pq_exposed` (`apiEnabled: true`), `pq_hidden` (`apiEnabled: false`), `pq_hidden_subset` (`apiEnabled: false` + `apiMethods: ['get','list']`), `pq_subset` (`apiMethods: ['get','list']`), `pq_private` (`access.default: 'private'`) and `pq_private_hidden` (private + `apiEnabled: false`). There are 0 such `apiEnabled: false` objects in `examples/`, so the reach is authored objects only. Every subject × every registered object × the 10 door requests above went through the real REST routes, with bodies that cannot mutate. Base is `16c5a33fdd`; head is this branch. | subject (resolved sets) | offered but refused, present entries: base → head | served but hidden: base → head | no-entry cells (unchanged) | route == member | |---|---|---|---|---| | seeded platform admin (`admin_full_access`, `organization_admin_no_bypass`, `member_default`) | 16 → **0** | 0 → 0 | 0 | yes | | `admin_full_access` + `member_default` + a plain `'*': { allowExport }` | 21 → **0** | 0 → 0 | 0 | yes | | `member_default` | 0 → 0 | 0 → 0 | 134 | yes | | member + explicit read on the app objects | 16 → **0** | 0 → 0 | 101 | yes | | … + a plain `'*': { allowExport }` | 20 → **0** | 0 → 0 | 101 | yes | | member + explicit read and export on the app objects | 19 → **0** | 0 → 0 | 101 | yes | | member + a plain `'*'` with every bit and export | 11 → **0** | 0 → 0 | 15 | yes | | member + `'*': { viewAllRecords, allowExport }` | 19 → **0** | 0 → 0 | 0 | yes | - **H1 holds.** At base, `pq_hidden` read `[get, list, create, update, delete, upsert, bulk, aggregate, search, import]` for the platform admin, and had no annotation at all for export-allowed subjects. Meanwhile every REST verb on it answered `404 OBJECT_API_DISABLED`. At head every API-disabled object reads `[]`. - **H2 holds.** For `admin_full_access` beside a plain export-only `'*'`, `sys_secret` read `[get, list, aggregate, search, export]` and `GET /data/sys_secret/export` answered `403 EXPORT_NOT_PERMITTED`. The same holds for the authored private `pq_private`, which had no annotation (default-allow) and a 403 export. The member with explicit read beside the plain export wildcard shows the same on `pq_private`. - **Entry-level diff, base-equivalent leg → head, all 8 subjects** (the base-equivalent leg is the ablation below; its door readings equal true base in all 8 subjects): 0 entries added or removed, 0 `allow*` bits changed, **0 operations added to any annotation**, 74 operations removed, 11 annotations added (9 × `[]`, 2 × the closure minus `export` on `pq_private`). - **No-entry cells** are objects the subject's map carries no entry for (e.g. `member_default` on the `sys_*` objects it cannot read). Whether an entry exists is the seed's question, and this PR leaves it alone. See the Acceptance notes. ### Unit fixture (H3): the committed parity column `plugin-security`'s `get-effective-object-permissions.test.ts` table covers PR #20145's 16 subjects plus 2 export-slot subjects. The 2 new subjects are `admin_full_access` beside a plain export-only `'*'`, and one set whose exporting `'*'` sits beside an explicit `crm_lead` entry without the grant. Each subject is checked against every registered object its map carries (the 11-object `REGISTERED` fixture) and every door-gated operation. The oracle is `canServeApiOperation` for the object half, the door's decision function, since plugin-security takes no dependency on `@objectstack/rest`. For `export` it is the real registered `svc.canExport`. The subjects also run through the existing `can()` ↔ `checkObjectPermission` rows, green with `KNOWN_OVER_GRANT` untouched. | subject | offered but refused: base → head | served but hidden: base → head | |---|---|---| | wall-less org admin · viewer · one set: plain `'*'` + narrower entry · platform admin · platform admin + wall-less · walled org admin · bare modify-all · super-read + plain bits · one set: super-user `'*'` + narrower entry | 7 → **0** each (`crm_hidden` × 7) | 0 → 0 | | explicit entry beside a plain `'*'` · super-user `'*'` + export · super-read `'*'` + export · explicit entry beside a super-user `'*'` | 8 → **0** each (`crm_hidden` × 8, `export` included) | 0 → 0 | | **platform admin beside a plain export-only `'*'`** | 10 → **0** (`crm_hidden` × 8, `crm_secret.export`, `sys_secret.export`) | 0 → 0 | | **one set: exporting `'*'` + explicit `crm_lead` without the grant** | 9 → **0** (`crm_hidden` × 8, `crm_lead.export`) | 0 → 0 | | member · export-only `'*'` beside a reader · a `'*'` granting nothing | 0 → 0 | 0 → 0 | Entry-level over these 18 subjects: 0 entries added or removed, 0 `allow*` bits changed, 0 operations added to any annotation, 92 removed, 7 annotations added. The core pins also hold an export grant without read to no `export`, and read and export arriving from two different sets to `export`. ## For `domain:cli` (the route) and `domain:services` (plugin-security): cross-lane No code in `plugin-hono-server` or `plugin-security` changes, only their pins. The bytes `/auth/me/permissions` serves change for the affected objects, and `getEffectiveObjectPermissions` returns the same map: - an object declaring `enable.apiEnabled: false` reads `apiOperations: []` in every entry. That includes an unrestricted one whose export stays allowed, which used to carry no annotation; - `export` leaves the annotation where the export door refuses it: a private object reached only through a plain wildcard export grant, an object named without the grant by the set whose wildcard carries it, and an entry granting export without read. A private, unrestricted object in that position gains an annotation, its closure minus `export`. Nothing else moves: no entry, no `allow*` bit, no added operation. Shape, keys and route are unchanged, and so is every server decision. `can()` reads the same bits. ## Clause-② `no`, as the claim carries it. The served annotation narrows only toward what the door already refuses (0 operations added, measured above), and the door is untouched, so no request that succeeded now fails. No exported name or type changes. `annotateEffectiveApiOperations` keeps its signature. As an exported helper called standalone, it no longer reads the map's `'*'`, a behaviour change named in the changeset. There are 0 non-test callers in this repo: every hit of `git grep annotateEffectiveApiOperations` outside tests is its definition, its re-export, the composition and comments. `check-changeset-no-major --base origin/main` exits 0 ("This diff introduces no `major` bump"), and `check-adr-0087-registration --base origin/main` exits 0 ("1 non-breaking changeset(s) seen"). The changeset grades `@objectstack/core` `patch`. ## Tests run, at head `f2904cd05b` (core `dist/` rebuilt from this source) Each suite ran under the verify lock, and each line quotes its `VERDICT command-exit 0`: - `pnpm --filter @objectstack/core test`: 53 files, 1353 tests passed. `typecheck` exit 0, test layer included. - `pnpm --filter @objectstack/plugin-hono-server test`: 27 files, 323 passed. `typecheck` exit 0. - `pnpm --filter @objectstack/plugin-security test`: 135 files, 2718 passed. `typecheck` exit 0. - Dogfood, 18 files, against the `dist/` closure built from this code (`turbo run build --filter='@objectstack/dogfood^...'`, 64 tasks), in two runs: - 126 passed and 1 skipped: `organization-update-door`, `me-apps-and-everyone-baseline`, `showcase-permission-projection`, `showcase-permission-seeding`, `showcase-permission-zoo`, `two-doors-permission`, `comments-permission-matrix`, `attachments-permission-matrix`, `authz-conformance`; - 135 passed: `showcase-private-owd`, `owner-anchor-and-bulk-writes`, `showcase-crud-persona-matrix`, `showcase-client-liaison-fixtures`, `showcase-fls-read-mask-strip`, `showcase-scope-depth`, `showcase-scope-depth-write`, `showcase-scope-depth-fallback`, `showcase-anonymous-deny-surfaces`. - New and changed pins: - core `effective-object-permissions.test.ts`: 7 new cases in a `[#20135]` block; - plugin-security `get-effective-object-permissions.test.ts`: 2 subjects, the `apiOperations` column (one case per subject) and a reported-cases case; - plugin-hono-server `current-user-endpoints-effective-objects.test.ts`: a route byte-equality case over an API-disabled and a private object; - plugin-hono-server `effective-api-operations.test.ts`: 4 API-disabled cases. Four existing cases were rewritten: one is **inverted on purpose**, since it pinned the merged-`'*'` fallback itself, and three called annotate on a map that never went through the per-set passes. Those three now go through `buildEffectiveObjectPermissions` and assert the same outcomes. ## Ablation The mutation restored the base reading in one block: the merged-`'*'` export bit back, and the door filter off. A string marker kept the dist reading exact. - It went through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `da81b319` → `33501e91`). Core's `dist/` was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. - **Observed direction: red.** - core: 5 failed, 22 passed. The two right-direction controls stayed green. - plugin-hono-server: 6 failed, 27 passed, over its two pin files together (`effective-api-operations.test.ts`, 29 cases, and `current-user-endpoints-effective-objects.test.ts`, 4 cases). - plugin-security: 16 failed, 36 passed. These are the 15 subject rows with an API-disabled or export mismatch, and the reported-cases case. The three subjects with no mismatch stayed green, as did every `can()` row. - The stack probe on the ablated build reproduced true base's door readings in all 8 subjects. - **Restore.** The blob equals HEAD, and `git diff HEAD` is empty (the tool's own verdict). Core was rebuilt, and `ablation-dist-preflight --absent` reports the marker absent from all 14 built files and a clean tree. All three suites are green again: 27, 33 and 52 passed. A second ablation leg, run to dump both legs' maps for the tables above, restored the same way. ## Gates, at head `f2904cd05b` - The list comes from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 64 commands, derived from 6 paths against merge base `16c5a33fd`. I ran each one and recorded its exit code. **64 of 64 exit 0.** - One needed a second run. `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3, not measured) because 8 packages had no `dist/`. After `turbo run build` of those 8, all cache hits, it answered: "105 published require entry point(s) across 67 package(s) load; 660 emitted CommonJS file(s) parse". - `dispatch-gates --ran`: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN". - `node scripts/check-issue-citations.mjs --base 16c5a33`: exit 0, 3 citations resolve. - **Lint, as a proven narrowing.** `eslint --no-inline-config --format json` over the 5 changed TypeScript files reports 5 files, 0 errors and 0 warnings. `eslint --print-config` resolves a config for each of them, and eslint ignores the changeset. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules, as its own comment states), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. - **NOT MEASURED locally** (CI owns them): Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance, the type-check lanes, and the families `dispatch-gates` names outside its 64. ## DELIBERATE CORRECTION: three pending changesets (seat amendment 2, 5851881633) Each change rewrites ONE sentence of a pending release note that this PR makes false, following the #20132 / #20145 precedent. Every other line of each file is byte-identical: `git diff --numstat f2904cd HEAD` reads `1 1` on each file, and with the changed line deleted from both sides `diff` exits 0. Check Changeset turns red on the foreign-changeset rule by design, naming exactly these three files. ### 1. `.changeset/18931-me-permissions-unrestricted-export-annotation.md`, line 13 (blob `6eac3716` → `d49f56e3`) - **Before:** 「an object that is unrestricted **and** keeps `export` gets none.」 - **After:** 「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.」 ### 2. `.changeset/18990-viewall-only-permissions-seed.md`, line 12 (blob `261e412f` → `727fbffa`) - **Before:** 「… 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.」 - **After:** 「… an unrestricted object whose export stays allowed still gets no `apiOperations` (the entry itself is seeded since #20134), because for it the client's default-allow path is already right — except an object with `enable.apiEnabled: false`, annotated `[]` since #20135 because the REST door refuses every verb on it.」 ### 3. `.changeset/20134-super-user-entries-every-bit.md`, line 15 (blob `a113da40` → `d2ee6a58`) - **Before:** 「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.」 - **After:** 「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.」 **The new sentences are TRUE at this head.** An object with `enable.apiEnabled: false` is annotated `[]` in every entry, and the REST door answers `404 OBJECT_API_DISABLED` for every verb on it; both were measured on the stack above. Every other unrestricted object whose export stays allowed still carries no annotation. That holds for the controls `pq_open` and `pq_exposed`, and for `crm_account` in the core pins. **This card's own changeset follows.** Its line 21 said those notes "otherwise describe [such an object] as carrying none", which the corrections make false. That one sentence now reads 「That includes an unrestricted object whose export stays allowed, which used to carry no annotation at all; this release's notes for #18931, #18990 and #20134 name that exception.」 `git diff --numstat` reads `1 1`, and every other line is byte-identical. ## Patch round 1, at head `ba66a482b2` (contract review 5851878434, prose only) Two commits sit on top of `f2904cd05b`: the three corrections, then this card's changeset sentence. They touch only those four `.changeset` files. Every code and test blob is identical to `f2904cd05b`: `git diff --stat f2904cd HEAD -- . ':(exclude).changeset'` is empty, and the five files carry blobs `da81b3192b`, `cd0367c631`, `ab113b718a`, `070606df33` and `0ea90cbfed` at both heads. So the suites, ablation, stack and gate readings above stand for the code. The branch was not merged with `main`, and its merge base is still `16c5a33fdd`. Gates re-run at `ba66a482b2`, with `--base 16c5a33` (the merge base) and this body as the `--event`: - `node scripts/check-empty-changeset.mjs --base 16c5a33`: **exit 1, as expected**. It names exactly the three corrected files, each as "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`, `.changeset/18990-viewall-only-permissions-seed.md` and `.changeset/20134-super-user-entries-every-bit.md`. Its first line is "✓ No empty-frontmatter changeset introduced by this diff (4 declaring changeset(s) added)". The class is DELIBERATE CORRECTION, not COLLISION, so the base text must **not** be restored; a same-head contract-review PASS is what confirms it. - `node scripts/check-changeset-no-major.mjs --base 16c5a33 --event` (this body): exit 0. It prints "✓ This diff introduces no `major` bump." and "✓ LEVEL AXIS: this PR declares clause-② `no`, so no package here is declared to have grown a published surface.", with declaration line `Clause-②: no` and no direction arm. - `node scripts/check-adr-0087-registration.mjs --base 16c5a33 --event` (this body): 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 16c5a33`: exit 0, "✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference)." (3 judged, 3 resolve). - `origin/main` has since moved one commit, to `8d1f7ab785` (#20125). It touches none of the six files in this diff, and in `packages/core/src/security` it touches only `operation-private-keys.ts` and its pin. The branch was not merged, as the seat directed. ## Acceptance notes - **Entry presence is untouched, and it has its own mismatches.** On the stack, a subject whose map has no entry for an object (the "no-entry cells" column) leaves the client on default-allow for that object's operations while the door refuses some of them. Two examples: `member_default` on `sys_metadata` `create`, and `export` on objects it cannot read. Those counts are identical at base and head. Which entries exist is the seed's and the folds' question: it is excluded from this claim, and #20136 is next in this file. It is not filed here. `can()` reads such an object as "no grant" on every verb, which is the entry-presence ruling's territory (#18990). - **Pending release notes, corrected in this PR.** Seat amendment 2 (5851881633 on #20135) ruled Q1 → B after contract review 5851878434. The review measured `resolveEffectiveApiMethods({ apiEnabled: false }).mode === 'unrestricted'`, so an API-disabled object with no `apiMethods` is "unrestricted" in the resolver's own vocabulary, and all three pending sentences read false for it at this head. Each gets a one-clause DELIBERATE CORRECTION; see the section below. - **Spec describe string.** `EffectiveObjectPermissionSchema.apiOperations`' `.describe()` says "Present only when the object tightens exposure via apiMethods". That has been inexact since the export axis (#3544), and is now inexact for `apiEnabled: false` too. `packages/spec` is out of scope, so this is noted only. - **`history` / `restore` / `purge` / `search` are outside the parity column** by construction. No REST route gates them by name. The spec's `apiExposureDenialReason` admits every operation on an unrestricted object, while `resolveEffectiveApiMethods` withholds the flag-gated ones, and that difference lives in `packages/spec`. It is unchanged, and the annotation keeps the closure's answer. - The stack probe and the ablation dump were throwaway files and are not in this diff. The tree was proven clean against HEAD after both. Session `session_01Bvd69VPa6puiNzzPUroDBx` (the `domain:engine` seat 1 dispatch, round 22), on branch `claude/issue-20135-api-operations-parity`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9401b84 commit 49144fc

9 files changed

Lines changed: 376 additions & 44 deletions

‎.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 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.
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 — 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.
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 still gets no `apiOperations` (the entry itself is seeded since #20134), 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 — except an object with `enable.apiEnabled: false`, annotated `[]` since #20135 because the REST door refuses every verb on it.
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.

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

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

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

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

1717
**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.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): the `apiOperations` of `/auth/me/permissions` offers only what the REST door serves — nothing on an object with `enable.apiEnabled: false`, and `export` only where the export door admits it (#20135)
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`). Its last pass, `annotateEffectiveApiOperations`, attaches each entry's `apiOperations`: the operation set a client renders, where an absent annotation means default-allow. That set disagreed with the REST door in two places, both in the direction of offering an operation the door refuses:
8+
9+
- **`enable.apiEnabled: false`.** The door answers `404 OBJECT_API_DISABLED` for every verb on such an object, whatever `apiMethods` says. The annotation ignored the switch. It carried the object's whole closure, or, for a subject whose export stays allowed on an otherwise unrestricted object, no annotation at all, which a client reads as default-allow.
10+
- **The export slot.** It fell back to the merged `'*'` export bit whenever an entry carried no `allowExport` of its own. The merged bit cannot say which set's wildcard reaches which object, so `export` was offered on a private object that only a plain `'*': { allowExport: true }` reached (a plain wildcard never covers a private object), and on an object that the exporting set itself names without the grant. The export door answers both `403 EXPORT_NOT_PERMITTED`.
11+
12+
Clause-②: no
13+
14+
**What changes.** The annotation now asks the door's own two questions, entry by entry:
15+
16+
- the object half is the spec's `canServeApiOperation`, the boolean face of `apiExposureDenialReason`, which `enforceApiAccess` in `@objectstack/rest` turns into its 404 and 405. An object with `enable.apiEnabled: false` is annotated `apiOperations: []`;
17+
- the export half is the entry's own grant as the export door reads it: read and `allowExport`, through the spec's `objectPermissionGrants`. The coverage passes that run first have already put each set's `'*'` on exactly the entries that set reaches, per posture.
18+
19+
The entry of an API-disabled object stays in the map with its grants: `apiEnabled` closes the API, not the data, and `current_user.can()` reads those grants on the server. Which entries carry an annotation keeps its rule: an unrestricted object whose every operation is still served gets none.
20+
21+
**What a reader of `/auth/me/permissions` sees.** An object declaring `enable.apiEnabled: false` now reads `apiOperations: []` in every entry. That includes an unrestricted object whose export stays allowed, which used to carry no annotation at all; this release's notes for #18931, #18990 and #20134 name that exception. `export` leaves the annotation of a private object reached only through a plain wildcard export grant, and of an object named without the grant by the set whose wildcard carries it. A private, unrestricted object that lost its `export` this way is now annotated with its closure minus `export`. Nothing else moves: no entry is added or removed, no `allow*` bit changes, and no annotation gains an operation. The response shape, its keys and the route are unchanged. The REST door is unchanged, so no request changes its answer.
22+
23+
**For a caller of the exported helper.** `annotateEffectiveApiOperations` keeps its signature. It no longer reads the map's `'*'` entry: it reads each entry's own grants, which `buildEffectiveObjectPermissions` puts there. A map composed some other way should be built with `buildEffectiveObjectPermissions`.

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,100 @@ describe('[#20134] super-user wildcard: every bit it grants, per set', () => {
283283
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
284284
});
285285
});
286+
287+
/**
288+
* [#20135] `apiOperations` is what the REST door SERVES this subject — the
289+
* door's own two questions, asked per entry:
290+
*
291+
* - the object half, `canServeApiOperation` (the spec's
292+
* `apiExposureDenialReason`, which `enforceApiAccess` turns into its 404 /
293+
* 405): `enable.apiEnabled: false` refuses every operation, whatever
294+
* `apiMethods` says, so the entry is annotated `[]` — never left bare, which
295+
* a client reads as default-allow;
296+
* - the user half, the export door's `read ∧ allowExport` on the entry
297+
* itself — per set and posture, because the coverage passes put each set's
298+
* `'*'` only where that set reaches — never the MERGED `'*'` export bit.
299+
*
300+
* Which entries carry the annotation is unchanged in rule: an unrestricted
301+
* object whose every operation is still served gets none. The route- and
302+
* member-level parity against the door is pinned in plugin-security's
303+
* `get-effective-object-permissions.test.ts`.
304+
*/
305+
describe('[#20135] apiOperations says what the REST door serves', () => {
306+
const SCHEMAS: Record<string, any> = {
307+
crm_account: { name: 'crm_account' },
308+
crm_exposed: { name: 'crm_exposed', enable: { apiEnabled: true } },
309+
crm_lead: { name: 'crm_lead', enable: { apiMethods: ['get', 'list'] } },
310+
crm_hidden: { name: 'crm_hidden', enable: { apiEnabled: false } },
311+
crm_hidden_lead: { name: 'crm_hidden_lead', enable: { apiEnabled: false, apiMethods: ['get', 'list'] } },
312+
crm_secret: { name: 'crm_secret', access: { default: 'private' } },
313+
};
314+
const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] };
315+
const EVERY = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true };
316+
/** `admin_full_access`' wildcard shape: both bypass bits, and (#8681) no `allowExport`. */
317+
const SUPER = { ...EVERY, viewAllRecords: true, modifyAllRecords: true };
318+
319+
it('an API-disabled object is annotated `[]` — for a subject that may export, which used to get no annotation at all', () => {
320+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': { ...EVERY, allowExport: true } } }], source);
321+
expect(map.crm_hidden.apiOperations).toEqual([]);
322+
expect(map.crm_hidden_lead.apiOperations).toEqual([]);
323+
// The controls: unrestricted and exposed, every operation still served — no annotation.
324+
expect(map.crm_account).not.toHaveProperty('apiOperations');
325+
expect(map.crm_exposed).not.toHaveProperty('apiOperations');
326+
// …and an `apiMethods` subset says exactly what it said before.
327+
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
328+
});
329+
330+
it('an API-disabled object is annotated `[]` — for a subject the export axis narrows, which used to get the full closure', () => {
331+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': SUPER } }], source);
332+
expect(map.crm_hidden.apiOperations).toEqual([]);
333+
expect(map.crm_hidden_lead.apiOperations).toEqual([]);
334+
// The controls: the export axis alone, which takes `export` and nothing else.
335+
expect(map.crm_account.apiOperations).toEqual(
336+
['get', 'list', 'create', 'update', 'delete', 'upsert', 'bulk', 'aggregate', 'search', 'import'],
337+
);
338+
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']);
339+
});
340+
341+
it('the entry itself stays: `apiEnabled` closes the API, not the data the server lets this subject write', () => {
342+
const map: any = buildEffectiveObjectPermissions([{ objects: { '*': SUPER } }], source);
343+
expect(map.crm_hidden).toMatchObject({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true });
344+
});
345+
346+
it('a private object reached only through a PLAIN `*` export grant is not annotated `export`', () => {
347+
// The platform admin beside a plain export-only wildcard: the plain `'*'` never covers a private
348+
// object, and the super-user `'*'` carries no `allowExport`, so the export door refuses there.
349+
const map: any = buildEffectiveObjectPermissions(
350+
[{ objects: { '*': SUPER } }, { objects: { '*': { allowExport: true } } }],
351+
source,
352+
);
353+
expect(map.crm_secret.apiOperations).toEqual(
354+
['get', 'list', 'create', 'update', 'delete', 'upsert', 'bulk', 'aggregate', 'search', 'import'],
355+
);
356+
// The public objects the plain wildcard DOES cover keep `export` — an unrestricted one keeps its whole closure.
357+
expect(map.crm_account).not.toHaveProperty('apiOperations');
358+
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
359+
});
360+
361+
it('an object the exporting set itself names without the grant is not annotated `export`', () => {
362+
// For that set its explicit entry is its whole answer, and no other set grants export.
363+
const map: any = buildEffectiveObjectPermissions(
364+
[{ objects: { '*': { allowRead: true, allowExport: true }, crm_lead: { allowRead: true } } }],
365+
source,
366+
);
367+
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']);
368+
});
369+
370+
it('an export grant without read is not annotated `export`: the export door is read ∧ grant', () => {
371+
const map: any = buildEffectiveObjectPermissions([{ objects: { crm_lead: { allowExport: true } } }], source);
372+
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']);
373+
});
374+
375+
it('read and export arriving from two different sets still annotate `export`, as the export door admits', () => {
376+
const map: any = buildEffectiveObjectPermissions(
377+
[{ objects: { crm_lead: { allowRead: true } } }, { objects: { '*': { allowExport: true } } }],
378+
source,
379+
);
380+
expect(map.crm_lead.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
381+
});
382+
});

0 commit comments

Comments
 (0)