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 *
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';
6488type 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+
74107const 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. */
83119function 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
284359describe ( '[#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