Skip to content

Commit 3d964b4

Browse files
committed
fix(rest,runtime): datasource metadata writes require the same capability as the datasource admin door
A /meta write (PUT, POST, PATCH, DELETE: save, reset, publish, rollback) of a `datasource` definition is admitted only for a caller holding `manage_platform_settings`, the capability the datasource admin door requires for the same create / update / remove. Judged once per type at each transport's /meta entry (RestServer's guarded registrar and the runtime dispatcher's handleMetadataRequest), beside the read-side admission, before the door's own authoring admission runs. Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a606a9 commit 3d964b4

4 files changed

Lines changed: 152 additions & 1 deletion

File tree

‎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
/**

‎packages/rest/src/rest-server.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5319,6 +5319,26 @@ export class RestServer {
53195319
sendEnvelopeError(res, typeRefusal.status, typeRefusal.code, typeRefusal.message);
53205320
return;
53215321
}
5322+
// [#21124] …and its write-side twin: a write of a
5323+
// datasource definition is admitted on the capability
5324+
// the datasource admin door requires for the same
5325+
// create / update / remove. Asked here, before the
5326+
// door's own authoring admission resolves the protocol,
5327+
// so a refused caller writes nothing and is told the
5328+
// same thing whether or not the name exists. The verb
5329+
// judged is the ROUTE's declared one (`RouteEntry.method`
5330+
// is required) — the door being entered. The decision
5331+
// is `metaTypeWriteRefusal`, which the runtime
5332+
// dispatcher's `/meta` entry asks too.
5333+
const writeRefusal = metaReadGate.metaTypeWriteRefusal(
5334+
entry.method,
5335+
req?.params?.type,
5336+
context,
5337+
);
5338+
if (writeRefusal) {
5339+
sendEnvelopeError(res, writeRefusal.status, writeRefusal.code, writeRefusal.message);
5340+
return;
5341+
}
53225342
return (inner as (rq: any, rs: any) => unknown)(req, res);
53235343
},
53245344
} as any);

‎packages/runtime/src/domains/meta.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
metaReadOrganizationId,
7272
metaRequestLocale,
7373
metaTypeReadRefusal,
74+
metaTypeWriteRefusal,
7475
projectMetaObjectSchema,
7576
refuseUnknownMetaListType,
7677
STORED_VERSION_DOOR_POLICY,
@@ -933,6 +934,22 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin
933934
};
934935
}
935936

937+
// [#21124] …and the write-side twin, asked at the same point of the same
938+
// predicate `RestServer`'s registrar asks: a write verb on a `/meta` path
939+
// whose type is a datasource definition is admitted on the capability the
940+
// datasource admin door requires for the same create / update / remove —
941+
// before the `PUT` branch's own authoring admission resolves the protocol,
942+
// so a refused caller writes nothing, whatever route shape the request
943+
// took and whether or not the name exists. A request that names no verb is
944+
// a read (see above), so it is never judged here.
945+
const typeWriteRefusal = metaTypeWriteRefusal(method, parts[0], _context.executionContext);
946+
if (typeWriteRefusal) {
947+
return {
948+
handled: true,
949+
response: deps.error(typeWriteRefusal.message, typeWriteRefusal.status, { code: typeWriteRefusal.code }),
950+
};
951+
}
952+
936953
// GET /metadata/types
937954
if (parts[0] === 'types') {
938955
// PRIORITY 1: Try protocol service — it returns BOTH legacy

0 commit comments

Comments
 (0)