diff --git a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts index 11655414aec..0d10cbf2823 100644 --- a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts +++ b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts @@ -127,6 +127,7 @@ export function useWorkspaceHost( version: null, platform: null, installSource: null, + allowCloudWorkspaces: false, // A sandbox is reachable or it isn't; there is no offline // device behind it to report on. isOnline: true, diff --git a/apps/relay/src/access.ts b/apps/relay/src/access.ts index 0aec02b4902..9619720a943 100644 --- a/apps/relay/src/access.ts +++ b/apps/relay/src/access.ts @@ -1,6 +1,6 @@ import { parseHostRoutingKey } from "@superset/shared/host-routing"; import type { AuthContext } from "@superset/shared/verify-jwt"; -import { createApiClient } from "./api-client"; +import { createApiClient, createSandboxApiClient } from "./api-client"; export const ALLOWED_TTL_MS = 15 * 60 * 1000; export const DENIED_TTL_MS = 30 * 1000; @@ -16,6 +16,7 @@ export function isServerPresenceScope(scope: string | undefined): boolean { export interface Caller { userId: string; token: string; + preAuthorized?: true; } export function callerOf(auth: AuthContext, token: string): Caller { @@ -26,6 +27,7 @@ export type AccessDenial = | "invalid_host" | "not_in_org" | "not_registered" + | "cloud_workspaces_off" | "error"; export type AccessResult = { ok: true } | { ok: false; reason: AccessDenial }; @@ -37,6 +39,8 @@ export function accessDenialMessage(reason: AccessDenial): string { return "not a member of this org"; case "not_registered": return "host not registered to this account - run `superset start` on it with this org"; + case "cloud_workspaces_off": + return "this host does not accept cloud workspaces - its owner can allow it in the host's settings"; case "invalid_host": return "invalid host id"; default: @@ -87,3 +91,51 @@ export async function checkHostAccessForRegister( return { ok: false, reason: "error" }; } } + +export type CloudWorkspaceAccess = + | { ok: true; userId: string; workspaceId: string } + | { ok: false; reason: AccessDenial } + | { ok: false; reason: "unauthorized" }; + +// `.`, as the API mints it. +const SANDBOX_CREDENTIAL_SHAPE = /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/; + +// Not cached: turning the host's setting off must stop the next call. +export async function checkCloudWorkspaceAccess( + credential: string, + hostId: string, + apiUrl: string, +): Promise { + if (!parseHostRoutingKey(hostId)) { + return { ok: false, reason: "invalid_host" }; + } + if (!SANDBOX_CREDENTIAL_SHAPE.test(credential)) { + return { ok: false, reason: "unauthorized" }; + } + try { + const result = await createSandboxApiClient( + credential, + apiUrl, + ).host.cloudWorkspaceAccess.query({ hostId }); + if (result.allowed) { + return { + ok: true, + userId: result.userId, + workspaceId: result.workspaceId, + }; + } + return { + ok: false, + reason: + result.reason === "host_closed" + ? "cloud_workspaces_off" + : "not_registered", + }; + } catch (error) { + const code = (error as { data?: { code?: string } }).data?.code; + return { + ok: false, + reason: code === "UNAUTHORIZED" ? "unauthorized" : "error", + }; + } +} diff --git a/apps/relay/src/api-client.ts b/apps/relay/src/api-client.ts index 386e70a2110..28b61328326 100644 --- a/apps/relay/src/api-client.ts +++ b/apps/relay/src/api-client.ts @@ -1,14 +1,23 @@ +import { SANDBOX_API_CREDENTIAL_HEADER } from "@superset/shared/sandbox-gate"; import type { AppRouter } from "@superset/trpc"; import { createTRPCClient, httpBatchLink } from "@trpc/client"; import SuperJSON from "superjson"; export function createApiClient(token: string, apiUrl: string) { + return createClient({ Authorization: `Bearer ${token}` }, apiUrl); +} + +export function createSandboxApiClient(credential: string, apiUrl: string) { + return createClient({ [SANDBOX_API_CREDENTIAL_HEADER]: credential }, apiUrl); +} + +function createClient(headers: Record, apiUrl: string) { return createTRPCClient({ links: [ httpBatchLink({ url: `${apiUrl}/api/trpc`, transformer: SuperJSON, - headers: () => ({ Authorization: `Bearer ${token}` }), + headers: () => headers, }), ], }); diff --git a/apps/relay/src/host-tunnel.ts b/apps/relay/src/host-tunnel.ts index f2810cb86c9..362911c499c 100644 --- a/apps/relay/src/host-tunnel.ts +++ b/apps/relay/src/host-tunnel.ts @@ -161,6 +161,7 @@ export class HostTunnel extends Server { } private async authorize(caller: Caller): Promise { + if (caller.preAuthorized) return "allowed"; const key = `access:${caller.userId}`; const cached = await this.ctx.storage.get(key); if (cached && cached.expiresAt > Date.now()) { diff --git a/apps/relay/src/index.test.ts b/apps/relay/src/index.test.ts index f3cd4b0c4bd..93d1415a139 100644 --- a/apps/relay/src/index.test.ts +++ b/apps/relay/src/index.test.ts @@ -2,9 +2,19 @@ import { afterAll, beforeEach, expect, test } from "bun:test"; import relay from "./index"; -type ProxiedRequest = { method: string; pathWithQuery: string }; +type ProxiedRequest = { + method: string; + pathWithQuery: string; + headers?: Record; +}; const proxied: ProxiedRequest[] = []; +const proxiedHeaders: Record[] = []; +let boxAccess: unknown = { allowed: false, reason: "host_closed" }; +let apiDown = false; +let apiCalls = 0; +const BOX_CREDENTIAL = `11111111-2222-4333-8444-555555555555.${"a".repeat(43)}`; +const FORGED_CREDENTIAL = `11111111-2222-4333-8444-555555555555.${"b".repeat(43)}`; const tunnel = { setName: async () => {}, @@ -13,6 +23,7 @@ const tunnel = { method: request.method, pathWithQuery: request.pathWithQuery, }); + proxiedHeaders.push(request.headers ?? {}); return { ok: true, status: 200, @@ -39,7 +50,33 @@ const jwk = { }; const jwks = Bun.serve({ port: 0, - fetch: () => Response.json({ keys: [jwk] }), + fetch: (request) => { + const url = new URL(request.url); + if (url.pathname.startsWith("/api/trpc/host.cloudWorkspaceAccess")) { + apiCalls++; + if (apiDown) return new Response("down", { status: 502 }); + if ( + request.headers.get("x-superset-sandbox-credential") !== BOX_CREDENTIAL + ) { + return Response.json( + [ + { + error: { + json: { + message: "no", + code: -32001, + data: { code: "UNAUTHORIZED", httpStatus: 401 }, + }, + }, + }, + ], + { status: 401 }, + ); + } + return Response.json([{ result: { data: { json: boxAccess } } }]); + } + return Response.json({ keys: [jwk] }); + }, }); const apiUrl = `http://localhost:${jwks.port}`; @@ -47,6 +84,10 @@ afterAll(() => jwks.stop(true)); beforeEach(() => { proxied.length = 0; + proxiedHeaders.length = 0; + boxAccess = { allowed: false, reason: "host_closed" }; + apiDown = false; + apiCalls = 0; }); const hostId = "org-1:machine-1"; @@ -176,3 +217,85 @@ test("rejects a stream path that a percent-encoded host id leaks into", async () expect(response.status).toBe(400); }); + +function callAsBox( + path: string, + init: { credential?: string; headers?: Record } = {}, +) { + return call(path, { + method: "POST", + authorized: false, + headers: { + "x-superset-sandbox-credential": init.credential ?? BOX_CREDENTIAL, + ...init.headers, + }, + }); +} + +test("a cloud workspace reaches a host that allowed it, and its credential stays at the relay", async () => { + boxAccess = { allowed: true, userId: "user-1", workspaceId: "ws-1" }; + + const response = await callAsBox("/trpc/workspace.list"); + + expect(response.status).toBe(200); + expect(proxied).toEqual([ + { method: "POST", pathWithQuery: "/trpc/workspace.list" }, + ]); + expect(proxiedHeaders[0]?.["x-superset-user-id"]).toBe("user-1"); + expect(proxiedHeaders[0]?.["x-superset-sandbox-credential"]).toBeUndefined(); +}); + +test("a cloud workspace is refused by a host that did not allow it, and told why", async () => { + const response = await callAsBox("/trpc/workspace.list"); + + expect(response.status).toBe(403); + expect(JSON.stringify(await response.json())).toContain( + "does not accept cloud workspaces", + ); + expect(proxied).toEqual([]); +}); + +test("a credential the API does not accept is unauthorized", async () => { + const response = await callAsBox("/trpc/workspace.list", { + credential: FORGED_CREDENTIAL, + }); + + expect(response.status).toBe(401); + expect(proxied).toEqual([]); +}); + +test("a cloud workspace cannot open a stream, even to a host that allowed it", async () => { + boxAccess = { allowed: true, userId: "user-1", workspaceId: "ws-1" }; + + const response = await call("/terminal/t1", { + authorized: false, + headers: { + "x-superset-sandbox-credential": BOX_CREDENTIAL, + Upgrade: "websocket", + }, + }); + + expect(response.status).toBe(403); + expect(apiCalls).toBe(0); +}); + +test("a malformed credential is unauthorized without asking the API", async () => { + const response = await callAsBox("/trpc/workspace.list", { + credential: "anything", + }); + + expect(response.status).toBe(401); + expect(apiCalls).toBe(0); +}); + +test("an API outage during the box's access check is a server error, not an auth failure", async () => { + apiDown = true; + + const response = await callAsBox("/trpc/workspace.list"); + + expect(response.status).toBe(500); + expect(JSON.stringify(await response.json())).toContain( + "INTERNAL_SERVER_ERROR", + ); + expect(proxied).toEqual([]); +}); diff --git a/apps/relay/src/index.ts b/apps/relay/src/index.ts index 139e62c0add..4356f0492a5 100644 --- a/apps/relay/src/index.ts +++ b/apps/relay/src/index.ts @@ -3,6 +3,7 @@ import { buildUpstreamHeaders, parseHostRoutingKey, } from "@superset/shared/host-routing"; +import { SANDBOX_API_CREDENTIAL_HEADER } from "@superset/shared/sandbox-gate"; import { RELAY_CLOSE } from "@superset/shared/tunnel-protocol"; import { type AuthContext, verifyJWT } from "@superset/shared/verify-jwt"; import type { Context, MiddlewareHandler } from "hono"; @@ -14,6 +15,7 @@ import { accessDenialMessage, type Caller, callerOf, + checkCloudWorkspaceAccess, checkHostAccessForRegister, checkOrgAccess, isServerPresenceScope, @@ -81,12 +83,29 @@ type Denial = { status: 401 | 403 | 500; message: string }; // host's object's decision, made inside each call it serves; a host's own // control connect is the exception, checked against the API here because its // object may not exist yet. +type Authenticated = { + auth: AuthContext; + token: string; + cloudWorkspaceId?: string; +}; + async function authenticate( c: Context, hostId: string, purpose: "register" | "reach", -): Promise<{ auth: AuthContext; token: string } | Denial> { +): Promise { const token = extractToken(c); + const boxCredential = c.req.header(SANDBOX_API_CREDENTIAL_HEADER); + if (!token && boxCredential && purpose === "reach") { + // A stream would outlive the per-call access check. + if (isWsUpgrade(c)) { + return { + status: 403, + message: "Forbidden: a cloud workspace can only make HTTP calls", + }; + } + return authenticateCloudWorkspace(c, hostId, boxCredential); + } if (!token) return { status: 401, message: "Unauthorized" }; const auth = await verifyJWT(token, c.env.NEXT_PUBLIC_API_URL); if (!auth) return { status: 401, message: "Unauthorized" }; @@ -103,6 +122,34 @@ async function authenticate( return { auth, token }; } +// A box holds no token; its firewall adds the workspace credential. +async function authenticateCloudWorkspace( + c: Context, + hostId: string, + credential: string, +): Promise { + const access = await checkCloudWorkspaceAccess( + credential, + hostId, + c.env.NEXT_PUBLIC_API_URL, + ); + if (!access.ok) { + return access.reason === "unauthorized" + ? { status: 401, message: "Unauthorized" } + : denial(access.reason); + } + const organizationId = parseHostRoutingKey(hostId)?.organizationId ?? ""; + return { + auth: { + sub: access.userId, + organizationIds: [organizationId], + teamIds: [], + }, + token: "", + cloudWorkspaceId: access.workspaceId, + }; +} + function denial(reason: AccessDenial): Denial { const message = `Forbidden: ${accessDenialMessage(reason)}`; // "error" means the access check itself failed (API unreachable), not a @@ -231,7 +278,7 @@ app.get("/hosts/:hostId/_whoowns", async (c) => { } const stub = await tunnelStub(c, hostId); if (!stub) return c.json({ error: "Host not connected" }, 503); - const connected = await stub.isConnected(callerOf(result.auth, result.token)); + const connected = await stub.isConnected(callerFor(result)); if (connected.access !== "allowed") { const refused = accessDenial(connected.access); return c.json({ error: refused.message }, refused.status); @@ -256,18 +303,28 @@ const authMiddleware: MiddlewareHandler = async (c, next) => { return trpcErrorResponse( c, router, - result.status === 403 ? "FORBIDDEN" : "UNAUTHORIZED", + result.status === 403 + ? "FORBIDDEN" + : result.status === 500 + ? "INTERNAL_SERVER_ERROR" + : "UNAUTHORIZED", result.message, ); } return c.json({ error: result.message }, result.status); } c.set("auth", result.auth); - c.set("caller", callerOf(result.auth, result.token)); + c.set("caller", callerFor(result)); c.set("hostId", hostId); return next(); }; +function callerFor(result: Authenticated): Caller { + return result.cloudWorkspaceId + ? { userId: result.auth.sub, token: "", preAuthorized: true } + : callerOf(result.auth, result.token); +} + app.use("/hosts/:hostId/*", authMiddleware); const proxyHostHttp = async ( diff --git a/docs/cloud-sandbox-considerations.md b/docs/cloud-sandbox-considerations.md index dbb22a6d39f..3ea7d616bdf 100644 --- a/docs/cloud-sandbox-considerations.md +++ b/docs/cloud-sandbox-considerations.md @@ -126,6 +126,19 @@ itself and the workspaces its organization shares, in `loadReadyWorkspace`, `loadVisibleWorkspace` and `delete` together; a check on one of them is bypassed through the others. +**A box reaches its creator's own hosts, when a host allows it. Accepted +(opt-in).** The firewall adds the workspace credential on requests to the +relay as well as the API, and the relay asks `host.cloudWorkspaceAccess` on +every call. A host is reachable only if the creator owns it (a member's box is +refused) and turned on "Allow my cloud workspaces to reach this host" +(`v2_hosts.allow_cloud_workspaces`, set by `host.setCloudWorkspaceAccess`, +which a box cannot call). The API is the only lock: host-service does not know +the call came from a box, so a wrong value in that column opens the host. Once +on, anything in any box the creator made can drive that host as them, +including a prompt-injected agent. Off by default; turning it off stops the +next call. Only HTTP gets through: the firewall refuses WebSocket upgrades on a +host it rewrites, and the relay refuses them from a box. + **A sandbox has exactly one gate, and it is ours.** A sandbox's own port is a public URL that clients never see; they reach a workspace through the sandbox gate (`apps/gate`), which verifies a ticket the API signed diff --git a/docs/cloud-sandbox-mismatches.md b/docs/cloud-sandbox-mismatches.md index c4bdc89109e..4f40db71b5c 100644 --- a/docs/cloud-sandbox-mismatches.md +++ b/docs/cloud-sandbox-mismatches.md @@ -72,6 +72,19 @@ the filesystem snapshot with no processes, so host-service is started again) and extends a running one so it never hits the idle stop while someone is in it. `resolveSandboxAddress` is the one place that knows the difference. +**A box reaches other hosts only through the relay, and only if they allow +it.** The CLI assumed its bearer is a user token the relay accepts. A box has +no bearer: its credential is a header the firewall adds, so `--host` calls +used to fail as "Session expired" while `auth whoami` worked, and `host.list` +showed every host offline because it forwarded the caller's absent bearer for +presence. Now `host.list` mints a server presence token when the caller has no +bearer, the firewall adds the workspace credential for the relay host too, and +the relay checks it with the API per call. A host is reachable only when its +owner allowed cloud workspaces on the host's settings page; otherwise the call +fails with a message that says so. Only HTTP calls work: the firewall refuses +WebSocket upgrades on a host whose headers it rewrites, so live terminal +streams from a box to a host do not. + **A box's agent status doesn't come from the box.** A host row's dot is a live subscription to that host's terminal bindings; the sidebar deliberately opens no such socket to a sandbox, since holding one keeps the VM awake all diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 504847f7e80..7dfd60b9032 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -13269,6 +13269,9 @@ msgstr "Komentář může upravit jen jeho autor" msgid "Only the author can edit a comment" msgstr "Komentář může upravit jen jeho autor" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Cloudovým pracovním prostorům může přístup povolit jen vlastník hosta" + msgid "Only the host owner can set its wake command" msgstr "Příkaz k probuzení může nastavit jen vlastník hosta" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 87b30e68ab4..d1fcd4c59f4 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -13269,6 +13269,9 @@ msgstr "Nur die verfassende Person kann einen Kommentar ändern" msgid "Only the author can edit a comment" msgstr "Nur der Autor kann einen Kommentar bearbeiten" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Nur der Host-Inhaber kann Cloud-Arbeitsbereichen den Zugriff erlauben" + msgid "Only the host owner can set its wake command" msgstr "Nur der Host-Inhaber kann dessen Wake-Befehl setzen" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index da5c3952f1d..d2a4f597874 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -13269,6 +13269,9 @@ msgstr "Only the author can change a comment" msgid "Only the author can edit a comment" msgstr "Only the author can edit a comment" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Only the host owner can let cloud workspaces reach it" + msgid "Only the host owner can set its wake command" msgstr "Only the host owner can set its wake command" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 3997ac1fa85..12480073ef2 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -13269,6 +13269,9 @@ msgstr "Solo el autor puede cambiar un comentario" msgid "Only the author can edit a comment" msgstr "Solo el autor puede editar un comentario" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Solo el propietario del host puede permitir que los espacios de trabajo en la nube accedan a él" + msgid "Only the host owner can set its wake command" msgstr "Solo el propietario del host puede definir su comando de arranque" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index dfde4801db6..24fff231bd5 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -13269,6 +13269,9 @@ msgstr "Seul l’auteur peut modifier un commentaire" msgid "Only the author can edit a comment" msgstr "Seul l'auteur peut modifier un commentaire" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Seul le propriétaire de l'hôte peut autoriser les espaces de travail cloud à y accéder" + msgid "Only the host owner can set its wake command" msgstr "Seul le propriétaire de l'hôte peut définir sa commande de réveil" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 75283300f5e..6854f7bf2d1 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -13269,6 +13269,9 @@ msgstr "Hanya penulis yang bisa mengubah komentar" msgid "Only the author can edit a comment" msgstr "Hanya penulis yang bisa mengedit komentar" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Hanya pemilik host yang bisa mengizinkan workspace cloud menjangkaunya" + msgid "Only the host owner can set its wake command" msgstr "Hanya pemilik host yang bisa menyetel perintah wake-nya" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 3bf76ac5d3b..6dc23be632e 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -13269,6 +13269,9 @@ msgstr "Solo l’autore può modificare un commento" msgid "Only the author can edit a comment" msgstr "Solo l'autore può modificare un commento" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Solo il proprietario dell'host può consentire ai workspace cloud di raggiungerlo" + msgid "Only the host owner can set its wake command" msgstr "Solo il proprietario dell'host può impostarne il comando di risveglio" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index a164b613366..ee11f3439ef 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -13269,6 +13269,9 @@ msgstr "コメントを変更できるのは作成者だけです" msgid "Only the author can edit a comment" msgstr "コメントを編集できるのは投稿者のみです" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "クラウドワークスペースからのアクセスを許可できるのはホストのオーナーだけです" + msgid "Only the host owner can set its wake command" msgstr "ウェイクコマンドを設定できるのはホストのオーナーのみです" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 47470daa78f..7129cb7cf97 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -13269,6 +13269,9 @@ msgstr "작성자만 댓글을 변경할 수 있습니다" msgid "Only the author can edit a comment" msgstr "작성자만 코멘트를 수정할 수 있습니다" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "호스트 소유자만 클라우드 워크스페이스의 접근을 허용할 수 있습니다" + msgid "Only the host owner can set its wake command" msgstr "호스트 소유자만 wake 명령을 설정할 수 있습니다" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index aacfb7a4722..ca6b4ee7591 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -13269,6 +13269,9 @@ msgstr "Alleen de auteur kan een reactie wijzigen" msgid "Only the author can edit a comment" msgstr "Alleen de auteur kan een opmerking bewerken" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Alleen de eigenaar van de host kan cloudwerkruimtes er toegang toe geven" + msgid "Only the host owner can set its wake command" msgstr "Alleen de eigenaar van de host kan het wake-commando instellen" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 744593f1039..bf477bccfcb 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -13269,6 +13269,9 @@ msgstr "Tylko autor może zmienić komentarz" msgid "Only the author can edit a comment" msgstr "Tylko autor może edytować komentarz" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Tylko właściciel hosta może zezwolić obszarom roboczym w chmurze na dostęp do niego" + msgid "Only the host owner can set its wake command" msgstr "Tylko właściciel hosta może ustawić polecenie wybudzania" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 63c9a26d53b..9c18d09f799 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -13269,6 +13269,9 @@ msgstr "Somente o autor pode alterar um comentário" msgid "Only the author can edit a comment" msgstr "Só a pessoa autora pode editar um comentário" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Só o dono do host pode permitir que áreas de trabalho na nuvem o acessem" + msgid "Only the host owner can set its wake command" msgstr "Só o proprietário do host pode definir o comando de wake dele" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index 3c3b9ab31bd..daa0318af84 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -13269,6 +13269,9 @@ msgstr "Изменить комментарий может только авто msgid "Only the author can edit a comment" msgstr "Изменить комментарий может только автор" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Разрешить облачным рабочим областям доступ к хосту может только его владелец" + msgid "Only the host owner can set its wake command" msgstr "Задать команду пробуждения может только владелец хоста" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 9d899be9e35..b1df8d0a7f2 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -13269,6 +13269,9 @@ msgstr "Yorumu yalnızca yazarı değiştirebilir" msgid "Only the author can edit a comment" msgstr "Bir yorumu yalnızca yazarı düzenleyebilir" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Bulut çalışma alanlarının erişimine yalnızca host'un sahibi izin verebilir" + msgid "Only the host owner can set its wake command" msgstr "Uyandırma komutunu yalnızca host sahibi ayarlayabilir" diff --git a/packages/i18n/locales/uk/messages.po b/packages/i18n/locales/uk/messages.po index d2d321659e4..d737ce0fabd 100644 --- a/packages/i18n/locales/uk/messages.po +++ b/packages/i18n/locales/uk/messages.po @@ -13263,6 +13263,9 @@ msgstr "Лише автор може змінити коментар" msgid "Only the author can edit a comment" msgstr "Лише автор може редагувати коментар" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Дозволити хмарним робочим просторам доступ до хоста може лише його власник" + msgid "Only the host owner can set its wake command" msgstr "Лише власник хоста може задати для нього команду пробудження" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 7b3e74b3254..513f740fd28 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -13269,6 +13269,9 @@ msgstr "Chỉ tác giả mới có thể sửa bình luận" msgid "Only the author can edit a comment" msgstr "Chỉ tác giả mới sửa được bình luận" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "Chỉ chủ sở hữu host mới có thể cho phép workspace cloud truy cập" + msgid "Only the host owner can set its wake command" msgstr "Chỉ chủ sở hữu host mới đặt được lệnh đánh thức" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index 5980ab758fa..3a059098d1b 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -13269,6 +13269,9 @@ msgstr "只有作者可以修改评论" msgid "Only the author can edit a comment" msgstr "只有作者才能编辑评论" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "只有主机所有者可以允许云端工作区访问它" + msgid "Only the host owner can set its wake command" msgstr "只有主机所有者才能设置其唤醒命令" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 13b1a4e62ce..14c322f4c65 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -13269,6 +13269,9 @@ msgstr "只有作者可以修改留言" msgid "Only the author can edit a comment" msgstr "只有作者才能編輯評論" +msgid "Only the host owner can let cloud workspaces reach it" +msgstr "只有主機擁有者可以允許雲端工作區存取它" + msgid "Only the host owner can set its wake command" msgstr "只有主機擁有者才能設定其喚醒命令" diff --git a/packages/i18n/src/server-errors.ts b/packages/i18n/src/server-errors.ts index 54ee85d2997..1640878c899 100644 --- a/packages/i18n/src/server-errors.ts +++ b/packages/i18n/src/server-errors.ts @@ -574,6 +574,12 @@ export const serverErrorMessages: Record< message: "Not a member of this organization", }), ), + "serverError.host.onlyTheHostOwnerCanAllowCloudWorkspaces": () => + i18n._( + msg({ + message: "Only the host owner can let cloud workspaces reach it", + }), + ), "serverError.host.onlyTheHostOwnerCanSet": () => i18n._( msg({ diff --git a/packages/shared/src/host-routing.test.ts b/packages/shared/src/host-routing.test.ts index 539489bedfa..8c6794284d7 100644 --- a/packages/shared/src/host-routing.test.ts +++ b/packages/shared/src/host-routing.test.ts @@ -28,4 +28,13 @@ describe("buildUpstreamHeaders", () => { expect(headers["x-superset-user-id"]).toBe("user-1"); }); + + test("a cloud workspace's credential never reaches the host", () => { + const headers = buildUpstreamHeaders( + new Headers({ "x-superset-sandbox-credential": "ws-1.secret" }), + "user-1", + ); + + expect(headers).toEqual({ "x-superset-user-id": "user-1" }); + }); }); diff --git a/packages/shared/src/host-routing.ts b/packages/shared/src/host-routing.ts index 5da514c2120..1b3a23e9660 100644 --- a/packages/shared/src/host-routing.ts +++ b/packages/shared/src/host-routing.ts @@ -1,3 +1,5 @@ +import { SANDBOX_API_CREDENTIAL_HEADER } from "./sandbox-gate"; + /** * Routing key the relay uses to identify a host service tunnel. The same * physical machine can be a host in multiple orgs, so machineId alone is @@ -54,13 +56,19 @@ export function buildUpstreamHeaders( ): Record { const headers: Record = {}; for (const [key, value] of incoming.entries()) { - if (key === "host" || key === "authorization") continue; - if (key === SUPERSET_USER_ID_HEADER) continue; + if (NEVER_FORWARDED_HEADERS.has(key)) continue; headers[key] = value; } headers[SUPERSET_USER_ID_HEADER] = userId; return headers; } +const NEVER_FORWARDED_HEADERS = new Set([ + "host", + "authorization", + SANDBOX_API_CREDENTIAL_HEADER, + SUPERSET_USER_ID_HEADER, +]); + /** The host id for the cloud. A cloud workspace has no `v2_hosts` row. */ export const CLOUD_HOST_ID = "cloud"; diff --git a/packages/trpc/src/lib/sandbox/api-credential.test.ts b/packages/trpc/src/lib/sandbox/api-credential.test.ts index d591406c9db..504514fcd12 100644 --- a/packages/trpc/src/lib/sandbox/api-credential.test.ts +++ b/packages/trpc/src/lib/sandbox/api-credential.test.ts @@ -10,4 +10,11 @@ describe("SANDBOX_ALLOWED_PROCEDURES", () => { ); expect(unknown).toEqual([]); }); + + test("never lets a box open a host to itself", () => { + expect(SANDBOX_ALLOWED_PROCEDURES.has("host.setCloudWorkspaceAccess")).toBe( + false, + ); + expect(SANDBOX_ALLOWED_PROCEDURES.has("host.ensure")).toBe(false); + }); }); diff --git a/packages/trpc/src/lib/sandbox/api-credential.ts b/packages/trpc/src/lib/sandbox/api-credential.ts index b7dde932f2f..e4eb045e011 100644 --- a/packages/trpc/src/lib/sandbox/api-credential.ts +++ b/packages/trpc/src/lib/sandbox/api-credential.ts @@ -46,6 +46,7 @@ export const SANDBOX_ALLOWED_PROCEDURES: ReadonlySet = new Set([ "environment.list", "environment.secrets.list", "githubUser.get", + "host.cloudWorkspaceAccess", "host.list", "host.relayEndpoint", "integration.github.getInstallation", diff --git a/packages/trpc/src/lib/sandbox/credentials.test.ts b/packages/trpc/src/lib/sandbox/credentials.test.ts index bda48d5004f..6ed7519b820 100644 --- a/packages/trpc/src/lib/sandbox/credentials.test.ts +++ b/packages/trpc/src/lib/sandbox/credentials.test.ts @@ -20,6 +20,11 @@ const rules = ( policy: Awaited>["networkPolicy"], ) => (policy as Policy).allow; +function restore(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + const WORKSPACE_ID = "11111111-2222-4333-8444-555555555555"; const author = { name: "Ada", email: "ada@example.com" }; @@ -132,6 +137,34 @@ describe("deriveSandboxCredentials", () => { expect(JSON.stringify(managedEnv)).not.toContain("ghs_token"); }); + test("the relay gets the same workspace credential as the API", async () => { + const saved = { + api: process.env.NEXT_PUBLIC_API_URL, + relay: process.env.RELAY_URL, + }; + process.env.NEXT_PUBLIC_API_URL = "https://api.example.com"; + process.env.RELAY_URL = "https://relay.example.com"; + try { + const { networkPolicy } = await deriveSandboxCredentials({ + workspaceId: WORKSPACE_ID, + environmentEnv: {}, + userAgentEnv: {}, + githubToken: null, + gitAuthor: author, + }); + const allow = rules(networkPolicy); + expect(allow["relay.example.com"]).toEqual(allow["api.example.com"]); + expect( + allow["relay.example.com"]?.[0]?.transform[0]?.headers[ + "x-superset-sandbox-credential" + ], + ).toStartWith(`${WORKSPACE_ID}.`); + } finally { + restore("NEXT_PUBLIC_API_URL", saved.api); + restore("RELAY_URL", saved.relay); + } + }); + test("brokered names in the environment's variables never reach the box", async () => { const { managedEnv } = await deriveSandboxCredentials({ workspaceId: WORKSPACE_ID, diff --git a/packages/trpc/src/lib/sandbox/credentials.ts b/packages/trpc/src/lib/sandbox/credentials.ts index 949c26f7d3f..0271706cd67 100644 --- a/packages/trpc/src/lib/sandbox/credentials.ts +++ b/packages/trpc/src/lib/sandbox/credentials.ts @@ -46,6 +46,14 @@ export interface SandboxCredentialInputs { gitAuthor: GitAuthor; } +function relayHost(): string | null { + try { + return new URL(env.RELAY_URL).host; + } catch { + return null; + } +} + export interface GitAuthor { name: string; email: string; @@ -310,6 +318,10 @@ export async function deriveSandboxCredentials( // PDEV_TOKEN keeps pdev from asking `superset auth`, which a box refuses. allow[PDEV_ROUTER_HOST] = boxCredential; managedEnv.PDEV_TOKEN = SANDBOX_CREDENTIAL_PLACEHOLDER; + // The firewall refuses WebSocket upgrades on a host it rewrites, so a + // box reaches hosts over HTTP only. + const relay = relayHost(); + if (relay) allow[relay] = boxCredential; } // The catch-all keeps the rest of the internet reachable; without it a diff --git a/packages/trpc/src/router/host/cloud-workspace-access.test.ts b/packages/trpc/src/router/host/cloud-workspace-access.test.ts new file mode 100644 index 00000000000..9487c45acf6 --- /dev/null +++ b/packages/trpc/src/router/host/cloud-workspace-access.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + decideCloudWorkspaceAccess, + type HostAsSeenByCreator, +} from "./cloud-workspace-access"; + +const box = { workspaceId: "ws-1", organizationId: "org-1", userId: "user-1" }; +const hostId = "org-1:machine-1"; + +const finding = (host: HostAsSeenByCreator | null) => mock(async () => host); + +describe("decideCloudWorkspaceAccess", () => { + test("lets the creator's box into a host they own and opened", async () => { + const findHost = finding({ role: "owner", allowCloudWorkspaces: true }); + + expect(await decideCloudWorkspaceAccess(box, hostId, findHost)).toEqual({ + allowed: true, + userId: "user-1", + workspaceId: "ws-1", + }); + expect(findHost).toHaveBeenCalledWith({ + userId: "user-1", + organizationId: "org-1", + machineId: "machine-1", + }); + }); + + test("refuses a host its owner has not opened", async () => { + expect( + await decideCloudWorkspaceAccess( + box, + hostId, + finding({ role: "owner", allowCloudWorkspaces: false }), + ), + ).toEqual({ allowed: false, reason: "host_closed" }); + }); + + test("refuses a host the creator is only a member of, even when it is open", async () => { + expect( + await decideCloudWorkspaceAccess( + box, + hostId, + finding({ role: "member", allowCloudWorkspaces: true }), + ), + ).toEqual({ allowed: false, reason: "no_access" }); + }); + + test("refuses a host the creator has no access to", async () => { + expect( + await decideCloudWorkspaceAccess(box, hostId, finding(null)), + ).toEqual({ allowed: false, reason: "no_access" }); + }); + + test("refuses a host in another organization without looking it up", async () => { + const findHost = finding({ role: "owner", allowCloudWorkspaces: true }); + + expect( + await decideCloudWorkspaceAccess(box, "org-2:machine-1", findHost), + ).toEqual({ allowed: false, reason: "no_access" }); + expect(findHost).not.toHaveBeenCalled(); + }); + + test("refuses a caller that is not a cloud workspace", async () => { + expect( + await decideCloudWorkspaceAccess( + null, + hostId, + finding({ role: "owner", allowCloudWorkspaces: true }), + ), + ).toEqual({ allowed: false, reason: "no_access" }); + }); +}); diff --git a/packages/trpc/src/router/host/cloud-workspace-access.ts b/packages/trpc/src/router/host/cloud-workspace-access.ts new file mode 100644 index 00000000000..cf44b36c777 --- /dev/null +++ b/packages/trpc/src/router/host/cloud-workspace-access.ts @@ -0,0 +1,37 @@ +import { parseHostRoutingKey } from "@superset/shared/host-routing"; +import type { SandboxCaller } from "../../lib/sandbox/api-credential"; + +export interface HostAsSeenByCreator { + role: string; + allowCloudWorkspaces: boolean; +} + +export type CloudWorkspaceAccess = + | { allowed: true; userId: string; workspaceId: string } + | { allowed: false; reason: "no_access" | "host_closed" }; + +/** Only the creator's own hosts: a member's switch is not theirs to flip. */ +export async function decideCloudWorkspaceAccess( + box: SandboxCaller | null, + hostId: string, + findHost: (where: { + userId: string; + organizationId: string; + machineId: string; + }) => Promise, +): Promise { + const parsed = parseHostRoutingKey(hostId); + if (!box || !parsed || parsed.organizationId !== box.organizationId) { + return { allowed: false, reason: "no_access" }; + } + const host = await findHost({ + userId: box.userId, + organizationId: parsed.organizationId, + machineId: parsed.machineId, + }); + if (host?.role !== "owner") return { allowed: false, reason: "no_access" }; + if (!host.allowCloudWorkspaces) { + return { allowed: false, reason: "host_closed" }; + } + return { allowed: true, userId: box.userId, workspaceId: box.workspaceId }; +} diff --git a/packages/trpc/src/router/host/host.ts b/packages/trpc/src/router/host/host.ts index 8c2ac9a2316..bbd050033b4 100644 --- a/packages/trpc/src/router/host/host.ts +++ b/packages/trpc/src/router/host/host.ts @@ -1,3 +1,4 @@ +import { mintUserJwt } from "@superset/auth/server"; import { db, dbWs } from "@superset/db/client"; import { members, users, v2Hosts, v2UsersHosts } from "@superset/db/schema"; import { @@ -15,6 +16,7 @@ import { isMember } from "../../lib/cloud-guards"; import { nudge } from "../../lib/realtime"; import { fetchRelayPresence } from "../../lib/relay-presence"; import { personProcedure, userError } from "../../trpc"; +import { decideCloudWorkspaceAccess } from "./cloud-workspace-access"; import { registerHost } from "./registration"; import { authorizeHostUpdate, @@ -103,6 +105,7 @@ export const hostRouter = { version: v2Hosts.version, platform: v2Hosts.platform, installSource: v2Hosts.installSource, + allowCloudWorkspaces: v2Hosts.allowCloudWorkspaces, }) .from(v2Hosts) .innerJoin( @@ -119,18 +122,22 @@ export const hostRouter = { ), ); - // The relay's Durable Objects are the presence authority. Callers' - // own bearer token is forwarded for the access checks. - const bearer = ctx.headers.get("authorization")?.slice("Bearer ".length); - const presence = bearer - ? await fetchRelayPresence( - env.RELAY_URL, - bearer, - rows.map((row) => - buildHostRoutingKey(row.organizationId, row.machineId), - ), - ) - : null; + // A cloud workspace or an API key sends no bearer the relay accepts. + const bearer = + ctx.headers.get("authorization")?.slice("Bearer ".length) || + (await mintUserJwt({ + userId: ctx.principal.userId, + organizationIds: [input.organizationId], + scope: "automation-presence", + ttlSeconds: 60, + })); + const presence = await fetchRelayPresence( + env.RELAY_URL, + bearer, + rows.map((row) => + buildHostRoutingKey(row.organizationId, row.machineId), + ), + ); return rows.map((row) => ({ id: row.machineId, @@ -143,6 +150,7 @@ export const hostRouter = { version: row.version, platform: row.platform, installSource: row.installSource, + allowCloudWorkspaces: row.allowCloudWorkspaces, })); }), @@ -268,6 +276,40 @@ export const hostRouter = { return { allowed, paidPlan }; }), + // Called by the relay with the box's firewall-added credential. + cloudWorkspaceAccess: personProcedure + .input(z.object({ hostId: z.string().min(1) })) + .query(({ ctx, input }) => + decideCloudWorkspaceAccess( + ctx.sandboxCaller, + input.hostId, + async ({ userId, organizationId, machineId }) => { + const [row] = await db + .select({ + role: v2UsersHosts.role, + allowCloudWorkspaces: v2Hosts.allowCloudWorkspaces, + }) + .from(v2UsersHosts) + .innerJoin( + v2Hosts, + and( + eq(v2Hosts.organizationId, v2UsersHosts.organizationId), + eq(v2Hosts.machineId, v2UsersHosts.hostId), + ), + ) + .where( + and( + eq(v2UsersHosts.userId, userId), + eq(v2UsersHosts.organizationId, organizationId), + eq(v2UsersHosts.hostId, machineId), + ), + ) + .limit(1); + return row ?? null; + }, + ), + ), + setWakeCommand: personProcedure .input( z.object({ @@ -315,4 +357,48 @@ export const hostRouter = { ); return { success: true }; }), + + // Never add to SANDBOX_ALLOWED_PROCEDURES: a box must not open a host to itself. + setCloudWorkspaceAccess: personProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + machineId: z.string().min(1), + allowed: z.boolean(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (!(await isMember(ctx, input.organizationId))) { + throw userError({ + code: "FORBIDDEN", + message: "No access to this host", + i18nKey: "serverError.host.noAccessToThisHost", + }); + } + if ( + !(await isHostOwner( + input.organizationId, + input.machineId, + ctx.principal.userId, + )) + ) { + throw userError({ + code: "FORBIDDEN", + message: "Only the host owner can let cloud workspaces reach it", + i18nKey: "serverError.host.onlyTheHostOwnerCanAllowCloudWorkspaces", + }); + } + + await db + .update(v2Hosts) + .set({ allowCloudWorkspaces: input.allowed }) + .where( + and( + eq(v2Hosts.organizationId, input.organizationId), + eq(v2Hosts.machineId, input.machineId), + ), + ); + nudge(input.organizationId, "hosts"); + return { allowed: input.allowed }; + }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/host/management.ts b/packages/trpc/src/router/host/management.ts index 2f610b40441..ece7e0e4901 100644 --- a/packages/trpc/src/router/host/management.ts +++ b/packages/trpc/src/router/host/management.ts @@ -96,6 +96,7 @@ export const hostManagementRouter = { version: v2Hosts.version, platform: v2Hosts.platform, installSource: v2Hosts.installSource, + allowCloudWorkspaces: v2Hosts.allowCloudWorkspaces, }) .from(v2Hosts) .innerJoin(