| author | |
| committer | |
| log | a78760e5e7b11ea51e21a1640fec54bd3f210fd8 |
| tree | e1fdc3e2d273f2cf0ba5edebf56b4b87aeb7767d |
| parent | b05adb70b00feac0883b2e364256100cfab4b6ca |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 7 | WsRpcGroup, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { describe, expect, it } from "@effect/vitest"; |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as RpcTest from "effect/unstable/rpc/RpcTest"; | |
| 10 | 13 | |
| 11 | 14 | import { |
| 12 | 15 | RPC_REQUIRED_SCOPES, |
| 13 | 16 | requiredScopeForRpcMethod, |
| 14 | 17 | requiredScopeForDeviceList, |
| 18 | rpcScopeAuthorizationLayer, | |
| 15 | 19 | } from "./RpcAuthorization.ts"; |
| 16 | 20 | |
| 17 | 21 | describe("RPC authorization scopes", () => { |
| ... | ... | @@ -116,3 +120,39 @@ it("requires operate permission for tool updates even alongside a read-only chec |
| 116 | 120 | ); |
| 117 | 121 | expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); |
| 118 | 122 | }); |
| 123 | ||
| 124 | describe("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 | AuthTerminalOperateScope, |
| 10 | 10 | ORCHESTRATION_V2_WS_METHODS, |
| 11 | 11 | type AuthEnvironmentScope, |
| 12 | EnvironmentAuthorizationError, | |
| 13 | RpcScopeAuthorization, | |
| 12 | 14 | WS_METHODS, |
| 13 | 15 | WsRpcGroup, |
| 14 | 16 | } from "@t3tools/contracts"; |
| 17 | import * as Effect from "effect/Effect"; | |
| 18 | import * as Layer from "effect/Layer"; | |
| 15 | 19 | import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 16 | 20 | |
| 17 | 21 | type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"]; |
| ... | ... | @@ -212,6 +216,21 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope |
| 212 | 216 | return requiredScope; |
| 213 | 217 | } |
| 214 | 218 | |
| 219 | export 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. */ | |
| 226 | export 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 | 234 | /** Retrying can install or restart tools even though ordinary listing is readable. */ |
| 216 | 235 | export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => |
| 217 | 236 | input.retryHostId || input.updateTool |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+9-3| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | 3 | import { |
| 4 | AuthOrchestrationOperateScope, | |
| 4 | 5 | EnvironmentId, |
| 5 | 6 | PreviewAutomationClientDisconnectedError, |
| 6 | 7 | PreviewAutomationInvalidSelectorError, |
| ... | ... | @@ -17,6 +18,7 @@ import { |
| 17 | 18 | type PreviewAutomationStreamEvent, |
| 18 | 19 | } from "@t3tools/contracts"; |
| 19 | 20 | import * as Effect from "effect/Effect"; |
| 21 | import * as Layer from "effect/Layer"; | |
| 20 | 22 | import * as Exit from "effect/Exit"; |
| 21 | 23 | import * as Deferred from "effect/Deferred"; |
| 22 | 24 | import * as Fiber from "effect/Fiber"; |
| ... | ... | @@ -27,6 +29,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 27 | 29 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 28 | 30 | import * as RpcTest from "effect/unstable/rpc/RpcTest"; |
| 29 | 31 | |
| 32 | import { rpcScopeAuthorizationLayer } from "../auth/RpcAuthorization.ts"; | |
| 30 | 33 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 31 | 34 | |
| 32 | 35 | const 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 | 1258 | ); |
| 1256 | 1259 | const client = yield* RpcTest.makeClient(group).pipe( |
| 1257 | 1260 | 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 | ), | |
| 1261 | 1267 | ), |
| 1262 | 1268 | ); |
| 1263 | 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 | 157 | import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts"; |
| 158 | 158 | import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; |
| 159 | 159 | import { |
| 160 | observeRpcEffect as instrumentRpcEffect, | |
| 161 | observeRpcStream as instrumentRpcStream, | |
| 162 | observeRpcStreamEffect as instrumentRpcStreamEffect, | |
| 160 | observeRpcEffect, | |
| 161 | observeRpcStream, | |
| 162 | observeRpcStreamEffect, | |
| 163 | 163 | } from "./observability/RpcInstrumentation.ts"; |
| 164 | 164 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| 165 | 165 | import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; |
| ... | ... | @@ -207,7 +207,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 207 | 207 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| 208 | 208 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| 209 | 209 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 210 | import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; | |
| 210 | import { | |
| 211 | requiredScopeForDeviceList, | |
| 212 | rpcAuthorizationError, | |
| 213 | rpcScopeAuthorizationLayer, | |
| 214 | } from "./auth/RpcAuthorization.ts"; | |
| 211 | 215 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 212 | 216 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 213 | 217 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| ... | ... | @@ -1309,25 +1313,15 @@ const makeWsRpcLayer = ( |
| 1309 | 1313 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; |
| 1310 | 1314 | const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 1311 | 1315 | 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. | |
| 1317 | 1318 | const authorizeEffect = <A, E, R>( |
| 1318 | 1319 | requiredScope: AuthEnvironmentScope, |
| 1319 | 1320 | effect: Effect.Effect<A, E, R>, |
| 1320 | 1321 | ): Effect.Effect<A, E | EnvironmentAuthorizationError, R> => |
| 1321 | 1322 | currentSession.scopes.includes(requiredScope) |
| 1322 | 1323 | ? 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)); | |
| 1331 | 1325 | |
| 1332 | 1326 | const acpRegistryProject = Effect.fn("ws.acpRegistry.project")(function* ( |
| 1333 | 1327 | projectId: ProjectId, |
| ... | ... | @@ -1650,40 +1644,6 @@ const makeWsRpcLayer = ( |
| 1650 | 1644 | yield* providerRegistry.refreshInstance(input.instanceId); |
| 1651 | 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 | 1647 | const loadAuthAccessSnapshot = () => |
| 1688 | 1648 | Effect.all({ |
| 1689 | 1649 | pairingLinks: serverAuth.listPairingLinks(), |
| ... | ... | @@ -3842,6 +3802,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 3842 | 3802 | const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; |
| 3843 | 3803 | yield* RpcServer.make(ServerWsRpcGroup, { disableTracing: true }).pipe( |
| 3844 | 3804 | Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), |
| 3805 | Effect.provide(rpcScopeAuthorizationLayer(session.scopes)), | |
| 3845 | 3806 | Effect.forkScoped, |
| 3846 | 3807 | ); |
| 3847 | 3808 | // @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 | 28 | HTTP so long-lived tokens stay out of socket URLs. Browser sessions can |
| 29 | 29 | authenticate the upgrade with their cookie. A successful handshake grants no |
| 30 | 30 | extra authority: [every RPC declares a required |
| 31 | scope](../../apps/server/src/auth/RpcAuthorization.ts). | |
| 31 | scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC | |
| 32 | group's `RpcScopeAuthorization` middleware checks it before any handler runs. | |
| 32 | 33 | |
| 33 | 34 | Desktop restarts forget the previous local bearer token, so its reusable |
| 34 | 35 | bootstrap grant replaces earlier sessions for the same subject and method. |
packages/contracts/src/rpc.ts+12-1| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 11 | 11 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 12 | import * as RpcMiddleware from "effect/unstable/rpc/RpcMiddleware"; | |
| 12 | 13 | import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 13 | 14 | import { |
| 14 | 15 | CodexAuthCallbackInput, |
| ... | ... | @@ -1682,6 +1683,16 @@ const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTel |
| 1682 | 1683 | stream: true, |
| 1683 | 1684 | }); |
| 1684 | 1685 | |
| 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 | */ | |
| 1691 | export class RpcScopeAuthorization extends RpcMiddleware.Service<RpcScopeAuthorization>()( | |
| 1692 | "t3/contracts/RpcScopeAuthorization", | |
| 1693 | { error: EnvironmentAuthorizationError }, | |
| 1694 | ) {} | |
| 1695 | ||
| 1685 | 1696 | export const WsRpcGroup = RpcGroup.make( |
| 1686 | 1697 | WsServerProbeRpc, |
| 1687 | 1698 | WsServerGetConfigRpc, |
| ... | ... | @@ -1856,4 +1867,4 @@ export const WsRpcGroup = RpcGroup.make( |
| 1856 | 1867 | WsOrchestrationV2SubscribeArchivedShellRpc, |
| 1857 | 1868 | WsOrchestrationV2SubscribeShellRpc, |
| 1858 | 1869 | WsOrchestrationV2SubscribeThreadRpc, |
| 1859 | ); | |
| 1870 | ).middleware(RpcScopeAuthorization); |