Skip to content

Commit 05864fb

Browse files
os-warrenclaude
andauthored
security(service-datasource): require manage_platform_settings on the datasource-admin routes (#9887)
* security(service-datasource): require manage_platform_settings on the datasource-admin routes The datasource-admin HTTP family took any authenticated caller once #9391 landed its anonymous floor. Datasource create/patch/remove/introspect are platform-configuration actions, so all eleven routes now also require the capability the adjacent Setup-admin families gate on. The capability is measured, not minted: service-settings' platform-infrastructure namespaces (mail, storage, sms, auth, ai, knowledge) and objectql's lifecycle namespace all declare manage_platform_settings for reads AND writes, while the cohort that splits setup.access/setup.write is the tenant-cosmetic one. This service's own Setup nav entry already declared requiredPermissions: ['manage_platform_settings'] on the console door in front of these routes, so the change makes declared equal enforced. Refusal is the standard-catalog 403 PERMISSION_DENIED through the shared sendError, not the grandfathered FORBIDDEN synonym whose ADR-0112 waiver covers three other packages and does not endorse the spelling for new code. The both-sides-on-one-boot pin grows a third posture (entitled succeeds, authenticated-but-unentitled is refused, anonymous stays refused), and the two suites for which an entitled caller is the premise share its fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * test(rest): entitle the listRemoteTables twin fixture and pin the new capability divergence The datasource-admin capability gate flips a public admission semantic, and the twin-equivalence suite one package over was the consumer pin asserting the old one: its fixture caller was authenticated but unentitled, so five request-shape cases compared a 200 against the new 403 and read an admission difference as a request-shape divergence. The fixture now resolves grants through the platform's own RBAC chain — one fake engine wired into BOTH spellings, the admin registrar's `objectql` lookup and the resolveAuthzContext call behind the federation registrar's resolveExecutionContext, so the twins still read one identity and now one grant aggregation. The #9686 "WHO may ask" block keeps both refusal cases unchanged (anonymous and unrecognised credential still refuse identically) and gains a case pinning what is now true: an authenticated but unentitled caller is refused 403 PERMISSION_DENIED at the admin spelling and served at the federation spelling. That asymmetry is filed as #9901, not accepted; the case is labelled a record of a known gap and is expected to fail when the gap closes. No packages/rest runtime code is touched — gating the federation family is a separate decision and a different lane. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d79e33 commit 05864fb

7 files changed

Lines changed: 570 additions & 113 deletions

File tree

.changeset/rare-donkeys-repeat.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
Datasource-admin HTTP routes now require the `manage_platform_settings` capability, not merely authentication.
6+
7+
All eleven routes under `/api/v1/datasources` — list, read, driver catalog, remote-table
8+
introspection, connection probes, credential migration, create, patch and remove — answer
9+
`403 PERMISSION_DENIED` to a caller that resolves to an identity holding no
10+
`manage_platform_settings` grant. The anonymous floor is unchanged (`401 UNAUTHENTICATED`).
11+
12+
The capability is matched to what the adjacent Setup-admin families already gate on, not
13+
minted: `@objectstack/service-settings`'s platform-infrastructure namespaces (`mail`,
14+
`storage`, `sms`, `auth`, `ai`, `knowledge`) declare it for reads and writes alike, and this
15+
service's own Setup nav entry already declared `requiredPermissions:
16+
['manage_platform_settings']` for the console door in front of these routes. There is no
17+
read/write split for the same reason those namespaces have none: a datasource read returns
18+
stored connection configuration and live remote-schema introspection.
19+
20+
Impact: `admin_full_access` carries `manage_platform_settings`, so platform admins are
21+
unaffected. A deployment that granted non-admin users access to Setup → Datasources through
22+
some other capability must now grant `manage_platform_settings` (or bind those users to a
23+
permission set carrying it).

packages/rest/src/remote-tables-twin.equivalence.test.ts

Lines changed: 154 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,89 @@ const REMOTE: IntrospectedSchema = {
9494
},
9595
};
9696

97-
/** The credential the admin spelling's authentication floor admits (#9391). */
97+
/**
98+
* The credential every request-shape case presents: authenticated (#9391) AND
99+
* holding `manage_platform_settings`, which the admin spelling requires as of
100+
* #9593.
101+
*
102+
* The entitlement is not decoration. Before #9593 the admin spelling admitted
103+
* any authenticated caller, so a bare session was enough to compare the two
104+
* answers; now an unentitled session makes the admin spelling answer 403 and
105+
* every case below would be comparing a 200 against a refusal — reading an
106+
* ADMISSION difference as a request-shape divergence, the one thing this file
107+
* exists not to confuse (the same reason #9391 made it wire `auth` at all).
108+
*/
98109
const SESSION = 'Bearer twin-session';
110+
111+
/**
112+
* [#9593] A second credential: authenticated, holding nothing. This is the
113+
* posture on which the two spellings now genuinely DIVERGE, and the divergence
114+
* gets its own pinned case at the bottom of this file rather than being
115+
* papered over here.
116+
*/
117+
const UNENTITLED_SESSION = 'Bearer twin-session-unentitled';
118+
119+
const USERS: Record<string, string> = {
120+
[SESSION]: 'u_twin',
121+
[UNENTITLED_SESSION]: 'u_twin_plain',
122+
};
123+
99124
const authService = {
100125
api: {
101-
getSession: async ({ headers }: { headers: Headers }) =>
102-
headers?.get?.('authorization') === SESSION ? { user: { id: 'u_twin' } } : null,
126+
getSession: async ({ headers }: { headers: Headers }) => {
127+
const id = USERS[headers?.get?.('authorization') ?? ''];
128+
return id ? { user: { id } } : null;
129+
},
103130
},
104131
};
105132

133+
/** The permission set carrying the grant `u_twin` holds and `u_twin_plain` does not. */
134+
const GRANT_SET_ID = 'ps_twin_datasource_operator';
135+
136+
/**
137+
* The RBAC tables `resolveAuthzContext` reads, as a fake data engine — the
138+
* same idiom this package's other authz fixtures use
139+
* (`rest-exec-ctx-principal-kind.test.ts`), and the same four-table shape the
140+
* admin family's own pin builds in `@objectstack/service-datasource`.
141+
*
142+
* ONE engine serves BOTH spellings: it is wired into the plugin context the
143+
* admin registrar resolves `objectql` from, and handed to the
144+
* `resolveAuthzContext` call behind the federation registrar's
145+
* `resolveExecutionContext`. That is deliberate and load-bearing — the two
146+
* spellings must read one identity AND one grant aggregation, or this file
147+
* could manufacture agreement (or disagreement) out of two different notions of
148+
* who the caller is.
149+
*
150+
* The set is deliberately not `admin_full_access`: that platform set carries
151+
* `manage_platform_settings` among six other capabilities, so a gate keyed on
152+
* platform-admin posture rather than on the capability would pass unnoticed.
153+
*/
154+
const makeQl = () => ({
155+
find: async (object: string, opts: any) => {
156+
const where = opts?.where ?? {};
157+
if (object === 'sys_user_permission_set') {
158+
return where.user_id === 'u_twin'
159+
? [{ id: 'ups_twin', user_id: 'u_twin', permission_set_id: GRANT_SET_ID, organization_id: null }]
160+
: [];
161+
}
162+
if (object === 'sys_permission_set') {
163+
const ids: string[] = where.id?.$in ?? [];
164+
return ids.includes(GRANT_SET_ID)
165+
? [{
166+
id: GRANT_SET_ID,
167+
name: 'twin_datasource_operator',
168+
// JSON string — the spelling SQLite hands back, which the resolver
169+
// parses. Pinning the stored shape keeps the fixture on the real
170+
// read path.
171+
system_permissions: JSON.stringify(['manage_platform_settings']),
172+
object_permissions: '{}',
173+
}]
174+
: [];
175+
}
176+
return [];
177+
},
178+
});
179+
106180
/**
107181
* One server, one service, both registrars — the point of the fixture.
108182
*
@@ -119,6 +193,7 @@ function mountBoth() {
119193
listObjects: async () => [],
120194
});
121195
const server = new HonoHttpServer(0);
196+
const ql = makeQl();
122197
const ctx = {
123198
getService: (name: string) => {
124199
if (name === 'external-datasource') return service;
@@ -128,6 +203,12 @@ function mountBoth() {
128203
// compare a 200 against a 401 and read the difference as a request-shape
129204
// divergence — which is the one thing it exists NOT to confuse.
130205
if (name === 'auth') return authService;
206+
// [#9593] …and the admin spelling now also requires a CAPABILITY, which
207+
// the same resolver aggregates off the data engine. Same reasoning one
208+
// step further: without a grant to read, the comparison would be a 200
209+
// against a 403. `objectql` and `data` are one registration under two
210+
// names, and the registrar tries them in that order.
211+
if (name === 'objectql' || name === 'data') return ql;
131212
throw new Error(`no service: ${name}`);
132213
},
133214
} as any;
@@ -153,15 +234,24 @@ function mountBoth() {
153234
}
154235
}
155236
const authz = await resolveAuthzContext({
156-
// No data engine here, stated rather than omitted: `ql` is a required
157-
// member, and it is what the api-key admission path reads. This fixture
158-
// wires only a session, so that path resolves nothing and the session
159-
// path is the one under comparison.
160-
ql: undefined,
237+
// [#9593] The SAME engine the admin spelling resolves `objectql` to,
238+
// stated rather than omitted. It used to be `undefined` here, which was
239+
// right while only a session mattered; now that one spelling reads
240+
// GRANTS, handing this side a different (or absent) engine would let the
241+
// two spellings disagree about the caller for a reason that is the
242+
// fixture's, not the code's. One identity function, and now one grant
243+
// aggregation.
244+
ql,
161245
headers,
162246
getSession: async (h: any) => authService.api.getSession({ headers: h }),
163247
});
164-
return authz.userId ? { userId: authz.userId } : undefined;
248+
// `systemPermissions` is carried through even though no route in this
249+
// package reads it today: the federation spelling gates on authentication
250+
// only (see the divergence case at the bottom of this file), and the day
251+
// that changes, this resolver already supplies what such a gate would read.
252+
return authz.userId
253+
? { userId: authz.userId, systemPermissions: authz.systemPermissions }
254+
: undefined;
165255
};
166256

167257
registerExternalDatasourceRoutes(server, ctx, '/api/v1', { resolveExecutionContext });
@@ -294,8 +384,30 @@ describe('listRemoteTables twins agree on the request shape (#7955)', () => {
294384
* answers, exactly as the request-shape cases compare table sets. A guard added
295385
* to one spelling and not the other now fails here, whichever side it is added
296386
* to — which is the property the equivalence is for.
387+
*
388+
* ## [#9593] The axis is no longer a single line, and this block says so
389+
*
390+
* #9593 raised the ADMIN spelling from "any authenticated caller" to
391+
* `manage_platform_settings`; the federation spelling still gates on
392+
* authentication alone, by its own registrar's stated decision (#9686 ruled
393+
* the capability question out of its scope and pointed it here). So the
394+
* spellings now agree at the two ends of the axis and diverge in the middle:
395+
*
396+
* - anonymous — both refuse `401 UNAUTHENTICATED` (unchanged);
397+
* - unrecognised credential — both refuse `401 UNAUTHENTICATED` (unchanged);
398+
* - authenticated AND entitled — both serve, identically (unchanged);
399+
* - authenticated but UNENTITLED — the admin spelling refuses `403`, the
400+
* federation spelling serves.
401+
*
402+
* The last row is a real governance asymmetry — one operation, two doors, one
403+
* gate — and it is FILED, not accepted: #9901. It is pinned here rather than left unasserted for the reason this
404+
* whole block exists: an axis nothing drives is an axis that goes silently
405+
* false, which is exactly how the pre-#9686 gap survived. ⚠️ When the
406+
* federation spelling grows its own capability gate, that case is EXPECTED to
407+
* fail — it is a record of a known gap, not a defence of it, and the correct
408+
* response is to fold the row back into the agreement above.
297409
*/
298-
describe('listRemoteTables twins agree on WHO may ask (#9686)', () => {
410+
describe('listRemoteTables twins agree on WHO may ask (#9686, #9593)', () => {
299411
it('an anonymous caller is refused identically on both spellings', async () => {
300412
const { federation, admin } = await readBoth('', {});
301413

@@ -319,13 +431,44 @@ describe('listRemoteTables twins agree on WHO may ask (#9686)', () => {
319431
expect(admin.code).toBe(federation.code);
320432
});
321433

322-
it('the credential that clears one spelling clears the other — same identity, same answer', async () => {
434+
it('an ENTITLED credential clears both spellings — same identity, same answer', async () => {
323435
// The other direction, and the one that makes the refusal cases mean
324436
// something: the two spellings do not agree merely by refusing everyone.
437+
// "Entitled" is now two facts (authenticated, and holding
438+
// `manage_platform_settings`), and the default credential carries both.
325439
const { federation, admin } = await readBoth('?schema=public');
326440

327441
expect(federation.status).toBe(200);
328442
expect(admin.status).toBe(200);
329443
expect(qualified(admin)).toEqual(qualified(federation));
330444
});
445+
446+
it('[#9593] an authenticated but UNENTITLED caller diverges: admin refuses 403, federation serves', async () => {
447+
// ⚠️ A pinned RECORD OF A KNOWN GAP, not a contract worth keeping — see
448+
// this block's header and the finding card #9901. The two
449+
// halves are asserted separately and in full so that closing the gap
450+
// fails this case loudly instead of drifting past it.
451+
const { federation, admin } = await readBoth('', { authorization: UNENTITLED_SESSION });
452+
453+
// The admin spelling: the #9593 refusal, asserted by status AND
454+
// machine-readable code (ADR-0112 envelope) — "not 200" would be satisfied
455+
// by the 401 the anonymous case already covers, which would mean the
456+
// credential was never read.
457+
expect(admin.status).toBe(403);
458+
expect(admin.code).toBe('PERMISSION_DENIED');
459+
// …and it refused before serving anything.
460+
expect(admin.tables).toEqual([]);
461+
462+
// The federation spelling: authentication was the whole gate here, so the
463+
// same caller is served. This is the asymmetry, stated rather than implied.
464+
expect(federation.status).toBe(200);
465+
expect(qualified(federation)).toEqual([
466+
'analytics.events',
467+
'public.customers',
468+
'public.orders',
469+
]);
470+
471+
// And it is genuinely a divergence — the point the equivalence axis makes.
472+
expect(admin.status).not.toBe(federation.status);
473+
});
331474
});

0 commit comments

Comments
 (0)