Skip to content

Commit 454bbb6

Browse files
fix(rest,runtime): datasource metadata writes require the same capability as the datasource admin door (#21148)
Fixes #21124 Clause-②: no ## What changes Writing a `datasource` definition through `/api/v1/meta` now requires `manage_platform_settings`. That is the capability the datasource admin door (`POST /api/v1/datasources`, `PATCH` and `DELETE /api/v1/datasources/:name`, `DATASOURCE_ADMIN_CAPABILITY` in `admin-routes.ts`) already requires for the same create, update and remove. It is the write-side twin of the read admission #21119 added. - **One predicate, beside the read one.** `metaTypeWriteRefusal` over `META_TYPE_WRITE_CAPABILITIES` in `packages/rest/src/meta-item-read-gate.ts`, next to `metaTypeReadRefusal`. It judges every write verb (`PUT`, `POST`, `PATCH`, `DELETE`) whose `:type` folds to `datasource` (the plural spelling included). It answers `403 PERMISSION_DENIED` with a message naming the capability, or `401 UNAUTHENTICATED` with no identity. It has no `isSystem` arm, for the reason the read predicate gives. - **Asked at each transport's single `/meta` entry, before any write.** `RestServer`'s guarded registrar asks it right after the read admission. It judges the route's declared verb, so it covers the save (each mode, a draft included), the reset, `/publish` and `/rollback`. The runtime dispatcher's `handleMetadataRequest` asks it at the same point. A refused caller writes nothing, and the answer is the same whether or not the name exists. - **The door's own authoring admission still runs after it**, unchanged. A datasource write therefore needs `manage_platform_settings` and `manage_metadata` both. - **`external_catalog` has a read row but no write row.** Its own write door (`POST /datasources/:name/external/refresh-catalog`) requires `FEDERATION_WRITE_CAPABILITY` (`manage_metadata`), which every `/meta` write door already asks. A row names the capability the type's own write door requires: matched, never minted. Every other metadata type and every read route is unchanged. ## Tests - `packages/rest/src/meta-type-write-capability.test.ts`, three batteries: 1. The predicate: verbs judged and not judged, folding, holder admitted, authoring-only / member / unrelated-grant refused, 401 with no identity, the `external_catalog` non-row pinned against `FEDERATION_WRITE_CAPABILITY`. 2. Every write door read off the route table (`PUT`/`DELETE /:type/:name`, `POST /publish`, `POST /rollback`, plus `PUT ?mode=draft`), over a real in-memory store. Each refused caller gets `403 PERMISSION_DENIED` with the store deep-equal to before and zero protocol calls. The refusal is byte-identical for a present and an absent name. A holder still saves, creates, publishes, rolls back and resets. The capability alone does not write (the authoring admission still refuses). An unlisted type is still written by the authoring caller. 3. Agreement with the admin door over one grant store through `resolveAuthzContext`: every principal `/meta` admits to write a datasource is admitted by `POST /datasources`. Non-vacuous: the operator-author passes both, and the authoring-only principal passes neither. - `packages/runtime/src/domains/meta-type-write-capability-parity.test.ts`: `RestServer` and the dispatcher give the same answer for `PUT /meta/datasource[s]/:name` (present and absent name). Both stores are unchanged and no protocol call is made. On the dispatcher, `POST`/`PATCH`/`DELETE` on a datasource path (list and item shapes) are judged at the entry. A holder writes through both, and an unlisted type is unchanged. **Results.** Measured on head `583f706fef` (`git rev-parse --short HEAD` after the last commit, which is the merge of `origin/main` at `c6954d6d09`), unless a row says otherwise: - New and landed type-level batteries plus the every-type org-scope suites: `@objectstack/rest` (`meta-type-write-capability`, `meta-type-read-capability`, `rest-server-meta-org-scope-url-spelling`, `rest-server-meta-write-org-scope`) 4 files, 122 passed. `@objectstack/runtime` (`meta-type-write-capability-parity`, `meta-type-read-capability-parity`, `meta-write-org-scope`) 3 files, 52 passed. - Full package suites (`vitest run --project local`), run at `7cea2eeb76` before the second merge of `origin/main`: rest 4767 passed with 2 failing, and runtime 4314 passed with 1 failing. All three failures were the every-type org-scope fixtures. Their authorized caller held only `manage_metadata`, so its `datasource` write is now refused before it reaches the protocol. The fixture now also holds `manage_platform_settings` (commit `c21920aacd`), so the scope assertion still covers `datasource`, and those suites are green as the row above shows. The incoming `origin/main` commits touch no `/meta` seam. The overlap that does exist (the datasource admin door's record check) is covered by the agreement battery, re-run at `583f706fef`. - `pnpm --filter @objectstack/rest typecheck` exited 0 and `pnpm --filter @objectstack/runtime typecheck` exited 0, both at `7cea2eeb76`; runtime's test-layer debt ledger holds unchanged. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths; the change set derived from the merge base) gave 62 families. All 62 were run at `583f706fef` with exit codes recorded. 61 exited 0. `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs every package built), so it is NOT MEASURED here and is left to CI. As a declared narrowing, `require()` of the CJS `require` targets of the two packages this PR changes (`packages/rest/dist/index.cjs`, `packages/runtime/dist/index.cjs`) loads, and the rest one exports `metaTypeWriteRefusal`. `dispatch-gates --ran` reconciles to 62 derived, 61 run, 1 NOT MEASURED, 0 UNRUN, exit 0. - Lint (a proven narrowing, not the repo-wide run, which belongs to CI): `eslint --no-inline-config --format json` over the changed `.ts` files reported 11 files, 0 errors and 0 warnings. That is a superset of this PR's 9 `.ts` files. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on any untouched file. **Ablation (one-off, from committed head `81a64491f3`, via `scripts/ablation-replace.mjs` under an EXIT trap).** Leg A neutralised the `RestServer` call site, and `meta-type-write-capability.test.ts` went red: 9 failed / 43. Leg B neutralised the dispatcher call site, and `meta-type-write-capability-parity.test.ts` went red: 7 failed / 13. Each leg was restored to its HEAD blob with `git diff HEAD` empty. The direction was the expected one: the authoring-only caller was admitted and the store changed. The dispatcher's member legs stay green under leg B because that transport's existing authoring admission already refuses a member. Only the authoring-only caller depends on the new gate, and those legs went red. ## Acceptance notes - `external_catalog` writes stay at `manage_metadata` by the matched-capability rule above. Raising them would mint a policy that type's own write door does not have. - Doors outside `/meta` that write metadata keep their own admission and are not changed here (for example package install, `POST /packages`). - `POST /meta/_migrate-stored` has no `:type` segment and is not judged by the predicate. It stays `manage_metadata`-only, as ruled. - While the gates ran, the `turbo` CLI from the dev-dependency bump merged from `main` appended its managed `turborepo-agent-rules` block to `AGENTS.md` in this worktree. It was restored to HEAD and is not part of this PR. Reported separately. --- _Generated by [Claude Code](https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 336e191 commit 454bbb6

10 files changed

Lines changed: 740 additions & 5 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(rest,runtime): writing `datasource` metadata through `/api/v1/meta` requires `manage_platform_settings`, the capability the datasource admin door already requires (#21124)
7+
8+
Clause-②: no
9+
10+
- A write of a `datasource` definition through `/api/v1/meta` (and its plural spelling) is now admitted only for a caller who holds `manage_platform_settings`. That is the capability the datasource admin door (`POST /api/v1/datasources`, `PATCH` and `DELETE /api/v1/datasources/:name`) already requires for the same create, update and remove. Every write verb is judged alike: the save (`PUT /meta/datasource/:name`, a draft save included), the reset (`DELETE`), `/publish` and `/rollback`.
11+
- A caller without the capability gets `403` with `error.code` `PERMISSION_DENIED`, and a message that names the capability. Nothing is written. The answer is the same whether or not the named item exists.
12+
- The write doors' own authoring admission is unchanged and still applies, so a datasource write needs `manage_platform_settings` and `manage_metadata` both. Platform administrators hold both through `admin_full_access`. Every other metadata type, and every read route, is unchanged. `external_catalog` writes are unchanged: that type's own write door requires `manage_metadata`.
13+
- Both transports answer the same way: `RestServer`, and the runtime dispatcher's `/meta` domain that a host mounting only the `/api/v1/*` catch-all is served by.
14+
- If you write datasource definitions through `/api/v1/meta` with a caller that holds only an authoring capability (`manage_metadata`, `studio.access` or `setup.access`), grant `manage_platform_settings` to that caller, or write through the datasource admin door with a caller that already holds it.

‎packages/rest/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu
118118
// `/meta` entry, before any store read (`metaTypeReadRefusal` over
119119
// `META_TYPE_READ_CAPABILITIES`): a datasource-family type is read under the
120120
// capability its own door requires.
121+
//
122+
// [#21124] …and its write-side twin (`metaTypeWriteRefusal` over
123+
// `META_TYPE_WRITE_CAPABILITIES`), asked at the same two entries before any
124+
// store write: a datasource definition is written under the capability the
125+
// datasource admin door requires.
121126
export {
122127
createMetaBookTreeAnswer,
123128
createMetaItemAnswer,
@@ -127,11 +132,13 @@ export {
127132
createMetaListAnswer,
128133
isPublicAudienceRead,
129134
META_TYPE_READ_CAPABILITIES,
135+
META_TYPE_WRITE_CAPABILITIES,
130136
metaCallerOrganizationId,
131137
metaItemLayersDeprecationHeaders,
132138
metaReadOrganizationId,
133139
metaRequestLocale,
134140
metaTypeReadRefusal,
141+
metaTypeWriteRefusal,
135142
projectMetaObjectSchema,
136143
refuseUnknownMetaListType,
137144
STORED_VERSION_DOOR_POLICY,
@@ -159,4 +166,5 @@ export type {
159166
MetaReadGatePolicy,
160167
MetaRequestHttp,
161168
MetaTypeReadRefusal,
169+
MetaTypeWriteRefusal,
162170
} from './meta-item-read-gate.js';

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1684,7 +1684,9 @@ export function isPublicAudienceRead(
16841684
* Uniform, for the reason the datasource admin door gives for gating even its
16851685
* static driver catalog: a family whose floor has one hole has to be read
16861686
* route by route. Writes are not judged here — the save door keeps its own
1687-
* admission (`metaWriteCapabilityVerdict`).
1687+
* admission (`metaWriteCapabilityVerdict`), and a write of a type whose own
1688+
* write door requires more is judged by the twin below
1689+
* ({@link metaTypeWriteRefusal}, #21124).
16881690
*
16891691
* Both transports ask {@link metaTypeReadRefusal} at their single `/meta`
16901692
* entry, right after the anonymous deny: `RestServer`'s guarded registrar
@@ -1761,6 +1763,110 @@ export function metaTypeReadRefusal(
17611763
};
17621764
}
17631765

1766+
// ── The type-level write capability ───────────────────────────────────────────
1767+
1768+
/**
1769+
* [#21124] The metadata types whose `/meta` WRITES require a platform
1770+
* capability beyond the authoring admission every write door already asks
1771+
* (`metaWriteCapabilityVerdict`) — the write-side twin of
1772+
* {@link META_TYPE_READ_CAPABILITIES}, judged the same way: per TYPE, once, at
1773+
* each transport's `/meta` entry, before any handler resolves the protocol or
1774+
* writes the store; never per document.
1775+
*
1776+
* ## The row, and why the capability
1777+
*
1778+
* - `datasource` → `manage_platform_settings`. The datasource family's own
1779+
* write doors — `POST /api/v1/datasources`, `PATCH` and `DELETE
1780+
* /api/v1/datasources/:name` (`admin-routes.ts` in
1781+
* `@objectstack/service-datasource`, `DATASOURCE_ADMIN_CAPABILITY`) —
1782+
* create, update and remove the same stored definition a `/meta` write of
1783+
* this type persists, and admit only this capability. One operation reached
1784+
* through two mounted doors cannot admit two different sets of callers.
1785+
*
1786+
* ## Why `external_catalog` has no row here
1787+
*
1788+
* Its READ row exists because its read door requires
1789+
* `manage_platform_settings`. Its WRITE door — `POST
1790+
* /datasources/:name/external/refresh-catalog` — requires
1791+
* `FEDERATION_WRITE_CAPABILITY` (`manage_metadata`, in
1792+
* `./external-datasource-routes.ts`), which every `/meta` write door already
1793+
* demands. ⛔ A row here names the capability the type's own WRITE door
1794+
* requires — matched, never minted; a type whose write door asks nothing beyond
1795+
* the authoring admission has no row.
1796+
*
1797+
* ## Which requests
1798+
*
1799+
* Every write verb — `PUT`, `POST`, `PATCH`, `DELETE` — whose `:type` segment
1800+
* folds to a listed type, on every route shape: the item save in each of its
1801+
* modes (a draft included), the reset, `/publish` and `/rollback`, and any
1802+
* write verb a transport answers on a read route. A route with no `:type`
1803+
* segment (`POST /meta/_migrate-stored`) is not judged here. The authoring
1804+
* admission still runs after this one: a holder of the capability is also
1805+
* asked for `manage_metadata` by the door itself, as before.
1806+
*/
1807+
export const META_TYPE_WRITE_CAPABILITIES: Readonly<Record<string, string>> = Object.freeze({
1808+
datasource: 'manage_platform_settings',
1809+
});
1810+
1811+
/** The verbs {@link metaTypeWriteRefusal} judges. */
1812+
const META_WRITE_VERBS: ReadonlySet<string> = new Set(['PUT', 'POST', 'PATCH', 'DELETE']);
1813+
1814+
/**
1815+
* [#21124] Why a `/meta` write of a {@link META_TYPE_WRITE_CAPABILITIES} type is
1816+
* not taken — the same two arms, envelope and codes as
1817+
* {@link MetaTypeReadRefusal}.
1818+
*/
1819+
export type MetaTypeWriteRefusal = MetaTypeReadRefusal;
1820+
1821+
/**
1822+
* [#21124] THE type-level write admission of the `/meta` surface — see
1823+
* {@link META_TYPE_WRITE_CAPABILITIES} for the row and the reason.
1824+
*
1825+
* Same inputs and same folding as {@link metaTypeReadRefusal}: `method` the
1826+
* request's verb, `type` the RAW `:type` segment (folded here through
1827+
* `canonicalMetaUrlType` and `pluralToSingular`, so `/meta/datasources`
1828+
* cannot fall outside the row `/meta/datasource` is in), `caller` the
1829+
* request's resolved execution context.
1830+
*
1831+
* Answers `undefined` — go on, nothing sent — for a read verb, for an unlisted
1832+
* type, and for a caller whose resolved `systemPermissions` hold the row's
1833+
* capability. Otherwise the refusal, decided before the door resolves the
1834+
* protocol, so nothing is written and the answer is the same whether or not
1835+
* the named item exists. The message names the capability and nothing else.
1836+
*
1837+
* ⛔ No `isSystem` arm, for the reason {@link metaTypeReadRefusal} gives:
1838+
* inbound HTTP never carries `isSystem`, and the datasource admin door reads
1839+
* the held set alone.
1840+
*/
1841+
export function metaTypeWriteRefusal(
1842+
method: unknown,
1843+
type: unknown,
1844+
caller: unknown,
1845+
): MetaTypeWriteRefusal | undefined {
1846+
const verb = String(method ?? '').toUpperCase();
1847+
if (!META_WRITE_VERBS.has(verb)) return undefined;
1848+
if (typeof type !== 'string' || type.length === 0) return undefined;
1849+
const canonical = canonicalMetaUrlType(type);
1850+
const folded = Object.prototype.hasOwnProperty.call(META_TYPE_WRITE_CAPABILITIES, canonical)
1851+
? canonical
1852+
: pluralToSingular(type);
1853+
if (!Object.prototype.hasOwnProperty.call(META_TYPE_WRITE_CAPABILITIES, folded)) return undefined;
1854+
const capability = META_TYPE_WRITE_CAPABILITIES[folded];
1855+
const ctx = caller && typeof caller === 'object'
1856+
? caller as { userId?: unknown; systemPermissions?: unknown }
1857+
: undefined;
1858+
if (!ctx?.userId) {
1859+
return { status: ANONYMOUS_DENY_STATUS, code: ANONYMOUS_DENY_CODE, message: ANONYMOUS_DENY_MESSAGE };
1860+
}
1861+
const held = Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : [];
1862+
if (held.includes(capability)) return undefined;
1863+
return {
1864+
status: 403,
1865+
code: 'PERMISSION_DENIED',
1866+
message: `Writing ${folded} metadata requires the \`${capability}\` capability.`,
1867+
};
1868+
}
1869+
17641870
// ── The list route's unknown-type refusal ─────────────────────────────────────
17651871

17661872
/**

0 commit comments

Comments
 (0)