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 {
77 WsRpcGroup,
88} from "@t3tools/contracts";
99import { 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
1114import {
1215 RPC_REQUIRED_SCOPES,
1316 requiredScopeForRpcMethod,
1417 requiredScopeForDeviceList,
18 rpcScopeAuthorizationLayer,
1519} from "./RpcAuthorization.ts";
1620
1721describe("RPC authorization scopes", () => {
......@@ -116,3 +120,39 @@ it("requires operate permission for tool updates even alongside a read-only chec
116120 );
117121 expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope);
118122});
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 {
99 AuthTerminalOperateScope,
1010 ORCHESTRATION_V2_WS_METHODS,
1111 type AuthEnvironmentScope,
12 EnvironmentAuthorizationError,
13 RpcScopeAuthorization,
1214 WS_METHODS,
1315 WsRpcGroup,
1416} from "@t3tools/contracts";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
1519import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
1620
1721type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
......@@ -212,6 +216,21 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
212216 return requiredScope;
213217}
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
215234/** Retrying can install or restart tools even though ordinary listing is readable. */
216235export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope =>
217236 input.retryHostId || input.updateTool
apps/server/src/mcp/PreviewAutomationBroker.test.ts+9-3
......@@ -1,6 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { expect, it } from "@effect/vitest";
33import {
4 AuthOrchestrationOperateScope,
45 EnvironmentId,
56 PreviewAutomationClientDisconnectedError,
67 PreviewAutomationInvalidSelectorError,
......@@ -17,6 +18,7 @@ import {
1718 type PreviewAutomationStreamEvent,
1819} from "@t3tools/contracts";
1920import * as Effect from "effect/Effect";
21import * as Layer from "effect/Layer";
2022import * as Exit from "effect/Exit";
2123import * as Deferred from "effect/Deferred";
2224import * as Fiber from "effect/Fiber";
......@@ -27,6 +29,7 @@ import * as TestClock from "effect/testing/TestClock";
2729import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
2830import * as RpcTest from "effect/unstable/rpc/RpcTest";
2931
32import { rpcScopeAuthorizationLayer } from "../auth/RpcAuthorization.ts";
3033import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
3134
3235const 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"
12551258 );
12561259 const client = yield* RpcTest.makeClient(group).pipe(
12571260 Effect.provide(
1258 group.toLayer({
1259 [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)),
1260 }),
1261 Layer.merge(
1262 group.toLayer({
1263 [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)),
1264 }),
1265 rpcScopeAuthorizationLayer([AuthOrchestrationOperateScope]),
1266 ),
12611267 ),
12621268 );
12631269 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";
157157import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";
158158import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";
159159import {
160 observeRpcEffect as instrumentRpcEffect,
161 observeRpcStream as instrumentRpcStream,
162 observeRpcStreamEffect as instrumentRpcStreamEffect,
160 observeRpcEffect,
161 observeRpcStream,
162 observeRpcStreamEffect,
163163} from "./observability/RpcInstrumentation.ts";
164164import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
165165import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";
......@@ -207,7 +207,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
207207import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
208208import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
209209import * 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";
211215import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
212216import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
213217import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
......@@ -1309,25 +1313,15 @@ const makeWsRpcLayer = (
13091313 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
13101314 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;
13111315 const relayClient = yield* RelayClient.RelayClient;
1312 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
1313 new EnvironmentAuthorizationError({
1314 message: `The authenticated token is missing required scope: ${requiredScope}.`,
1315 requiredScope,
1316 });
1316 // RpcScopeAuthorization checks each RPC's declared scope before its handler
1317 // runs. This covers the one RPC whose scope depends on its input.
13171318 const authorizeEffect = <A, E, R>(
13181319 requiredScope: AuthEnvironmentScope,
13191320 effect: Effect.Effect<A, E, R>,
13201321 ): Effect.Effect<A, E | EnvironmentAuthorizationError, R> =>
13211322 currentSession.scopes.includes(requiredScope)
13221323 ? effect
1323 : Effect.fail(authorizationError(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));
1324 : Effect.fail(rpcAuthorizationError(requiredScope));
13311325
13321326 const acpRegistryProject = Effect.fn("ws.acpRegistry.project")(function* (
13331327 projectId: ProjectId,
......@@ -1650,40 +1644,6 @@ const makeWsRpcLayer = (
16501644 yield* providerRegistry.refreshInstance(input.instanceId);
16511645 return { disabled: true } as const;
16521646 });
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 );
16871647 const loadAuthAccessSnapshot = () =>
16881648 Effect.all({
16891649 pairingLinks: serverAuth.listPairingLinks(),
......@@ -3842,6 +3802,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
38423802 const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;
38433803 yield* RpcServer.make(ServerWsRpcGroup, { disableTracing: true }).pipe(
38443804 Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)),
3805 Effect.provide(rpcScopeAuthorizationLayer(session.scopes)),
38453806 Effect.forkScoped,
38463807 );
38473808 // @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
2828HTTP so long-lived tokens stay out of socket URLs. Browser sessions can
2929authenticate the upgrade with their cookie. A successful handshake grants no
3030extra 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
3334Desktop restarts forget the previous local bearer token, so its reusable
3435bootstrap grant replaces earlier sessions for the same subject and method.
packages/contracts/src/rpc.ts+12-1
......@@ -9,6 +9,7 @@ import {
99import * as Schema from "effect/Schema";
1010import * as Rpc from "effect/unstable/rpc/Rpc";
1111import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
12import * as RpcMiddleware from "effect/unstable/rpc/RpcMiddleware";
1213import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
1314import {
1415 CodexAuthCallbackInput,
......@@ -1682,6 +1683,16 @@ const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTel
16821683 stream: true,
16831684});
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
16851696export const WsRpcGroup = RpcGroup.make(
16861697 WsServerProbeRpc,
16871698 WsServerGetConfigRpc,
......@@ -1856,4 +1867,4 @@ export const WsRpcGroup = RpcGroup.make(
18561867 WsOrchestrationV2SubscribeArchivedShellRpc,
18571868 WsOrchestrationV2SubscribeShellRpc,
18581869 WsOrchestrationV2SubscribeThreadRpc,
1859);
1870).middleware(RpcScopeAuthorization);