Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/federation-family-capability-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
---
"@objectstack/rest": patch
---

**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: 「其他接受你的建议。」).

**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.

| route | SDK call | now requires |
| --- | --- | --- |
| `GET /:name/external/tables` | `datasources.external.listTables` | `manage_platform_settings` |
| `POST /:name/external/tables/:remote/draft` | `datasources.external.draft` | `manage_platform_settings` |
| `POST /:name/external/tables/:remote/import` | `datasources.external.import` | `manage_metadata` |
| `POST /:name/external/refresh-catalog` | `datasources.external.refreshCatalog` | `manage_metadata` |
| `POST /:name/external/validate` | `datasources.external.validate` | *(unchanged — authentication only)* |

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`.

**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.

**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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,8 +143,16 @@ function boot(opts: {
// authorized caller reaches it in production, keeping this test's subject
// (does the advertised URL resolve and answer in the mounted table) intact.
// The gate itself is pinned in `package-envelope.conformance.test.ts`.
// [#9901] `manage_platform_settings` joins the set for the same reason:
// the federation family's read routes now carry a capability gate too, and
// the advertised `…/external/tables` URL is driven below. Without it that
// probe would read a 403 and this pin's subject (does the advertised URL
// resolve and answer in the mounted table) would quietly become an authz
// assertion. That gate is pinned in
// `external-datasource-routes-auth-guard.test.ts`.
resolveExecutionContext: async () => ({
userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
userId: 'u_pkg',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access', 'manage_platform_settings'],
}),
enableProjectScoping: opts.enableProjectScoping,
projectResolution: opts.projectResolution,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,10 +51,22 @@ interface Captured {
* would read the 401 body instead of the arm it names, and this file would
* silently stop measuring what it exists to measure.
*
* [#9901] …and an ENTITLED one: four of the five routes now also require a
* capability (`manage_platform_settings` on the reads, `manage_metadata` on the
* writes), so this stub holds both. Same reasoning one step further — a
* resolver carrying an identity but no grants would turn every case below into
* a reading of the 403 body. Holding both rather than one per case is
* deliberate: which capability each route requires is not this file's subject,
* and pinning it twice would make the split harder to change in the one place
* that does own it.
*
* The guard itself is pinned in `external-datasource-routes-auth-guard.test.ts`;
* the 401's own envelope is the last case below, which is this file's business.
*/
const CREDENTIALED = async () => ({ userId: 'u_env_conformance' });
const CREDENTIALED = async () => ({
userId: 'u_env_conformance',
systemPermissions: ['manage_platform_settings', 'manage_metadata'],
});

/**
* A resolver that RESOLVES, and resolves to no identity — the anonymous case as
Expand Down
216 changes: 199 additions & 17 deletions packages/rest/src/external-datasource-routes-auth-guard.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,8 @@

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

/**
* The five routes of the family, each with the status it answers a credentialed
* caller and the service method it dispatches to.
* The five routes of the family, each with the status it answers an entitled
* caller, the service method it dispatches to, and [#9901] the capability it
* requires above authentication.
*
* `writes` marks the two that change state — the import creates a live
* runtime-origin federated object, the refresh rewrites the cached catalog
* snapshot. Both are asserted to be unreachable without an identity.
*
* `capability: null` is `POST /external/validate`, the one route the ruling
* does not name. Spelled as an explicit `null` rather than omitted so that a
* later edit which gates it has to change this table — an absent field would
* let that happen silently.
*/
const READ_CAPABILITY = 'manage_platform_settings';
const WRITE_CAPABILITY = 'manage_metadata';

const FAMILY = [
{ method: 'GET', url: `${BASE}/datasources/${DS}/external/tables`, ok: 200, call: 'listRemoteTables', writes: false },
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/draft`, ok: 200, call: 'generateObjectDraft', writes: false },
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/import`, ok: 201, call: 'importObject', writes: true },
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/refresh-catalog`, ok: 200, call: 'refreshCatalog', writes: true },
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/validate`, ok: 200, call: 'validateAll', writes: false },
{ method: 'GET', url: `${BASE}/datasources/${DS}/external/tables`, ok: 200, call: 'listRemoteTables', writes: false, capability: READ_CAPABILITY },
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/draft`, ok: 200, call: 'generateObjectDraft', writes: false, capability: READ_CAPABILITY },
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/import`, ok: 201, call: 'importObject', writes: true, capability: WRITE_CAPABILITY },
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/refresh-catalog`, ok: 200, call: 'refreshCatalog', writes: true, capability: WRITE_CAPABILITY },
{ method: 'POST', url: `${BASE}/datasources/${DS}/external/validate`, ok: 200, call: 'validateAll', writes: false, capability: null },
] as const;

/** Every capability an entitled caller needs to clear all five routes. */
const FULL_GRANT = [READ_CAPABILITY, WRITE_CAPABILITY] as const;

/** A host server whose registrations land in a real handler table. */
function createRecordingServer() {
const table = new Map<string, Handler>();
Expand DownExpand Up@@ -141,7 +177,9 @@ function federationServiceSpies() {
* consults to resolve a caller; a boot may wire either, both or neither, which
* is how the cases below separate the credential kinds and the anonymous floor.
*/
async function bootFederation(opts: { withAuth?: boolean; withEngine?: boolean } = {}) {
async function bootFederation(
opts: { withAuth?: boolean; withEngine?: boolean; grants?: readonly string[] } = {},
) {
const server = createRecordingServer();
const service = federationServiceSpies();
const lookups: string[] = [];
Expand All@@ -155,15 +193,52 @@ async function bootFederation(opts: { withAuth?: boolean; withEngine?: boolean }
},
};

// A minimal engine: the api-key admission path reads `sys_api_key` by the
// at-rest hash of the presented secret, and the grant aggregation that
// follows reads membership/position objects that simply have no rows here.
/**
* A minimal engine: the api-key admission path reads `sys_api_key` by the
* at-rest hash of the presented secret, and the grant aggregation that
* follows reads membership/position objects that simply have no rows here.
*
* [#9901] …except the two it now MUST have rows in. The capability gate reads
* `systemPermissions`, which `resolveAuthzContext` aggregates off
* `sys_user_permission_set` → `sys_permission_set`, so a boot with no engine
* resolves an identity holding NOTHING — which is a real posture (pinned
* below) but not the entitled one. `opts.grants` is what the caller
* `u_federation` holds, so one fixture expresses every posture on the axis.
*
* The set is deliberately not `admin_full_access`: that platform set carries
* `manage_platform_settings` among six other capabilities, so a gate keyed on
* platform-admin posture rather than on the named capability would pass here
* unnoticed — the same reason the twin-equivalence fixture builds its own.
*/
const grants = opts.grants ?? FULL_GRANT;
const GRANT_SET_ID = 'ps_federation_caller';
const engine = {
find: async (object: string, query: any) => {
if (object !== 'sys_api_key') return [];
return query?.where?.key === hashApiKey(API_KEY) && query?.where?.revoked === false
? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false }]
: [];
if (object === 'sys_api_key') {
return query?.where?.key === hashApiKey(API_KEY) && query?.where?.revoked === false
? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false }]
: [];
}
if (object === 'sys_user_permission_set') {
return query?.where?.user_id === 'u_federation' && grants.length > 0
? [{ id: 'ups_1', user_id: 'u_federation', permission_set_id: GRANT_SET_ID, organization_id: null }]
: [];
}
if (object === 'sys_permission_set') {
const ids: string[] = query?.where?.id?.$in ?? [];
return ids.includes(GRANT_SET_ID)
? [{
id: GRANT_SET_ID,
name: 'federation_caller',
// JSON string — the spelling SQLite hands back, which the
// resolver parses. Pinning the stored shape keeps the fixture on
// the real read path.
system_permissions: JSON.stringify([...grants]),
object_permissions: '{}',
}]
: [];
}
return [];
},
};

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

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

for (const route of FAMILY) {
const { statusCode, body } = await call(table, route, { authorization: `Bearer ${SESSION}` });
Expand DownExpand Up@@ -312,3 +392,105 @@ describe('[#9686] the same boot still serves an entitled caller', () => {
expect(service.importObject).toHaveBeenCalledWith(DS, 'customers', {});
});
});

describe('[#9901] the family requires a capability above authentication', () => {
it('refuses an authenticated caller holding NOTHING on all four ruled routes — 403 PERMISSION_DENIED, before the service', async () => {
const { table, service, lookups } = await bootFederation({
withAuth: true, withEngine: true, grants: [],
});

for (const route of FAMILY.filter((r) => r.capability !== null)) {
const { statusCode, body } = await call(table, route, { authorization: `Bearer ${SESSION}` });

// Status AND code. "not 200" would be satisfied by the 401 the anonymous
// cases already cover, which would mean the credential was never read.
expect(statusCode, `${route.method} ${route.url}`).toBe(403);
expect(body?.success, `${route.method} ${route.url}`).toBe(false);
expect(body?.error?.code, `${route.method} ${route.url}`).toBe('PERMISSION_DENIED');
// The named capability is in the message, because that is the one thing a
// refused caller must be able to act on.
expect(body?.error?.message, `${route.method} ${route.url}`).toContain(route.capability);
}

// The refusal precedes dispatch — so on the two routes that WRITE, nothing
// was created before the caller was turned away.
for (const route of FAMILY.filter((r) => r.capability !== null)) {
expect(
(service as any)[route.call],
`${route.call} must not run for an unentitled caller`,
).not.toHaveBeenCalled();
}
expect(lookups).not.toContain('external-datasource');
});

it('the read/write split is real: `manage_platform_settings` alone clears the reads and is refused the writes', async () => {
const { table } = await bootFederation({
withAuth: true, withEngine: true, grants: [READ_CAPABILITY],
});

for (const route of FAMILY.filter((r) => r.capability === READ_CAPABILITY)) {
const { statusCode } = await call(table, route, { authorization: `Bearer ${SESSION}` });
expect(statusCode, `${route.method} ${route.url}`).toBe(route.ok);
}
for (const route of FAMILY.filter((r) => r.capability === WRITE_CAPABILITY)) {
const { statusCode, body } = await call(table, route, { authorization: `Bearer ${SESSION}` });
expect(statusCode, `${route.method} ${route.url}`).toBe(403);
expect(body?.error?.code, `${route.method} ${route.url}`).toBe('PERMISSION_DENIED');
}
});

it('…and the other way round: `manage_metadata` alone clears the writes and is refused the reads', async () => {
// Both directions, because a gate that required EITHER capability on every
// route would satisfy the unentitled case above and the two halves of the
// previous case would still pass one at a time. Only the crossed pair can
// tell "two capabilities" from "one capability spelled twice".
const { table } = await bootFederation({
withAuth: true, withEngine: true, grants: [WRITE_CAPABILITY],
});

for (const route of FAMILY.filter((r) => r.capability === WRITE_CAPABILITY)) {
const { statusCode } = await call(table, route, { authorization: `Bearer ${SESSION}` });
expect(statusCode, `${route.method} ${route.url}`).toBe(route.ok);
}
for (const route of FAMILY.filter((r) => r.capability === READ_CAPABILITY)) {
const { statusCode, body } = await call(table, route, { authorization: `Bearer ${SESSION}` });
expect(statusCode, `${route.method} ${route.url}`).toBe(403);
expect(body?.error?.code, `${route.method} ${route.url}`).toBe('PERMISSION_DENIED');
}
});

it('POST /external/validate keeps the #9686 authentication floor — the ruling does not name it', async () => {
// The route the 2026-08-20 ruling enumerates NO capability for: no admin
// twin, no metadata created. An authenticated caller holding nothing is
// served here while being refused the other four on the same boot, which is
// the difference stated rather than implied. A later card may change this;
// it will have to change this case to do it.
const { table, service } = await bootFederation({
withAuth: true, withEngine: true, grants: [],
});

const validate = FAMILY.find((r) => r.capability === null)!;
const { statusCode, body } = await call(table, validate, { authorization: `Bearer ${SESSION}` });

expect(statusCode).toBe(validate.ok);
expect(body?.success).toBe(true);
expect(service.validateAll).toHaveBeenCalled();
});

it('a capability the caller does not hold is not granted by an api key either', async () => {
// The api-key admission path seeds `permissions` from the key's scopes and
// then aggregates grants off the SAME `sys_*` tables — so an unentitled key
// holder is refused exactly like an unentitled session. A gate that read
// the key's scopes instead of `systemPermissions` would pass the session
// cases above and open the family to every key.
const { table, service } = await bootFederation({
withAuth: true, withEngine: true, grants: [],
});

const { statusCode, body } = await call(table, FAMILY[2], { 'x-api-key': API_KEY });

expect(statusCode).toBe(403);
expect(body?.error?.code).toBe('PERMISSION_DENIED');
expect(service.importObject).not.toHaveBeenCalled();
});
});
Loading
Loading