From 1914a234cd967f74ac7f7d6b6a1b8e8cfa042a1b Mon Sep 17 00:00:00 2001 From: baggiiiie Date: Mon, 14 Sep 2026 15:29:04 +0800 Subject: [PATCH] host(CF): add Access principal in listMembers so console recognizes admins The console derives the current user's role from /account/members, not /account/me (which carries no role). The Cloudflare provider hardcoded an empty member list, so an ADMIN_EMAILS admin was never recognized client-side and every workspace action (Workspace owner option, Edit/Reconnect/Remove on org connections) was hidden even though the server authorized the writes. --- .../cloudflare-admin-workspace-controls.md | 5 ++ .../src/account/account-provider.test.ts | 71 +++++++++++++++++++ .../src/account/account-provider.ts | 29 ++++++-- .../admin-workspace-controls.test.ts | 70 ++++++++++++++++++ 4 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 .changeset/cloudflare-admin-workspace-controls.md create mode 100644 apps/host-cloudflare/src/account/account-provider.test.ts create mode 100644 e2e/cloudflare/admin-workspace-controls.test.ts diff --git a/.changeset/cloudflare-admin-workspace-controls.md b/.changeset/cloudflare-admin-workspace-controls.md new file mode 100644 index 0000000000..7bcbe3892f --- /dev/null +++ b/.changeset/cloudflare-admin-workspace-controls.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Fix Cloudflare-hosted consoles failing to recognize administrators configured through `ADMIN_EMAILS`. The account member response now exposes the current Access principal's role, restoring workspace connection controls while server-side authorization remains authoritative. diff --git a/apps/host-cloudflare/src/account/account-provider.test.ts b/apps/host-cloudflare/src/account/account-provider.test.ts new file mode 100644 index 0000000000..2bf3de2685 --- /dev/null +++ b/apps/host-cloudflare/src/account/account-provider.test.ts @@ -0,0 +1,71 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "@effect/vitest"; + +import { AccountProvider } from "@executor-js/api/server"; +import { + canCreateWorkspaceConnectionsForHost, + isTenantAdminMember, + type TenantMemberRow, +} from "@executor-js/react/lib/admin-access"; + +import type { CloudflareConfig } from "../config"; +import { cloudflareAccountProvider } from "./account-provider"; + +// Regression for #1958: an empty member list hid admin-only workspace actions. + +const baseConfig: CloudflareConfig = { + accessTeamDomain: "team.cloudflareaccess.com", + accessAud: "aud-tag", + accessNameClaim: "name", + accessGroupsClaim: "groups", + adminEmails: ["admin@example.com"], + organizationId: "default", + organizationName: "Default", + organizationSlug: "default", + secretKey: "x".repeat(32), + allowLocalNetwork: false, + webBaseUrl: "https://localhost", + enableDevAuth: false, +}; + +const adminConfig: CloudflareConfig = { ...baseConfig, enableDevAuth: true }; + +const listMembers = (config: CloudflareConfig, headers: Record = {}) => + Effect.gen(function* () { + const provider = yield* AccountProvider; + return yield* provider.listMembers(headers); + }).pipe(Effect.provide(cloudflareAccountProvider(config))); + +describe("cloudflareAccountProvider.listMembers", () => { + it.effect("reports the current admin principal as an active admin member", () => + Effect.gen(function* () { + const { members } = yield* listMembers(adminConfig); + + expect(members).toHaveLength(1); + const [member] = members; + expect(member.isCurrentUser).toBe(true); + expect(member.status).toBe("active"); + expect(member.role).toBe("admin"); + }), + ); + + it.effect("surfaces the Workspace connection owner option via the real UI admin gate", () => + Effect.gen(function* () { + const { members } = yield* listMembers(adminConfig); + + const rows = members as readonly TenantMemberRow[]; + const isAdmin = isTenantAdminMember(rows); + expect(isAdmin).toBe(true); + + expect(canCreateWorkspaceConnectionsForHost(baseConfig.organizationId, isAdmin)).toBe(true); + }), + ); + + it.effect("returns no members when the request carries no Access identity", () => + Effect.gen(function* () { + const { members } = yield* listMembers(baseConfig); + expect(members).toHaveLength(0); + expect(isTenantAdminMember(members as readonly TenantMemberRow[])).toBe(false); + }), + ); +}); diff --git a/apps/host-cloudflare/src/account/account-provider.ts b/apps/host-cloudflare/src/account/account-provider.ts index bbbbd3d522..431f64745c 100644 --- a/apps/host-cloudflare/src/account/account-provider.ts +++ b/apps/host-cloudflare/src/account/account-provider.ts @@ -17,10 +17,8 @@ import type { CloudflareConfig } from "../config"; // uses), reading the `Cf-Access-Jwt-Assertion` header off the request. // // Single-tenant + Access-managed: members, roles, and API keys live in -// Cloudflare Access, NOT in the app. The shell hides the API-keys footer and -// shows no members page, so those methods are never reached from the UI; they -// return empty (reads) or a clear "managed by Cloudflare Access" error (writes) -// to satisfy the provider shape. +// Cloudflare Access, not in the app. `listMembers` exposes the current principal +// for console role checks; unsupported reads are empty and writes fail. // --------------------------------------------------------------------------- const NOT_IN_APP = "Managed by Cloudflare Access, not in the app."; @@ -66,7 +64,28 @@ export const cloudflareAccountProvider = ( listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }), createOrgApiKey: () => forbiddenWrite, revokeOrgApiKey: () => forbiddenWrite, - listMembers: () => Effect.succeed({ members: [] }), + listMembers: (headers) => + principalFrom(headers).pipe( + Effect.map((principal) => + principal + ? { + members: [ + { + id: principal.accountId, + userId: principal.accountId, + email: principal.email, + name: principal.name, + avatarUrl: principal.avatarUrl, + role: principal.orgRole ?? "member", + status: "active", + lastActiveAt: null, + isCurrentUser: true, + }, + ], + } + : { members: [] }, + ), + ), listRoles: () => Effect.succeed({ roles: [] }), inviteMember: () => forbiddenWrite, removeMember: () => forbiddenWrite, diff --git a/e2e/cloudflare/admin-workspace-controls.test.ts b/e2e/cloudflare/admin-workspace-controls.test.ts new file mode 100644 index 0000000000..6acdff018c --- /dev/null +++ b/e2e/cloudflare/admin-workspace-controls.test.ts @@ -0,0 +1,70 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const spec = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Cloudflare admin fixture", version: "1.0.0" }, + servers: [{ url: "https://example.test" }], + paths: { + "/ping": { + get: { operationId: "ping", responses: { "200": { description: "ok" } } }, + }, + }, +}); + +scenario( + "Cloudflare ยท an Access admin can choose Workspace for a connection", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client } = yield* Api; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const apiClient = yield* client(api, identity); + const slug = `cf_admin_${randomBytes(4).toString("hex")}`; + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* apiClient.openapi.addSpec({ + payload: { spec: { kind: "blob", value: spec }, slug }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the test integration", async () => { + await visit(page, `/integrations/${slug}`); + await page.getByText("Connections").first().waitFor(); + }); + + await step("Open Add connection", async () => { + await page.getByRole("button", { name: "Add connection" }).first().click(); + await page.getByRole("dialog", { name: /Add connection/ }).waitFor(); + }); + + await step("The Access admin can choose Workspace", async () => { + const dialog = page.getByRole("dialog", { name: /Add connection/ }); + await dialog.getByRole("combobox").click(); + await page.getByRole("option", { name: "Workspace", exact: true }).waitFor(); + expect( + await page.getByRole("option", { name: "Personal", exact: true }).isVisible(), + "the personal owner remains available", + ).toBe(true); + }); + }); + }), + apiClient.openapi + .removeSpec({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ); + }), +);