feat(self-host): share one workspace across Cloudflare Access users (#467)

This commit is contained in:
Ben Senescu 2026-08-08 18:23:31 -04:00 committed by GitHub
parent f14aa4c746
commit 17e7515e82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 525 additions and 4 deletions

View File

@ -80,6 +80,8 @@ pnpm deploy:selfhost --yes
Add the teammate to `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. Dashboard edits to that Access policy are overwritten on the next deploy. (If you manage the Access application yourself, edit its Allow policy in Zero Trust instead.) Add the teammate to `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. Dashboard edits to that Access policy are overwritten on the next deploy. (If you manage the Access application yourself, edit its Allow policy in Zero Trust instead.)
Everyone allowed through Cloudflare Access works in one shared workspace and sees the same projects. Deployments upgraded from older versions (which gave each user a separate workspace) show a one-time dashboard banner — clicking it migrates all previous per-user work into the shared workspace.
## Troubleshooting ## Troubleshooting
- Login fails: re-check `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. - Login fails: re-check `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy.

View File

@ -16,6 +16,7 @@ import {
GscCard, GscCard,
} from "@/client/features/dashboard/DashboardCards"; } from "@/client/features/dashboard/DashboardCards";
import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard"; import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard";
import { WorkspaceMergeBanner } from "@/client/features/dashboard/WorkspaceMergeBanner";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService"; import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
import { import {
@ -306,6 +307,8 @@ export function DashboardPage({ projectId }: { projectId: string }) {
<div className="mx-auto flex max-w-5xl flex-col gap-5"> <div className="mx-auto flex max-w-5xl flex-col gap-5">
<h1 className="text-2xl font-semibold">Dashboard</h1> <h1 className="text-2xl font-semibold">Dashboard</h1>
<WorkspaceMergeBanner />
<OnboardingChecklist projectId={projectId} activation={activation} /> <OnboardingChecklist projectId={projectId} activation={activation} />
{/* Every card is half width on large screens (only the checklist spans). {/* Every card is half width on large screens (only the checklist spans).

View File

@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import {
getWorkspaceMergeStatus,
mergeLegacyWorkspaces,
} from "@/serverFunctions/workspace";
// Shown on self-hosted Cloudflare Access deployments that still have per-user
// workspaces from before the shared workspace existed. The server decides
// visibility (AUTH_MODE is a runtime var there); hosted builds skip the query
// entirely since the mode is known at build time.
export function WorkspaceMergeBanner() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ["workspaceMergeStatus"],
queryFn: () => getWorkspaceMergeStatus(),
enabled: !isHostedClientAuthMode(),
});
const mergeMutation = useMutation({
mutationFn: () => mergeLegacyWorkspaces(),
onSuccess: ({ mergedWorkspaces }) => {
toast.success(
`Migrated ${mergedWorkspaces} workspace${mergedWorkspaces === 1 ? "" : "s"} into the shared workspace.`,
);
// The merge changes projects, connections, and the banner's own status —
// refetch everything rather than enumerating keys.
void queryClient.invalidateQueries();
},
onError: (error) =>
toast.error(
getStandardErrorMessage(
error,
"Couldn't migrate the workspaces. Try again.",
),
),
});
if (!statusQuery.data || statusQuery.data.legacyWorkspaceCount === 0) {
return null;
}
return (
<div className="rounded-xl border border-warning/40 bg-warning/10 p-5">
<p className="max-w-3xl text-sm">
When self-hosting on Cloudflare, there was a bug where each user had
their own workspace. It was intended for all users to be in one
workspace. Clicking the button below will migrate everyone&apos;s
previous work into this shared workspace.
</p>
<button
type="button"
className="btn btn-primary btn-sm mt-4"
disabled={mergeMutation.isPending}
onClick={() => mergeMutation.mutate()}
>
{mergeMutation.isPending ? "Migrating…" : "Migrate workspaces"}
</button>
</div>
);
}

View File

@ -3,7 +3,7 @@ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { validateTeamDomain } from "@/shared/selfhost-checks"; import { validateTeamDomain } from "@/shared/selfhost-checks";
import { classifyAccessVerificationError } from "./accessTokenErrors"; import { classifyAccessVerificationError } from "./accessTokenErrors";
import { resolveDelegatedContext } from "./delegated"; import { resolveSharedWorkspaceContext } from "./delegated";
import type { EnsuredUserContext } from "./types"; import type { EnsuredUserContext } from "./types";
const jwksByTeamDomain = new Map< const jwksByTeamDomain = new Map<
@ -94,5 +94,5 @@ export async function resolveCloudflareAccessContext(
throw new AppError("UNAUTHENTICATED"); throw new AppError("UNAUTHENTICATED");
} }
return resolveDelegatedContext(userId, userEmail); return resolveSharedWorkspaceContext(userId, userEmail);
} }

View File

@ -1,6 +1,9 @@
import { db } from "@/db"; import { db } from "@/db";
import { user } from "@/db/schema"; import { user } from "@/db/schema";
import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization"; import {
ensureDelegatedOrganizationForUser,
ensureSharedWorkspaceOrganization,
} from "@/server/auth/delegated-organization";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { EnsuredUserContext } from "./types"; import type { EnsuredUserContext } from "./types";
@ -52,7 +55,7 @@ async function ensureUserRecord(userId: string, userEmail: string) {
return existing.email; return existing.email;
} }
export async function resolveDelegatedContext( async function resolveDelegatedContext(
userId: string, userId: string,
userEmail: string, userEmail: string,
): Promise<EnsuredUserContext> { ): Promise<EnsuredUserContext> {
@ -71,6 +74,24 @@ export async function resolveDelegatedContext(
}; };
} }
// Cloudflare Access mode: everyone the Access policy lets in works in one
// shared workspace, keeping their own user identity. Per-user workspaces were
// the pre-shared-workspace behavior; workspace-merge.ts folds those in.
export async function resolveSharedWorkspaceContext(
userId: string,
userEmail: string,
): Promise<EnsuredUserContext> {
const ensuredEmail = await ensureUserRecord(userId, userEmail);
const organizationId = await ensureSharedWorkspaceOrganization();
return {
userId,
userEmail: ensuredEmail,
emailVerified: true,
organizationId,
};
}
export async function resolveLocalNoAuthContext(): Promise<EnsuredUserContext> { export async function resolveLocalNoAuthContext(): Promise<EnsuredUserContext> {
return resolveDelegatedContext(LOCAL_ADMIN_USER_ID, LOCAL_ADMIN_EMAIL); return resolveDelegatedContext(LOCAL_ADMIN_USER_ID, LOCAL_ADMIN_EMAIL);
} }

View File

@ -1,6 +1,21 @@
import { AuthRepository } from "@/server/auth/repositories/AuthRepository"; import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { slugify, toHex } from "./org-slug"; import { slugify, toHex } from "./org-slug";
// Every Cloudflare Access user on a deployment shares this one workspace. The
// id deliberately lacks the "delegated-" prefix so the legacy per-user pattern
// below can be matched (and merged) without excluding it.
export const SHARED_WORKSPACE_ORGANIZATION_ID = "shared-workspace";
export async function ensureSharedWorkspaceOrganization() {
await AuthRepository.upsertDelegatedOrganization({
id: SHARED_WORKSPACE_ORGANIZATION_ID,
name: "Shared workspace",
slug: SHARED_WORKSPACE_ORGANIZATION_ID,
});
return SHARED_WORKSPACE_ORGANIZATION_ID;
}
function getDelegatedOrganizationId(userId: string) { function getDelegatedOrganizationId(userId: string) {
return `delegated-${userId}`; return `delegated-${userId}`;
} }

View File

@ -0,0 +1,221 @@
import { createClient, type Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import type * as WorkspaceMergeModule from "./workspace-merge";
// Real in-memory SQLite so the repoint-before-delete ordering, the Default
// rename against the partial unique index, and the org-delete cascades run
// against actual SQL — the parts a mocked db can't see.
const mockEnv = vi.hoisted(
() =>
({ DATABASE_PROVIDER: "d1" }) as {
DATABASE_PROVIDER: string;
AUTH_MODE?: string;
},
);
vi.mock("cloudflare:workers", () => ({ env: mockEnv }));
let client: Client;
let testDb: ReturnType<typeof drizzle>;
let WorkspaceMergeService: typeof WorkspaceMergeModule.WorkspaceMergeService;
async function rows(sql: string) {
return (await client.execute(sql)).rows;
}
beforeAll(async () => {
client = createClient({ url: "file::memory:" });
testDb = drizzle(client);
// testDb only exists at runtime, so the module under test must load after
// these mocks — the one sanctioned use of doMock + dynamic import.
vi.doMock("@/db", () => ({ db: testDb }));
vi.doMock("@/db/d1/client", () => ({ d1Db: testDb }));
vi.doMock("@/db/pg/client", () => ({ pgDb: null }));
await client.executeMultiple(`
PRAGMA foreign_keys = ON;
CREATE TABLE organization (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT
);
CREATE TABLE projects (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
name TEXT NOT NULL,
domain TEXT,
archived_at TEXT
);
CREATE UNIQUE INDEX projects_one_default_per_organization_idx
ON projects (organization_id)
WHERE name = 'Default' AND domain IS NULL AND archived_at IS NULL;
CREATE TABLE user_onboarding_answers (
user_id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE
);
CREATE TABLE organization_activation_state (
organization_id TEXT PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE,
first_mcp_authorized_at TEXT,
first_mcp_tool_call_at TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE gsc_connections (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
site_url TEXT NOT NULL,
connected_by_user_id TEXT NOT NULL
);
CREATE TABLE ga4_connections (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE
);
`);
({ WorkspaceMergeService } = await import("./workspace-merge"));
});
afterAll(() => {
client.close();
});
beforeEach(async () => {
mockEnv.AUTH_MODE = "cloudflare_access";
await client.executeMultiple(`
DELETE FROM projects;
DELETE FROM user_onboarding_answers;
DELETE FROM organization_activation_state;
DELETE FROM gsc_connections;
DELETE FROM ga4_connections;
DELETE FROM organization;
`);
});
async function seedLegacyWorkspaces() {
await client.executeMultiple(`
INSERT INTO organization (id, name, slug) VALUES
('shared-workspace', 'Shared workspace', 'shared-workspace'),
('delegated-u1', 'ben workspace', 'delegated-ben-1'),
('delegated-u2', 'sam workspace', 'delegated-sam-2'),
('org_hosted', 'Hosted org', 'hosted');
INSERT INTO projects (id, organization_id, name, domain, archived_at) VALUES
('p-shared', 'shared-workspace', 'Default', NULL, NULL),
('p1-default', 'delegated-u1', 'Default', NULL, NULL),
('p1-acme', 'delegated-u1', 'Acme', 'acme.com', NULL),
('p2-default', 'delegated-u2', 'Default', NULL, NULL),
('p-hosted', 'org_hosted', 'Default', NULL, NULL);
INSERT INTO user_onboarding_answers (user_id, organization_id) VALUES
('u1', 'delegated-u1');
INSERT INTO organization_activation_state
(organization_id, first_mcp_authorized_at, first_mcp_tool_call_at) VALUES
('delegated-u1', '2026-03-01 09:00:00', '2026-03-02 09:00:00'),
('delegated-u2', '2026-01-02 09:00:00', NULL);
INSERT INTO gsc_connections
(id, project_id, organization_id, site_url, connected_by_user_id) VALUES
('gsc1', 'p1-acme', 'delegated-u1', 'sc-domain:acme.com', 'u1');
`);
}
describe("WorkspaceMergeService", () => {
it("folds legacy workspaces into the shared workspace and leaves other orgs alone", async () => {
await seedLegacyWorkspaces();
await expect(WorkspaceMergeService.countLegacyWorkspaces()).resolves.toBe(
2,
);
await expect(
WorkspaceMergeService.mergeLegacyWorkspaces(),
).resolves.toEqual({ mergedWorkspaces: 2 });
expect(
await rows("SELECT id, organization_id, name FROM projects ORDER BY id"),
).toEqual([
expect.objectContaining({
id: "p-hosted",
organization_id: "org_hosted",
}),
expect.objectContaining({
id: "p-shared",
organization_id: "shared-workspace",
name: "Default",
}),
expect.objectContaining({
id: "p1-acme",
organization_id: "shared-workspace",
name: "Acme",
}),
expect.objectContaining({
id: "p1-default",
organization_id: "shared-workspace",
name: "Default (ben)",
}),
expect.objectContaining({
id: "p2-default",
organization_id: "shared-workspace",
name: "Default (sam)",
}),
]);
// Legacy orgs are gone; their activation rows cascaded away after the
// earliest milestones were folded into the shared row.
expect(await rows("SELECT id FROM organization ORDER BY id")).toEqual([
expect.objectContaining({ id: "org_hosted" }),
expect.objectContaining({ id: "shared-workspace" }),
]);
expect(await rows("SELECT * FROM organization_activation_state")).toEqual([
expect.objectContaining({
organization_id: "shared-workspace",
first_mcp_authorized_at: "2026-01-02 09:00:00",
first_mcp_tool_call_at: "2026-03-02 09:00:00",
}),
]);
expect(await rows("SELECT organization_id FROM gsc_connections")).toEqual([
expect.objectContaining({ organization_id: "shared-workspace" }),
]);
expect(
await rows("SELECT organization_id FROM user_onboarding_answers"),
).toEqual([
expect.objectContaining({ organization_id: "shared-workspace" }),
]);
});
it("refuses to run outside cloudflare_access mode", async () => {
await seedLegacyWorkspaces();
mockEnv.AUTH_MODE = "local_noauth";
await expect(
WorkspaceMergeService.mergeLegacyWorkspaces(),
).rejects.toMatchObject({ code: "FORBIDDEN" });
// Nothing was touched.
expect(await rows("SELECT count(*) AS n FROM organization")).toEqual([
expect.objectContaining({ n: 4 }),
]);
});
it("is a no-op when run again", async () => {
await seedLegacyWorkspaces();
await WorkspaceMergeService.mergeLegacyWorkspaces();
await expect(
WorkspaceMergeService.mergeLegacyWorkspaces(),
).resolves.toEqual({ mergedWorkspaces: 0 });
await expect(WorkspaceMergeService.countLegacyWorkspaces()).resolves.toBe(
0,
);
// Nothing renamed twice.
expect(
await rows("SELECT name FROM projects WHERE id = 'p1-default'"),
).toEqual([expect.objectContaining({ name: "Default (ben)" })]);
});
});

View File

@ -0,0 +1,163 @@
import { env } from "cloudflare:workers";
import { and, eq, inArray, isNull, like } from "drizzle-orm";
import { db } from "@/db";
import { runBatch } from "@/db/runBatch";
import { getAuthMode } from "@/lib/auth-mode";
import { AppError } from "@/server/lib/errors";
import {
ga4Connections,
gscConnections,
organization,
organizationActivationState,
projects,
userOnboardingAnswers,
} from "@/db/schema";
import { SHARED_WORKSPACE_ORGANIZATION_ID } from "./delegated-organization";
// Earliest-wins pick across nullable UTC timestamp strings (string compare is
// chronological for these).
function earliest(values: (string | null)[]) {
return (
values.filter((value): value is string => value !== null).toSorted()[0] ??
null
);
}
// Before the shared workspace, every Cloudflare Access user got their own
// `delegated-${userId}` organization (and local_noauth got delegated-local-admin,
// which an operator switching modes wants folded in too). The shared workspace
// id has no such prefix, so this matches exactly the legacy set.
const legacyWorkspaceFilter = like(organization.id, "delegated-%");
async function countLegacyWorkspaces() {
const rows = await db
.select({ id: organization.id })
.from(organization)
.where(legacyWorkspaceFilter);
return rows.length;
}
// Folds every legacy per-user workspace into the shared workspace: projects
// (which carry all their keywords, rank tracking, audits, and agent data with
// them) plus the org-scoped rows, then deletes the emptied legacy orgs. Safe
// to re-run and safe under concurrent clicks — every statement is a no-op once
// the legacy orgs are gone.
async function mergeLegacyWorkspaces() {
// Hard gate, independent of any caller-side check: outside cloudflare_access
// mode the "delegated-%" orgs are either live (local_noauth resolves to
// delegated-local-admin — merging would strand its data in a workspace that
// mode never shows) or should not exist at all (hosted).
if (getAuthMode(env.AUTH_MODE) !== "cloudflare_access") {
throw new AppError(
"FORBIDDEN",
"Workspace merge is only available in cloudflare_access auth mode.",
);
}
const legacyOrgs = await db
.select({ id: organization.id, name: organization.name })
.from(organization)
.where(legacyWorkspaceFilter);
if (legacyOrgs.length === 0) {
return { mergedWorkspaces: 0 };
}
const legacyIds = legacyOrgs.map((org) => org.id);
// Legacy org names are "<email localpart> workspace" — reuse the localpart
// to label renamed projects with their previous owner.
const ownerLabelByOrgId = new Map(
legacyOrgs.map((org) => [org.id, org.name.replace(/ workspace$/, "")]),
);
// Each legacy workspace auto-created a ("Default", no-domain) project, and
// the shared workspace allows only one active such project. Rename them
// before repointing so the partial unique index can't reject the move —
// renaming (never deleting) means a Default that holds work survives intact.
const conflictingDefaults = await db
.select({ id: projects.id, organizationId: projects.organizationId })
.from(projects)
.where(
and(
inArray(projects.organizationId, legacyIds),
eq(projects.name, "Default"),
isNull(projects.domain),
isNull(projects.archivedAt),
),
);
// Earliest-wins merge of activation milestones across the shared row and
// all legacy rows.
const activationRows = await db
.select()
.from(organizationActivationState)
.where(
inArray(organizationActivationState.organizationId, [
SHARED_WORKSPACE_ORGANIZATION_ID,
...legacyIds,
]),
);
const mergedActivation = {
firstMcpAuthorizedAt: earliest(
activationRows.map((row) => row.firstMcpAuthorizedAt),
),
firstMcpToolCallAt: earliest(
activationRows.map((row) => row.firstMcpToolCallAt),
),
};
const repointToShared = { organizationId: SHARED_WORKSPACE_ORGANIZATION_ID };
await runBatch((tx) => [
...conflictingDefaults.map((project) =>
tx
.update(projects)
.set({
name: `Default (${ownerLabelByOrgId.get(project.organizationId) ?? "imported"})`,
})
.where(eq(projects.id, project.id)),
),
tx
.update(projects)
.set(repointToShared)
.where(inArray(projects.organizationId, legacyIds)),
tx
.update(userOnboardingAnswers)
.set(repointToShared)
.where(inArray(userOnboardingAnswers.organizationId, legacyIds)),
tx
.update(gscConnections)
.set(repointToShared)
.where(inArray(gscConnections.organizationId, legacyIds)),
tx
.update(ga4Connections)
.set(repointToShared)
.where(inArray(ga4Connections.organizationId, legacyIds)),
...(activationRows.length > 0
? [
tx
.insert(organizationActivationState)
.values({
organizationId: SHARED_WORKSPACE_ORGANIZATION_ID,
...mergedActivation,
})
.onConflictDoUpdate({
target: organizationActivationState.organizationId,
set: mergedActivation,
}),
]
: []),
// Everything user-visible is repointed above; deleting the legacy orgs
// cascades away only the per-org leftovers (members, billing status,
// legacy activation rows).
tx.delete(organization).where(inArray(organization.id, legacyIds)),
]);
return { mergedWorkspaces: legacyOrgs.length };
}
export const WorkspaceMergeService = {
countLegacyWorkspaces,
mergeLegacyWorkspaces,
} as const;

View File

@ -0,0 +1,32 @@
import { env } from "cloudflare:workers";
import { createServerFn } from "@tanstack/react-start";
import { getAuthMode } from "@/lib/auth-mode";
import { WorkspaceMergeService } from "@/server/auth/workspace-merge";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
// Legacy per-user workspaces only ever existed in cloudflare_access mode, and
// the merge must not be reachable anywhere else. AUTH_MODE is a runtime var on
// self-host deployments, so the client can't gate this itself — the status
// response is what hides the banner in other modes.
function isCloudflareAccessMode() {
return getAuthMode(env.AUTH_MODE) === "cloudflare_access";
}
export const getWorkspaceMergeStatus = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.handler(async () => {
if (!isCloudflareAccessMode()) {
return { legacyWorkspaceCount: 0 };
}
return {
legacyWorkspaceCount: await WorkspaceMergeService.countLegacyWorkspaces(),
};
});
// Any authenticated user may run the merge: everyone behind the same Access
// policy is equally trusted in this deployment model. The service itself
// refuses to run outside cloudflare_access mode.
export const mergeLegacyWorkspaces = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.handler(async () => WorkspaceMergeService.mergeLegacyWorkspaces());