Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 53 additions & 1 deletion apps/relay/src/access.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -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 };
Expand All @@ -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:
Expand Down Expand Up @@ -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" };

// `<workspace uuid>.<base64url HMAC-SHA256>`, 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<CloudWorkspaceAccess> {
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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: An API outage is converted to 401 for host tRPC routes. Preserve the 500 result as INTERNAL_SERVER_ERROR, otherwise cloud-workspace callers refresh credentials instead of retrying the transient access-check failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/relay/src/access.ts, line 132:

<comment>An API outage is converted to 401 for host tRPC routes. Preserve the 500 result as `INTERNAL_SERVER_ERROR`, otherwise cloud-workspace callers refresh credentials instead of retrying the transient access-check failure.</comment>

<file context>
@@ -87,3 +91,45 @@ export async function checkHostAccessForRegister(
+		const code = (error as { data?: { code?: string } }).data?.code;
+		return {
+			ok: false,
+			reason: code === "UNAUTHORIZED" ? "unauthorized" : "error",
+		};
+	}
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a65d1b4. A failed access check (status 500) now maps to INTERNAL_SERVER_ERROR on the host tRPC routes, not UNAUTHORIZED, with a test that simulates the API being down.

};
}
}
11 changes: 10 additions & 1 deletion apps/relay/src/api-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, apiUrl: string) {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${apiUrl}/api/trpc`,
transformer: SuperJSON,
headers: () => ({ Authorization: `Bearer ${token}` }),
headers: () => headers,
}),
],
});
Expand Down
1 change: 1 addition & 0 deletions apps/relay/src/host-tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export class HostTunnel extends Server<RelayEnv> {
}

private async authorize(caller: Caller): Promise<AccessDecision> {
if (caller.preAuthorized) return "allowed";
const key = `access:${caller.userId}`;
const cached = await this.ctx.storage.get<AccessEntry>(key);
if (cached && cached.expiresAt > Date.now()) {
Expand Down
127 changes: 125 additions & 2 deletions apps/relay/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};

const proxied: ProxiedRequest[] = [];
const proxiedHeaders: Record<string, string>[] = [];
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 () => {},
Expand All @@ -13,6 +23,7 @@ const tunnel = {
method: request.method,
pathWithQuery: request.pathWithQuery,
});
proxiedHeaders.push(request.headers ?? {});
return {
ok: true,
status: 200,
Expand All @@ -39,14 +50,44 @@ 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}`;

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";
Expand Down Expand Up @@ -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<string, string> } = {},
) {
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([]);
});
Loading
Loading