Skip to content

fix(rest): enforce per-object API access on the cross-object batch route (#1604) - #3229

Merged
os-zhuang merged 4 commits into
mainfrom
claude/cross-object-atomic-batch-write-2k04db
Jul 18, 2026
Merged

fix(rest): enforce per-object API access on the cross-object batch route (#1604)#3229
os-zhuang merged 4 commits into
mainfrom
claude/cross-object-atomic-batch-write-2k04db

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Summary

Closes the security gap that kept the cross-object atomic batch (issue #1604 / ADR-0034) from being shippable. The engine foundation (ambient transaction, ADR-0034) and the POST {basePath}/batch route already exist and work; the endpoint's own "dedicated reviewed change" caveat surfaced one real hole and some rough edges, fixed here.

The gap

POST {basePath}/batch wraps N cross-object create/update/delete ops in one engine transaction, but — unlike every single-record write route — it skipped the per-object API-exposure gate (enforceApiAccess). An authenticated caller could therefore:

  • write to an object with enable.apiEnabled: false (hidden from the API), or
  • run an operation outside an object's enable.apiMethods whitelist,

straight through the batch surface. This is the same "declared ≠ enforced" hole (ADR-0049 / #1889) recently closed for the generic write path in #3220 / #3213 — the batch route was the one remaining bypass.

What changed (packages/rest, packages/spec)

  • Per-object API access on every op.enable.apiEnabled / enable.apiMethods are now enforced for each operation before the transaction is opened — 404 OBJECT_API_DISABLED / 405 OBJECT_API_METHOD_NOT_ALLOWED. Object metadata is fetched once and each distinct (object, action) checked once. enforceApiAccess was refactored to share a pure apiAccessDenialFromEnable check + a loadObjectItems helper with the batch route — single-record behavior is unchanged (covered by the existing rest.test.ts).
  • Zod-First request contract. New CrossObjectBatchRequestSchema / CrossObjectBatchOperationSchema / CrossObjectBatchResponseSchema in @objectstack/spec/api; the route validates the body against it, so a malformed op / unknown action / missing object is a 400, not a 500.
  • Honest edges:update/delete require an id (400); an unresolvable { $ref } is 400 BATCH_UNRESOLVED_REF instead of a silently-written null FK; an explicit atomic: false is rejected (400 BATCH_NOT_ATOMIC) rather than silently applied atomically (non-atomic per-object batches stay on POST /data/:object/batch).

Tests

Adds packages/rest/src/rest-batch-endpoint.test.ts — the REST-boundary coverage ADR-0034 explicitly flagged as missing (multi-op commit, $ref resolution, atomic rollback surfacing, API-access denial 404/405, and request validation 400s). Engine-level atomicity remains covered by engine-ambient-transaction.test.ts.

Verified locally: @objectstack/rest 323 passed (incl. 15 new), @objectstack/spec batch 25 passed, @objectstack/objectql ambient-tx 4 passed; rest + spec build clean.

ObjectUI

No change needed — the masterDetailTxdataSource.batchTransactionPOST /api/v1/batch wiring already exists and is compatible: it always supplies an id for update/delete ops and only sends the four contract fields.

🤖 Generated with Claude Code

https://claude.ai/code/session_015DgfsaEfmwVAY1SsPtQJ6U


Generated by Claude Code

…ute (#1604)
The POST {basePath}/batch cross-object transactional batch (issue #1604 /
ADR-0034) wraps N create/update/delete ops in one engine transaction but skipped
the per-object API-exposure gate every single-record write applies — so an
authenticated caller could write to an apiEnabled:false object, or run an
operation outside an object's apiMethods whitelist, straight through the batch
surface (ADR-0049 / #1889; the same declared-not-enforced hole closed for the
generic write path in #3220 / #3213).
- Validate the request against a new CrossObjectBatchRequestSchema
(@objectstack/spec/api, Zod-First); a malformed op / unknown action / missing
object is now a 400, not a 500.
- Enforce enable.apiEnabled / apiMethods for EVERY op (metadata fetched once,
each distinct object+action checked once) BEFORE opening the transaction →
404 OBJECT_API_DISABLED / 405 OBJECT_API_METHOD_NOT_ALLOWED.
- Require an id for update/delete; reject an unresolvable {$ref} with 400
BATCH_UNRESOLVED_REF instead of writing a silent null FK; reject an explicit
atomic:false (400 BATCH_NOT_ATOMIC).
- Refactor enforceApiAccess to share the pure apiAccessDenialFromEnable check +
a loadObjectItems helper with the batch route (single-record behavior
unchanged).
- Add rest-batch-endpoint.test.ts — the REST-boundary coverage ADR-0034 flagged
as missing (commit, $ref, rollback surfacing, API-access denial, validation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DgfsaEfmwVAY1SsPtQJ6U
@vercel

vercelBot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 18, 2026 4:31pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/l labels Jul 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/spec.

102 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx(via packages/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via packages/spec)
  • content/docs/concepts/north-star.mdx(via packages/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/cli.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via packages/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via packages/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/spec)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via packages/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…cs reference
CI follow-up on the cross-object batch hardening (#1604):
- The per-op (object, action) dedup key in the /batch handler used a raw NUL
(0x00) byte as its separator, which trips the check:nul-bytes gate (a raw NUL
makes the file read as binary to grep/ripgrep). Replaced with the standard
unicode NUL escape sequence, matching the convention already used for the
exec-ctx memo key elsewhere in rest-server.ts. Byte-identical at runtime.
- Regenerated content/docs/references/api/batch.mdx (generated from the Zod
spec) so it documents the new CrossObjectBatch* schemas — the check:docs gate
requires the reference to track packages/spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DgfsaEfmwVAY1SsPtQJ6U
claude added 2 commits July 18, 2026 15:55
…ports
check:api-surface flagged 6 additive public exports (CrossObjectBatch{Operation,Request,Response} + their schemas) from #1604 — 0 breaking, 6 added. Regenerate the committed snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DgfsaEfmwVAY1SsPtQJ6U
…nt + error semantics
Keep the hand-written batch-endpoint docs honest about the behavior added in
this PR (Prime Directive #10): per-object API-exposure gate (404/405), request
validation (400), unresolvable $ref (400 BATCH_UNRESOLVED_REF), and atomic-only
(400 BATCH_NOT_ATOMIC). Also list the cross-object POST /batch row in the
implementation-status endpoint table. Generated reference (api/batch.mdx) is
regenerated separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DgfsaEfmwVAY1SsPtQJ6U
@os-zhuang
os-zhuang marked this pull request as ready for review July 18, 2026 16:03
@os-zhuang
os-zhuang merged commit 43a3efb into mainJul 18, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/cross-object-atomic-batch-write-2k04db branch July 18, 2026 16:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude