From b1670c703516be6ff9ffa1fc73e6c56c200515a2 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 21 Apr 2026 12:23:09 -0700 Subject: [PATCH] fix(coolify): tenant-match by environment_id via project envs The v4 /applications, /databases, /services list endpoints don't return project_uuid; authoritative link is environment_id. Replace the explicit-only tenant check (which was rejecting every resource) with a check that: - trusts explicit project_uuid if present - else looks up project envs via GET /projects/{uuid} and matches environment_id Also switch the project list helpers to use GET /projects/{uuid}/{env} so listing returns only the resources scoped to the workspace's project + environments. Made-with: Cursor --- lib/coolify.ts | 144 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 30 deletions(-) diff --git a/lib/coolify.ts b/lib/coolify.ts index 35d3af7..1203c9d 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -43,7 +43,8 @@ export interface CoolifyDatabase { is_public?: boolean; public_port?: number; project_uuid?: string; - environment?: { project_uuid?: string; project?: { uuid?: string } }; + environment_id?: number; + environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } }; } export interface CoolifyApplication { @@ -55,8 +56,9 @@ export interface CoolifyApplication { git_repository?: string; git_branch?: string; project_uuid?: string; + environment_id?: number; environment_name?: string; - environment?: { project_uuid?: string; project?: { uuid?: string } }; + environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } }; } export interface CoolifyEnvVar { @@ -553,7 +555,8 @@ export interface CoolifyService { name: string; status?: string; project_uuid?: string; - environment?: { project_uuid?: string; project?: { uuid?: string } }; + environment_id?: number; + environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } }; } export async function listServices(): Promise { @@ -608,9 +611,32 @@ export async function deleteService( // ────────────────────────────────────────────────── // Tenant helpers — every endpoint that returns an app/db/service runs // through one of these so cross-project access is impossible. +// +// Coolify v4's /applications, /databases and /services list endpoints do NOT +// include `project_uuid` on the returned resources. The authoritative link is +// `environment_id`, which we match against the project's environments (fetched +// via /projects/{uuid}). +// +// For efficient listing per project, we use `/projects/{uuid}/{envName}` which +// returns the resources scoped to that project+environment in one call. // ────────────────────────────────────────────────── -export function projectUuidOf( +export class TenantError extends Error { + status = 403 as const; +} + +function envIdOf( + resource: CoolifyApplication | CoolifyDatabase | CoolifyService +): number | null { + return ( + (typeof resource.environment_id === 'number' ? resource.environment_id : null) ?? + (resource.environment && typeof resource.environment.id === 'number' + ? resource.environment.id + : null) + ); +} + +function explicitProjectUuidOf( resource: CoolifyApplication | CoolifyDatabase | CoolifyService ): string | null { return ( @@ -621,8 +647,40 @@ export function projectUuidOf( ); } -export class TenantError extends Error { - status = 403 as const; +/** Fetch the set of environment IDs that belong to a project. */ +async function projectEnvIds(projectUuid: string): Promise> { + const project = await getProject(projectUuid); + const ids = new Set(); + for (const env of project.environments ?? []) { + if (typeof env.id === 'number') ids.add(env.id); + } + return ids; +} + +async function ensureResourceInProject( + resource: CoolifyApplication | CoolifyDatabase | CoolifyService, + resourceKind: string, + expectedProjectUuid: string +): Promise { + const explicit = explicitProjectUuidOf(resource); + if (explicit && explicit === expectedProjectUuid) return; + if (explicit && explicit !== expectedProjectUuid) { + throw new TenantError( + `${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}` + ); + } + const envId = envIdOf(resource); + if (envId == null) { + throw new TenantError( + `${resourceKind} ${resource.uuid} has no environment_id; cannot verify project ${expectedProjectUuid}` + ); + } + const envIds = await projectEnvIds(expectedProjectUuid); + if (!envIds.has(envId)) { + throw new TenantError( + `${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}` + ); + } } export async function getApplicationInProject( @@ -630,12 +688,7 @@ export async function getApplicationInProject( expectedProjectUuid: string ): Promise { const app = await getApplication(appUuid); - const actualProject = projectUuidOf(app); - if (!actualProject || actualProject !== expectedProjectUuid) { - throw new TenantError( - `Application ${appUuid} does not belong to project ${expectedProjectUuid}` - ); - } + await ensureResourceInProject(app, 'Application', expectedProjectUuid); return app; } @@ -644,12 +697,7 @@ export async function getDatabaseInProject( expectedProjectUuid: string ): Promise { const db = await getDatabase(dbUuid); - const actualProject = projectUuidOf(db); - if (!actualProject || actualProject !== expectedProjectUuid) { - throw new TenantError( - `Database ${dbUuid} does not belong to project ${expectedProjectUuid}` - ); - } + await ensureResourceInProject(db, 'Database', expectedProjectUuid); return db; } @@ -658,34 +706,70 @@ export async function getServiceInProject( expectedProjectUuid: string ): Promise { const svc = await getService(serviceUuid); - const actualProject = projectUuidOf(svc); - if (!actualProject || actualProject !== expectedProjectUuid) { - throw new TenantError( - `Service ${serviceUuid} does not belong to project ${expectedProjectUuid}` - ); - } + await ensureResourceInProject(svc, 'Service', expectedProjectUuid); return svc; } +/** Response shape of GET /projects/{uuid}/{envName}. */ +interface CoolifyProjectEnvResources { + id: number; + uuid: string; + name: string; + applications?: CoolifyApplication[]; + databases?: CoolifyDatabase[]; + services?: CoolifyService[]; +} + +async function getProjectEnvResources( + projectUuid: string, + envName: string +): Promise { + return coolifyFetch(`/projects/${projectUuid}/${encodeURIComponent(envName)}`); +} + +/** + * List all apps/dbs/services across all environments of a project. + * Uses one `/projects/{uuid}` call + one call per environment. + */ +async function listResourcesInProject( + projectUuid: string, + key: K +): Promise> { + const project = await getProject(projectUuid); + const out: NonNullable = [] as never; + for (const env of project.environments ?? []) { + const envResources = await getProjectEnvResources(projectUuid, env.name); + const list = envResources[key]; + if (Array.isArray(list)) { + (out as unknown[]).push(...list); + } + } + return out; +} + export async function listApplicationsInProject( projectUuid: string ): Promise { - const all = await listApplications(); - return all.filter(a => projectUuidOf(a) === projectUuid); + return listResourcesInProject(projectUuid, 'applications'); } export async function listDatabasesInProject( projectUuid: string ): Promise { - const all = await listDatabases(); - return all.filter(d => projectUuidOf(d) === projectUuid); + return listResourcesInProject(projectUuid, 'databases'); } export async function listServicesInProject( projectUuid: string ): Promise { - const all = await listServices(); - return all.filter(s => projectUuidOf(s) === projectUuid); + return listResourcesInProject(projectUuid, 'services'); +} + +/** @deprecated Use getApplicationInProject / ensureResourceInProject instead. */ +export function projectUuidOf( + resource: CoolifyApplication | CoolifyDatabase | CoolifyService +): string | null { + return explicitProjectUuidOf(resource); } // ──────────────────────────────────────────────────