Show protected when using mtls

This commit is contained in:
Owen
2026-10-20 15:02:07 -04:00
parent ee8724f117
commit 77c0c96823
6 changed files with 102 additions and 9 deletions
+72 -1
View File
@@ -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<void> {
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<boolean> {
return sql<boolean>`(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<boolean> {
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);
}
@@ -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,
+8 -1
View File
@@ -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,
@@ -99,6 +99,7 @@ export default async function ProxyResourcesPage(
resource.pincodeId !== null ||
resource.passwordId !== null ||
resource.whitelist ||
resource.mtls ||
resource.headerAuthId
? "protected"
: "not_protected",
+2 -2
View File
@@ -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 ? (
<div className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("protected")}</span>
@@ -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
});
}}
/>
)}
</SettingsSectionBody>