Skip to content

Commit 27f702d

Browse files
fix(gateway): authorize plugin methods from attached registry (#94343)
Authorize plugin gateway methods against the exact registry attached to dispatch, preserving fallback behavior for dynamic methods and deleting one-off repro scripts. Fixes #92044. Co-authored-by: wangmiao0668000666 <[email protected]>
1 parent 0781dae commit 27f702d

3 files changed

Lines changed: 88 additions & 3 deletions

File tree

src/gateway/method-scopes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,17 @@ export function authorizeOperatorScopesForMethod(
175175
return missingScope ? { allowed: false, missingScope } : { allowed: true };
176176
}
177177
const requiredScope = resolveRequiredOperatorScopeForMethod(method) ?? ADMIN_SCOPE;
178+
return authorizeOperatorScopesForRequiredScope(requiredScope, scopes);
179+
}
180+
181+
/** Checks a method registry's already-resolved static scope against presented operator scopes. */
182+
export function authorizeOperatorScopesForRequiredScope(
183+
requiredScope: OperatorScope,
184+
scopes: readonly string[],
185+
): { allowed: true } | { allowed: false; missingScope: OperatorScope } {
186+
if (scopes.includes(ADMIN_SCOPE)) {
187+
return { allowed: true };
188+
}
178189
if (requiredScope === READ_SCOPE) {
179190
if (scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE)) {
180191
return { allowed: true };
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
3+
import { setActivePluginRegistry } from "../plugins/runtime.js";
4+
import {
5+
createGatewayMethodRegistry,
6+
createPluginGatewayMethodDescriptor,
7+
} from "./methods/registry.js";
8+
import { handleGatewayRequest } from "./server-methods.js";
9+
import type { GatewayRequestHandler } from "./server-methods/types.js";
10+
11+
const METHOD = "workboard.cards.dispatch";
12+
13+
afterEach(() => {
14+
setActivePluginRegistry(createEmptyPluginRegistry());
15+
});
16+
17+
describe("gateway method authorization", () => {
18+
async function dispatch(scopes: string[]) {
19+
const handler: GatewayRequestHandler = ({ respond }) => respond(true, { ok: true });
20+
const methodRegistry = createGatewayMethodRegistry([
21+
createPluginGatewayMethodDescriptor({
22+
pluginId: "workboard",
23+
name: METHOD,
24+
handler,
25+
scope: "operator.write",
26+
}),
27+
]);
28+
const respond = vi.fn();
29+
30+
// Reproduce a request whose attached dispatch registry is newer than the global runtime state.
31+
setActivePluginRegistry(createEmptyPluginRegistry());
32+
await handleGatewayRequest({
33+
req: { type: "req", id: "req-1", method: METHOD },
34+
respond,
35+
client: {
36+
connId: "conn-1",
37+
connect: {
38+
role: "operator",
39+
scopes,
40+
client: { id: "test", version: "1", platform: "test", mode: "test" },
41+
minProtocol: 1,
42+
maxProtocol: 1,
43+
},
44+
} as Parameters<typeof handleGatewayRequest>[0]["client"],
45+
isWebchatConnect: () => false,
46+
context: { logGateway: { warn: vi.fn() } } as unknown as Parameters<
47+
typeof handleGatewayRequest
48+
>[0]["context"],
49+
methodRegistry,
50+
});
51+
return respond;
52+
}
53+
54+
it("authorizes from the attached registry used for dispatch", async () => {
55+
const allowed = await dispatch(["operator.write"]);
56+
const denied = await dispatch(["operator.read"]);
57+
58+
expect(allowed).toHaveBeenCalledWith(true, { ok: true });
59+
expect(denied).toHaveBeenCalledWith(
60+
false,
61+
undefined,
62+
expect.objectContaining({ message: "missing scope: operator.write" }),
63+
);
64+
});
65+
});

src/gateway/server-methods.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { getPluginRegistryState } from "../plugins/runtime-state.js";
99
import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
1010
import { formatControlPlaneActor, resolveControlPlaneActor } from "./control-plane-audit.js";
1111
import { consumeControlPlaneWriteBudget } from "./control-plane-rate-limit.js";
12-
import { ADMIN_SCOPE, authorizeOperatorScopesForMethod } from "./method-scopes.js";
12+
import {
13+
ADMIN_SCOPE,
14+
authorizeOperatorScopesForMethod,
15+
authorizeOperatorScopesForRequiredScope,
16+
} from "./method-scopes.js";
1317
import {
1418
createCoreGatewayMethodDescriptors,
1519
createGatewayMethodDescriptorsFromHandlers,
@@ -18,6 +22,7 @@ import {
1822
isCoreGatewayMethodClassified,
1923
type GatewayMethodRegistry,
2024
} from "./methods/registry.js";
25+
import { isOperatorScope } from "./operator-scopes.js";
2126
import { isRoleAuthorizedForMethod, parseGatewayRole } from "./role-policy.js";
2227
import type {
2328
GatewayRequestHandler,
@@ -226,6 +231,7 @@ function authorizeGatewayMethod(
226231
method: string,
227232
client: GatewayRequestOptions["client"],
228233
params: unknown,
234+
methodRegistry: GatewayMethodRegistry,
229235
) {
230236
// Pre-connect and health requests are allowed through; role/scope checks require the
231237
// authenticated connect metadata established by the gateway handshake.
@@ -250,7 +256,10 @@ function authorizeGatewayMethod(
250256
if (scopes.includes(ADMIN_SCOPE)) {
251257
return null;
252258
}
253-
const scopeAuth = authorizeOperatorScopesForMethod(method, scopes, params);
259+
const registeredScope = methodRegistry.getScope(method);
260+
const scopeAuth = isOperatorScope(registeredScope)
261+
? authorizeOperatorScopesForRequiredScope(registeredScope, scopes)
262+
: authorizeOperatorScopesForMethod(method, scopes, params);
254263
if (!scopeAuth.allowed) {
255264
return errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${scopeAuth.missingScope}`);
256265
}
@@ -632,7 +641,7 @@ export async function handleGatewayRequest(
632641
const { req, respond, client, isWebchatConnect, context } = opts;
633642
const methodRegistry =
634643
opts.methodRegistry ?? createRequestGatewayMethodRegistry(opts.extraHandlers);
635-
const authError = authorizeGatewayMethod(req.method, client, req.params);
644+
const authError = authorizeGatewayMethod(req.method, client, req.params, methodRegistry);
636645
if (authError) {
637646
respond(false, undefined, authError);
638647
return;

0 commit comments

Comments
 (0)