From 2bcd4e9a792ff77c0a44eea80c1bdd90cf422e77 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 21 Sep 2026 06:14:23 -0400 Subject: [PATCH] Report the Cloudflare Access principal on /account/members The console infers workspace-admin from that list, so an empty response locked edit/delete even when ADMIN_EMAILS granted admin. --- .../src/account/account-provider.test.ts | 54 +++++++++++++++++++ .../src/account/account-provider.ts | 31 +++++++++-- 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 apps/host-cloudflare/src/account/account-provider.test.ts 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..de0c8bb8f8 --- /dev/null +++ b/apps/host-cloudflare/src/account/account-provider.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { AccountUnauthorized } from "@executor-js/api"; +import { AccountProvider } from "@executor-js/api/server"; + +import type { CloudflareConfig } from "../config"; +import { cloudflareAccountProvider } from "./account-provider"; + +const config = (overrides: Partial = {}): 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: true, + ...overrides, +}); + +describe("cloudflareAccountProvider.listMembers", () => { + it.effect("reports the Access principal so the console can see ADMIN_EMAILS", () => + Effect.gen(function* () { + const provider = yield* AccountProvider; + const { members } = yield* provider.listMembers({}); + expect(members).toEqual([ + { + id: "dev", + userId: "dev", + email: "admin@example.com", + name: "Dev", + avatarUrl: null, + role: "admin", + status: "active", + lastActiveAt: null, + isCurrentUser: true, + }, + ]); + }).pipe(Effect.provide(cloudflareAccountProvider(config()))), + ); + + it.effect("refuses when Access did not authenticate the request", () => + Effect.gen(function* () { + const provider = yield* AccountProvider; + const error = yield* provider.listMembers({}).pipe(Effect.flip); + expect(error).toBeInstanceOf(AccountUnauthorized); + }).pipe(Effect.provide(cloudflareAccountProvider(config({ enableDevAuth: false })))), + ); +}); diff --git a/apps/host-cloudflare/src/account/account-provider.ts b/apps/host-cloudflare/src/account/account-provider.ts index bbbbd3d522..b6260689aa 100644 --- a/apps/host-cloudflare/src/account/account-provider.ts +++ b/apps/host-cloudflare/src/account/account-provider.ts @@ -17,10 +17,10 @@ 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. Writes stay refused. `listMembers` still +// has to return the current Access principal — the console infers admin from +// that list (`isCurrentUser` + role), and an empty list fail-closes every +// workspace-admin action even when `ADMIN_EMAILS` granted `orgRole: "admin"`. // --------------------------------------------------------------------------- const NOT_IN_APP = "Managed by Cloudflare Access, not in the app."; @@ -66,7 +66,28 @@ export const cloudflareAccountProvider = ( listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }), createOrgApiKey: () => forbiddenWrite, revokeOrgApiKey: () => forbiddenWrite, - listMembers: () => Effect.succeed({ members: [] }), + listMembers: (headers) => + principalFrom(headers).pipe( + Effect.flatMap((principal) => + principal + ? Effect.succeed({ + members: [ + { + id: principal.accountId, + userId: principal.accountId, + email: principal.email.length > 0 ? principal.email : null, + name: principal.name, + avatarUrl: principal.avatarUrl, + role: principal.orgRole === "admin" ? "admin" : "member", + status: "active", + lastActiveAt: null, + isCurrentUser: true, + }, + ], + }) + : Effect.fail(new AccountUnauthorized()), + ), + ), listRoles: () => Effect.succeed({ roles: [] }), inviteMember: () => forbiddenWrite, removeMember: () => forbiddenWrite,