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
Conversation
…very bit checkObjectPermission grants The super-user seed now places an entry for every registered object a super-user wildcard reaches (it skipped an unrestricted object whose export stays allowed), and a per-set pass puts each set's super-user wildcard grants on the entries that set does not name: transfer through modifyAllRecords, and the wildcard's own plain bits. The merged fold is unchanged. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
… verb The parity table gains one row per super-user wildcard shape and holds each at 0 under-granted cells; the fold's two known over-grants are pre-registered cell for cell with a flip trigger. The seed pins that asserted no entry for an unrestricted, export-allowed object now assert an entry with no apiOperations. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d6de03d7a23dc84119e30513a657f8faeaab7665 && git checkout d6de03d7a23dc84119e30513a657f8faeaab7665
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 949e99bed9352d49a82aa186fffef86468a9d533 c63f61522c274bc29fb8dc9fb330fd5bc517fe88 && git checkout -B drift-repro 949e99bed9352d49a82aa186fffef86468a9d533 && git merge --no-ff c63f61522c274bc29fb8dc9fb330fd5bc517fe88
node scripts/docs-audit/affected-docs.mjs --json 949e99bed9352d49a82aa186fffef86468a9d533 |
…branch makes false The pending can() write-path changeset said an entry reached through a super-user wildcard carries no transfer. This branch closes that half, so the clause now points at the changeset that closes it; every other line is unchanged. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope: 7 files (+368/−55) on merge base
No governed surface and no ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Must-change (prose only; every code and test judgment above PASSES and needs no re-measurement on a head that changes
|
…makes inexact The unrestricted-export-annotation note said the seed skips an unrestricted, export-allowed object and that no pass adds allowExport to a seeded entry; the viewAll-only seed note said that object is still skipped. The seed now places the entry and only the apiOperations annotation skips it. One bullet each; every other line is unchanged. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…without an entry Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope: a DELTA review. It supersedes FAIL 5832348775 (on ① Derived judgments
② Semver level
③ Boundary flagsDELIBERATE CORRECTION confirmed on
Q1 → A, Q2 → A and amendment 2 are carried out as ruled. The out-of-scope rows stand for #20136's enumeration (pointers 5831899863 and 5832366012) and for #20135 (5831905897). Implemented-by: VERDICT: PASS |
…ing on apiEnabled: false, export only where the export door admits it (objectstack-ai#20135) (objectstack-ai#20151) Fixes objectstack-ai#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 objectstack-ai#20083 and objectstack-ai#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 objectstack-ai#20136. It moved no cell of the parity column below. Which entries carry an annotation keeps the objectstack-ai#18931 / objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 `[objectstack-ai#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 objectstack-ai#20132 / objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#20134), because for it the client's default-allow path is already right — except an object with `enable.apiEnabled: false`, annotated `[]` since objectstack-ai#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 objectstack-ai#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 objectstack-ai#18931, objectstack-ai#18990 and objectstack-ai#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` (objectstack-ai#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 objectstack-ai#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 (objectstack-ai#18990). - **Pending release notes, corrected in this PR.** Seat amendment 2 (5851881633 on objectstack-ai#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 (objectstack-ai#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>
Fixes #20134
Clause-②: yes (widening)
The effective object-permission map (the
objectsslot ofGET /auth/me/permissions, and whatISecurityService.getEffectiveObjectPermissionshands tocurrent_user.can()) now carries every bit a super-user'*'grants on the server. A super-user wildcard is one carryingviewAllRecordsormodifyAllRecords. Before this change, its entries diverged fromPermissionEvaluator.checkObjectPermissionin three places, all in the refuse direction:transfer. The fold put only read, create, edit and delete on an entry, socan(X, 'transfer')wasfalseforadmin_full_accesson every object, and for the walledorganization_adminon every object its own set does not name. The server grantstransferthroughmodifyAllRecords.'*': { viewAllRecords, allowEdit }put only the read on an entry.allowExport. It skipped the super-user seed for every unrestricted object. This is the third location of the family recorded on security: the effective permission map folds a super-user'*'over the same set's narrower explicit entries — a walled org admin getscan('sys_position','edit')true where enforcement refuses #20136, measured at 214 cells by the reviewer of fix(core): the effective object-permission map covers a plain*grant, so current_user.can() agrees with checkObjectPermission for a wall-less org admin (#20083) #20132.The fix is at the producer,
@objectstack/corebuildEffectiveObjectPermissions, as triage directed (「seed and fold every bitcheckObjectPermissionreads」).checkObjectPermission, formula'scan(),annotateEffectiveApiOperationsand every route and plugin source are untouched.What changes
packages/core/src/security/effective-object-permissions.ts:seedSuperUserRestrictedObjectsplaces 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 noapiOperations. The seed no longer resolves the operation set at all. Its signature and its{allow*: false}initial shape are unchanged.foldSuperUserWildcardGrantsis new and module-private. It runs right afterfoldWildcardSuperUserand 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'sobjectPermissionGrantssays that wildcard grants: the one foldcheckObjectPermissionitself asks. This isresolveObjectPermission's per-set reading:truebits 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.foldWildcardSuperUserhas 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()overtoEvalPermissions(map). It is compared withPermissionEvaluator.checkObjectPermissionon all 10 verbs the vocabulary accepts.undermeans the map refuses a verb the evaluator grants, andoverthe 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, andapiEnabled: false. Base isa08e059c61, with core'sdist/built from base source. Head is071e3f49fb.admin_full_accessadmin_full_access+member_defaultadmin_full_access+organization_admin_no_bypass+member_defaultorganization_admin+member_default'*': { modifyAllRecords }'*': { modifyAllRecords, allowCreate, allowRead }'*': { viewAllRecords, allowEdit }'*': { viewAllRecords, allowEdit, allowTransfer, allowDelete }'*': { viewAllRecords }'*': admin bits +allowExport'*': { viewAllRecords, allowExport }'*': admin bits +allowExport, +member_defaultcrm_account: readbeside another set's super-user'*''*'and a narrower explicitcrm_account'*'beside a plain'*'admin_full_accessbeside a plain export-only'*'member_defaultorganization_admin_no_bypass+member_defaultviewer_readonly+member_defaulttruebit turnsfalse, and noapiOperationslist loses or gains an operation. The head adds 53 entries (theallowExportshapes' unrestricted objects) andtruebits:allowTransfer× 688,allowExport× 212,allowEdit× 42,allowDelete× 18.Public door on a booted showcase.
bootStack(showcase)in its default composition, with the seeded platform admin (positionsplatform_admin,everyone; setsshowcase_member_default+admin_full_access+member_default). The answer read is the response bytes ofGET /auth/me/permissionsitself. The comparison covers 78 registered objects × 10 verbs againstcheckObjectPermissionover the same resolved sets.transfer)objectsbytesshowcase_accountallowTransfer: false,can(…, 'transfer')false; server trueallowTransfer: true,can()trueThat 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 basea08e059c61's, with the same under and over counts, in all 23 subjects.Write path. The real
ObjectQLengine, with the realSecurityPluginmember registered as its resolver.crm_case.stagehas options gated oncurrent_user.can('crm_account', …), and a boolean field has CELdefaultValuecurrent_user.can('crm_account', 'transfer').transfereditreaddefaultValueadmin_full_access+member_defaultVALIDATION_FAILED/invalid_option→ admittedorganization_admin+member_default'*': { viewAllRecords, allowEdit }'*': admin bits +allowExportmember_defaultorganization_admin_no_bypass+member_defaultviewer_readonly+member_defaultEvery 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'seffective-api-operations.test.tsquote the #18990 ruling (batch #159 item 1, letter A). The ruling's headline is 「/auth/me/permissionsowes 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 aboutannotateEffectiveApiOperationsattachingapiOperations.This PR keeps that carve-out exactly. An entry seeded for an unrestricted, export-allowed object carries no
apiOperations, andannotateEffectiveApiOperationsis 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:
The entry is owed. The #18931 skip predicate governs the
apiOperationsannotation, which this PR keeps byte-identical (0apiOperationslost 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 threeeffective-api-operations.test.tsseed 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.
organization_admin's 38 cells (create, edit, delete and import on its five read-only RBAC objects, and edit onsys_organization). A one-set authored shape shows 7.allowCreatepulled onmodifyAllRecordsalone. The spec'sobjectPermissionGrantsgives 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 carryallowCreate: true. This second location is not in security: the effective permission map folds a super-user'*'over the same set's narrower explicit entries — a walled org admin getscan('sys_position','edit')true where enforcement refuses #20136's body. The seat has routed it to security: the effective permission map folds a super-user'*'over the same set's narrower explicit entries — a walled org admin getscan('sys_position','edit')true where enforcement refuses #20136's enumeration (amendment 5831894689).KNOWN_OVER_GRANTin 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) anddomain:services(plugin-security)No code in
plugin-hono-serverorplugin-securitychanges, only their pins. What their readers see:GET /auth/me/permissions→objects. For a subject holding a super-user wildcard, entries gaintruebits (allowTransfer, and the wildcard's own plain and export bits). Where that subject's wildcards also grantallowExport, the map gains an entry for each registered unrestricted object that had none, and that entry carries noapiOperations. Nothing is removed, and noapiOperationslist changes. Shape, keys and route are unchanged.ISecurityService.getEffectiveObjectPermissions. The same map, since it is the same function. On the write path, acan()-gated option ordefaultValueis admitted for these subjects wherever the server grants.There is no non-test requester of
/auth/me/permissionsin this repo. objectui'scan()is UNMEASURED: the sibling repo is not in this container.Clause-②
can()answers widen to match enforcement, and acan()-gated write for these subjects is now admitted. Nothing narrows (see the entry-level diff above), so@objectstack/coreisminor, following the security: the effective object-permission map omits objects covered only by a plain'*'grant, socurrent_user.can()answers false where enforcement answers true (wall-less org admins) #20083 and [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 precedents.check-changeset-no-major --base origin/mainandcheck-adr-0087-registration --base origin/mainboth 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.
transfer(or a super-read wildcard's plain bits) —current_user.can(obj, 'transfer')is false foradmin_full_accesswhere enforcement answers true viamodifyAllRecords#20134) covers the [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 clause./auth/me/permissionsis silent for a wildcard-onlyviewAllRecordsprincipal too — the class #18931 fixed only formodifyAllRecords#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.PermissionEvaluator.checkObjectPermissionfor 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 notransfer.」PermissionEvaluator.checkObjectPermissionfor subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted. Its missingtransferis closed in this same release (.changeset/20134-super-user-entries-every-bit.md).」Byte identity.
git diff --numstatgives1 1on the file, 39 lines before and after. With line 37 removed, the before and after files are identical (diffexit 0). Line 37 is identical up to and including 「…names narrower can read as granted」, and only its tail changes. The blob goes fromf357292ato29a6ba2e.The new sentences are TRUE at this head (
c63f61522c; its code and test blobs are identical to071e3f49fb, where the measurements above were taken):'*'over the same set's narrower explicit entries — a walled org admin getscan('sys_position','edit')true where enforcement refuses #20136's cells: 38 for the walledorganization_adminon the 63-object fixture, and 7 for the one-set authored shape. Both are byte-identical at base and head and pre-registered inKNOWN_OVER_GRANT.transferis closed in this same release」 holds.transferunder-grants are 0 in every super-user row of the fixture table and 78 → 0 on the booted showcase.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 [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #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)export. A seeded entry carries noallowExportof its own andfoldWildcardSuperUserdoes not add one, so annotate'sacc.allowExport ?? wildExportresolves to the same wildcard bit the seed read — the two passes cannot diverge again.」transfer(or a super-read wildcard's plain bits) —current_user.can(obj, 'transfer')is false foradmin_full_accesswhere enforcement answers true viamodifyAllRecords#20134 the seed places an entry for every registered object a super-user wildcard reaches that the merge left without one, andannotateEffectiveApiOperationsalone decides which entries carryapiOperations: an object that is unrestricted and keepsexportgets 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
allowExporton a seeded entry whenever a set's super-user wildcard grants it, so 「a seeded entry carries noallowExportof its own」 no longer holds.Why the new bullet is TRUE.
seedSuperUserRestrictedObjectsgives 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.annotateEffectiveApiOperationsis untouched, and it is the only pass that writesapiOperations. It skips exactly an object that is unrestricted and whose export bit is true.apiOperationslost or gained across 23 fixture subjects and on the booted showcase.Byte identity.
git diff --numstatgives1 1, 16 lines before and after. With line 13 removed, the files are identical (diffexit 0). The blob goes from38bfb50fto6eac3716.3.
.changeset/18990-viewall-only-permissions-seed.md, line 12 (one bullet)apiOperationsis 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.」apiOperationsis attached through the predicate already shared with the modify-all class — an unrestricted object whose export stays allowed still gets noapiOperations(the entry itself is seeded since core: effective-map super-user entries never carrytransfer(or a super-read wildcard's plain bits) —current_user.can(obj, 'transfer')is false foradmin_full_accesswhere enforcement answers true viamodifyAllRecords#20134), because for it the client's default-allow path is already right.」Why. 「is still skipped」 was true of
apiOperationsand is false of the entry, which the seed now places. The corrected text states both halves. The ruling's carve-out governsapiOperationsonly, which this PR keeps byte-identical.Byte identity.
git diff --numstatgives1 1, 14 lines before and after. With line 12 removed, the files are identical (diffexit 0). The blob goes from3ed4a3feto261e412f.Check Changesetstays red on this PR by design.check-empty-changesetnames exactly these three files, because it treats an edit to another card's pending changeset as a foreign-changeset edit.pr-automation.ymlruns it onpull_requestonly, never onmerge_group, as with #20083. Three named files add no new class of red.Changeset gates at
c63f61522c, run against merge basea08e059c61.check-changeset-no-majorread this body as its--eventpayload.Tests
All at
071e3f49fb,dist/of the closure rebuilt, each run under the shared verify lock. The head is nowc63f61522c, which adds only the three changeset corrections above; every code and test blob is identical to071e3f49fb:pnpm --filter @objectstack/core test: 53 files / 1346 passed.typecheckexit 0 (check:test-typecheckOK, 4 pinned signatures held).pnpm --filter @objectstack/plugin-security test: 135 files / 2697 passed.typecheckexit 0.pnpm --filter @objectstack/plugin-hono-server test: 27 files / 317 passed.typecheckexit 0.current_user.can, plus security: the effective object-permission map omits objects covered only by a plain'*'grant, socurrent_user.can()answers false where enforcement answers true (wall-less org admins) #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:
get-effective-object-permissions.test.ts: the security: the effective object-permission map omits objects covered only by a plain'*'grant, socurrent_user.can()answers false where enforcement answers true (wall-less org admins) #20083 parity table gains 9 super-user rows, plus 2 spelled-out cases and a registration-integrity case (31 tests).effective-object-permissions.test.ts: a 5-case[#20134]battery, and one updated key-order assertion (20 tests).Ablation, from the committed head through
scripts/ablation-replace.mjs, two nested WRAP legs:0955a452→c8367daf.c8367daf→eb95e485.ablation-dist-preflightfound both markers in 2 coredist/files.git diff HEADempty on both legs. After the rebuild,--absentpasses for both markers over 14 dist files, the whole tree is clean, and the pins are green again (31 / 20 / 40).Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derives 64 commands at071e3f49fb; all 64 exit 0.pnpm check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3), because 8 unrelated packages had nodist/. That was NOT MEASURED, not red. After building them it exits 0 (105 entries / 67 packages / 660 CJS files).--ranreads 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.node scripts/check-issue-citations.mjs --base a08e059c61exits 0, with 11 citations resolving.071e3f49fb:.tsfiles (isPathIgnoredfalse).--format jsoncounts 5 files, 0 errors, 0 warnings.parserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict.The repo-wide
pnpm lintis CI's.Acceptance notes
18783,18931,18990) are corrected here, one clause or bullet each (see DELIBERATE CORRECTION above).transferon guarded managed objects. The managed-write clamp narrows create, edit and delete, nottransfer. The map therefore answerstransferascheckObjectPermissiondoes on a guarded object too. The 63-object fixture registry holds 45 guarded objects: 44 from@objectstack/platform-objects, plus plugin-security's engine-ownedsys_audience_binding_suggestion. An earlier count of 44 read the platform objects alone. Of the 45, onlysys_metadatahas an owner field. Whether its engine-owned guard refuses the owner update that a transfer rides is UNMEASURED here.a08e059c61.origin/mainhas moved one commit since (f09d4122bc,driver-sql/driver-tursoonly), which touches none of this diff's packages.