mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-06 04:39:06 +02:00
DELETE /v1/org/{orgId} on the Integration API always returned
500 "An error occurred..." and left the organization in place, even for a
root API key holding the deleteOrg action. The handler looked up the
caller's ownership with req.user!.userId, but Integration API requests are
authenticated by verifyApiKey, which sets req.apiKey and never req.user,
so the lookup threw "Cannot read properties of undefined (reading
'userId')".
#1376 was fixed in f37eda47 by dropping the user-only permission check;
79cf7c84 later added the owner check to the handler shared by both
routers, reintroducing the failure for API keys.
Keep the owner check for dashboard sessions. For Integration API keys the
route is already restricted by verifyApiKeyIsRoot and
verifyApiKeyHasAction(deleteOrg), so load the organization directly. Both
paths still refuse a billing organization and return the same 404 for an
unknown organization.
Verified against a local SQLite instance with a root key: before the
change, create returned 201 and delete returned 500 with the organization
still present; after it, delete returns 200 and a repeated delete or GET
returns 404. npx tsc --noEmit passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
4.4 KiB
TypeScript
150 lines
4.4 KiB
TypeScript
import { Request, Response, NextFunction } from "express";
|
|
import { z } from "zod";
|
|
import response from "@server/lib/response";
|
|
import HttpCode from "@server/types/HttpCode";
|
|
import createHttpError from "http-errors";
|
|
import logger from "@server/logger";
|
|
import { fromError } from "zod-validation-error";
|
|
import { OpenAPITags, registry } from "@server/openApi";
|
|
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
|
import { db, Org, userOrgs, orgs } from "@server/db";
|
|
import { eq, and } from "drizzle-orm";
|
|
|
|
const deleteOrgSchema = z.strictObject({
|
|
orgId: z.string()
|
|
});
|
|
|
|
export type DeleteOrgResponse = {};
|
|
|
|
registry.registerPath({
|
|
method: "delete",
|
|
path: "/org/{orgId}",
|
|
description: "Delete an organization",
|
|
tags: [OpenAPITags.Org],
|
|
request: {
|
|
params: deleteOrgSchema
|
|
},
|
|
responses: {
|
|
200: {
|
|
description: "Successful response",
|
|
content: {
|
|
"application/json": {
|
|
schema: z.object({
|
|
data: z.record(z.string(), z.any()).nullable(),
|
|
success: z.boolean(),
|
|
error: z.boolean(),
|
|
message: z.string(),
|
|
status: z.number()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
export async function deleteOrg(
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
): Promise<any> {
|
|
try {
|
|
const parsedParams = deleteOrgSchema.safeParse(req.params);
|
|
if (!parsedParams.success) {
|
|
return next(
|
|
createHttpError(
|
|
HttpCode.BAD_REQUEST,
|
|
fromError(parsedParams.error).toString()
|
|
)
|
|
);
|
|
}
|
|
const { orgId } = parsedParams.data;
|
|
|
|
let org: Org | undefined;
|
|
|
|
if (req.user) {
|
|
// Dashboard sessions may delete only an organization the user owns.
|
|
const [data] = await db
|
|
.select()
|
|
.from(userOrgs)
|
|
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId))
|
|
.where(
|
|
and(
|
|
eq(userOrgs.orgId, orgId),
|
|
eq(userOrgs.userId, req.user.userId)
|
|
)
|
|
);
|
|
|
|
if (!data) {
|
|
return next(
|
|
createHttpError(
|
|
HttpCode.NOT_FOUND,
|
|
`Organization with ID ${orgId} not found`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (!data.userOrgs.isOwner) {
|
|
return next(
|
|
createHttpError(
|
|
HttpCode.FORBIDDEN,
|
|
"Only organization owners can delete the organization"
|
|
)
|
|
);
|
|
}
|
|
|
|
org = data.orgs;
|
|
} else if (req.apiKey) {
|
|
// Integration API requests carry no user session. The route is already
|
|
// restricted by verifyApiKeyIsRoot and verifyApiKeyHasAction(deleteOrg).
|
|
[org] = await db
|
|
.select()
|
|
.from(orgs)
|
|
.where(eq(orgs.orgId, orgId))
|
|
.limit(1);
|
|
|
|
if (!org) {
|
|
return next(
|
|
createHttpError(
|
|
HttpCode.NOT_FOUND,
|
|
`Organization with ID ${orgId} not found`
|
|
)
|
|
);
|
|
}
|
|
} else {
|
|
return next(
|
|
createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated")
|
|
);
|
|
}
|
|
|
|
if (org.isBillingOrg) {
|
|
return next(
|
|
createHttpError(
|
|
HttpCode.BAD_REQUEST,
|
|
"Cannot delete a primary organization"
|
|
)
|
|
);
|
|
}
|
|
|
|
const result = await deleteOrgById(orgId);
|
|
sendTerminationMessages(result);
|
|
return response(res, {
|
|
data: null,
|
|
success: true,
|
|
error: false,
|
|
message: "Organization deleted successfully",
|
|
status: HttpCode.OK
|
|
});
|
|
} catch (error) {
|
|
if (createHttpError.isHttpError(error)) {
|
|
return next(error);
|
|
}
|
|
logger.error(error);
|
|
return next(
|
|
createHttpError(
|
|
HttpCode.INTERNAL_SERVER_ERROR,
|
|
"An error occurred..."
|
|
)
|
|
);
|
|
}
|
|
}
|