Skip to content

Commit 7ad035b

Browse files
authored
Merge pull request #1710 from cnoe-io/fix/shareable-rbac-mcp-hardening
fix(rbac): harden shareable-resource access control for RAG MCP tools and KB sharing
2 parents b9d968e + a2ddb27 commit 7ad035b

7 files changed

Lines changed: 450 additions & 20 deletions

File tree

ui/src/app/api/rag/[...path]/route.ts

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { RbacScope } from '@/lib/rbac/types';
1010
import {
1111
filterResourcesByPermission,
1212
requireResourcePermission,
13+
canTransferResourceOwnership,
1314
type ResourcePermissionAction,
1415
} from '@/lib/rbac/resource-authz';
1516
import {
@@ -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 {

ui/src/app/api/rag/__tests__/mcp-tool-can-call.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,38 @@ describe("POST /v1/mcp/invoke — can_call gate", () => {
195195
);
196196
expect(res.status).toBe(200);
197197
});
198+
199+
it("fails CLOSED (503, no forward) when the custom-tools listing errors", async () => {
200+
await asUser("alice-sub");
201+
// The custom-tools listing fails — we cannot tell if `tool_name` is a
202+
// custom tool, so the gate must DENY rather than forward (deny-by-default),
203+
// so a transient error can't be used to bypass `can_call`.
204+
const forward = jest.fn();
205+
global.fetch = jest.fn((url: string | URL) => {
206+
const u = String(url);
207+
if (u.includes("/v1/mcp/custom-tools")) {
208+
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as Response);
209+
}
210+
forward();
211+
return Promise.resolve({ ok: true, status: 200, json: async () => ({}) } as Response);
212+
}) as jest.Mock;
213+
mockCheckOpenFgaTuple.mockResolvedValue({ allowed: false });
214+
215+
const { POST } = await import("@/app/api/rag/[...path]/route");
216+
const res = await POST(
217+
ragRequest("/api/rag/v1/mcp/invoke", {
218+
method: "POST",
219+
body: JSON.stringify({ tool_name: "infra-search", arguments: {} }),
220+
headers: { "content-type": "application/json" },
221+
}),
222+
INVOKE,
223+
);
224+
expect(res.status).toBe(503);
225+
const body = await res.json();
226+
expect(body.code).toBe("mcp_tool#call_unavailable");
227+
// Critically: the invocation was never forwarded to the RAG server.
228+
expect(forward).not.toHaveBeenCalled();
229+
});
198230
});
199231

200232
describe("DELETE /v1/mcp/custom-tools/<id> — orphan tuple cleanup", () => {

0 commit comments

Comments
 (0)