Skip to content

Commit e158f81

Browse files
committed
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.
1 parent a0b0d91 commit e158f81

4 files changed

Lines changed: 180 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
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.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Effect } from "effect";
2+
import { describe, expect, it } from "@effect/vitest";
3+
4+
import { AccountUnauthorized } from "@executor-js/api";
5+
import { AccountProvider } from "@executor-js/api/server";
6+
import {
7+
canCreateWorkspaceConnectionsForHost,
8+
isTenantAdminMember,
9+
type TenantMemberRow,
10+
} from "@executor-js/react/lib/admin-access";
11+
12+
import type { CloudflareConfig } from "../config";
13+
import { cloudflareAccountProvider } from "./account-provider";
14+
15+
// Regression for #1958: an empty member list hid admin-only workspace actions.
16+
17+
const baseConfig: CloudflareConfig = {
18+
accessTeamDomain: "team.cloudflareaccess.com",
19+
accessAud: "aud-tag",
20+
accessNameClaim: "name",
21+
accessGroupsClaim: "groups",
22+
adminEmails: ["admin@example.com"],
23+
organizationId: "default",
24+
organizationName: "Default",
25+
organizationSlug: "default",
26+
secretKey: "x".repeat(32),
27+
allowLocalNetwork: false,
28+
webBaseUrl: "https://localhost",
29+
enableDevAuth: false,
30+
};
31+
32+
const adminConfig: CloudflareConfig = { ...baseConfig, enableDevAuth: true };
33+
34+
const listMembers = (config: CloudflareConfig, headers: Record<string, string> = {}) =>
35+
Effect.gen(function* () {
36+
const provider = yield* AccountProvider;
37+
return yield* provider.listMembers(headers);
38+
}).pipe(Effect.provide(cloudflareAccountProvider(config)));
39+
40+
describe("cloudflareAccountProvider.listMembers", () => {
41+
it.effect("reports the current admin principal as an active admin member", () =>
42+
Effect.gen(function* () {
43+
const { members } = yield* listMembers(adminConfig);
44+
45+
expect(members).toEqual([
46+
{
47+
id: "dev",
48+
userId: "dev",
49+
email: "admin@example.com",
50+
name: "Dev",
51+
avatarUrl: null,
52+
role: "admin",
53+
status: "active",
54+
lastActiveAt: null,
55+
isCurrentUser: true,
56+
},
57+
]);
58+
}),
59+
);
60+
61+
it.effect("surfaces the Workspace connection owner option via the real UI admin gate", () =>
62+
Effect.gen(function* () {
63+
const { members } = yield* listMembers(adminConfig);
64+
65+
const rows = members as readonly TenantMemberRow[];
66+
const isAdmin = isTenantAdminMember(rows);
67+
expect(isAdmin).toBe(true);
68+
69+
expect(canCreateWorkspaceConnectionsForHost(baseConfig.organizationId, isAdmin)).toBe(true);
70+
}),
71+
);
72+
73+
it.effect("refuses when the request carries no Access identity", () =>
74+
Effect.gen(function* () {
75+
const error = yield* listMembers(baseConfig).pipe(Effect.flip);
76+
expect(error).toBeInstanceOf(AccountUnauthorized);
77+
}),
78+
);
79+
});

‎apps/host-cloudflare/src/account/account-provider.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ import type { CloudflareConfig } from "../config";
1717
// uses), reading the `Cf-Access-Jwt-Assertion` header off the request.
1818
//
1919
// Single-tenant + Access-managed: members, roles, and API keys live in
20-
// Cloudflare Access, NOT in the app. The shell hides the API-keys footer and
21-
// shows no members page, so those methods are never reached from the UI; they
22-
// return empty (reads) or a clear "managed by Cloudflare Access" error (writes)
23-
// to satisfy the provider shape.
20+
// Cloudflare Access, not in the app. Writes stay refused. `listMembers` still
21+
// has to return the current Access principal — the console infers admin from
22+
// that list (`isCurrentUser` + role), and an empty list fail-closes every
23+
// workspace-admin action even when `ADMIN_EMAILS` granted `orgRole: "admin"`.
2424
// ---------------------------------------------------------------------------
2525

2626
const NOT_IN_APP = "Managed by Cloudflare Access, not in the app.";
@@ -66,7 +66,28 @@ export const cloudflareAccountProvider = (
6666
listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }),
6767
createOrgApiKey: () => forbiddenWrite,
6868
revokeOrgApiKey: () => forbiddenWrite,
69-
listMembers: () => Effect.succeed({ members: [] }),
69+
listMembers: (headers) =>
70+
principalFrom(headers).pipe(
71+
Effect.flatMap((principal) =>
72+
principal
73+
? Effect.succeed({
74+
members: [
75+
{
76+
id: principal.accountId,
77+
userId: principal.accountId,
78+
email: principal.email.length > 0 ? principal.email : null,
79+
name: principal.name,
80+
avatarUrl: principal.avatarUrl,
81+
role: principal.orgRole === "admin" ? "admin" : "member",
82+
status: "active",
83+
lastActiveAt: null,
84+
isCurrentUser: true,
85+
},
86+
],
87+
})
88+
: Effect.fail(new AccountUnauthorized()),
89+
),
90+
),
7091
listRoles: () => Effect.succeed({ roles: [] }),
7192
inviteMember: () => forbiddenWrite,
7293
removeMember: () => forbiddenWrite,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { randomBytes } from "node:crypto";
2+
3+
import { expect } from "@effect/vitest";
4+
import { Effect } from "effect";
5+
import { composePluginApi } from "@executor-js/api/server";
6+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
7+
import { IntegrationSlug } from "@executor-js/sdk/shared";
8+
9+
import { scenario } from "../src/scenario";
10+
import { Api, Browser, Target } from "../src/services";
11+
import { visit } from "../src/surfaces/browser";
12+
13+
const api = composePluginApi([openApiHttpPlugin()] as const);
14+
15+
const spec = JSON.stringify({
16+
openapi: "3.0.3",
17+
info: { title: "Cloudflare admin fixture", version: "1.0.0" },
18+
servers: [{ url: "https://example.test" }],
19+
paths: {
20+
"/ping": {
21+
get: { operationId: "ping", responses: { "200": { description: "ok" } } },
22+
},
23+
},
24+
});
25+
26+
scenario(
27+
"Cloudflare · an Access admin can choose Workspace for a connection",
28+
{},
29+
Effect.gen(function* () {
30+
const target = yield* Target;
31+
const { client } = yield* Api;
32+
const browser = yield* Browser;
33+
const identity = yield* target.newIdentity();
34+
const apiClient = yield* client(api, identity);
35+
const slug = `cf_admin_${randomBytes(4).toString("hex")}`;
36+
37+
yield* Effect.ensuring(
38+
Effect.gen(function* () {
39+
yield* apiClient.openapi.addSpec({
40+
payload: { spec: { kind: "blob", value: spec }, slug },
41+
});
42+
43+
yield* browser.session(identity, async ({ page, step }) => {
44+
await step("Open the test integration", async () => {
45+
await visit(page, `/integrations/${slug}`);
46+
await page.getByText("Connections").first().waitFor();
47+
});
48+
49+
await step("Open Add connection", async () => {
50+
await page.getByRole("button", { name: "Add connection" }).first().click();
51+
await page.getByRole("dialog", { name: /Add connection/ }).waitFor();
52+
});
53+
54+
await step("The Access admin can choose Workspace", async () => {
55+
const dialog = page.getByRole("dialog", { name: /Add connection/ });
56+
await dialog.getByRole("combobox").click();
57+
await page.getByRole("option", { name: "Workspace", exact: true }).waitFor();
58+
expect(
59+
await page.getByRole("option", { name: "Personal", exact: true }).isVisible(),
60+
"the personal owner remains available",
61+
).toBe(true);
62+
});
63+
});
64+
}),
65+
apiClient.openapi
66+
.removeSpec({ params: { slug: IntegrationSlug.make(slug) } })
67+
.pipe(Effect.ignore),
68+
);
69+
}),
70+
);

0 commit comments

Comments
 (0)