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
This commit is contained in:
2026-04-21 12:23:09 -07:00
parent eacec74701
commit b1670c7035

View File

@@ -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<CoolifyService[]> {
@@ -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<Set<number>> {
const project = await getProject(projectUuid);
const ids = new Set<number>();
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<void> {
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<CoolifyApplication> {
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<CoolifyDatabase> {
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<CoolifyService> {
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<CoolifyProjectEnvResources> {
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<K extends 'applications' | 'databases' | 'services'>(
projectUuid: string,
key: K
): Promise<NonNullable<CoolifyProjectEnvResources[K]>> {
const project = await getProject(projectUuid);
const out: NonNullable<CoolifyProjectEnvResources[K]> = [] 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<CoolifyApplication[]> {
const all = await listApplications();
return all.filter(a => projectUuidOf(a) === projectUuid);
return listResourcesInProject(projectUuid, 'applications');
}
export async function listDatabasesInProject(
projectUuid: string
): Promise<CoolifyDatabase[]> {
const all = await listDatabases();
return all.filter(d => projectUuidOf(d) === projectUuid);
return listResourcesInProject(projectUuid, 'databases');
}
export async function listServicesInProject(
projectUuid: string
): Promise<CoolifyService[]> {
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);
}
// ──────────────────────────────────────────────────