From b5d785f1a59a56c3471f2cef328f7c9a6c15f3e7 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Thu, 26 Mar 2026 10:34:09 -0700 Subject: [PATCH] Gateway: require caller scope for subagent session deletion (#55281) --- src/gateway/server-plugins.test.ts | 51 +++++++++++++++++++++++++++--- src/gateway/server-plugins.ts | 14 +++----- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 41a8185a2c7..8af13556dea 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -470,16 +470,59 @@ describe("loadGatewayPlugins", () => { expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); }); - test("keeps admin scope for fallback session deletion", async () => { + test("rejects fallback session deletion without minting admin scope", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-delete-session")); - await runtime.deleteSession({ - sessionKey: "s-delete", - deleteTranscript: true, + handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { + // Re-run the gateway scope check here so the test proves fallback dispatch + // does not smuggle admin into the request client. + const scopes = Array.isArray(opts.client?.connect?.scopes) ? opts.client.connect.scopes : []; + const auth = methodScopesModule.authorizeOperatorScopesForMethod("sessions.delete", scopes); + if (!auth.allowed) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: `missing scope: ${auth.missingScope}`, + }); + return; + } + opts.respond(true, {}); }); + await expect( + runtime.deleteSession({ + sessionKey: "s-delete", + deleteTranscript: true, + }), + ).rejects.toThrow("missing scope: operator.admin"); + + expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); + expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); + }); + + test("allows session deletion when the request scope already has admin", async () => { + const serverPlugins = serverPluginsModule; + const runtime = await createSubagentRuntime(serverPlugins); + const scope = { + context: createTestContext("request-scope-delete-session"), + client: { + connect: { + scopes: ["operator.admin"], + }, + } as GatewayRequestOptions["client"], + isWebchatConnect: () => false, + } satisfies PluginRuntimeGatewayRequestScope; + + await expect( + gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => + runtime.deleteSession({ + sessionKey: "s-delete-admin", + deleteTranscript: true, + }), + ), + ).resolves.toBeUndefined(); + expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); }); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 33203c4c10f..c59e4be6982 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -367,16 +367,10 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { return getSessionMessages(params); }, async deleteSession(params) { - await dispatchGatewayMethod( - "sessions.delete", - { - key: params.sessionKey, - deleteTranscript: params.deleteTranscript ?? true, - }, - { - syntheticScopes: [ADMIN_SCOPE], - }, - ); + await dispatchGatewayMethod("sessions.delete", { + key: params.sessionKey, + deleteTranscript: params.deleteTranscript ?? true, + }); }, }; }