Skip to content

Commit 9a1ed7a

Browse files
Elon Muskclaude
andauthored
security(rest): require a capability on the external-datasource federation family (#10254)
* security(rest): gate the external-datasource federation family on a capability Read routes require manage_platform_settings (converging with the #9593 admin twin); the two write routes require manage_metadata. Refs #9901 * test(rest): pin the federation capability gate and fold back the twin known-gap row Refs #9901 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 128684d commit 9a1ed7a

6 files changed

Lines changed: 497 additions & 95 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
**Behaviour change (tightening) — a capability is now required on the external-datasource federation family** (`/api/v1/datasources/:name/external/*`, #9901). These routes previously admitted **any authenticated caller**; four of the five now also require a platform capability. Maintainer ruling, 2026-08-20 (verbatim: 「其他接受你的建议。」).
6+
7+
**This is published SDK surface.** `datasources.external.*` on `ObjectStackClient` reaches exactly these routes, and the CLI's `datasource` commands go through them. An existing integration that presents a valid credential — a better-auth session or a `sys_api_key` — and holds neither capability was served before and is **refused now**. Nothing about the credential itself changed; what changed is what the credential must carry.
8+
9+
| route | SDK call | now requires |
10+
| --- | --- | --- |
11+
| `GET /:name/external/tables` | `datasources.external.listTables` | `manage_platform_settings` |
12+
| `POST /:name/external/tables/:remote/draft` | `datasources.external.draft` | `manage_platform_settings` |
13+
| `POST /:name/external/tables/:remote/import` | `datasources.external.import` | `manage_metadata` |
14+
| `POST /:name/external/refresh-catalog` | `datasources.external.refreshCatalog` | `manage_metadata` |
15+
| `POST /:name/external/validate` | `datasources.external.validate` | *(unchanged — authentication only)* |
16+
17+
A refusal is **`403` with the standard catalog code `PERMISSION_DENIED`** (ADR-0112; deliberately not the grandfathered `FORBIDDEN` synonym), and the message names the missing capability so the caller knows which grant to request. The anonymous floor is unchanged: no identity is still `401 UNAUTHENTICATED`.
18+
19+
**Why these two capabilities.** The first two routes are the declared twins of `GET /:name/remote-tables` and `POST /:name/object-draft` on the datasource-admin spelling, which has required `manage_platform_settings` since #9593 — the same operation was reachable through two mounted routes with two different admission policies, so an agent or integration refused at one spelling was served at the other. The two write routes have no twin and create live metadata (the import mounts a runtime-origin federated object; the refresh rewrites the cached catalog snapshot), so they take `manage_metadata`, this package's existing gate for metadata creation.
20+
21+
**Migration.** Grant the caller's permission set the capability its routes need — `manage_platform_settings` for remote-schema introspection, `manage_metadata` for import/refresh. The platform's `admin_full_access` set already carries both, so admin-credentialed integrations are unaffected; a purpose-built operator set is the case to check.

‎packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,16 @@ function boot(opts: {
143143
// authorized caller reaches it in production, keeping this test's subject
144144
// (does the advertised URL resolve and answer in the mounted table) intact.
145145
// The gate itself is pinned in `package-envelope.conformance.test.ts`.
146+
// [#9901] `manage_platform_settings` joins the set for the same reason:
147+
// the federation family's read routes now carry a capability gate too, and
148+
// the advertised `…/external/tables` URL is driven below. Without it that
149+
// probe would read a 403 and this pin's subject (does the advertised URL
150+
// resolve and answer in the mounted table) would quietly become an authz
151+
// assertion. That gate is pinned in
152+
// `external-datasource-routes-auth-guard.test.ts`.
146153
resolveExecutionContext: async () => ({
147-
userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
154+
userId: 'u_pkg',
155+
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access', 'manage_platform_settings'],
148156
}),
149157
enableProjectScoping: opts.enableProjectScoping,
150158
projectResolution: opts.projectResolution,

‎packages/rest/src/external-datasource-envelope.conformance.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,22 @@ interface Captured {
5151
* would read the 401 body instead of the arm it names, and this file would
5252
* silently stop measuring what it exists to measure.
5353
*
54+
* [#9901] …and an ENTITLED one: four of the five routes now also require a
55+
* capability (`manage_platform_settings` on the reads, `manage_metadata` on the
56+
* writes), so this stub holds both. Same reasoning one step further — a
57+
* resolver carrying an identity but no grants would turn every case below into
58+
* a reading of the 403 body. Holding both rather than one per case is
59+
* deliberate: which capability each route requires is not this file's subject,
60+
* and pinning it twice would make the split harder to change in the one place
61+
* that does own it.
62+
*
5463
* The guard itself is pinned in `external-datasource-routes-auth-guard.test.ts`;
5564
* the 401's own envelope is the last case below, which is this file's business.
5665
*/
57-
const CREDENTIALED = async () => ({ userId: 'u_env_conformance' });
66+
const CREDENTIALED = async () => ({
67+
userId: 'u_env_conformance',
68+
systemPermissions: ['manage_platform_settings', 'manage_metadata'],
69+
});
5870

5971
/**
6072
* A resolver that RESOLVES, and resolves to no identity — the anonymous case as

‎packages/rest/src/external-datasource-routes-auth-guard.test.ts‎

Lines changed: 199 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
/**
44
* [#9686] The `/api/v1/datasources/:name/external/*` federation family requires
5-
* an authenticated caller — on every route, read and write alike.
5+
* an authenticated caller — on every route, read and write alike — and
6+
* [#9901] a CAPABILITY above that on four of the five.
67
*
78
* ## What this pins, and why it is driven through the real plugin
89
*
@@ -37,6 +38,29 @@
3738
* refused credentialed callers would be this fix breaking the feature, and
3839
* a one-sided pin could not tell the two apart.
3940
*
41+
* ## [#9901] "Entitled" is now two facts, and the middle of the axis is pinned
42+
*
43+
* Authentication was the whole gate until the 2026-08-20 ruling (verbatim:
44+
* 「其他接受你的建议。」) put `manage_platform_settings` on the two read twins
45+
* and `manage_metadata` on the two writes. So a THIRD posture now exists
46+
* between "anonymous" and "entitled" — authenticated, holding nothing — and it
47+
* gets its own cases below rather than being left to the two ends. Each asserts
48+
* `403` AND the machine-readable `PERMISSION_DENIED`, never "not 200": the 401
49+
* the anonymous cases already cover would satisfy that, which would mean the
50+
* credential was never read at all.
51+
*
52+
* The split itself is asserted, not just the refusals: a caller holding ONLY
53+
* `manage_platform_settings` clears the reads and is refused the writes, and a
54+
* caller holding ONLY `manage_metadata` the reverse. A single gate keyed on
55+
* either capability alone would pass an "unentitled is refused" case and fail
56+
* here, which is what makes the read/write split falsifiable rather than
57+
* merely written down.
58+
*
59+
* `POST /external/validate` is the one route the ruling does not name: it has
60+
* no admin twin and creates no metadata, so it keeps the #9686 authentication
61+
* floor. That is pinned too — an un-ruled route silently acquiring a
62+
* neighbour's gate is a change nobody decided.
63+
*
4064
* Both credential kinds the platform admits are exercised, because the cheap
4165
* mistake here is to read only a better-auth session: that would refuse a
4266
* caller presenting a valid `sys_api_key`, a credential admitted everywhere
@@ -64,21 +88,33 @@ const API_KEY = 'osk_federation_caller_secret';
6488
type Handler = (req: any, res: any) => any;
6589

6690
/**
67-
* The five routes of the family, each with the status it answers a credentialed
68-
* caller and the service method it dispatches to.
91+
* The five routes of the family, each with the status it answers an entitled
92+
* caller, the service method it dispatches to, and [#9901] the capability it
93+
* requires above authentication.
6994
*
7095
* `writes` marks the two that change state — the import creates a live
7196
* runtime-origin federated object, the refresh rewrites the cached catalog
7297
* snapshot. Both are asserted to be unreachable without an identity.
98+
*
99+
* `capability: null` is `POST /external/validate`, the one route the ruling
100+
* does not name. Spelled as an explicit `null` rather than omitted so that a
101+
* later edit which gates it has to change this table — an absent field would
102+
* let that happen silently.
73103
*/
104+
const READ_CAPABILITY = 'manage_platform_settings';
105+
const WRITE_CAPABILITY = 'manage_metadata';
106+
74107
const FAMILY = [
75-
{ method: 'GET', url: `${BASE}/datasources/${DS}/external/tables`, ok: 200, call: 'listRemoteTables', writes: false },
76-
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/draft`, ok: 200, call: 'generateObjectDraft', writes: false },
77-
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/import`, ok: 201, call: 'importObject', writes: true },
78-
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/refresh-catalog`, ok: 200, call: 'refreshCatalog', writes: true },
79-
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/validate`, ok: 200, call: 'validateAll', writes: false },
108+
{ method: 'GET', url: `${BASE}/datasources/${DS}/external/tables`, ok: 200, call: 'listRemoteTables', writes: false, capability: READ_CAPABILITY },
109+
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/draft`, ok: 200, call: 'generateObjectDraft', writes: false, capability: READ_CAPABILITY },
110+
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/import`, ok: 201, call: 'importObject', writes: true, capability: WRITE_CAPABILITY },
111+
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/refresh-catalog`, ok: 200, call: 'refreshCatalog', writes: true, capability: WRITE_CAPABILITY },
112+
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/validate`, ok: 200, call: 'validateAll', writes: false, capability: null },
80113
] as const;
81114

115+
/** Every capability an entitled caller needs to clear all five routes. */
116+
const FULL_GRANT = [READ_CAPABILITY, WRITE_CAPABILITY] as const;
117+
82118
/** A host server whose registrations land in a real handler table. */
83119
function createRecordingServer() {
84120
const table = new Map<string, Handler>();
@@ -141,7 +177,9 @@ function federationServiceSpies() {
141177
* consults to resolve a caller; a boot may wire either, both or neither, which
142178
* is how the cases below separate the credential kinds and the anonymous floor.
143179
*/
144-
async function bootFederation(opts: { withAuth?: boolean; withEngine?: boolean } = {}) {
180+
async function bootFederation(
181+
opts: { withAuth?: boolean; withEngine?: boolean; grants?: readonly string[] } = {},
182+
) {
145183
const server = createRecordingServer();
146184
const service = federationServiceSpies();
147185
const lookups: string[] = [];
@@ -155,15 +193,52 @@ async function bootFederation(opts: { withAuth?: boolean; withEngine?: boolean }
155193
},
156194
};
157195

158-
// A minimal engine: the api-key admission path reads `sys_api_key` by the
159-
// at-rest hash of the presented secret, and the grant aggregation that
160-
// follows reads membership/position objects that simply have no rows here.
196+
/**
197+
* A minimal engine: the api-key admission path reads `sys_api_key` by the
198+
* at-rest hash of the presented secret, and the grant aggregation that
199+
* follows reads membership/position objects that simply have no rows here.
200+
*
201+
* [#9901] …except the two it now MUST have rows in. The capability gate reads
202+
* `systemPermissions`, which `resolveAuthzContext` aggregates off
203+
* `sys_user_permission_set` → `sys_permission_set`, so a boot with no engine
204+
* resolves an identity holding NOTHING — which is a real posture (pinned
205+
* below) but not the entitled one. `opts.grants` is what the caller
206+
* `u_federation` holds, so one fixture expresses every posture on the axis.
207+
*
208+
* The set is deliberately not `admin_full_access`: that platform set carries
209+
* `manage_platform_settings` among six other capabilities, so a gate keyed on
210+
* platform-admin posture rather than on the named capability would pass here
211+
* unnoticed — the same reason the twin-equivalence fixture builds its own.
212+
*/
213+
const grants = opts.grants ?? FULL_GRANT;
214+
const GRANT_SET_ID = 'ps_federation_caller';
161215
const engine = {
162216
find: async (object: string, query: any) => {
163-
if (object !== 'sys_api_key') return [];
164-
return query?.where?.key === hashApiKey(API_KEY) && query?.where?.revoked === false
165-
? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false }]
166-
: [];
217+
if (object === 'sys_api_key') {
218+
return query?.where?.key === hashApiKey(API_KEY) && query?.where?.revoked === false
219+
? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false }]
220+
: [];
221+
}
222+
if (object === 'sys_user_permission_set') {
223+
return query?.where?.user_id === 'u_federation' && grants.length > 0
224+
? [{ id: 'ups_1', user_id: 'u_federation', permission_set_id: GRANT_SET_ID, organization_id: null }]
225+
: [];
226+
}
227+
if (object === 'sys_permission_set') {
228+
const ids: string[] = query?.where?.id?.$in ?? [];
229+
return ids.includes(GRANT_SET_ID)
230+
? [{
231+
id: GRANT_SET_ID,
232+
name: 'federation_caller',
233+
// JSON string — the spelling SQLite hands back, which the
234+
// resolver parses. Pinning the stored shape keeps the fixture on
235+
// the real read path.
236+
system_permissions: JSON.stringify([...grants]),
237+
object_permissions: '{}',
238+
}]
239+
: [];
240+
}
241+
return [];
167242
},
168243
};
169244

@@ -283,7 +358,12 @@ describe('[#9686] the external-datasource federation family refuses an anonymous
283358

284359
describe('[#9686] the same boot still serves an entitled caller', () => {
285360
it('answers every route with its real success status for a session-authenticated caller', async () => {
286-
const { table, service } = await bootFederation({ withAuth: true });
361+
// [#9901] The engine is now part of what makes this caller ENTITLED, not
362+
// fixture noise: `systemPermissions` is aggregated off it, so a boot
363+
// without one resolves an identity holding nothing and every gated route
364+
// would answer 403. Wiring it here keeps this case measuring what it names
365+
// — the success arm — rather than quietly becoming a refusal case.
366+
const { table, service } = await bootFederation({ withAuth: true, withEngine: true });
287367

288368
for (const route of FAMILY) {
289369
const { statusCode, body } = await call(table, route, { authorization: `Bearer ${SESSION}` });
@@ -312,3 +392,105 @@ describe('[#9686] the same boot still serves an entitled caller', () => {
312392
expect(service.importObject).toHaveBeenCalledWith(DS, 'customers', {});
313393
});
314394
});
395+
396+
describe('[#9901] the family requires a capability above authentication', () => {
397+
it('refuses an authenticated caller holding NOTHING on all four ruled routes — 403 PERMISSION_DENIED, before the service', async () => {
398+
const { table, service, lookups } = await bootFederation({
399+
withAuth: true, withEngine: true, grants: [],
400+
});
401+
402+
for (const route of FAMILY.filter((r) => r.capability !== null)) {
403+
const { statusCode, body } = await call(table, route, { authorization: `Bearer ${SESSION}` });
404+
405+
// Status AND code. "not 200" would be satisfied by the 401 the anonymous
406+
// cases already cover, which would mean the credential was never read.
407+
expect(statusCode, `${route.method} ${route.url}`).toBe(403);
408+
expect(body?.success, `${route.method} ${route.url}`).toBe(false);
409+
expect(body?.error?.code, `${route.method} ${route.url}`).toBe('PERMISSION_DENIED');
410+
// The named capability is in the message, because that is the one thing a
411+
// refused caller must be able to act on.
412+
expect(body?.error?.message, `${route.method} ${route.url}`).toContain(route.capability);
413+
}
414+
415+
// The refusal precedes dispatch — so on the two routes that WRITE, nothing
416+
// was created before the caller was turned away.
417+
for (const route of FAMILY.filter((r) => r.capability !== null)) {
418+
expect(
419+
(service as any)[route.call],
420+
`${route.call} must not run for an unentitled caller`,
421+
).not.toHaveBeenCalled();
422+
}
423+
expect(lookups).not.toContain('external-datasource');
424+
});
425+
426+
it('the read/write split is real: `manage_platform_settings` alone clears the reads and is refused the writes', async () => {
427+
const { table } = await bootFederation({
428+
withAuth: true, withEngine: true, grants: [READ_CAPABILITY],
429+
});
430+
431+
for (const route of FAMILY.filter((r) => r.capability === READ_CAPABILITY)) {
432+
const { statusCode } = await call(table, route, { authorization: `Bearer ${SESSION}` });
433+
expect(statusCode, `${route.method} ${route.url}`).toBe(route.ok);
434+
}
435+
for (const route of FAMILY.filter((r) => r.capability === WRITE_CAPABILITY)) {
436+
const { statusCode, body } = await call(table, route, { authorization: `Bearer ${SESSION}` });
437+
expect(statusCode, `${route.method} ${route.url}`).toBe(403);
438+
expect(body?.error?.code, `${route.method} ${route.url}`).toBe('PERMISSION_DENIED');
439+
}
440+
});
441+
442+
it('…and the other way round: `manage_metadata` alone clears the writes and is refused the reads', async () => {
443+
// Both directions, because a gate that required EITHER capability on every
444+
// route would satisfy the unentitled case above and the two halves of the
445+
// previous case would still pass one at a time. Only the crossed pair can
446+
// tell "two capabilities" from "one capability spelled twice".
447+
const { table } = await bootFederation({
448+
withAuth: true, withEngine: true, grants: [WRITE_CAPABILITY],
449+
});
450+
451+
for (const route of FAMILY.filter((r) => r.capability === WRITE_CAPABILITY)) {
452+
const { statusCode } = await call(table, route, { authorization: `Bearer ${SESSION}` });
453+
expect(statusCode, `${route.method} ${route.url}`).toBe(route.ok);
454+
}
455+
for (const route of FAMILY.filter((r) => r.capability === READ_CAPABILITY)) {
456+
const { statusCode, body } = await call(table, route, { authorization: `Bearer ${SESSION}` });
457+
expect(statusCode, `${route.method} ${route.url}`).toBe(403);
458+
expect(body?.error?.code, `${route.method} ${route.url}`).toBe('PERMISSION_DENIED');
459+
}
460+
});
461+
462+
it('POST /external/validate keeps the #9686 authentication floor — the ruling does not name it', async () => {
463+
// The route the 2026-08-20 ruling enumerates NO capability for: no admin
464+
// twin, no metadata created. An authenticated caller holding nothing is
465+
// served here while being refused the other four on the same boot, which is
466+
// the difference stated rather than implied. A later card may change this;
467+
// it will have to change this case to do it.
468+
const { table, service } = await bootFederation({
469+
withAuth: true, withEngine: true, grants: [],
470+
});
471+
472+
const validate = FAMILY.find((r) => r.capability === null)!;
473+
const { statusCode, body } = await call(table, validate, { authorization: `Bearer ${SESSION}` });
474+
475+
expect(statusCode).toBe(validate.ok);
476+
expect(body?.success).toBe(true);
477+
expect(service.validateAll).toHaveBeenCalled();
478+
});
479+
480+
it('a capability the caller does not hold is not granted by an api key either', async () => {
481+
// The api-key admission path seeds `permissions` from the key's scopes and
482+
// then aggregates grants off the SAME `sys_*` tables — so an unentitled key
483+
// holder is refused exactly like an unentitled session. A gate that read
484+
// the key's scopes instead of `systemPermissions` would pass the session
485+
// cases above and open the family to every key.
486+
const { table, service } = await bootFederation({
487+
withAuth: true, withEngine: true, grants: [],
488+
});
489+
490+
const { statusCode, body } = await call(table, FAMILY[2], { 'x-api-key': API_KEY });
491+
492+
expect(statusCode).toBe(403);
493+
expect(body?.error?.code).toBe('PERMISSION_DENIED');
494+
expect(service.importObject).not.toHaveBeenCalled();
495+
});
496+
});

0 commit comments

Comments
 (0)