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:
144
lib/coolify.ts
144
lib/coolify.ts
@@ -43,7 +43,8 @@ export interface CoolifyDatabase {
|
|||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
public_port?: number;
|
public_port?: number;
|
||||||
project_uuid?: string;
|
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 {
|
export interface CoolifyApplication {
|
||||||
@@ -55,8 +56,9 @@ export interface CoolifyApplication {
|
|||||||
git_repository?: string;
|
git_repository?: string;
|
||||||
git_branch?: string;
|
git_branch?: string;
|
||||||
project_uuid?: string;
|
project_uuid?: string;
|
||||||
|
environment_id?: number;
|
||||||
environment_name?: string;
|
environment_name?: string;
|
||||||
environment?: { project_uuid?: string; project?: { uuid?: string } };
|
environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoolifyEnvVar {
|
export interface CoolifyEnvVar {
|
||||||
@@ -553,7 +555,8 @@ export interface CoolifyService {
|
|||||||
name: string;
|
name: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
project_uuid?: 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[]> {
|
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
|
// Tenant helpers — every endpoint that returns an app/db/service runs
|
||||||
// through one of these so cross-project access is impossible.
|
// 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
|
resource: CoolifyApplication | CoolifyDatabase | CoolifyService
|
||||||
): string | null {
|
): string | null {
|
||||||
return (
|
return (
|
||||||
@@ -621,8 +647,40 @@ export function projectUuidOf(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TenantError extends Error {
|
/** Fetch the set of environment IDs that belong to a project. */
|
||||||
status = 403 as const;
|
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(
|
export async function getApplicationInProject(
|
||||||
@@ -630,12 +688,7 @@ export async function getApplicationInProject(
|
|||||||
expectedProjectUuid: string
|
expectedProjectUuid: string
|
||||||
): Promise<CoolifyApplication> {
|
): Promise<CoolifyApplication> {
|
||||||
const app = await getApplication(appUuid);
|
const app = await getApplication(appUuid);
|
||||||
const actualProject = projectUuidOf(app);
|
await ensureResourceInProject(app, 'Application', expectedProjectUuid);
|
||||||
if (!actualProject || actualProject !== expectedProjectUuid) {
|
|
||||||
throw new TenantError(
|
|
||||||
`Application ${appUuid} does not belong to project ${expectedProjectUuid}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,12 +697,7 @@ export async function getDatabaseInProject(
|
|||||||
expectedProjectUuid: string
|
expectedProjectUuid: string
|
||||||
): Promise<CoolifyDatabase> {
|
): Promise<CoolifyDatabase> {
|
||||||
const db = await getDatabase(dbUuid);
|
const db = await getDatabase(dbUuid);
|
||||||
const actualProject = projectUuidOf(db);
|
await ensureResourceInProject(db, 'Database', expectedProjectUuid);
|
||||||
if (!actualProject || actualProject !== expectedProjectUuid) {
|
|
||||||
throw new TenantError(
|
|
||||||
`Database ${dbUuid} does not belong to project ${expectedProjectUuid}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,34 +706,70 @@ export async function getServiceInProject(
|
|||||||
expectedProjectUuid: string
|
expectedProjectUuid: string
|
||||||
): Promise<CoolifyService> {
|
): Promise<CoolifyService> {
|
||||||
const svc = await getService(serviceUuid);
|
const svc = await getService(serviceUuid);
|
||||||
const actualProject = projectUuidOf(svc);
|
await ensureResourceInProject(svc, 'Service', expectedProjectUuid);
|
||||||
if (!actualProject || actualProject !== expectedProjectUuid) {
|
|
||||||
throw new TenantError(
|
|
||||||
`Service ${serviceUuid} does not belong to project ${expectedProjectUuid}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return svc;
|
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(
|
export async function listApplicationsInProject(
|
||||||
projectUuid: string
|
projectUuid: string
|
||||||
): Promise<CoolifyApplication[]> {
|
): Promise<CoolifyApplication[]> {
|
||||||
const all = await listApplications();
|
return listResourcesInProject(projectUuid, 'applications');
|
||||||
return all.filter(a => projectUuidOf(a) === projectUuid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDatabasesInProject(
|
export async function listDatabasesInProject(
|
||||||
projectUuid: string
|
projectUuid: string
|
||||||
): Promise<CoolifyDatabase[]> {
|
): Promise<CoolifyDatabase[]> {
|
||||||
const all = await listDatabases();
|
return listResourcesInProject(projectUuid, 'databases');
|
||||||
return all.filter(d => projectUuidOf(d) === projectUuid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listServicesInProject(
|
export async function listServicesInProject(
|
||||||
projectUuid: string
|
projectUuid: string
|
||||||
): Promise<CoolifyService[]> {
|
): Promise<CoolifyService[]> {
|
||||||
const all = await listServices();
|
return listResourcesInProject(projectUuid, 'services');
|
||||||
return all.filter(s => projectUuidOf(s) === projectUuid);
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use getApplicationInProject / ensureResourceInProject instead. */
|
||||||
|
export function projectUuidOf(
|
||||||
|
resource: CoolifyApplication | CoolifyDatabase | CoolifyService
|
||||||
|
): string | null {
|
||||||
|
return explicitProjectUuidOf(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user