import { db, resourceMtlsCertificates, resourcePolicies, resourcePolicyMtlsCertificates, resources, type Resource, type Transaction } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { and, asc, count, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import { MAX_MTLS_CERTIFICATES_PER_RESOURCE, parseCaCertificates, type ParsedCaCertificate } from "./mtlsCertificate"; type DbOrTrx = Transaction | typeof db; /** * Like the other auth methods (pincode, whitelist, ...), mTLS can be set on a * resource policy or on the resource itself. The resource-level settings only * matter alongside a shared policy; without one the resource's auth lives on * its inline (default) policy. */ export type MtlsOwner = | { type: "resource"; resourceId: number } | { type: "policy"; resourcePolicyId: number }; export type MtlsCertificateInfo = { mtlsCertificateId: number; name: string | null; subject: string | null; issuer: string | null; serialNumber: string | null; fingerprint: string | null; notBefore: number | null; notAfter: number | null; createdAt: number; }; type MtlsCertificateRow = MtlsCertificateInfo & { certificate: string }; /** * Where resource-scoped mTLS writes go, mirroring setResourcePincode: the * inline policy when no shared policy is assigned, otherwise the resource. */ export function getResourceMtlsOwner( resource: Pick< Resource, "resourceId" | "resourcePolicyId" | "defaultResourcePolicyId" > ): MtlsOwner { if ( resource.resourcePolicyId === null && resource.defaultResourcePolicyId !== null ) { return { type: "policy", resourcePolicyId: resource.defaultResourcePolicyId }; } return { type: "resource", resourceId: resource.resourceId }; } function certificateColumns( table: typeof resourceMtlsCertificates | typeof resourcePolicyMtlsCertificates ) { return { mtlsCertificateId: table.mtlsCertificateId, name: table.name, certificate: table.certificate, subject: table.subject, issuer: table.issuer, serialNumber: table.serialNumber, fingerprint: table.fingerprint, notBefore: table.notBefore, notAfter: table.notAfter, createdAt: table.createdAt }; } function toInfo({ certificate: _, ...info }: MtlsCertificateRow) { return info; } async function selectCertificates( owner: MtlsOwner, trx: DbOrTrx ): Promise { if (owner.type === "resource") { return trx .select(certificateColumns(resourceMtlsCertificates)) .from(resourceMtlsCertificates) .where(eq(resourceMtlsCertificates.resourceId, owner.resourceId)) .orderBy(asc(resourceMtlsCertificates.mtlsCertificateId)); } return trx .select(certificateColumns(resourcePolicyMtlsCertificates)) .from(resourcePolicyMtlsCertificates) .where( eq( resourcePolicyMtlsCertificates.resourcePolicyId, owner.resourcePolicyId ) ) .orderBy(asc(resourcePolicyMtlsCertificates.mtlsCertificateId)); } async function insertCertificates( owner: MtlsOwner, certs: (ParsedCaCertificate & { name: string | null })[], trx: DbOrTrx ): Promise { if (certs.length === 0) return []; const createdAt = Date.now(); if (owner.type === "resource") { const rows = await trx .insert(resourceMtlsCertificates) .values( certs.map((c) => ({ ...c, resourceId: owner.resourceId, createdAt })) ) .returning(certificateColumns(resourceMtlsCertificates)); return rows.map(toInfo); } const rows = await trx .insert(resourcePolicyMtlsCertificates) .values( certs.map((c) => ({ ...c, resourcePolicyId: owner.resourcePolicyId, createdAt })) ) .returning(certificateColumns(resourcePolicyMtlsCertificates)); return rows.map(toInfo); } /** Deletes the given certificates, or all of the owner's when ids is omitted. */ async function deleteCertificates( owner: MtlsOwner, mtlsCertificateIds: number[] | undefined, trx: DbOrTrx ): Promise { if (mtlsCertificateIds?.length === 0) return 0; if (owner.type === "resource") { const deleted = await trx .delete(resourceMtlsCertificates) .where( and( eq(resourceMtlsCertificates.resourceId, owner.resourceId), mtlsCertificateIds ? inArray( resourceMtlsCertificates.mtlsCertificateId, mtlsCertificateIds ) : undefined ) ) .returning({ id: resourceMtlsCertificates.mtlsCertificateId }); return deleted.length; } const deleted = await trx .delete(resourcePolicyMtlsCertificates) .where( and( eq( resourcePolicyMtlsCertificates.resourcePolicyId, owner.resourcePolicyId ), mtlsCertificateIds ? inArray( resourcePolicyMtlsCertificates.mtlsCertificateId, mtlsCertificateIds ) : undefined ) ) .returning({ id: resourcePolicyMtlsCertificates.mtlsCertificateId }); return deleted.length; } export async function listMtlsCertificates( owner: MtlsOwner, trx: DbOrTrx = db ): Promise { const rows = await selectCertificates(owner, trx); return rows.map(toInfo); } /** PEMs of the owner's trusted CAs, e.g. for blueprint export. */ export async function getMtlsCertificatePems( owner: MtlsOwner, trx: DbOrTrx = db ): Promise { const rows = await selectCertificates(owner, trx); return rows.map((r) => r.certificate); } export async function countMtlsCertificates( owner: MtlsOwner, trx: DbOrTrx = db ): Promise { if (owner.type === "resource") { const [{ total }] = await trx .select({ total: count() }) .from(resourceMtlsCertificates) .where(eq(resourceMtlsCertificates.resourceId, owner.resourceId)); return total; } const [{ total }] = await trx .select({ total: count() }) .from(resourcePolicyMtlsCertificates) .where( eq( resourcePolicyMtlsCertificates.resourcePolicyId, owner.resourcePolicyId ) ); return total; } export async function isMtlsEnabled( owner: MtlsOwner, trx: DbOrTrx = db ): Promise { if (owner.type === "resource") { const [row] = await trx .select({ mtlsEnabled: resources.mtlsEnabled }) .from(resources) .where(eq(resources.resourceId, owner.resourceId)) .limit(1); return Boolean(row?.mtlsEnabled); } const [row] = await trx .select({ mtlsEnabled: resourcePolicies.mtlsEnabled }) .from(resourcePolicies) .where(eq(resourcePolicies.resourcePolicyId, owner.resourcePolicyId)) .limit(1); return Boolean(row?.mtlsEnabled); } /** Returns true if the stored value changed. */ export async function setMtlsEnabled( owner: MtlsOwner, enabled: boolean, trx: DbOrTrx = db ): Promise { if (owner.type === "resource") { const updated = await trx .update(resources) .set({ mtlsEnabled: enabled }) .where( and( eq(resources.resourceId, owner.resourceId), eq(resources.mtlsEnabled, !enabled) ) ) .returning({ id: resources.resourceId }); return updated.length > 0; } const updated = await trx .update(resourcePolicies) .set({ mtlsEnabled: enabled }) .where( and( eq(resourcePolicies.resourcePolicyId, owner.resourcePolicyId), eq(resourcePolicies.mtlsEnabled, !enabled) ) ) .returning({ id: resourcePolicies.resourcePolicyId }); return updated.length > 0; } /** * Throws a 400 when mTLS is being turned on without a trusted CA, since * there would be nothing to verify clients against. */ export async function assertCanEnableMtls( owner: MtlsOwner, trx: DbOrTrx = db ): Promise { if ((await countMtlsCertificates(owner, trx)) === 0) { throw createHttpError( HttpCode.BAD_REQUEST, "Add at least one trusted CA certificate before enabling mTLS." ); } } /** * Adds the certificates in a PEM bundle, skipping ones already present. * Throws an HttpError for invalid input, duplicates, or exceeding the limit. */ export async function addMtlsCertificates( owner: MtlsOwner, pem: string, name: string | undefined, trx: DbOrTrx = db ): Promise { let parsedCerts: ParsedCaCertificate[]; try { parsedCerts = parseCaCertificates(pem); } catch (e) { throw createHttpError( HttpCode.BAD_REQUEST, e instanceof Error ? e.message : "Invalid certificate" ); } const existing = await selectCertificates(owner, trx); const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); const newCerts = parsedCerts.filter( (c) => !existingFingerprints.has(c.fingerprint) ); if (newCerts.length === 0) { throw createHttpError( HttpCode.CONFLICT, parsedCerts.length === 1 ? "This CA certificate has already been added" : "All certificates in this bundle have already been added" ); } if ( existing.length + newCerts.length > MAX_MTLS_CERTIFICATES_PER_RESOURCE ) { throw createHttpError( HttpCode.BAD_REQUEST, `At most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates can be added` ); } return insertCertificates( owner, newCerts.map((c) => ({ ...c, // a label only makes sense for a single certificate name: newCerts.length === 1 ? (name ?? null) : null })), trx ); } /** * Removes one trusted CA. 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; it is switched off instead. * Returns null when the certificate does not belong to the owner. */ export async function removeMtlsCertificate( owner: MtlsOwner, mtlsCertificateId: number ): Promise<{ mtlsDisabled: boolean } | null> { return db.transaction(async (trx) => { const removed = await deleteCertificates( owner, [mtlsCertificateId], trx ); if (removed === 0) { return null; } let mtlsDisabled = false; if ((await countMtlsCertificates(owner, trx)) === 0) { mtlsDisabled = await setMtlsEnabled(owner, false, trx); } return { mtlsDisabled }; }); } /** * Makes the owner's trusted CAs exactly the given PEMs (each entry may be a * bundle). Used by blueprints, which declare the full desired set. */ export async function syncMtlsCertificates( owner: MtlsOwner, caCertificates: string[], trx: Transaction ): Promise { // A PEM entry may itself be a bundle; flatten and dedupe by fingerprint. const desired = new Map(); for (const pem of caCertificates) { for (const cert of parseCaCertificates(pem)) { desired.set(cert.fingerprint, cert); } } if (desired.size > MAX_MTLS_CERTIFICATES_PER_RESOURCE) { throw new Error( `At most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates can be added` ); } const existing = await selectCertificates(owner, trx); const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); await insertCertificates( owner, [...desired.values()] .filter((c) => !existingFingerprints.has(c.fingerprint)) .map((c) => ({ ...c, name: null })), trx ); await deleteCertificates( owner, existing .filter((r) => !r.fingerprint || !desired.has(r.fingerprint)) .map((r) => r.mtlsCertificateId), trx ); } /** Turns mTLS off and drops the trusted CAs. */ export async function clearMtls(owner: MtlsOwner, trx: DbOrTrx): Promise { await setMtlsEnabled(owner, false, trx); await deleteCertificates(owner, undefined, trx); }