fix(tenancy): stop leaking workspace-level Coolify services across projects
CRITICAL: every Vibn project was rendering every other project's
services in the same workspace (Twenty CRM, n8n, all databases,
all secrets). Tenancy was effectively broken — cross-project data
exposure inside a workspace.
Root cause:
- Coolify's POST /projects validates `description` against a strict
allowlist (letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`).
- Our description "Vibn project: <name> (workspace: <slug>)" contains
two colons. Every project-create on Coolify returned 422.
- lib/projects.ts caught that 422 and fell back to
`workspace.coolify_project_uuid` so deploys "weren't blocked."
- That UUID is shared by every Vibn project in the workspace, so
listServicesInProject(coolifyProjectUuid) returned the union of
all projects' services, applications, and databases for any
project in the workspace. The Product, Hosting, and Infrastructure
tabs all rendered cross-tenant data as if it were the current
project's.
Fixes (defense in depth — fix at every layer):
1. lib/coolify.ts createProject(): sanitize the description against
Coolify's allowlist at the boundary so no caller can ever ship
a description that 422s. Replaces disallowed chars with `-`,
collapses runs, caps at 255 chars.
2. lib/projects.ts ensureProjectCoolifyProject():
- Pre-sanitize the description we pass (belt + suspenders).
- Detect when `stored === workspace.coolify_project_uuid` (the
legacy bad state) and re-provision a dedicated project.
- REMOVE the workspace-UUID fallback on create failure. A 422
now leaves coolifyProjectUuid null and the UI shows an empty
state, which is correct: better to surface "no resources" than
to lie about which project owns what.
- Export sanitizeCoolifyDescription helper for reuse.
3. /api/projects/[projectId]/anatomy/route.ts: SELF-HEAL on every
read. If the project's stored Coolify UUID matches the
workspace's UUID, we treat it as missing, re-provision a
dedicated Coolify project on the fly (idempotent — reuses the
existing one if found by name), persist the new UUID, and
continue serving with the corrected scope. If provisioning
fails we fall back to undefined, NOT the workspace UUID, so
no cross-tenant data ever surfaces again.
The self-heal means existing already-broken projects will fix
themselves on the next page load — no manual data migration needed.
Made-with: Cursor
This commit is contained in:
@@ -33,6 +33,8 @@ import {
|
||||
listServicesInProject,
|
||||
listServiceEnvs,
|
||||
listDatabasesInProject,
|
||||
listProjects as listCoolifyProjects,
|
||||
createProject as createCoolifyProject,
|
||||
type CoolifyApplication,
|
||||
type CoolifyService,
|
||||
type CoolifyDatabase,
|
||||
@@ -597,7 +599,53 @@ export async function GET(
|
||||
const data = rows[0].data;
|
||||
const workspaceId = rows[0].vibn_workspace_id ?? undefined;
|
||||
const giteaRepo = data?.giteaRepo as string | undefined;
|
||||
const coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined;
|
||||
let coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined;
|
||||
|
||||
// Self-heal: if a previous bug stored the *workspace*'s Coolify
|
||||
// project UUID on this project (which would surface every other
|
||||
// project's services as if they belonged to this one), wipe it
|
||||
// and re-provision a dedicated Coolify project on the fly.
|
||||
if (coolifyProjectUuid && workspaceId) {
|
||||
try {
|
||||
const wsRow = await query<{ slug: string; coolify_project_uuid: string | null }>(
|
||||
`SELECT slug, coolify_project_uuid FROM vibn_workspaces WHERE id = $1 LIMIT 1`,
|
||||
[workspaceId],
|
||||
);
|
||||
const ws = wsRow[0];
|
||||
if (ws && ws.coolify_project_uuid && ws.coolify_project_uuid === coolifyProjectUuid) {
|
||||
console.warn(
|
||||
"[anatomy] Project", projectId,
|
||||
"had workspace-UUID stored as coolifyProjectUuid — re-provisioning."
|
||||
);
|
||||
const projectSlug = (data?.slug as string | undefined) ?? projectId;
|
||||
const projectNm = (data?.productName as string | undefined) ?? projectSlug;
|
||||
const wantName = `vibn-${ws.slug}-${projectSlug}`;
|
||||
try {
|
||||
const all = await listCoolifyProjects();
|
||||
const existing = all.find(p => p.name === wantName);
|
||||
const fresh = existing
|
||||
? existing
|
||||
: await createCoolifyProject(
|
||||
wantName,
|
||||
`Vibn project ${projectNm} - workspace ${ws.slug}`,
|
||||
);
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = data || jsonb_build_object('coolifyProjectUuid', $2::text),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[projectId, fresh.uuid],
|
||||
);
|
||||
coolifyProjectUuid = fresh.uuid;
|
||||
} catch (provErr) {
|
||||
console.error("[anatomy] auto-heal provisioning failed:", provErr);
|
||||
coolifyProjectUuid = undefined; // refuse to serve workspace-level resources
|
||||
}
|
||||
}
|
||||
} catch (wsErr) {
|
||||
console.error("[anatomy] workspace lookup for self-heal failed:", wsErr);
|
||||
}
|
||||
}
|
||||
const projectName =
|
||||
(data?.productName as string | undefined) ??
|
||||
(data?.name as string | undefined) ??
|
||||
|
||||
@@ -177,9 +177,22 @@ export async function listProjects(): Promise<CoolifyProject[]> {
|
||||
}
|
||||
|
||||
export async function createProject(name: string, description?: string): Promise<CoolifyProject> {
|
||||
// Coolify validates the description against a narrow allowlist:
|
||||
// letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`.
|
||||
// Anything else (colon, semicolon, brackets, pipe, …) errors out with
|
||||
// a 422 — and a 422 here used to silently fall back to the workspace
|
||||
// project, leaking services across tenants. Sanitize defensively so
|
||||
// every caller is safe.
|
||||
const cleaned = description
|
||||
? description
|
||||
.replace(/[^A-Za-z0-9 \-_.,!?()'"+=*/@&]/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.trim()
|
||||
.slice(0, 255)
|
||||
: undefined;
|
||||
return coolifyFetch('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
body: JSON.stringify({ name, description: cleaned }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ export function coolifyProjectNameForVibnProject(workspaceSlug: string, projectS
|
||||
return `vibn-${workspaceSlug}-${projectSlug}`;
|
||||
}
|
||||
|
||||
/** Coolify's `description` validator only accepts a narrow set of chars
|
||||
* (letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`).
|
||||
* Notably colon, semicolon, brackets, and pipe are rejected. We strip
|
||||
* anything else down to a hyphen so create payloads always validate. */
|
||||
export function sanitizeCoolifyDescription(s: string): string {
|
||||
return s
|
||||
.replace(/[^A-Za-z0-9 \-_.,!?()'"+=*/@&]/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently ensure the given Vibn project has its own Coolify Project.
|
||||
* Returns the Coolify project UUID. Persists it to fs_projects.data.coolifyProjectUuid.
|
||||
@@ -56,9 +68,25 @@ export async function ensureProjectCoolifyProject(
|
||||
if (!row) return null;
|
||||
|
||||
const stored = row.data?.coolifyProjectUuid as string | undefined;
|
||||
if (stored) return stored;
|
||||
|
||||
// SAFETY: if a prior bug stored the *workspace*'s Coolify project UUID
|
||||
// here, treat it as missing and re-provision. Sharing the workspace
|
||||
// Coolify project across Vibn projects causes services from one
|
||||
// project to bleed into another (we hit this on Apr 29).
|
||||
const isWorkspaceUuid =
|
||||
stored && workspace.coolify_project_uuid && stored === workspace.coolify_project_uuid;
|
||||
if (stored && !isWorkspaceUuid) return stored;
|
||||
if (isWorkspaceUuid) {
|
||||
console.warn(
|
||||
'[projects] Detected workspace-UUID stored as project Coolify UUID for',
|
||||
projectId, '— re-provisioning a dedicated project.'
|
||||
);
|
||||
}
|
||||
|
||||
const wantName = coolifyProjectNameForVibnProject(workspace.slug, opts.projectSlug);
|
||||
const description = sanitizeCoolifyDescription(
|
||||
`Vibn project ${opts.projectName || opts.projectSlug} - workspace ${workspace.slug}`,
|
||||
);
|
||||
|
||||
let coolifyUuid: string | null = null;
|
||||
try {
|
||||
@@ -69,10 +97,7 @@ export async function ensureProjectCoolifyProject(
|
||||
if (existing) {
|
||||
coolifyUuid = existing.uuid;
|
||||
} else {
|
||||
const created = await createCoolifyProject(
|
||||
wantName,
|
||||
`Vibn project: ${opts.projectName || opts.projectSlug} (workspace: ${workspace.slug})`,
|
||||
);
|
||||
const created = await createCoolifyProject(wantName, description);
|
||||
coolifyUuid = created.uuid;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -81,9 +106,12 @@ export async function ensureProjectCoolifyProject(
|
||||
projectId,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
// Fall back to the workspace's legacy Coolify project so the user can
|
||||
// still deploy. Lifecycle isolation is degraded but functionality works.
|
||||
coolifyUuid = workspace.coolify_project_uuid;
|
||||
// Do NOT fall back to the workspace project. Sharing it across Vibn
|
||||
// projects leaks every project's services into every other project
|
||||
// (the bug this branch was originally written to "soften"). Better
|
||||
// to leave coolifyProjectUuid null — the UI surfaces an empty state
|
||||
// and the user can retry once the underlying error is fixed.
|
||||
coolifyUuid = null;
|
||||
}
|
||||
|
||||
if (coolifyUuid) {
|
||||
|
||||
Reference in New Issue
Block a user