@@ -10,6 +10,7 @@ import type { RbacScope } from '@/lib/rbac/types';
1010import {
1111 filterResourcesByPermission ,
1212 requireResourcePermission ,
13+ canTransferResourceOwnership ,
1314 type ResourcePermissionAction ,
1415} from '@/lib/rbac/resource-authz' ;
1516import {
@@ -159,6 +160,10 @@ interface AuthorizedRagContext {
159160 sharedTeamSlugs : string [ ] ;
160161 /** Previously-persisted shared teams, read from config for the revoke diff. */
161162 previousSharedTeamSlugs : string [ ] ;
163+ /** Previously-persisted owner team, read from config. When the owner team
164+ * changes, this is passed to the reconciler so the old team's grants are
165+ * revoked instead of orphaned. */
166+ previousOwnerTeamSlug : string | null ;
162167 } ;
163168}
164169
@@ -247,14 +252,23 @@ async function requireMcpToolCallPermission(
247252 if ( ! toolName ) return ;
248253
249254 // Only custom tools have an mcp_tool object. Resolve the custom-tool set
250- // from the RAG server; a tool_name not in it is built-in → not gated.
255+ // from the RAG server; a tool_name not in it is built-in → not gated. The
256+ // gate is FAIL-CLOSED: if we cannot determine whether `tool_name` is a
257+ // custom tool (listing error/parse failure), we deny rather than forward,
258+ // so a transient RAG-server error cannot be used to bypass `can_call`.
251259 let customToolIds : Set < string > ;
252260 try {
253261 const response = await fetch ( `${ getRagServerUrl ( ) } /v1/mcp/custom-tools` , {
254262 method : 'GET' ,
255263 headers,
256264 } ) ;
257- if ( ! response . ok ) return ; // fail-open on listing error — RAG server still role-checks
265+ if ( ! response . ok ) {
266+ throw new ApiError (
267+ 'Unable to verify tool-call permission. Please retry.' ,
268+ 503 ,
269+ 'mcp_tool#call_unavailable' ,
270+ ) ;
271+ }
258272 const data = await response . json ( ) ;
259273 const list = Array . isArray ( data ) ? data : [ ] ;
260274 customToolIds = new Set (
@@ -263,8 +277,13 @@ async function requireMcpToolCallPermission(
263277 . map ( ( t ) => ( typeof t . tool_id === 'string' ? t . tool_id : '' ) )
264278 . filter ( Boolean ) ,
265279 ) ;
266- } catch {
267- return ;
280+ } catch ( error ) {
281+ if ( error instanceof ApiError ) throw error ;
282+ throw new ApiError (
283+ 'Unable to verify tool-call permission. Please retry.' ,
284+ 503 ,
285+ 'mcp_tool#call_unavailable' ,
286+ ) ;
268287 }
269288 if ( ! customToolIds . has ( toolName ) ) return ; // built-in tool — not gated
270289
@@ -310,14 +329,26 @@ async function reconcileMcpToolForOwnership(pending: {
310329 creatorSubject : string | null ;
311330 sharedTeamSlugs : string [ ] ;
312331 previousSharedTeamSlugs : string [ ] ;
332+ previousOwnerTeamSlug : string | null ;
313333} ) : Promise < void > {
334+ // Pass the previous owner team when it differs from the next owner so the
335+ // reconciler revokes the old team's grants on an ownership change (the
336+ // mcp_tool builder treats it symmetrically with shared-team removals).
337+ // Without this, changing owner_team_slug would orphan the old team's
338+ // reader/user/manager tuples, leaving stale access.
339+ const previousOwnerTeamSlug =
340+ pending . previousOwnerTeamSlug &&
341+ pending . previousOwnerTeamSlug !== pending . ownerTeamSlug
342+ ? pending . previousOwnerTeamSlug
343+ : undefined ;
314344 await reconcileMcpToolRelationships ( {
315345 toolId : pending . toolId ,
316346 ownerSubject : pending . ownerSubject ,
317347 ownerTeamSlug : pending . ownerTeamSlug ,
318348 creatorSubject : pending . creatorSubject ,
319349 nextSharedTeamSlugs : pending . sharedTeamSlugs ,
320350 previousSharedTeamSlugs : pending . previousSharedTeamSlugs ,
351+ previousOwnerTeamSlug,
321352 } ) ;
322353}
323354
@@ -425,32 +456,87 @@ async function getAuthorizedRagContext(
425456 extractMcpToolId ( pathSegments ) ??
426457 ( isRecord ( body ) ? normalizeString ( body . tool_id ) : null ) ;
427458 if ( toolId ) {
428- const ownerTeamSlug = isRecord ( body ) ? normalizeString ( body . owner_team_slug ) : null ;
429- if ( ownerTeamSlug ) {
430- await requireResourcePermission (
431- { sub : session . sub , role : session . role , user : session . user } ,
432- { type : 'team' , id : ownerTeamSlug , action : 'use' } ,
433- ) ;
434- }
435459 const ownerSubject = normalizeString ( session . sub ) ;
436460 const sharedTeamSlugs = isRecord ( body )
437461 ? normalizeSlugList ( body . shared_with_teams )
438462 : [ ] ;
439- // Read the previously-persisted shared set from config so the
440- // reconciler can emit revoke deletes for unshared teams (mirrors the
441- // agent route's previous-set read). Creator is set-once: keep the
442- // existing one if present.
463+ // Config is the source of truth: read the previous owner/creator/shared
464+ // so we can keep set-once fields, emit revoke deletes for removed teams,
465+ // and detect an ownership transfer (mirrors the agent route).
443466 const previous = await loadMcpToolConfig ( toolId , {
444467 accessToken : session . accessToken ,
445468 org : session . org ,
446469 } ) ;
470+
471+ const authzSession = { sub : session . sub , role : session . role , user : session . user } ;
472+ const requestedOwnerTeamSlug = isRecord ( body )
473+ ? normalizeString ( body . owner_team_slug )
474+ : null ;
475+ const previousOwnerTeamSlug = previous . ownerTeamSlug ;
476+ // Keep the existing owner when the request omits it — the RAG server
477+ // replaces the whole config on PUT, so an omitted owner must not be
478+ // dropped (which would also wrongly revoke the owner team's grants).
479+ const nextOwnerTeamSlug = requestedOwnerTeamSlug ?? previousOwnerTeamSlug ;
480+
481+ const isOwnerChange =
482+ requestedOwnerTeamSlug !== null &&
483+ previousOwnerTeamSlug !== null &&
484+ requestedOwnerTeamSlug !== previousOwnerTeamSlug ;
485+
486+ if ( isOwnerChange ) {
487+ // Ownership transfer (spec 2026-06-03, US3): the owner team is
488+ // immutable on a normal edit and may only be reassigned by an
489+ // owner-team admin or org admin. Mirrors the dynamic-agents guard.
490+ const allowed = await canTransferResourceOwnership ( authzSession , {
491+ type : 'mcp_tool' ,
492+ id : toolId ,
493+ } ) ;
494+ if ( ! allowed ) {
495+ throw new ApiError (
496+ 'Only an owner-team admin or org admin can transfer this tool.' ,
497+ 403 ,
498+ 'TRANSFER_FORBIDDEN' ,
499+ ) ;
500+ }
501+ // A transfer to a team the caller is not a member of requires explicit
502+ // confirmation (the <TeamOwnershipFields> not-a-member prompt).
503+ let canUseDestination = false ;
504+ try {
505+ await requireResourcePermission ( authzSession , {
506+ type : 'team' ,
507+ id : requestedOwnerTeamSlug ,
508+ action : 'use' ,
509+ } ) ;
510+ canUseDestination = true ;
511+ } catch {
512+ canUseDestination = false ;
513+ }
514+ const confirmedNotMember = isRecord ( body ) && body . confirm_not_member === true ;
515+ if ( ! canUseDestination && ! confirmedNotMember ) {
516+ throw new ApiError (
517+ 'You are not a member of the destination team. Confirm the transfer to proceed.' ,
518+ 409 ,
519+ 'TRANSFER_NOT_MEMBER_UNCONFIRMED' ,
520+ ) ;
521+ }
522+ } else if ( requestedOwnerTeamSlug && previousOwnerTeamSlug === null ) {
523+ // First-set (create / pre-ownership tool): the caller must belong to
524+ // the owner team they assign.
525+ await requireResourcePermission ( authzSession , {
526+ type : 'team' ,
527+ id : requestedOwnerTeamSlug ,
528+ action : 'use' ,
529+ } ) ;
530+ }
531+
447532 pendingMcpToolOwnership = {
448533 toolId,
449534 ownerSubject,
450- ownerTeamSlug,
535+ ownerTeamSlug : nextOwnerTeamSlug ,
451536 creatorSubject : previous . creatorSubject ?? ownerSubject ,
452537 sharedTeamSlugs,
453538 previousSharedTeamSlugs : previous . sharedTeamSlugs ,
539+ previousOwnerTeamSlug,
454540 } ;
455541 }
456542 }
@@ -860,9 +946,15 @@ export async function PUT(
860946 const targetPath = path . join ( '/' ) ;
861947 const targetUrl = `${ ragServerUrl } /${ targetPath } ` ;
862948
949+ // Parse the JSON body when present. Mirror the POST handler: attempt a
950+ // parse whenever content-length is absent-but-nonempty OR positive, because
951+ // the ownership/transfer logic below needs the body fields and some clients
952+ // omit content-length on small JSON payloads. A parse failure leaves body
953+ // undefined.
863954 let body : unknown = undefined ;
864955 const contentLength = request . headers . get ( 'content-length' ) ;
865- if ( contentLength && parseInt ( contentLength ) > 0 ) {
956+ const hasBody = contentLength === null || parseInt ( contentLength ) > 0 ;
957+ if ( hasBody ) {
866958 try {
867959 body = await request . json ( ) ;
868960 } catch {
0 commit comments