diff --git a/server/lib/mtls.ts b/server/lib/mtls.ts index dfb19c14f..7e82f80fe 100644 --- a/server/lib/mtls.ts +++ b/server/lib/mtls.ts @@ -1,4 +1,5 @@ import { + alias, db, resourceMtlsCertificates, resourcePolicies, @@ -8,13 +9,23 @@ import { type Transaction } from "@server/db"; import HttpCode from "@server/types/HttpCode"; -import { and, asc, count, eq, inArray } from "drizzle-orm"; +import { + and, + asc, + count, + eq, + inArray, + sql, + type AnyColumn, + type SQL +} from "drizzle-orm"; import createHttpError from "http-errors"; import { MAX_MTLS_CERTIFICATES_PER_RESOURCE, parseCaCertificates, type ParsedCaCertificate } from "./mtlsCertificate"; +import { MTLS_RESOURCE_MODES } from "./mtlsModes"; type DbOrTrx = Transaction | typeof db; @@ -426,3 +437,63 @@ export async function clearMtls(owner: MtlsOwner, trx: DbOrTrx): Promise { await setMtlsEnabled(owner, false, trx); await deleteCertificates(owner, undefined, trx); } + +type PolicyMtlsColumns = { + resourcePolicyId: AnyColumn; + mtlsEnabled: AnyColumn; +}; + +/** + * SQL boolean: whether Traefik enforces mTLS on the resource, mirroring + * getMtlsCaCertificatesByResource. The shared policy wins over the default + * (inline) policy; when that policy has mTLS off the resource-level setting + * applies; either way there must be a trusted CA, SSL and a mode served over + * HTTP(S). The policies are the caller's joins on resourcePolicyId and + * defaultResourcePolicyId. + */ +export function effectiveMtlsSql( + sharedPolicy: PolicyMtlsColumns, + defaultPolicy: PolicyMtlsColumns +): SQL { + return sql`(CASE + WHEN NOT (${resources.ssl} AND ${inArray(resources.mode, [...MTLS_RESOURCE_MODES])}) THEN false + WHEN COALESCE(CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.mtlsEnabled} + ELSE ${defaultPolicy.mtlsEnabled} + END, false) THEN EXISTS ( + SELECT 1 FROM ${resourcePolicyMtlsCertificates} + WHERE ${resourcePolicyMtlsCertificates.resourcePolicyId} = COALESCE(${sharedPolicy.resourcePolicyId}, ${defaultPolicy.resourcePolicyId}) + ) + WHEN ${resources.mtlsEnabled} THEN EXISTS ( + SELECT 1 FROM ${resourceMtlsCertificates} + WHERE ${resourceMtlsCertificates.resourceId} = ${resources.resourceId} + ) + ELSE false + END)`; +} + +/** Whether Traefik enforces mTLS on the resource; see effectiveMtlsSql. */ +export async function isResourceMtlsEnforced( + resourceId: number, + trx: DbOrTrx = db +): Promise { + const sharedPolicy = alias(resourcePolicies, "sharedPolicy"); + const defaultPolicy = alias(resourcePolicies, "defaultPolicy"); + const [row] = await trx + .select({ mtls: effectiveMtlsSql(sharedPolicy, defaultPolicy) }) + .from(resources) + .leftJoin( + sharedPolicy, + eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId) + ) + .leftJoin( + defaultPolicy, + eq( + defaultPolicy.resourcePolicyId, + resources.defaultResourcePolicyId + ) + ) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + return Boolean(row?.mtls); +} diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 667870959..07984efa4 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -19,6 +19,7 @@ import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { build } from "@server/build"; +import { isResourceMtlsEnforced } from "@server/lib/mtls"; const getResourceAuthInfoSchema = z.strictObject({ resourceGuid: z.string() @@ -39,6 +40,8 @@ export type GetResourceAuthInfoResponse = { wildcard: boolean; fullDomain: string | null; whitelist: boolean; + // a client certificate is required during the TLS handshake + mtls: boolean; skipToIdpId: number | null; orgId: string; postAuthPath: string | null; @@ -226,6 +229,7 @@ export async function getResourceAuthInfo( wildcard: resource.wildcard ?? false, fullDomain: resource.fullDomain, whitelist: effectivePolicy?.emailWhitelistEnabled ?? false, + mtls: await isResourceMtlsEnforced(resource.resourceId), skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId, orgId: resource.orgId, postAuthPath: resource.postAuthPath ?? null, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 124c480c2..171aa9250 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -19,6 +19,7 @@ import { type Label } from "@server/db"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { effectiveMtlsSql } from "@server/lib/mtls"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; import logger from "@server/logger"; @@ -111,7 +112,7 @@ const listResourcesSchema = z.strictObject({ type: "string", enum: ["protected", "not_protected", "none"], description: - "Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)." + "Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, email whitelist, or mTLS). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)." }), healthStatus: z .enum(["healthy", "degraded", "unhealthy", "unknown"]) @@ -188,6 +189,7 @@ export type ResourceWithTargets = { sso: boolean; pincodeId: number | null; whitelist: boolean; + mtls: boolean; proxyPort: number | null; enabled: boolean; domainId: string | null; @@ -306,6 +308,7 @@ function queryResourcesBase() { sso: effectiveSso, pincodeId: effectivePincodeId, whitelist: effectiveWhitelist, + mtls: effectiveMtlsSql(sharedPolicy, defaultPolicy), proxyPort: resources.proxyPort, enabled: resources.enabled, domainId: resources.domainId, @@ -646,6 +649,7 @@ export async function listResources( ${resourcePassword.passwordId} ) `; + const effectiveMtls = effectiveMtlsSql(sharedPolicy, defaultPolicy); const browserGatewayModes = ["http", "ssh", "rdp", "vnc"] as const; switch (authState) { @@ -662,6 +666,7 @@ export async function listResources( or( effectiveSso, effectiveWhitelist, + effectiveMtls, not(isNull(effectiveHeaderAuthId)), not(isNull(effectivePincodeId)), not(isNull(effectivePasswordId)) @@ -675,6 +680,7 @@ export async function listResources( inArray(resources.mode, browserGatewayModes), not(effectiveSso), not(effectiveWhitelist), + not(effectiveMtls), isNull(effectiveHeaderAuthId), isNull(effectivePincodeId), isNull(effectivePasswordId) @@ -850,6 +856,7 @@ export async function listResources( sso: row.sso ?? false, pincodeId: row.pincodeId, whitelist: row.whitelist ?? false, + mtls: Boolean(row.mtls), proxyPort: row.proxyPort, wildcard: row.wildcard, mode: row.mode, diff --git a/src/app/[orgId]/settings/resources/public/page.tsx b/src/app/[orgId]/settings/resources/public/page.tsx index d7fd80d4a..27ca4e110 100644 --- a/src/app/[orgId]/settings/resources/public/page.tsx +++ b/src/app/[orgId]/settings/resources/public/page.tsx @@ -99,6 +99,7 @@ export default async function ProxyResourcesPage( resource.pincodeId !== null || resource.passwordId !== null || resource.whitelist || + resource.mtls || resource.headerAuthId ? "protected" : "not_protected", diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 4480b959a..58389022f 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -19,7 +19,6 @@ import { import { useTranslations } from "next-intl"; import CertificateStatus from "@app/components/CertificateStatus"; import { toUnicode } from "punycode"; -import { build } from "@server/build"; type ResourceInfoBoxType = {}; @@ -118,7 +117,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { authInfo.pincode || authInfo.sso || authInfo.whitelist || - authInfo.headerAuth ? ( + authInfo.headerAuth || + authInfo.mtls ? (
{t("protected")} diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx index 1e7d1de24..9b6dd27c8 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -454,15 +454,22 @@ export function PolicyAuthStackSectionEdit({ updatePolicy(policyUpdates); + // Traefik only enforces mTLS with a trusted CA and SSL. + const mtlsEnforced = + mtls.supported && + mtls.enabled && + mtls.hasCertificates && + mtls.sslEnabled; resourceContext?.updateAuthInfo( isInferenceResource - ? { sso: true } + ? { sso: true, mtls: mtlsEnforced } : { sso: payload.sso, whitelist: payload.emailWhitelistEnabled, password: passcodeOnServerRef.current, pincode: pincodeOnServerRef.current, - headerAuth: headerAuthOnServerRef.current + headerAuth: headerAuthOnServerRef.current, + mtls: mtlsEnforced } ); @@ -831,9 +838,12 @@ export function PolicyAuthStackSectionEdit({ resourcePolicyId={policy.resourcePolicyId} certificates={mtls.certificates} isLoading={mtls.isLoadingCertificates} - onMtlsDisabled={() => - updatePolicy({ mtlsEnabled: false }) - } + onMtlsDisabled={() => { + updatePolicy({ mtlsEnabled: false }); + resourceContext?.updateAuthInfo({ + mtls: false + }); + }} /> )}