From b3551c16b71a24a67183417535224397a28c0292 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:41:05 -0700 Subject: [PATCH 1/9] feat(auth): authorize diagnostics and usage reads separately --- .../src/features/usage/UsageRouteScreen.tsx | 15 +++-- apps/mobile/src/state/usage.ts | 41 +++++++++++- apps/server/src/auth/RpcAuthorization.ts | 15 +++-- apps/server/src/server.test.ts | 66 +++++++++++++++++++ .../settings/ConnectionsSettings.tsx | 6 ++ .../settings/DiagnosticsSettings.tsx | 18 ++++- .../src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 19 +++++- apps/web/src/lib/resourceTelemetryState.ts | 9 ++- apps/web/src/state/usage.test.tsx | 1 + apps/web/src/state/usage.ts | 41 +++++++++++- packages/contracts/src/auth.ts | 3 + 12 files changed, 215 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 17841cbd74fa..4ccfaff344c3 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -66,6 +66,7 @@ export function UsageRouteScreen() { const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); const limits = useRefreshLimits(); + const canReadDiagnostics = environments.some((environment) => environment.canReadDiagnostics); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -134,10 +135,12 @@ export function UsageRouteScreen() { contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} refreshControl={ - void limits.refresh() : refreshWindow} - /> + showingLimits || canReadDiagnostics ? ( + void limits.refresh() : refreshWindow} + /> + ) : undefined } > @@ -177,6 +180,10 @@ export function UsageRouteScreen() { Connect an environment to see usage. + ) : !canReadDiagnostics ? ( + + This connection does not have access to diagnostics and usage. + ) : ( <> const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { + const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); + const session = Option.getOrNull(AsyncResult.value(sessionResult)); + const isCheckingAccess = + sessionResult.waiting || (session === null && sessionResult._tag !== "Failure"); + const canReadDiagnostics = + sessionResult._tag === "Success" && + !isCheckingAccess && + session?.authenticated === true && + (session.scopes?.includes(AuthDiagnosticsReadScope) ?? false); + if (!canReadDiagnostics) { + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: isCheckingAccess, + canReadDiagnostics: false, + error: isCheckingAccess + ? null + : "This connection does not have access to diagnostics and usage.", + summary: null, + }); + continue; + } const result = get(serverEnvironment.usageSummary({ environmentId, input })); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, + canReadDiagnostics: true, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary: Option.getOrNull(AsyncResult.value(result)), }); @@ -109,13 +135,26 @@ export function useUsage(input: UsageSummaryInput): UsageView { const input = JSON.parse(windowKey) as UsageSummaryInput; for (const environment of environments) { const { environmentId } = environment; + const hasAccess = () => { + const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); + const session = Option.getOrNull(AsyncResult.value(result)); + return ( + result._tag === "Success" && + !result.waiting && + session?.authenticated === true && + (session.scopes?.includes(AuthDiagnosticsReadScope) ?? false) + ); + }; + if (!environment.canReadDiagnostics || !hasAccess()) continue; const query = serverEnvironment.usageSummary({ environmentId, input }); void runAtomCommand( appAtomRegistry, serverEnvironment.refreshUsageRates, { environmentId, input: {} }, { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); + ).finally(() => { + if (hasAccess()) appAtomRegistry.refresh(query); + }); } }, [environments, windowKey]); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index cab3410249d8..2794116a6619 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -5,6 +5,7 @@ import { AuthEnvironmentMaintainScope, AuthFilesystemReadScope, AuthFilesystemWriteScope, + AuthDiagnosticsReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -57,13 +58,13 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope, - [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, - [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope, + [WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope, [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, @@ -110,7 +111,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, - [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthSourceControlWriteScope, [WS_METHODS.gitRunStackedAction]: AuthSourceControlWriteScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fa472f4fcd4a..fc7dfdba5e5e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -16,6 +16,7 @@ import { CommandId, DEFAULT_SERVER_SETTINGS, type DpopFailureReason, + EnvironmentAuthorizationError, EnvironmentId, EventId, GitCommandError, @@ -4251,6 +4252,71 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const scope of ["orchestration:read", "diagnostics:read"] as const) { + it.effect(`separates diagnostics RPC access for ${scope} sessions`, () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const { response, body } = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope, + }); + assert.equal(response.status, 200); + assert.equal(body.scope, scope); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${body.access_token ?? ""}` }, + }); + const { ticket } = (yield* ticketResponse.json) as { readonly ticket: string }; + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const diagnosticsReads: ReadonlyArray> = [ + client[WS_METHODS.serverGetTraceDiagnostics]({}), + client[WS_METHODS.serverGetProcessDiagnostics]({}), + client[WS_METHODS.serverGetProcessResourceHistory]({ + windowMs: 60_000, + bucketMs: 10_000, + }), + client[WS_METHODS.serverGetResourceTelemetryHistory]({ + windowMs: 60_000, + bucketMs: 10_000, + }), + client[WS_METHODS.subscribeResourceTelemetry]({}).pipe(Stream.runHead), + client[WS_METHODS.serverGetUsageSummary]({ + sinceDay: "2026-09-01", + untilDay: "2026-09-01", + timeZone: "UTC", + }), + client[WS_METHODS.serverRefreshUsageRates]({}), + ]; + for (const read of diagnosticsReads) { + if (scope === "diagnostics:read") { + yield* read; + } else { + const error = yield* Effect.flip(read); + if (!Schema.is(EnvironmentAuthorizationError)(error)) { + assert.fail(`Expected a diagnostics authorization error, got ${String(error)}`); + } + assert.equal(error.requiredScope, "diagnostics:read"); + } + } + if (scope === "orchestration:read") { + yield* client[WS_METHODS.serverGetConfig]({}); + } else { + const error = yield* Effect.flip(client[WS_METHODS.serverGetConfig]({})); + assert.equal(error._tag, "EnvironmentAuthorizationError"); + const mutationError = yield* Effect.flip( + client[WS_METHODS.serverRetryResourceTelemetry]({}), + ); + assert.equal(mutationError._tag, "EnvironmentAuthorizationError"); + } + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + } + it.effect("includes CORS headers on remote auth success responses", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index f94d366eaa2d..c35b2f296eb1 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -17,6 +17,7 @@ import { AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthDiagnosticsReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -224,6 +225,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ title: "Control previews", description: "Open browser previews and host browser automation.", }, + { + scope: AuthDiagnosticsReadScope, + title: "View diagnostics and usage", + description: "Read process diagnostics, resource history, and usage totals.", + }, { scope: AuthTerminalOperateScope, title: "Use terminals", diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 052962b87dd6..419fc9d2a3bd 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -16,6 +16,7 @@ import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, + AuthDiagnosticsReadScope, type ServerProcessDiagnosticsEntry, type ServerProcessResourceHistorySummary, type ServerProcessSignal, @@ -789,6 +790,7 @@ export function DiagnosticsSettingsPanel() { const environmentId = primaryEnvironment?.environmentId ?? null; const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope); const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope); const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -798,7 +800,7 @@ export function DiagnosticsSettingsPanel() { RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? RESOURCE_HISTORY_WINDOWS[1]; const { data, error, isPending, refresh } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), ); @@ -808,7 +810,7 @@ export function DiagnosticsSettingsPanel() { isPending: isProcessPending, refresh: refreshProcesses, } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.processDiagnostics({ environmentId, input: {} }), ); @@ -818,7 +820,7 @@ export function DiagnosticsSettingsPanel() { isPending: isResourcePending, refresh: refreshResources, } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.processResourceHistory({ environmentId, @@ -966,6 +968,16 @@ export function DiagnosticsSettingsPanel() { ? Option.getOrElse(data.partialFailure, () => false) : false; + if (!canReadDiagnostics) { + return ( + +

+ This connection does not have access to diagnostics. +

+
+ ); + } + return ( diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 944987388b06..afbfb10b4f2a 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -109,6 +109,7 @@ const environments = [ environmentId: EnvironmentId.make("test-environment"), label: "Test environment", isPending: false, + canReadDiagnostics: true, error: null, summary: { contractVersion: USAGE_CONTRACT_VERSION, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index e957002115ab..876a32897448 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -102,6 +102,10 @@ export function UsagePage() { reportFailure: false, }); + const canReadDiagnostics = selectedEnvironments.some( + (environment) => environment.canReadDiagnostics, + ); + const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], @@ -224,6 +228,7 @@ export function UsagePage() {