fix(core): apiOperations offers only what the REST door serves — nothing on apiEnabled: false, export only where the export door admits it (#20135) - #20151
Conversation
annotateEffectiveApiOperations now reads the door's own two questions per entry: canServeApiOperation (the spec's apiExposureDenialReason, which enforceApiAccess turns into its 404/405) for the object half, so an object with enable.apiEnabled false is annotated [] instead of the full closure or nothing; and objectPermissionGrants(entry, 'allowExport') for the user half, so the export slot is the entry's own per-set, per-posture grant rather than the merged '*' export bit. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
plugin-security's parity table gains an apiOperations column: every subject x every registered entry x every operation the door gates by name, against canServeApiOperation (the door's own decision function) and the security member's canExport. plugin-hono-server pins the route bytes for an API-disabled and a private object, and its helper battery reaches a wildcard's export grant through the composition, where the per-set passes put it. 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 CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
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 e8d53777fc33dc30bcdd305c3547916bc3ba6956 && git checkout e8d53777fc33dc30bcdd305c3547916bc3ba6956
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8d1f7ab78545dea1b46a0bab064676950007b815 ba66a482b29b12d93224afba754447432724d1d6 && git checkout -B drift-repro 8d1f7ab78545dea1b46a0bab064676950007b815 && git merge --no-ff ba66a482b29b12d93224afba754447432724d1d6
node scripts/docs-audit/affected-docs.mjs --json 8d1f7ab78545dea1b46a0bab064676950007b815
|
Contract reviewServed-tier: Scope: 6 files, +373/−41, 3 commits (fix / test / changeset). Head sits directly on merge base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL — prose only; the code half is PASS-ready as measured. Must-change (each one clause; every other line of each file byte-identical,
Re-review at the new head needs only: the three rewritten sentences judged, |
One clause each, every other line byte-identical: an object with enable.apiEnabled false is annotated [] rather than left without apiOperations, so the 18931, 18990 and 20134 notes now name that exception. 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>
Contract reviewServed-tier: Scope: delta review after FAIL 5851878434 (prose only) at ① Derived judgments
② Semver levelUnchanged: ③ Boundary flags
Implemented-by: VERDICT: PASS |
Merge-queue removal: disposition (one re-queue)
|
Fixes #20135
Clause-②: no
apiOperations, the operation set/auth/me/permissions(andISecurityService.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 answers404 OBJECT_API_DISABLEDfor 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.annotateEffectiveApiOperationsfell back to the MERGED'*'export bit (acc.allowExport ?? wildExport). So a private object reached only through a plain'*': { allowExport: true }was annotatedexport, and so was an object the exporting set itself names without the grant. The export door answers both403 EXPORT_NOT_PERMITTED.The fix is in the producer,
annotateEffectiveApiOperationsin@objectstack/core(packages/core/src/security/effective-object-permissions.ts), as triage directed: 「read the same resolverenforceApiAccessreads, 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:
canServeApiOperation(@objectstack/spec/data). It is the boolean face ofapiExposureDenialReason, the functionapiAccessDenialFromEnable(enforceApiAccess) turns into its 404 and 405. The served set is the closure filtered through it. It judgesapiEnabled === falsefirst and for every operation, so an API-disabled object is annotated[]. For any otherenablethe filter is the identity, because the closure already is what the door admits.objectPermissionGrants(entry, 'allowExport'): read andallowExporton the entry itself. That is the export door's conjunction, andPermissionEvaluator'sexportbranch documents it as what the merged per-object entry answers. For the export bit, since 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 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 the coverage passes put each set's'*'grant only on the entries that set reaches, per posture, so the entry'sallowExportalready 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 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. 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,
exportincluded, gets none. What moved is the evaluation of "still served": an API-disabled object never qualifies.H4:
apiOperations: [], and the entry staysapiOperations.git grep apiOperations -- packages/client packages/client-reactfinds 0 lines;@objectstack/clientonly re-exports theGetEffectivePermissionsResponsetype. 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 finding(plugin-hono-server): /auth/me/permissions never seeds an unrestricted object for a wildcard-only principal, so the Console renders Export where the server answers 403 EXPORT_NOT_PERMITTED #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.current_user.can()reads on the server, and it reads an absent entry as "no grant".apiEnabledcloses the API, not data access (enforceApiAccess' docblock: "apiEnabledcontrols automatic API exposure, not data access"). Dropping the entry would change a server-sidecan()answer, which H5 rules out, and would contradict the [finding]/auth/me/permissionsis silent for a wildcard-onlyviewAllRecordsprincipal too — the class #18931 fixed only formodifyAllRecords#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, forexport, 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.bulkcounts as served when any ofcreateMany/updateMany/deleteManypasses the door. "Offered" means the entry'sapiOperationslists the operation, or the entry has no annotation (default-allow).Real stack (H1, H2): the real
GET /auth/me/permissionsagainst real REST requestsThis used a throwaway dogfood probe that is not committed. It ran
bootStackwith the realSecurityPlugin,orgContext: true, 61 registered objects, and one authored app. The app holdspq_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') andpq_private_hidden(private +apiEnabled: false). There are 0 suchapiEnabled: falseobjects inexamples/, 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 is16c5a33fdd; head is this branch.admin_full_access,organization_admin_no_bypass,member_default)admin_full_access+member_default+ a plain'*': { allowExport }member_default'*': { allowExport }'*'with every bit and export'*': { viewAllRecords, allowExport }pq_hiddenread[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 answered404 OBJECT_API_DISABLED. At head every API-disabled object reads[].admin_full_accessbeside a plain export-only'*',sys_secretread[get, list, aggregate, search, export]andGET /data/sys_secret/exportanswered403 EXPORT_NOT_PERMITTED. The same holds for the authored privatepq_private, which had no annotation (default-allow) and a 403 export. The member with explicit read beside the plain export wildcard shows the same onpq_private.allow*bits changed, 0 operations added to any annotation, 74 operations removed, 11 annotations added (9 ×[], 2 × the closure minusexportonpq_private).member_defaulton thesys_*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'sget-effective-object-permissions.test.tstable covers PR #20145's 16 subjects plus 2 export-slot subjects. The 2 new subjects areadmin_full_accessbeside a plain export-only'*', and one set whose exporting'*'sits beside an explicitcrm_leadentry without the grant. Each subject is checked against every registered object its map carries (the 11-objectREGISTEREDfixture) and every door-gated operation. The oracle iscanServeApiOperationfor the object half, the door's decision function, since plugin-security takes no dependency on@objectstack/rest. Forexportit is the real registeredsvc.canExport. The subjects also run through the existingcan()↔checkObjectPermissionrows, green withKNOWN_OVER_GRANTuntouched.'*'+ narrower entry · platform admin · platform admin + wall-less · walled org admin · bare modify-all · super-read + plain bits · one set: super-user'*'+ narrower entrycrm_hidden× 7)'*'· super-user'*'+ export · super-read'*'+ export · explicit entry beside a super-user'*'crm_hidden× 8,exportincluded)'*'crm_hidden× 8,crm_secret.export,sys_secret.export)'*'+ explicitcrm_leadwithout the grantcrm_hidden× 8,crm_lead.export)'*'beside a reader · a'*'granting nothingEntry-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 noexport, and read and export arriving from two different sets toexport.For
domain:cli(the route) anddomain:services(plugin-security): cross-laneNo code in
plugin-hono-serverorplugin-securitychanges, only their pins. The bytes/auth/me/permissionsserves change for the affected objects, andgetEffectiveObjectPermissionsreturns the same map:enable.apiEnabled: falsereadsapiOperations: []in every entry. That includes an unrestricted one whose export stays allowed, which used to carry no annotation;exportleaves 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 minusexport.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.annotateEffectiveApiOperationskeeps 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 ofgit grep annotateEffectiveApiOperationsoutside tests is its definition, its re-export, the composition and comments.check-changeset-no-major --base origin/mainexits 0 ("This diff introduces nomajorbump"), andcheck-adr-0087-registration --base origin/mainexits 0 ("1 non-breaking changeset(s) seen"). The changeset grades@objectstack/corepatch.Tests run, at head
f2904cd05b(coredist/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.typecheckexit 0, test layer included.pnpm --filter @objectstack/plugin-hono-server test: 27 files, 323 passed.typecheckexit 0.pnpm --filter @objectstack/plugin-security test: 135 files, 2718 passed.typecheckexit 0.dist/closure built from this code (turbo run build --filter='@objectstack/dogfood^...', 64 tasks), in two runs: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;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.effective-object-permissions.test.ts: 7 new cases in a[#20135]block;get-effective-object-permissions.test.ts: 2 subjects, theapiOperationscolumn (one case per subject) and a reported-cases case;current-user-endpoints-effective-objects.test.ts: a route byte-equality case over an API-disabled and a private object;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 throughbuildEffectiveObjectPermissionsand 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.scripts/ablation-replace.mjs(anchor 1 → 0, blobda81b319→33501e91). Core'sdist/was rebuilt, andablation-dist-preflightfound the marker in 2 built files.effective-api-operations.test.ts, 29 cases, andcurrent-user-endpoints-effective-objects.test.ts, 4 cases).can()row.git diff HEADis empty (the tool's own verdict). Core was rebuilt, andablation-dist-preflight --absentreports 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
f2904cd05bnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 64 commands, derived from 6 paths against merge base16c5a33fd. I ran each one and recorded its exit code. 64 of 64 exit 0.pnpm check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3, not measured) because 8 packages had nodist/. Afterturbo run buildof 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 16c5a33fdd: exit 0, 3 citations resolve.eslint --no-inline-config --format jsonover the 5 changed TypeScript files reports 5 files, 0 errors and 0 warnings.eslint --print-configresolves a config for each of them, and eslint ignores the changeset.eslint.config.mjsnever enables type-aware linting (noparserOptions.project, no typed rules, as its own comment states), so this diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.dispatch-gatesnames 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 f2904cd05b HEADreads1 1on each file, and with the changed line deleted from both sidesdiffexits 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 (blob6eac3716→d49f56e3)exportgets none.」exportgets none — unless itsenable.apiEnabledisfalse, which is annotated[]since core:/auth/me/permissionsapiOperationsignoresenable.apiEnabled: false— an object the REST layer answers 404 for is annotated with the full operation list (annotateEffectiveApiOperations→resolveEffectiveApiMethods) #20135 because the REST door answers 404 for every verb on it.」2.
.changeset/18990-viewall-only-permissions-seed.md, line 12 (blob261e412f→727fbffa)apiOperations(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.」apiOperations(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 — except an object withenable.apiEnabled: false, annotated[]since core:/auth/me/permissionsapiOperationsignoresenable.apiEnabled: false— an object the REST layer answers 404 for is annotated with the full operation list (annotateEffectiveApiOperations→resolveEffectiveApiMethods) #20135 because the REST door refuses every verb on it.」3.
.changeset/20134-super-user-entries-every-bit.md, line 15 (bloba113da40→d2ee6a58)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.」apiOperations— unless the object declaresenable.apiEnabled: false, which is annotated[]since core:/auth/me/permissionsapiOperationsignoresenable.apiEnabled: false— an object the REST layer answers 404 for is annotated with the full operation list (annotateEffectiveApiOperations→resolveEffectiveApiMethods) #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: falseis annotated[]in every entry, and the REST door answers404 OBJECT_API_DISABLEDfor 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 controlspq_openandpq_exposed, and forcrm_accountin 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 --numstatreads1 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.changesetfiles. Every code and test blob is identical tof2904cd05b:git diff --stat f2904cd05b HEAD -- . ':(exclude).changeset'is empty, and the five files carry blobsda81b3192b,cd0367c631,ab113b718a,070606df33and0ea90cbfedat both heads. So the suites, ablation, stack and gate readings above stand for the code. The branch was not merged withmain, and its merge base is still16c5a33fdd.Gates re-run at
ba66a482b2, with--base 16c5a33fdd(the merge base) and this body as the--event:node scripts/check-empty-changeset.mjs --base 16c5a33fdd: 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.mdand.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 16c5a33fdd --event(this body): exit 0. It prints "✓ This diff introduces nomajorbump." and "✓ LEVEL AXIS: this PR declares clause-②no, so no package here is declared to have grown a published surface.", with declaration lineClause-②: noand no direction arm.node scripts/check-adr-0087-registration.mjs --base 16c5a33fdd --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 16c5a33fdd: exit 0, "✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference)." (3 judged, 3 resolve).origin/mainhas since moved one commit, to8d1f7ab785(feat!: retire the saved-report stack — /api/v1/reports, client.reports, IReportService, the reports capability, sys_saved_report / sys_report_schedule, @objectstack/plugin-reports #20125). It touches none of the six files in this diff, and inpackages/core/src/securityit touches onlyoperation-private-keys.tsand its pin. The branch was not merged, as the seat directed.Acceptance notes
member_defaultonsys_metadatacreate, andexporton 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 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 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 ([finding]/auth/me/permissionsis silent for a wildcard-onlyviewAllRecordsprincipal too — the class #18931 fixed only formodifyAllRecords#18990)./auth/me/permissionsapiOperationsignoresenable.apiEnabled: false— an object the REST layer answers 404 for is annotated with the full operation list (annotateEffectiveApiOperations→resolveEffectiveApiMethods) #20135) ruled Q1 → B after contract review 5851878434. The review measuredresolveEffectiveApiMethods({ apiEnabled: false }).mode === 'unrestricted', so an API-disabled object with noapiMethodsis "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.EffectiveObjectPermissionSchema.apiOperations'.describe()says "Present only when the object tightens exposure via apiMethods". That has been inexact since the export axis (用户级 export 权限轴(接入 P1 预留的 userExportAllowed 槽) #3544), and is now inexact forapiEnabled: falsetoo.packages/specis out of scope, so this is noted only.history/restore/purge/searchare outside the parity column by construction. No REST route gates them by name. The spec'sapiExposureDenialReasonadmits every operation on an unrestricted object, whileresolveEffectiveApiMethodswithholds the flag-gated ones, and that difference lives inpackages/spec. It is unchanged, and the annotation keeps the closure's answer.Session
session_01Bvd69VPa6puiNzzPUroDBx(thedomain:engineseat 1 dispatch, round 22), on branchclaude/issue-20135-api-operations-parity.Generated by Claude Code