Merge branch 'mtls' into dev

This commit is contained in:
Owen
2026-10-12 16:25:25 -04:00
22 changed files with 1521 additions and 6 deletions
+22
View File
@@ -848,6 +848,28 @@ authenticated.delete(
resource.deleteResourceRule
);
authenticated.put(
"/resource/:resourceId/mtls/certificate",
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createResourceMtlsCertificate),
logActionAudit(ActionsEnum.createResourceMtlsCertificate),
resource.createResourceMtlsCertificate
);
authenticated.get(
"/resource/:resourceId/mtls/certificates",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceMtlsCertificates),
resource.listResourceMtlsCertificates
);
authenticated.delete(
"/resource/:resourceId/mtls/certificate/:mtlsCertificateId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.deleteResourceMtlsCertificate),
logActionAudit(ActionsEnum.deleteResourceMtlsCertificate),
resource.deleteResourceMtlsCertificate
);
authenticated.get(
"/target/:targetId",
verifyTargetAccess,
+33
View File
@@ -738,6 +738,39 @@ authenticated.delete(
resource.deleteResourceRule
);
authenticated.put(
[
"/resource/:resourceId/mtls/certificate",
"/public-resource/:resourceId/mtls/certificate"
],
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createResourceMtlsCertificate),
logActionAudit(ActionsEnum.createResourceMtlsCertificate),
resource.createResourceMtlsCertificate
);
authenticated.get(
[
"/resource/:resourceId/mtls/certificates",
"/public-resource/:resourceId/mtls/certificates"
],
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.listResourceMtlsCertificates),
resource.listResourceMtlsCertificates
);
authenticated.delete(
[
"/resource/:resourceId/mtls/certificate/:mtlsCertificateId",
"/public-resource/:resourceId/mtls/certificate/:mtlsCertificateId"
],
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.deleteResourceMtlsCertificate),
logActionAudit(ActionsEnum.deleteResourceMtlsCertificate),
resource.deleteResourceMtlsCertificate
);
authenticated.get(
"/target/:targetId",
verifyApiKeyTargetAccess,
@@ -0,0 +1,239 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourceMtlsCertificates, resources } from "@server/db";
import { eq } from "drizzle-orm";
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 { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
MAX_MTLS_CERTIFICATES_PER_RESOURCE,
parseCaCertificates
} from "@server/lib/mtlsCertificate";
const createResourceMtlsCertificateParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
const createResourceMtlsCertificateBodySchema = z.strictObject({
name: z.string().min(1).max(255).optional(),
certificate: z.string().min(1).max(100_000)
});
export type CreateResourceMtlsCertificateResponse = {
certificates: {
mtlsCertificateId: number;
resourceId: number;
name: string | null;
subject: string | null;
issuer: string | null;
serialNumber: string | null;
fingerprint: string | null;
notBefore: number | null;
notAfter: number | null;
createdAt: number;
}[];
};
const description =
"Add a trusted CA certificate that clients must present a certificate signed by when mTLS is enabled on a resource. Accepts a PEM bundle; each certificate in the bundle is stored separately.";
registry.registerPath({
method: "put",
path: "/resource/{resourceId}/mtls/certificate",
description,
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: createResourceMtlsCertificateParamsSchema,
body: {
content: {
"application/json": {
schema: createResourceMtlsCertificateBodySchema
}
}
}
},
responses: {}
});
registry.registerPath({
method: "put",
path: "/public-resource/{resourceId}/mtls/certificate",
description,
tags: [OpenAPITags.PublicResource],
request: {
params: createResourceMtlsCertificateParamsSchema,
body: {
content: {
"application/json": {
schema: createResourceMtlsCertificateBodySchema
}
}
}
},
responses: {}
});
export async function createResourceMtlsCertificate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams =
createResourceMtlsCertificateParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = createResourceMtlsCertificateBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { name, certificate } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (resource.mode !== "http") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"mTLS is only supported on HTTP resources"
)
);
}
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.mtls
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"mTLS is not supported on your current plan. Please upgrade to access this feature."
)
);
}
let parsedCerts;
try {
parsedCerts = parseCaCertificates(certificate);
} catch (e) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
e instanceof Error ? e.message : "Invalid certificate"
)
);
}
const existing = await db
.select({ fingerprint: resourceMtlsCertificates.fingerprint })
.from(resourceMtlsCertificates)
.where(eq(resourceMtlsCertificates.resourceId, resourceId));
const existingFingerprints = new Set(existing.map((c) => c.fingerprint));
const newCerts = parsedCerts.filter(
(c) => !existingFingerprints.has(c.fingerprint)
);
if (newCerts.length === 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
parsedCerts.length === 1
? "This CA certificate has already been added to this resource"
: "All certificates in this bundle have already been added to this resource"
)
);
}
if (
existing.length + newCerts.length >
MAX_MTLS_CERTIFICATES_PER_RESOURCE
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`A resource can have at most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates`
)
);
}
const now = Date.now();
const inserted = await db
.insert(resourceMtlsCertificates)
.values(
newCerts.map((c) => ({
resourceId,
// a label only makes sense for a single certificate
name: newCerts.length === 1 ? (name ?? null) : null,
certificate: c.certificate,
subject: c.subject,
issuer: c.issuer,
serialNumber: c.serialNumber,
fingerprint: c.fingerprint,
notBefore: c.notBefore,
notAfter: c.notAfter,
createdAt: now
}))
)
.returning({
mtlsCertificateId: resourceMtlsCertificates.mtlsCertificateId,
resourceId: resourceMtlsCertificates.resourceId,
name: resourceMtlsCertificates.name,
subject: resourceMtlsCertificates.subject,
issuer: resourceMtlsCertificates.issuer,
serialNumber: resourceMtlsCertificates.serialNumber,
fingerprint: resourceMtlsCertificates.fingerprint,
notBefore: resourceMtlsCertificates.notBefore,
notAfter: resourceMtlsCertificates.notAfter,
createdAt: resourceMtlsCertificates.createdAt
});
return response<CreateResourceMtlsCertificateResponse>(res, {
data: { certificates: inserted },
success: true,
error: false,
message: "CA certificate added successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,135 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourceMtlsCertificates, resources } from "@server/db";
import { and, count, eq } from "drizzle-orm";
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";
const deleteResourceMtlsCertificateParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive(),
mtlsCertificateId: z.coerce.number().int().positive()
});
const description =
"Remove a trusted CA certificate from a resource. If it is the last one, mTLS is disabled on the resource since it can no longer verify any client.";
registry.registerPath({
method: "delete",
path: "/resource/{resourceId}/mtls/certificate/{mtlsCertificateId}",
description,
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: deleteResourceMtlsCertificateParamsSchema
},
responses: {}
});
registry.registerPath({
method: "delete",
path: "/public-resource/{resourceId}/mtls/certificate/{mtlsCertificateId}",
description,
tags: [OpenAPITags.PublicResource],
request: {
params: deleteResourceMtlsCertificateParamsSchema
},
responses: {}
});
export type DeleteResourceMtlsCertificateResponse = {
// true when removing the last CA certificate forced mTLS off
mtlsDisabled: boolean;
};
export async function deleteResourceMtlsCertificate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams =
deleteResourceMtlsCertificateParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId, mtlsCertificateId } = parsedParams.data;
const [existing] = await db
.select()
.from(resourceMtlsCertificates)
.where(
and(
eq(
resourceMtlsCertificates.mtlsCertificateId,
mtlsCertificateId
),
eq(resourceMtlsCertificates.resourceId, resourceId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`CA certificate with ID ${mtlsCertificateId} not found`
)
);
}
let mtlsDisabled = false;
await db.transaction(async (trx) => {
await trx
.delete(resourceMtlsCertificates)
.where(
eq(
resourceMtlsCertificates.mtlsCertificateId,
mtlsCertificateId
)
);
const [{ remaining }] = await trx
.select({ remaining: count() })
.from(resourceMtlsCertificates)
.where(eq(resourceMtlsCertificates.resourceId, resourceId));
// With no CA left there is nothing to verify clients against, so
// leaving mTLS "enabled" would either lock everyone out or be
// silently ignored by the Traefik config builder.
if (remaining === 0) {
const updated = await trx
.update(resources)
.set({ mtlsEnabled: false })
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.mtlsEnabled, true)
)
)
.returning({ resourceId: resources.resourceId });
mtlsDisabled = updated.length > 0;
}
});
return response<DeleteResourceMtlsCertificateResponse>(res, {
data: { mtlsDisabled },
success: true,
error: false,
message: "CA certificate removed successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+3
View File
@@ -21,6 +21,9 @@ export * from "./createResourceRule";
export * from "./deleteResourceRule";
export * from "./listResourceRules";
export * from "./updateResourceRule";
export * from "./createResourceMtlsCertificate";
export * from "./deleteResourceMtlsCertificate";
export * from "./listResourceMtlsCertificates";
export * from "./getUserResources";
export * from "./listUserResourceAliases";
export * from "./setResourceHeaderAuth";
@@ -0,0 +1,111 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourceMtlsCertificates, resources } from "@server/db";
import { asc, eq } from "drizzle-orm";
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";
const listResourceMtlsCertificatesParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
function queryResourceMtlsCertificates(resourceId: number) {
return db
.select({
mtlsCertificateId: resourceMtlsCertificates.mtlsCertificateId,
resourceId: resourceMtlsCertificates.resourceId,
name: resourceMtlsCertificates.name,
subject: resourceMtlsCertificates.subject,
issuer: resourceMtlsCertificates.issuer,
serialNumber: resourceMtlsCertificates.serialNumber,
fingerprint: resourceMtlsCertificates.fingerprint,
notBefore: resourceMtlsCertificates.notBefore,
notAfter: resourceMtlsCertificates.notAfter,
createdAt: resourceMtlsCertificates.createdAt
})
.from(resourceMtlsCertificates)
.where(eq(resourceMtlsCertificates.resourceId, resourceId))
.orderBy(asc(resourceMtlsCertificates.mtlsCertificateId));
}
export type ListResourceMtlsCertificatesResponse = {
certificates: Awaited<ReturnType<typeof queryResourceMtlsCertificates>>;
};
const description = "List the trusted CA certificates for a resource's mTLS.";
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/mtls/certificates",
description,
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: listResourceMtlsCertificatesParamsSchema
},
responses: {}
});
registry.registerPath({
method: "get",
path: "/public-resource/{resourceId}/mtls/certificates",
description,
tags: [OpenAPITags.PublicResource],
request: {
params: listResourceMtlsCertificatesParamsSchema
},
responses: {}
});
export async function listResourceMtlsCertificates(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams =
listResourceMtlsCertificatesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const certificates = await queryResourceMtlsCertificates(resourceId);
return response<ListResourceMtlsCertificatesResponse>(res, {
data: { certificates },
success: true,
error: false,
message: "CA certificates retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+59 -2
View File
@@ -6,6 +6,7 @@ import {
loginPage,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourceMtlsCertificates,
resourcePassword,
resourcePincode,
resourceRules,
@@ -24,7 +25,7 @@ import {
resourcePolicies,
resources
} from "@server/db";
import { eq, and, ne } from "drizzle-orm";
import { eq, and, ne, count } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -117,6 +118,12 @@ const updateHttpResourceBodySchema = z
maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
mtlsEnabled: z
.boolean()
.optional()
.describe(
"Require clients to present a certificate signed by one of the resource's trusted CA certificates. Requires SSL and at least one CA certificate."
),
postAuthPath: z.string().nullable().optional(),
// SSH settings
pamMode: z.enum(["passthrough", "push"]).optional(),
@@ -508,8 +515,58 @@ async function updateHttpResource(
}
}
const effectiveSsl = updateData.ssl ?? resource.ssl;
const effectiveMtlsEnabled = updateData.mtlsEnabled ?? resource.mtlsEnabled;
if (updateData.mtlsEnabled === true) {
if (resource.mode !== "http") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"mTLS is only supported on HTTP resources."
)
);
}
const isMtlsLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.mtls
);
if (!isMtlsLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"mTLS is not supported on your current plan. Please upgrade to access this feature."
)
);
}
const [{ certificateCount }] = await db
.select({ certificateCount: count() })
.from(resourceMtlsCertificates)
.where(eq(resourceMtlsCertificates.resourceId, resource.resourceId));
if (certificateCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Add at least one trusted CA certificate before enabling mTLS."
)
);
}
}
if (effectiveMtlsEnabled && !effectiveSsl) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"mTLS requires SSL to be enabled for this resource. Disable mTLS before turning SSL off."
)
);
}
// catch when the resource policy changes or gets cleared
if (updateData.resourcePolicyId !== undefined &&
if (updateData.resourcePolicyId !== undefined &&
resource.resourcePolicyId !== updateData.resourcePolicyId) {
await clearResourceSpecificSettings(
resource.resourceId,