You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit adb2b89
Browse filesBrowse the repository at this point in the historyBrowse files
fix(rest,runtime)!: the import doors and the catalog refresh read the walled-catalog verdict the metadata save door reads (#30702)
Clause-②: no (narrowing) — under the `isolated` and `group` tenancy
postures, importing a remote table as an object (`POST
/api/v1/datasources/:name/external/tables/:remote/import`, and the MCP
`import_object` tool) and refreshing a federated datasource's cached
catalog (`POST /api/v1/datasources/:name/external/refresh-catalog`) are
writes to the environment's metadata catalog, and they are the platform
operator's, as every other write to that catalog is.
Metadata-protocol: none
Skills-docs: none — no `skills/**` text teaches any of the three doors
or who may call them (0 hits for the import route, the refresh route,
`refreshCatalog` and `import_object` under `skills/`; `datasource`
itself is named there, the positive control); the rule is stated in the
docs this diff updates (`content/docs/deployment/tenancy-modes.mdx`,
`content/docs/data-modeling/odata-datasources.mdx`).
## 维护者速读
- 改了什么:三道写入门——REST 远程表导入路由、MCP `import_object` 工具、REST 外部目录刷新路由——在
`isolated` / `group` 租户姿态下,与元数据保存门问同一个“围墙目录写入”裁决。
- 为什么改:导入把一个对象存进环境共享的元数据目录,目录刷新把数据源的 `external_catalog`
快照存进同一个目录;围墙姿态下这个目录只归平台运营方(与引擎自身)写,所以这三道门与保存门读同一条裁决。
- 风险与回滚:围墙姿态下,平台运营方以下的调用方导入或刷新目录改为 `403
PERMISSION_DENIED`,什么都不读取、不保存;租户服务已注册却答不出时回 `503`;`single`
与未注册租户服务的部署不变;回滚即还原本 PR,已导入的对象与已存的快照不受影响。
-
先看:`packages/rest/src/external-datasource-routes.ts`(`refuseFederationRequest`
的 `catalogWrite`
段,导入与刷新两条路由共用)、`packages/runtime/src/domains/mcp.ts`(`federationService`
的 `catalogWrite`
段)、`packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts`。
- 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run
test/external-import-walled-catalog-refusal.dogfood.test.ts`,预期 9
个用例全部通过(围墙下三道门拒绝、运营方可导入与刷新、`single` 下照常导入与刷新)。
- 需要你做的:无,席位落地。
## What changes
- **The rule.** Under `isolated` and `group` every organization shares
the environment's metadata catalog, and a write to it is admitted only
for the platform operator and for the engine's own writes (ADR-0131 D3,
D12 items 7–8, D11). Three doors of the federation family write that
catalog: the import of a remote table as an object, at the REST route
and at the MCP `import_object` tool, saves an `object`; the catalog
refresh at the REST route saves the datasource's `external_catalog`
snapshot. Each of them now asks the walled-catalog verdict the metadata
save door asks (`walledCatalogWriteVerdict`,
`@objectstack/metadata-core`) for the type it writes, after the
federation family's capability rule and before the federation service is
looked up.
- **The answer.** A refused call answers `403 PERMISSION_DENIED` with
the verdict's sentence for the type written, which is the sentence the
metadata save door answers, naming the posture. Nothing is introspected
and nothing is saved. A `tenancy` service that is registered and cannot
answer refuses the call `503 SERVICE_UNAVAILABLE`, never a permit.
- **Unchanged.** The platform operator imports and refreshes under a
wall as before. Under `single`, and on a deployment with no `tenancy`
service, every caller holding both federation capabilities imports and
refreshes as before. Listing remote tables, drafting an object and
validating are unchanged, and the federation capability rule answers
first, as before.
- **Docs.** `deployment/tenancy-modes` lists the import and the catalog
refresh among the walled writes; `data-modeling/odata-datasources`
states who imports and refreshes under a wall; row 55b of the
system-context census names the doors, with its register regenerated.
The `walledCatalogWriteVerdict` doc comment and its ADR anchor name the
three doors among the doors that ask it.
## Tests
- `packages/rest/src/external-datasource-routes-auth-guard.test.ts`: for
the import route and for the catalog refresh route alike, under `group`
and `isolated` an administrator holding both federation capabilities is
refused `403 PERMISSION_DENIED` before the service is looked up; the
sentence equals the verdict's sentence for the type the route writes
(`object`, `external_catalog`); the capability rule still answers first;
listing is still served; the platform operator and the `single` /
no-tenancy controls are served; a failing `tenancy` service answers `503
SERVICE_UNAVAILABLE`.
- `packages/runtime/src/domains/mcp-federation-bridge.test.ts`: the same
rule, refusals and controls at the MCP `import_object` bridge.
-
`packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts`:
the ledger's context registers no `tenancy` service, as on a deployment
that is not walled, so its import rows still reach the federation
service the ledger reads.
-
`packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts`:
a booted showcase under `group` with the federation service and the MCP
server mounted. An organization administrator holding both capabilities
is refused at the REST import door and at the catalog refresh with the
save door's status and sentence for the type written, and at
`import_object` (a real OAuth agent token granted `metadata:write` and
`actions:execute`) with the same code and sentence; nothing is saved or
stored. Controls: the platform operator imports at both import doors and
refreshes the catalog under the same wall; under `single` the same kind
of administrator does each as before.
## Acceptance notes
- **Where the rule sits: at the doors, not in the shared save path.**
The save path below the doors (`saveMetaItem` behind `importObject`, the
catalog persistence behind `refreshCatalog`) receives the item, not the
caller's rung or whether the caller is the engine, so judging there
would mean threading the caller into the federation service contract.
The doors are where caller admission already lives in this family (the
capability rule runs there), so the walled verdict runs beside it and
refuses before any landing is resolved or any remote schema is read. On
the REST side the two write routes share the one guard
(`refuseFederationRequest`), each naming the type it writes.
- **The code.** The status and the sentence are the save door's. The
code is `PERMISSION_DENIED`, the standard-catalog member this family
already answers for a 403; the REST `/meta` door answers its
grandfathered synonym (`STANDARD_SYNONYM_WAIVERS`, ADR-0112 D3), which
keeps a wire value and is not endorsed for new code.
- **Breaking, patch.** An accept-set narrowing under a walled posture
only; no key of any schema moves and nothing stored changes, so both
packages take a patch with a BREAKING banner (ADR-0087 marker
`not-required (no-migration-prescription)`). The changeset names all
three doors.
## Verification
All readings below are at head
`b5648f2fb463a1d4c70e8198d98fe3093711c5a4`, after `origin/main` was
merged in (merge commit, no conflicts; `main` had moved `rest`,
`runtime` and `service-datasource`), the closure rebuilt (`pnpm --filter
'@objectstack/dogfood^...' build`) and every build and test run under
the shared verify lock.
<details><summary>Pins at the head</summary>
- `external-datasource-routes-auth-guard.test.ts`: Tests 27 passed (27).
- `mcp-federation-bridge.test.ts`: Tests 17 passed (17).
- `external-import-walled-catalog-refusal.dogfood.test.ts`: Tests 9
passed (9).
</details>
<details><summary>Ablations</summary>
Each through `scripts/ablation-replace.mjs` (anchor hit once, blob
changed, restored blob-equal to HEAD with `git diff HEAD` empty), on the
committed tree. Where the dogfood pin was read, the package was rebuilt
and `scripts/ablation-dist-preflight.mjs` proved the mutation in `dist/`
first; after each restore the package was rebuilt and the preflight
proved `dist/` back at the head with the tree clean.
| subject | ablation | unit reading | dogfood reading |
| --- | --- | --- | --- |
| the shared REST refusal (import and refresh) | the walled refusal made
unreachable | 6 failed \| 21 passed (27): `group`, `isolated` and the
verdict sentence, for the import and for the refresh | 3 failed \| 6
passed (9): the REST import, the MCP import (its sentence is compared
with the REST door's) and the refresh refusal pins |
| the MCP refusal | the same, in `federationService` | 3 failed \| 14
passed (17): `group`, `isolated`, verdict sentence | 1 failed \| 8
passed (9): the MCP refusal pin |
| the REST outage arm | an unreadable posture read as no posture | 2
failed \| 25 passed (27): the import and the refresh `503` pins | not
read (no `503` pin there) |
| the MCP outage arm | the same, at the MCP posture read | 1 failed \|
16 passed (17): the `503` pin | not read |
| the catalog refresh's wiring | the refresh route stops naming the type
it writes | 4 failed \| 23 passed (27): the refresh's `group`,
`isolated`, verdict-sentence and `503` pins; every import pin green | 1
failed \| 8 passed (9): the refresh refusal pin; every import pin and
every control green |
After the last restore: `git status --porcelain` empty and `git diff
HEAD` empty.
</details>
<details><summary>Package tests and typecheck</summary>
Under the verify lock at the head:
- `pnpm --filter @objectstack/rest test`: Test Files 391 passed (391),
Tests 7409 passed | 332 skipped.
- `pnpm --filter @objectstack/runtime test`: Test Files 547 passed
(547), Tests 8762 passed | 19 skipped.
- `pnpm --filter @objectstack/metadata-core test`: Test Files 23 passed
(23), Tests 911 passed.
- `pnpm --filter @objectstack/service-datasource test` (the service
behind all three doors, not in this diff): Test Files 72 passed (72),
Tests 1239 passed.
- `typecheck` for rest, runtime, metadata-core, service-datasource and
dogfood: exit 0, 0 `error TS` lines each.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) at `b5648f2fb4`: 99 commands,
each run as printed with stdin from `/dev/null` and its exit code
recorded. `--ran`: 99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN. Two
first answered exit 3 (`check:skill-examples` and
`check:dual-build-cjs-loads`, prerequisite packages outside this diff
unbuilt) and were re-run after building those packages: exit 0. Every
recorded exit is 0.
`pnpm --filter @objectstack/spec check:generated` after the merge
(`main` moved `packages/spec`): all 14 generated artifacts up to date.
</details>
<details><summary>Full dogfood battery at the head</summary>
`b5648f2fb463a1d4c70e8198d98fe3093711c5a4`, the closure built at that
head, then under the lock `pnpm --filter @objectstack/dogfood test` with
no shard and no path:
- `os-verify-lock: VERDICT command-exit 0`
- Test Files 424 passed | 3 skipped (427)
- Tests 3541 passed | 41 skipped (3582)
- no failures; `git status --porcelain` empty after the run
</details>
This body was rewritten for head `b5648f2fb4` in session
`session_01L9qibvynRbCtMndW8EHNGc`, after the catalog refresh joined the
two import doors.
<!-- signed: security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
-->
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(rest,runtime)!: the import doors and the catalog refresh read the walled-catalog verdict the metadata save door reads
7
+
8
+
Clause-②: no (narrowing) — under the `isolated` and `group` tenancy postures, importing a remote table as an object (`POST /api/v1/datasources/:name/external/tables/:remote/import`, and the MCP `import_object` tool) and refreshing a federated datasource's cached catalog (`POST /api/v1/datasources/:name/external/refresh-catalog`) are writes to the environment's metadata catalog, and they are the platform operator's, as every other write to that catalog is.
9
+
Metadata-protocol: none
10
+
11
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and nothing at rest changes: which caller an import door or the catalog refresh admits under a walled tenancy posture is all that moves, and every object already imported and every catalog snapshot already stored stays as it is. -->
12
+
13
+
**BREAKING** accept-set narrowing at both import doors and at the catalog refresh (`@objectstack/rest`, `@objectstack/runtime`) under a walled tenancy posture.
14
+
15
+
-**The rule.** Under `isolated` and `group` every organization shares the environment's metadata catalog, and a write to it is admitted only for the platform operator and for the engine's own writes (ADR-0131 D3, D12 items 7–8, D11). An import saves an object into that catalog, and a catalog refresh saves the datasource's `external_catalog` snapshot there, so each of those doors asks the walled-catalog verdict the metadata save door asks (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`) for the type it writes, after the federation capability rule and before the federation service is looked up.
16
+
-**The answer.** A refused call answers `403 PERMISSION_DENIED` with the verdict's sentence for the type written, the one the metadata save door answers, naming the posture. Nothing is introspected and nothing is saved. A `tenancy` service that is registered and cannot answer refuses the call `503 SERVICE_UNAVAILABLE`.
17
+
-**Unchanged.** The platform operator imports and refreshes under a wall as before. Under `single`, and on a deployment with no `tenancy` service, every caller holding both federation capabilities imports and refreshes as before. Listing remote tables, drafting an object and validating are unchanged, and the federation capability rule answers first, as before.
18
+
-**Who moves.** On a walled deployment, an import or a catalog refresh by a caller below the platform operator is refused; the platform operator performs it.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+3-2Lines changed: 3 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -204,7 +204,7 @@ One of the largest consumers of the flag;
204
204
| 54 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served |`refusePackageRequest`|
205
205
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability |`requireManageMetadata`, `requireReadCapability`|
206
206
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability |`refuseUngrantedActivationWrite`, `refuseUngrantedActivationAuthoring`|
207
-
| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite` |
207
+
| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), at the permission-set clone door, at the import of a remote table as an object (the REST import route and the MCP `import_object` tool), and at the refresh of a federated datasource's cached catalog. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite`, `registerExternalDatasourceRoutes`, `federationService` |
208
208
| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes, and the elevated self-triggered start and the start-record check at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). And starting, through the same doors, a flow on a record key the caller's own scope does not deliver, which every other caller is refused with the action door's `404 RECORD_NOT_FOUND` before the run exists (and `400 VALIDATION_FAILED` for a key that is not one id, or for an elevated start that names no object). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal`, `refuseUnreadableFlowStartRecords` |
209
209
| 57b | Connector credential door's capability and posture gates pass | runtime | Get: listing, setting and clearing the environment's stored connector credentials (`/automation/connectors/credential-refs`) without `manage_metadata`, and under the `group` / `isolated` postures, where the door refuses every other caller `403 PERMISSION_DENIED` naming the posture, the platform operator included, because the store is environment-level. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more about the value: no answer of this door carries it |`refuseUngrantedConnectorCredentialAccess`, `refuseWalledConnectorCredentialAccess`|
210
210
| 57c | Connector connection door's capability gate passes | runtime | Get: reading, beginning, completing (the OAuth callback) and revoking an organization's connection of an `oauth2``authorization_code` connector instance (`/automation/connectors/:name/connection`, `/automation/connectors/oauth/callback`, ADR-0143 D5) without `manage_metadata`. No posture refuses it: a connection belongs to its organization. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more: no answer of this door carries a token, and the callback's `state` is still bound to the user and organization that began the connect |`refuseUngrantedConnectorConnectionAccess`|
@@ -618,7 +618,7 @@ and CI refuses one that differs from what the tree produces.
0 commit comments