Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cloudflare-admin-workspace-controls.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions apps/host-cloudflare/src/account/account-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) =>
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);
}),
);
});
29 changes: 24 additions & 5 deletions apps/host-cloudflare/src/account/account-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions e2e/cloudflare/admin-workspace-controls.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}),
);
Loading