authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-03 14:58:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 14:58:25-07:00
loga78760e5e7b11ea51e21a1640fec54bd3f210fd8
treee1fdc3e2d273f2cf0ba5edebf56b4b87aeb7767d
parentb05adb70b00feac0883b2e364256100cfab4b6ca
signature Signed by PGP key B5690EEEBB952194

refactor(server): check RPC scopes in group middleware (#15324)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 94 insertions(+), 56 deletions(-)

apps/server/src/auth/RpcAuthorization.test.ts+40
...@@ -7,11 +7,15 @@ import {...@@ -7,11 +7,15 @@ import {
7 WsRpcGroup,7 WsRpcGroup,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { describe, expect, it } from "@effect/vitest";9import { describe, expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as RpcTest from "effect/unstable/rpc/RpcTest";
1013
11import {14import {
12 RPC_REQUIRED_SCOPES,15 RPC_REQUIRED_SCOPES,
13 requiredScopeForRpcMethod,16 requiredScopeForRpcMethod,
14 requiredScopeForDeviceList,17 requiredScopeForDeviceList,
18 rpcScopeAuthorizationLayer,
15} from "./RpcAuthorization.ts";19} from "./RpcAuthorization.ts";
1620
17describe("RPC authorization scopes", () => {21describe("RPC authorization scopes", () => {
...@@ -116,3 +120,39 @@ it("requires operate permission for tool updates even alongside a read-only chec...@@ -116,3 +120,39 @@ it("requires operate permission for tool updates even alongside a read-only chec
116 );120 );
117 expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope);121 expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope);
118});122});
123
124describe("RPC scope middleware", () => {
125 const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const;
126 const group = WsRpcGroup.omit(
127 ...[...WsRpcGroup.requests.keys()].filter(
128 (tag): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, (typeof tested)[number]> =>
129 !(tested as ReadonlyArray<string>).includes(tag),
130 ),
131 );
132
133 it.effect("checks each RPC's declared scope before its handler runs", () =>
134 Effect.gen(function* () {
135 const handled: Array<string> = [];
136 const client = yield* RpcTest.makeClient(group).pipe(
137 Effect.provide(
138 Layer.mergeAll(
139 group.toLayerHandler(WS_METHODS.serverProbe, () => Effect.succeed({})),
140 group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () =>
141 Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)),
142 ),
143 rpcScopeAuthorizationLayer([AuthOrchestrationReadScope]),
144 ),
145 ),
146 );
147
148 expect(yield* client[WS_METHODS.serverProbe]({})).toEqual({});
149 expect(
150 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
151 ).toMatchObject({
152 _tag: "EnvironmentAuthorizationError",
153 requiredScope: AuthOrchestrationOperateScope,
154 });
155 expect(handled).toEqual([]);
156 }).pipe(Effect.scoped),
157 );
158});
apps/server/src/auth/RpcAuthorization.ts+19
...@@ -9,9 +9,13 @@ import {...@@ -9,9 +9,13 @@ import {
9 AuthTerminalOperateScope,9 AuthTerminalOperateScope,
10 ORCHESTRATION_V2_WS_METHODS,10 ORCHESTRATION_V2_WS_METHODS,
11 type AuthEnvironmentScope,11 type AuthEnvironmentScope,
12 EnvironmentAuthorizationError,
13 RpcScopeAuthorization,
12 WS_METHODS,14 WS_METHODS,
13 WsRpcGroup,15 WsRpcGroup,
14} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
15import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";19import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
1620
17type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];21type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
...@@ -212,6 +216,21 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope...@@ -212,6 +216,21 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
212 return requiredScope;216 return requiredScope;
213}217}
214218
219export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>
220 new EnvironmentAuthorizationError({
221 message: `The authenticated token is missing required scope: ${requiredScope}.`,
222 requiredScope,
223 });
224
225/** Authorizes every RPC on one connection against that connection's session scopes. */
226export const rpcScopeAuthorizationLayer = (scopes: ReadonlyArray<AuthEnvironmentScope>) =>
227 Layer.succeed(RpcScopeAuthorization)((effect, { rpc }) => {
228 const requiredScope = requiredScopeForRpcMethod(rpc._tag);
229 return scopes.includes(requiredScope)
230 ? effect
231 : Effect.fail(rpcAuthorizationError(requiredScope));
232 });
233
215/** Retrying can install or restart tools even though ordinary listing is readable. */234/** Retrying can install or restart tools even though ordinary listing is readable. */
216export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope =>235export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope =>
217 input.retryHostId || input.updateTool236 input.retryHostId || input.updateTool
apps/server/src/mcp/PreviewAutomationBroker.test.ts+9-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import {3import {
4 AuthOrchestrationOperateScope,
4 EnvironmentId,5 EnvironmentId,
5 PreviewAutomationClientDisconnectedError,6 PreviewAutomationClientDisconnectedError,
6 PreviewAutomationInvalidSelectorError,7 PreviewAutomationInvalidSelectorError,
...@@ -17,6 +18,7 @@ import {...@@ -17,6 +18,7 @@ import {
17 type PreviewAutomationStreamEvent,18 type PreviewAutomationStreamEvent,
18} from "@t3tools/contracts";19} from "@t3tools/contracts";
19import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
21import * as Layer from "effect/Layer";
20import * as Exit from "effect/Exit";22import * as Exit from "effect/Exit";
21import * as Deferred from "effect/Deferred";23import * as Deferred from "effect/Deferred";
22import * as Fiber from "effect/Fiber";24import * as Fiber from "effect/Fiber";
...@@ -27,6 +29,7 @@ import * as TestClock from "effect/testing/TestClock";...@@ -27,6 +29,7 @@ import * as TestClock from "effect/testing/TestClock";
27import * as RpcGroup from "effect/unstable/rpc/RpcGroup";29import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
28import * as RpcTest from "effect/unstable/rpc/RpcTest";30import * as RpcTest from "effect/unstable/rpc/RpcTest";
2931
32import { rpcScopeAuthorizationLayer } from "../auth/RpcAuthorization.ts";
30import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";33import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
3134
32const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer));35const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer));
...@@ -1255,9 +1258,12 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"...@@ -1255,9 +1258,12 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"
1255 );1258 );
1256 const client = yield* RpcTest.makeClient(group).pipe(1259 const client = yield* RpcTest.makeClient(group).pipe(
1257 Effect.provide(1260 Effect.provide(
1258 group.toLayer({1261 Layer.merge(
1259 [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)),1262 group.toLayer({
1260 }),1263 [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)),
1264 }),
1265 rpcScopeAuthorizationLayer([AuthOrchestrationOperateScope]),
1266 ),
1261 ),1267 ),
1262 );1268 );
1263 const events = client[WS_METHODS.previewAutomationConnect](makeHost());1269 const events = client[WS_METHODS.previewAutomationConnect](makeHost());
apps/server/src/ws.ts+12-51
...@@ -157,9 +157,9 @@ import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts";...@@ -157,9 +157,9 @@ import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts";
157import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";157import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";
158import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";158import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";
159import {159import {
160 observeRpcEffect as instrumentRpcEffect,160 observeRpcEffect,
161 observeRpcStream as instrumentRpcStream,161 observeRpcStream,
162 observeRpcStreamEffect as instrumentRpcStreamEffect,162 observeRpcStreamEffect,
163} from "./observability/RpcInstrumentation.ts";163} from "./observability/RpcInstrumentation.ts";
164import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";164import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
165import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";165import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";
...@@ -207,7 +207,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";...@@ -207,7 +207,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
207import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";207import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
208import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";208import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
209import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";209import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
210import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts";210import {
211 requiredScopeForDeviceList,
212 rpcAuthorizationError,
213 rpcScopeAuthorizationLayer,
214} from "./auth/RpcAuthorization.ts";
211import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";215import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
212import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";216import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
213import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";217import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
...@@ -1309,25 +1313,15 @@ const makeWsRpcLayer = (...@@ -1309,25 +1313,15 @@ const makeWsRpcLayer = (
1309 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;1313 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
1310 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;1314 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;
1311 const relayClient = yield* RelayClient.RelayClient;1315 const relayClient = yield* RelayClient.RelayClient;
1312 const authorizationError = (requiredScope: AuthEnvironmentScope) =>1316 // RpcScopeAuthorization checks each RPC's declared scope before its handler
1313 new EnvironmentAuthorizationError({1317 // runs. This covers the one RPC whose scope depends on its input.
1314 message: `The authenticated token is missing required scope: ${requiredScope}.`,
1315 requiredScope,
1316 });
1317 const authorizeEffect = <A, E, R>(1318 const authorizeEffect = <A, E, R>(
1318 requiredScope: AuthEnvironmentScope,1319 requiredScope: AuthEnvironmentScope,
1319 effect: Effect.Effect<A, E, R>,1320 effect: Effect.Effect<A, E, R>,
1320 ): Effect.Effect<A, E | EnvironmentAuthorizationError, R> =>1321 ): Effect.Effect<A, E | EnvironmentAuthorizationError, R> =>
1321 currentSession.scopes.includes(requiredScope)1322 currentSession.scopes.includes(requiredScope)
1322 ? effect1323 ? effect
1323 : Effect.fail(authorizationError(requiredScope));1324 : Effect.fail(rpcAuthorizationError(requiredScope));
1324 const authorizeStream = <A, E, R>(
1325 requiredScope: AuthEnvironmentScope,
1326 stream: Stream.Stream<A, E, R>,
1327 ): Stream.Stream<A, E | EnvironmentAuthorizationError, R> =>
1328 currentSession.scopes.includes(requiredScope)
1329 ? stream
1330 : Stream.fail(authorizationError(requiredScope));
13311325
1332 const acpRegistryProject = Effect.fn("ws.acpRegistry.project")(function* (1326 const acpRegistryProject = Effect.fn("ws.acpRegistry.project")(function* (
1333 projectId: ProjectId,1327 projectId: ProjectId,
...@@ -1650,40 +1644,6 @@ const makeWsRpcLayer = (...@@ -1650,40 +1644,6 @@ const makeWsRpcLayer = (
1650 yield* providerRegistry.refreshInstance(input.instanceId);1644 yield* providerRegistry.refreshInstance(input.instanceId);
1651 return { disabled: true } as const;1645 return { disabled: true } as const;
1652 });1646 });
1653 const observeRpcEffect = <A, E, R>(
1654 method: string,
1655 effect: Effect.Effect<A, E, R>,
1656 traceAttributes?: Readonly<Record<string, unknown>>,
1657 ) =>
1658 instrumentRpcEffect(
1659 method,
1660 authorizeEffect(requiredScopeForRpcMethod(method), effect),
1661 traceAttributes,
1662 );
1663 const observeRpcStream = <A, E, R>(
1664 method: string,
1665 stream: Stream.Stream<A, E, R>,
1666 traceAttributes?: Readonly<Record<string, unknown>>,
1667 ) =>
1668 instrumentRpcStream(
1669 method,
1670 authorizeStream(requiredScopeForRpcMethod(method), stream),
1671 traceAttributes,
1672 );
1673 const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>(
1674 method: string,
1675 effect: Effect.Effect<
1676 Stream.Stream<A, StreamError, StreamContext>,
1677 EffectError,
1678 EffectContext
1679 >,
1680 traceAttributes?: Readonly<Record<string, unknown>>,
1681 ) =>
1682 instrumentRpcStreamEffect(
1683 method,
1684 authorizeEffect(requiredScopeForRpcMethod(method), effect),
1685 traceAttributes,
1686 );
1687 const loadAuthAccessSnapshot = () =>1647 const loadAuthAccessSnapshot = () =>
1688 Effect.all({1648 Effect.all({
1689 pairingLinks: serverAuth.listPairingLinks(),1649 pairingLinks: serverAuth.listPairingLinks(),
...@@ -3842,6 +3802,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -3842,6 +3802,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
3842 const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;3802 const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;
3843 yield* RpcServer.make(ServerWsRpcGroup, { disableTracing: true }).pipe(3803 yield* RpcServer.make(ServerWsRpcGroup, { disableTracing: true }).pipe(
3844 Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)),3804 Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)),
3805 Effect.provide(rpcScopeAuthorizationLayer(session.scopes)),
3845 Effect.forkScoped,3806 Effect.forkScoped,
3846 );3807 );
3847 // @effect-diagnostics-next-line returnEffectInGen:off3808 // @effect-diagnostics-next-line returnEffectInGen:off
docs/internals/environment-auth.md+2-1
...@@ -28,7 +28,8 @@ Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticat...@@ -28,7 +28,8 @@ Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticat
28HTTP so long-lived tokens stay out of socket URLs. Browser sessions can28HTTP so long-lived tokens stay out of socket URLs. Browser sessions can
29authenticate the upgrade with their cookie. A successful handshake grants no29authenticate the upgrade with their cookie. A successful handshake grants no
30extra authority: [every RPC declares a required30extra authority: [every RPC declares a required
31scope](../../apps/server/src/auth/RpcAuthorization.ts).31scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC
32group's `RpcScopeAuthorization` middleware checks it before any handler runs.
3233
33Desktop restarts forget the previous local bearer token, so its reusable34Desktop restarts forget the previous local bearer token, so its reusable
34bootstrap grant replaces earlier sessions for the same subject and method.35bootstrap grant replaces earlier sessions for the same subject and method.
packages/contracts/src/rpc.ts+12-1
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10import * as Rpc from "effect/unstable/rpc/Rpc";10import * as Rpc from "effect/unstable/rpc/Rpc";
11import * as RpcGroup from "effect/unstable/rpc/RpcGroup";11import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
12import * as RpcMiddleware from "effect/unstable/rpc/RpcMiddleware";
12import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";13import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
13import {14import {
14 CodexAuthCallbackInput,15 CodexAuthCallbackInput,
...@@ -1682,6 +1683,16 @@ const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTel...@@ -1682,6 +1683,16 @@ const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTel
1682 stream: true,1683 stream: true,
1683});1684});
16841685
1686/**
1687 * Checks the connection's scopes against the scope each RPC declares, before
1688 * the handler runs. Every RPC in `WsRpcGroup` carries it, so a handler cannot
1689 * be added without authorization.
1690 */
1691export class RpcScopeAuthorization extends RpcMiddleware.Service<RpcScopeAuthorization>()(
1692 "t3/contracts/RpcScopeAuthorization",
1693 { error: EnvironmentAuthorizationError },
1694) {}
1695
1685export const WsRpcGroup = RpcGroup.make(1696export const WsRpcGroup = RpcGroup.make(
1686 WsServerProbeRpc,1697 WsServerProbeRpc,
1687 WsServerGetConfigRpc,1698 WsServerGetConfigRpc,
...@@ -1856,4 +1867,4 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1856,4 +1867,4 @@ export const WsRpcGroup = RpcGroup.make(
1856 WsOrchestrationV2SubscribeArchivedShellRpc,1867 WsOrchestrationV2SubscribeArchivedShellRpc,
1857 WsOrchestrationV2SubscribeShellRpc,1868 WsOrchestrationV2SubscribeShellRpc,
1858 WsOrchestrationV2SubscribeThreadRpc,1869 WsOrchestrationV2SubscribeThreadRpc,
1859);1870).middleware(RpcScopeAuthorization);