diff --git a/messages/en-US.json b/messages/en-US.json index 31b653467..c87efd724 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -829,6 +829,8 @@ "save": "Save", "proxyAdditional": "Additional Proxy Settings", "proxyAdditionalDescription": "Configure how the resource handles proxy settings", + "proxyForwardIdentityHeaders": "Forward Identity Headers", + "proxyForwardIdentityHeadersDescription": "Send the authenticated user's identity to the backend in the Remote-User, Remote-Email, Remote-Name, Remote-Role, and Remote-User-Id headers. Disable for backends that reject requests with unrecognized headers.", "proxyCustomHeader": "Custom Host Header", "proxyCustomHeaderDescription": "The host header to set when proxying requests. Leave empty to use the default.", "proxyAdditionalSubmit": "Save Proxy Settings", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index c3b9d8d9d..bd79fc7f4 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -697,22 +697,6 @@ export const aiSessionLog = pgTable( ] ); -export const resourceMtlsCertificates = pgTable("resourceMtlsCertificates", { - mtlsCertificateId: serial("mtlsCertificateId").primaryKey(), - resourceId: integer("resourceId") - .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), - name: varchar("name"), - certificate: text("certificate").notNull(), - subject: varchar("subject"), - issuer: varchar("issuer"), - serialNumber: varchar("serialNumber"), - fingerprint: varchar("fingerprint"), - notBefore: bigint("notBefore", { mode: "number" }), - notAfter: bigint("notAfter", { mode: "number" }), - createdAt: bigint("createdAt", { mode: "number" }).notNull() -}); - export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -760,6 +744,3 @@ export type AlertEmailRecipients = InferSelectModel< export type AlertWebhookActions = InferSelectModel; export type TrialNotification = InferSelectModel; export type AiSessionLog = InferSelectModel; -export type ResourceMtlsCertificate = InferSelectModel< - typeof resourceMtlsCertificates ->; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 31cedeba9..aff7cc70e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -3,7 +3,6 @@ import { InferSelectModel, sql } from "drizzle-orm"; import { bigint, boolean, - check, index, integer, pgTable, @@ -205,6 +204,9 @@ export const resources = pgTable( maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), mtlsEnabled: boolean("mtlsEnabled").notNull().default(false), + forwardIdentityHeaders: boolean("forwardIdentityHeaders") + .notNull() + .default(true), postAuthPath: text("postAuthPath"), health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: boolean("wildcard").notNull().default(false), @@ -1245,6 +1247,7 @@ export const resourcePolicies = pgTable( emailWhitelistEnabled: boolean("emailWhitelistEnabled") .notNull() .default(false), + mtlsEnabled: boolean("mtlsEnabled").notNull().default(false), idpId: integer("idpId").references(() => idp.idpId, { onDelete: "set null" }), @@ -2045,6 +2048,43 @@ export const certificates = pgTable("certificates", { keyFile: text("keyFile") }); +export const resourceMtlsCertificates = pgTable("resourceMtlsCertificates", { + mtlsCertificateId: serial("mtlsCertificateId").primaryKey(), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + name: varchar("name"), + certificate: text("certificate").notNull(), + subject: varchar("subject"), + issuer: varchar("issuer"), + serialNumber: varchar("serialNumber"), + fingerprint: varchar("fingerprint"), + notBefore: bigint("notBefore", { mode: "number" }), + notAfter: bigint("notAfter", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull() +}); + +export const resourcePolicyMtlsCertificates = pgTable( + "resourcePolicyMtlsCertificates", + { + mtlsCertificateId: serial("mtlsCertificateId").primaryKey(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }), + name: varchar("name"), + certificate: text("certificate").notNull(), + subject: varchar("subject"), + issuer: varchar("issuer"), + serialNumber: varchar("serialNumber"), + fingerprint: varchar("fingerprint"), + notBefore: bigint("notBefore", { mode: "number" }), + notAfter: bigint("notAfter", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull() + } +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -2147,3 +2187,9 @@ export type SiteResourceAiProvider = InferSelectModel< export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; export type Certificate = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; +export type ResourcePolicyMtlsCertificate = InferSelectModel< + typeof resourcePolicyMtlsCertificates +>; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 4f0ae60c9..8db5bf99d 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -696,27 +696,6 @@ export const aiSessionLog = sqliteTable( ] ); -export const resourceMtlsCertificates = sqliteTable( - "resourceMtlsCertificates", - { - mtlsCertificateId: integer("mtlsCertificateId").primaryKey({ - autoIncrement: true - }), - resourceId: integer("resourceId") - .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), - name: text("name"), - certificate: text("certificate").notNull(), - subject: text("subject"), - issuer: text("issuer"), - serialNumber: text("serialNumber"), - fingerprint: text("fingerprint"), - notBefore: integer("notBefore"), - notAfter: integer("notAfter"), - createdAt: integer("createdAt").notNull() - } -); - export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -756,6 +735,3 @@ export type AlertEmailRecipient = InferSelectModel; export type AlertWebhookAction = InferSelectModel; export type TrialNotification = InferSelectModel; export type AiSessionLog = InferSelectModel; -export type ResourceMtlsCertificate = InferSelectModel< - typeof resourceMtlsCertificates ->; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index ff2c29046..2cd3494e5 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,7 +1,6 @@ import { randomUUID } from "crypto"; import { InferSelectModel, sql } from "drizzle-orm"; import { - check, index, integer, primaryKey, @@ -227,6 +226,11 @@ export const resources = sqliteTable( mtlsEnabled: integer("mtlsEnabled", { mode: "boolean" }) .notNull() .default(false), + forwardIdentityHeaders: integer("forwardIdentityHeaders", { + mode: "boolean" + }) + .notNull() + .default(true), postAuthPath: text("postAuthPath"), health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: integer("wildcard", { mode: "boolean" }) @@ -1553,6 +1557,9 @@ export const resourcePolicies = sqliteTable("resourcePolicies", { emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() .default(false), + mtlsEnabled: integer("mtlsEnabled", { mode: "boolean" }) + .notNull() + .default(false), niceId: text("niceId").notNull(), idpId: integer("idpId").references(() => idp.idpId, { onDelete: "set null" @@ -2090,6 +2097,50 @@ export const certificates = sqliteTable("certificates", { keyFile: text("keyFile") }); +export const resourceMtlsCertificates = sqliteTable( + "resourceMtlsCertificates", + { + mtlsCertificateId: integer("mtlsCertificateId").primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + name: text("name"), + certificate: text("certificate").notNull(), + subject: text("subject"), + issuer: text("issuer"), + serialNumber: text("serialNumber"), + fingerprint: text("fingerprint"), + notBefore: integer("notBefore"), + notAfter: integer("notAfter"), + createdAt: integer("createdAt").notNull() + } +); + +export const resourcePolicyMtlsCertificates = sqliteTable( + "resourcePolicyMtlsCertificates", + { + mtlsCertificateId: integer("mtlsCertificateId").primaryKey({ + autoIncrement: true + }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }), + name: text("name"), + certificate: text("certificate").notNull(), + subject: text("subject"), + issuer: text("issuer"), + serialNumber: text("serialNumber"), + fingerprint: text("fingerprint"), + notBefore: integer("notBefore"), + notAfter: integer("notAfter"), + createdAt: integer("createdAt").notNull() + } +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -2190,3 +2241,9 @@ export type SiteResourceAiProvider = InferSelectModel< export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; export type Certificate = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; +export type ResourcePolicyMtlsCertificate = InferSelectModel< + typeof resourcePolicyMtlsCertificates +>; diff --git a/server/lib/blueprints/exportResourceBlueprint.ts b/server/lib/blueprints/exportResourceBlueprint.ts index ed5b02bd0..81119bd82 100644 --- a/server/lib/blueprints/exportResourceBlueprint.ts +++ b/server/lib/blueprints/exportResourceBlueprint.ts @@ -31,6 +31,7 @@ import { users } from "@server/db"; import { queryResourcePolicy } from "@server/routers/policy/getResourcePolicy"; +import { getMtlsCertificatePems, type MtlsOwner } from "@server/lib/mtls"; import { listPublicResourceAiProviders, listSiteResourceAiProviders @@ -106,12 +107,24 @@ function buildHealthcheck(row: { }); } +async function buildMtlsConfig(owner: MtlsOwner, enabled: boolean) { + const caCertificates = await getMtlsCertificatePems(owner); + if (!enabled && caCertificates.length === 0) { + return undefined; + } + return stripUndefined({ + enabled, + "ca-certificates": caCertificates.length ? caCertificates : undefined + }); +} + async function buildPublicAuthAndRules(resource: { resourceId: number; resourcePolicyId: number | null; defaultResourcePolicyId: number | null; sso: boolean | null; skipToIdpId: number | null; + mtlsEnabled: boolean; }): Promise<{ policy: string | undefined; auth: Record | undefined; @@ -204,7 +217,11 @@ async function buildPublicAuthAndRules(resource: { "whitelist-users": whitelistRows.length ? whitelistRows.map((w) => w.email) : undefined, - "auto-login-idp": resource.skipToIdpId ?? undefined + "auto-login-idp": resource.skipToIdpId ?? undefined, + mtls: await buildMtlsConfig( + { type: "resource", resourceId: resource.resourceId }, + resource.mtlsEnabled + ) }); return { @@ -264,7 +281,11 @@ async function buildPublicAuthAndRules(resource: { "whitelist-users": policy.emailWhiteList.length ? policy.emailWhiteList.map((w) => w.email) : undefined, - "auto-login-idp": policy.idpId ?? undefined + "auto-login-idp": policy.idpId ?? undefined, + mtls: await buildMtlsConfig( + { type: "policy", resourcePolicyId: policy.resourcePolicyId }, + policy.mtlsEnabled + ) }); return { diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 76bf7fa82..d25433f16 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -10,7 +10,6 @@ import { Resource, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, - resourceMtlsCertificates, resourcePassword, resourcePincode, resourcePolicies, @@ -56,17 +55,19 @@ import { isValidUrlGlobPattern, parseHttpMethodList } from "../validators"; -import { Config, isTargetsOnlyResource, TargetData } from "./types"; +import { + Config, + getBlueprintResourceMtls, + isTargetsOnlyResource, + TargetData +} from "./types"; import { getOrCreateLabelIds, syncResourceLabels } from "./labels"; import { findOrgUsersByIdentifier } from "./findOrgUser"; import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; import { syncInferenceAiConfig } from "./aiProviders"; import { syncAiBudgets } from "./aiBudgets"; -import { - MAX_MTLS_CERTIFICATES_PER_RESOURCE, - parseCaCertificates -} from "../mtlsCertificate"; +import { clearMtls, syncMtlsCertificates } from "../mtls"; export type PublicResourcesResults = { proxyResource: Resource; @@ -408,6 +409,9 @@ export async function updatePublicResources( resourceData.auth?.["auto-login-idp"] || null, ssl: resourceSsl, setHostHeader: resourceData["host-header"] || null, + forwardIdentityHeaders: + resourceData["forward-identity-headers"] ?? + true, tlsServerName: resourceData["tls-server-name"] || null, loadBalancingStrategy: @@ -439,7 +443,8 @@ export async function updatePublicResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], - mtlsEnabled: resourceData.mtls?.enabled, + mtlsEnabled: + getBlueprintResourceMtls(resourceData)?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -576,6 +581,21 @@ export async function updatePublicResources( trx ); } + + const mtlsCaCertificates = + getBlueprintResourceMtls(resourceData)?.[ + "ca-certificates" + ]; + if (mtlsCaCertificates) { + await syncMtlsCertificates( + { + type: "resource", + resourceId: existingResource.resourceId + }, + mtlsCaCertificates, + trx + ); + } } else { // INLINE POLICY MODE: ensure inline policy exists const inlinePolicyId = await ensureInlinePolicy( @@ -615,6 +635,9 @@ export async function updatePublicResources( enabled: resourceEnabled, ssl: resourceSsl, setHostHeader: resourceData["host-header"] || null, + forwardIdentityHeaders: + resourceData["forward-identity-headers"] ?? + true, tlsServerName: resourceData["tls-server-name"] || null, loadBalancingStrategy: @@ -630,7 +653,6 @@ export async function updatePublicResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], - mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -700,7 +722,14 @@ export async function updatePublicResources( resourceWhitelist.resourceId, existingResource.resourceId ) - ) + ), + clearMtls( + { + type: "resource", + resourceId: existingResource.resourceId + }, + trx + ) ]); // Update inline policy auth fields and policy-level tables @@ -735,14 +764,6 @@ export async function updatePublicResources( resourceId: existingResource.resourceId, budgets: resourceData["ai-budget"] || [] }); - - if (resourceData.mtls?.["ca-certificates"]) { - await syncResourceMtlsCertificates( - existingResource.resourceId, - resourceData.mtls["ca-certificates"], - trx - ); - } } const existingResourceTargets = await trx @@ -1251,6 +1272,8 @@ export async function updatePublicResources( wildcard: domain ? domain.wildcard : false, enabled: resourceEnabled, setHostHeader: resourceData["host-header"] || null, + forwardIdentityHeaders: + resourceData["forward-identity-headers"] ?? true, tlsServerName: resourceData["tls-server-name"] || null, loadBalancingStrategy: resourceData["load-balancing-strategy"] ?? "wrr", @@ -1269,7 +1292,6 @@ export async function updatePublicResources( maintenanceMessage: resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], - mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -1294,7 +1316,10 @@ export async function updatePublicResources( : false, applyRules: resourceData.rules && - resourceData.rules.length > 0 + resourceData.rules.length > 0, + mtlsEnabled: + getBlueprintResourceMtls(resourceData) + ?.enabled } : {}) }) @@ -1324,14 +1349,6 @@ export async function updatePublicResources( budgets: resourceData["ai-budget"] || [] }); - if (resourceData.mtls?.["ca-certificates"]) { - await syncResourceMtlsCertificates( - newResource.resourceId, - resourceData.mtls["ca-certificates"], - trx - ); - } - await trx.insert(roleResources).values({ roleId: adminRole.roleId, resourceId: newResource.resourceId @@ -1420,6 +1437,19 @@ export async function updatePublicResources( ); } + const mtlsCaCertificates = + getBlueprintResourceMtls(resourceData)?.["ca-certificates"]; + if (mtlsCaCertificates) { + await syncMtlsCertificates( + { + type: "resource", + resourceId: newResource.resourceId + }, + mtlsCaCertificates, + trx + ); + } + // Rules into OLD resourceRules table for (const [index, rule] of resourceData.rules?.entries() || []) { @@ -1675,66 +1705,6 @@ async function syncUserResources( } } -async function syncResourceMtlsCertificates( - resourceId: number, - caCertificates: string[], - trx: Transaction -) { - // A PEM entry may itself be a bundle; flatten and dedupe by fingerprint. - const desired = new Map< - string, - ReturnType[0] - >(); - 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( - `A resource can have at most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates` - ); - } - - const existing = await trx - .select() - .from(resourceMtlsCertificates) - .where(eq(resourceMtlsCertificates.resourceId, resourceId)); - - const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); - - for (const cert of desired.values()) { - if (existingFingerprints.has(cert.fingerprint)) { - continue; - } - await trx.insert(resourceMtlsCertificates).values({ - resourceId, - certificate: cert.certificate, - subject: cert.subject, - issuer: cert.issuer, - serialNumber: cert.serialNumber, - fingerprint: cert.fingerprint, - notBefore: cert.notBefore, - notAfter: cert.notAfter, - createdAt: Date.now() - }); - } - - for (const row of existing) { - if (!row.fingerprint || !desired.has(row.fingerprint)) { - await trx - .delete(resourceMtlsCertificates) - .where( - eq( - resourceMtlsCertificates.mtlsCertificateId, - row.mtlsCertificateId - ) - ); - } - } -} - async function syncWhitelistUsers( resourceId: number, whitelistUsers: string[], @@ -1821,7 +1791,9 @@ async function syncInlinePolicyAuth( resourceData: any, trx: Transaction ) { - // Update policy-level SSO/whitelist/applyRules fields + const mtls = getBlueprintResourceMtls(resourceData); + + // Update policy-level SSO/whitelist/applyRules/mTLS fields await trx .update(resourcePolicies) .set({ @@ -1830,10 +1802,20 @@ async function syncInlinePolicyAuth( emailWhitelistEnabled: resourceData.auth?.["whitelist-users"] ? resourceData.auth["whitelist-users"].length > 0 : false, - applyRules: !!(resourceData.rules && resourceData.rules.length > 0) + applyRules: !!(resourceData.rules && resourceData.rules.length > 0), + mtlsEnabled: mtls?.enabled }) .where(eq(resourcePolicies.resourcePolicyId, policyId)); + // mTLS trusted CAs → resourcePolicyMtlsCertificates + if (mtls?.["ca-certificates"]) { + await syncMtlsCertificates( + { type: "policy", resourcePolicyId: policyId }, + mtls["ca-certificates"], + trx + ); + } + // Password await trx .delete(resourcePolicyPassword) diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts index babb1c10a..0158b3818 100644 --- a/server/lib/blueprints/resourcePolicies.ts +++ b/server/lib/blueprints/resourcePolicies.ts @@ -14,7 +14,7 @@ import { users } from "@server/db"; import { eq, and } from "drizzle-orm"; -import { Config, ResourcePolicyData } from "./types"; +import { BlueprintMtls, Config, ResourcePolicyData } from "./types"; import logger from "@server/logger"; import { getUniqueResourcePolicyName } from "@server/db/names"; import { hashPassword } from "@server/auth/password"; @@ -29,6 +29,7 @@ import { import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; import { findOrgUsersByIdentifier } from "./findOrgUser"; +import { syncMtlsCertificates } from "../mtls"; export type ResourcePoliciesResults = { resourcePolicyId: number; @@ -97,6 +98,8 @@ export async function updateResourcePolicies( } } + const mtls = policyData.mtls as BlueprintMtls | undefined; + // Look up the admin role const [adminRole] = await trx .select() @@ -134,7 +137,9 @@ export async function updateResourcePolicies( policyData["email-whitelist-enabled"] ?? policyData["whitelist-users"].length > 0, applyRules: - policyData["apply-rules"] || policyData.rules.length > 0 + policyData["apply-rules"] || + policyData.rules.length > 0, + mtlsEnabled: mtls?.enabled }) .where( eq( @@ -247,6 +252,7 @@ export async function updateResourcePolicies( applyRules: policyData["apply-rules"] || policyData.rules.length > 0, + mtlsEnabled: mtls?.enabled ?? false, scope: "global" }) .returning(); @@ -340,6 +346,14 @@ export async function updateResourcePolicies( ); } + if (mtls?.["ca-certificates"]) { + await syncMtlsCertificates( + { type: "policy", resourcePolicyId }, + mtls["ca-certificates"], + trx + ); + } + results.push({ resourcePolicyId, niceId: policyNiceId }); } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 94b9d380b..8a6ba1088 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -123,9 +123,28 @@ export const AuthSchema = z.object({ }), "sso-users": z.array(z.string()).optional().default([]), "whitelist-users": z.array(z.email()).optional().default([]), - "auto-login-idp": z.int().positive().optional() + "auto-login-idp": z.int().positive().optional(), + mtls: MtlsSchema.optional() }); +// The shape MtlsSchema has where mTLS is supported; the open-source schema is +// empty, so callers read the fields through this type. +export type BlueprintMtls = { + enabled?: boolean; + "ca-certificates"?: string[]; +}; + +/** + * A resource's mTLS settings: under 'auth' like the other auth methods, or + * the deprecated top-level 'mtls'. + */ +export function getBlueprintResourceMtls(resource: { + auth?: { mtls?: unknown }; + mtls?: unknown; +}): BlueprintMtls | undefined { + return (resource.auth?.mtls ?? resource.mtls) as BlueprintMtls | undefined; +} + export const RuleSchema = z .object({ action: z.enum(["allow", "deny", "pass"]), @@ -326,6 +345,7 @@ export const PublicResourceSchema = z targets: z.array(TargetSchema.nullable()).optional().default([]), auth: AuthSchema.optional(), "host-header": z.string().optional(), + "forward-identity-headers": z.boolean().optional(), "tls-server-name": z.string().optional(), "load-balancing-strategy": z .enum(["wrr", "p2c", "hrw", "leasttime"]) @@ -335,7 +355,7 @@ export const PublicResourceSchema = z responseHeaders: z.array(HeaderSchema).optional(), rules: z.array(RuleSchema).optional(), maintenance: MaintenanceSchema.optional(), - mtls: MtlsSchema.optional(), + mtls: MtlsSchema.optional(), // deprecated alias for auth.mtls "auth-daemon": AuthDaemonSchema.optional(), "proxy-protocol": z.boolean().optional(), "proxy-protocol-version": z.int().min(1).optional(), @@ -452,43 +472,51 @@ export const PublicResourceSchema = z ) .refine( (resource) => { - if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + if ( + isTargetsOnlyResource(resource) || + !getBlueprintResourceMtls(resource)?.enabled + ) { return true; } return (resource.mode ?? resource.protocol) === "http"; }, { - path: ["mtls"], + path: ["auth", "mtls"], error: "mTLS is only supported when mode is 'http'" } ) .refine( (resource) => { - if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + if ( + isTargetsOnlyResource(resource) || + !getBlueprintResourceMtls(resource)?.enabled + ) { return true; } return resource.ssl !== false; }, { - path: ["mtls"], + path: ["auth", "mtls"], error: "mTLS requires 'ssl' to be enabled" } ) .refine( (resource) => { - if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + if ( + isTargetsOnlyResource(resource) || + !getBlueprintResourceMtls(resource)?.enabled + ) { return true; } - const caCertificates = ( - resource.mtls as { "ca-certificates"?: string[] } - )["ca-certificates"]; + const caCertificates = + getBlueprintResourceMtls(resource)?.["ca-certificates"]; return (caCertificates?.length ?? 0) > 0; }, { - path: ["mtls", "ca-certificates"], + path: ["auth", "mtls", "ca-certificates"], error: "mTLS requires at least one entry in 'ca-certificates'" } ) @@ -828,47 +856,62 @@ export const PrivateResourceSchema = z export const ResourcePolicyRuleSchema = RuleSchema; -export const ResourcePolicySchema = z.object({ - name: z.string().min(1).max(255), - sso: z.boolean().optional().default(true), - "auto-login-idp": z.int().positive().optional().nullable(), - "sso-roles": z - .array(z.string()) - .optional() - .default([]) - .refine((roles) => !roles.includes("Admin"), { - error: "Admin role cannot be included in sso-roles" - }), - "sso-users": z.array(z.string()).optional().default([]), - password: z.string().min(4).max(100).optional().nullable(), - pincode: z - .string() - .regex(/^\d{6}$/) - .optional() - .nullable(), - "basic-auth": z - .object({ - user: z.string().min(4).max(100), - password: z.string().min(4).max(100), - "extended-compatibility": z.boolean().default(true) - }) - .optional() - .nullable(), - "email-whitelist-enabled": z.boolean().optional().default(false), - "whitelist-users": z - .array( - z.email().or( - z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { - error: "Invalid email address. Wildcard (*) must be the entire local part." - }) +export const ResourcePolicySchema = z + .object({ + name: z.string().min(1).max(255), + sso: z.boolean().optional().default(true), + "auto-login-idp": z.int().positive().optional().nullable(), + "sso-roles": z + .array(z.string()) + .optional() + .default([]) + .refine((roles) => !roles.includes("Admin"), { + error: "Admin role cannot be included in sso-roles" + }), + "sso-users": z.array(z.string()).optional().default([]), + password: z.string().min(4).max(100).optional().nullable(), + pincode: z + .string() + .regex(/^\d{6}$/) + .optional() + .nullable(), + "basic-auth": z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + "extended-compatibility": z.boolean().default(true) + }) + .optional() + .nullable(), + "email-whitelist-enabled": z.boolean().optional().default(false), + "whitelist-users": z + .array( + z.email().or( + z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { + error: "Invalid email address. Wildcard (*) must be the entire local part." + }) + ) ) - ) - .transform((v) => v.map((e) => e.toLowerCase())) - .optional() - .default([]), - "apply-rules": z.boolean().optional().default(false), - rules: z.array(ResourcePolicyRuleSchema).optional().default([]) -}); + .transform((v) => v.map((e) => e.toLowerCase())) + .optional() + .default([]), + "apply-rules": z.boolean().optional().default(false), + rules: z.array(ResourcePolicyRuleSchema).optional().default([]), + mtls: MtlsSchema.optional() + }) + .refine( + (policy) => { + const mtls = policy.mtls as BlueprintMtls | undefined; + if (!mtls?.enabled) { + return true; + } + return (mtls["ca-certificates"]?.length ?? 0) > 0; + }, + { + path: ["mtls", "ca-certificates"], + error: "mTLS requires at least one entry in 'ca-certificates'" + } + ); export type ResourcePolicyData = z.infer; // Schema for the entire configuration object diff --git a/server/lib/mtls.ts b/server/lib/mtls.ts new file mode 100644 index 000000000..dfb19c14f --- /dev/null +++ b/server/lib/mtls.ts @@ -0,0 +1,428 @@ +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); +} diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 980837cf3..8721982a4 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -48,6 +48,7 @@ import { buildTcpUdpLoadBalancerServers } from "./loadBalancer"; import { applyPathRewriteMiddleware } from "./middleware"; +import { applyMtlsConfig, getMtlsCaCertificatesByResource } from "./mtls"; import { buildRedirectConfig, RedirectRouteRow } from "./redirect"; import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule"; import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias"; @@ -266,6 +267,13 @@ export async function getTraefikConfig( }); }); + // Trusted client-CA certificates for resources that require mTLS. + const mtlsCaCertsByResource = await getMtlsCaCertificatesByResource( + [...resourcesMap.values()] + .filter((r) => r.mode === "http" && r.ssl) + .map((r) => r.resourceId as number) + ); + // Group browser gateway targets by resource (SSH/VNC/RDP-mode resources // served through the browser gateway web UI instead of a real target). const browserGatewayResourcesMap = browserGatewayUiUrl @@ -465,7 +473,7 @@ export async function getTraefikConfig( config_output.http.services = {}; } - const tls = buildWildcardTls({ + let tls: Record = buildWildcardTls({ fullDomain, hasSubdomain: !!resource.subdomain, domainCertResolver: resource.domainCertResolver, @@ -508,6 +516,18 @@ export async function getTraefikConfig( routerMiddlewares.push(headersMiddlewareName); } + const mtlsOptionsName = resource.ssl + ? applyMtlsConfig( + config_output, + resource.resourceId, + mtlsCaCertsByResource.get(resource.resourceId), + routerMiddlewares + ) + : undefined; + if (mtlsOptionsName) { + tls = { ...tls, options: mtlsOptionsName }; + } + // Build routing rules let rule = buildHostRule(fullDomain); const priority = computeRoutePriority( diff --git a/server/lib/traefik/mtls.ts b/server/lib/traefik/mtls.ts new file mode 100644 index 000000000..e1415f479 --- /dev/null +++ b/server/lib/traefik/mtls.ts @@ -0,0 +1,192 @@ +import { + alias, + db, + resourceMtlsCertificates, + resourcePolicies, + resourcePolicyMtlsCertificates, + resources +} from "@server/db"; +import { eq, inArray } from "drizzle-orm"; + +/** + * Trusted client-CA PEMs for each resource that requires mTLS, keyed by + * resourceId. Resolved like the other auth methods in getResourceByDomain: + * the assigned shared policy wins, otherwise the resource's default (inline) + * policy; when that policy has mTLS off, the resource-level setting applies. + * Resources whose effective mTLS has no CA certificates are left out. + */ +export async function getMtlsCaCertificatesByResource( + resourceIds: number[] +): Promise> { + const byResource = new Map(); + const uniqueResourceIds = [...new Set(resourceIds)]; + if (uniqueResourceIds.length === 0) { + return byResource; + } + + const sharedPolicy = alias(resourcePolicies, "sharedPolicy"); + const defaultPolicy = alias(resourcePolicies, "defaultPolicy"); + + const rows = await db + .select({ + resourceId: resources.resourceId, + resourceMtlsEnabled: resources.mtlsEnabled, + sharedPolicyId: sharedPolicy.resourcePolicyId, + sharedPolicyMtlsEnabled: sharedPolicy.mtlsEnabled, + defaultPolicyId: defaultPolicy.resourcePolicyId, + defaultPolicyMtlsEnabled: defaultPolicy.mtlsEnabled + }) + .from(resources) + .leftJoin( + sharedPolicy, + eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId) + ) + .leftJoin( + defaultPolicy, + eq( + defaultPolicy.resourcePolicyId, + resources.defaultResourcePolicyId + ) + ) + .where(inArray(resources.resourceId, uniqueResourceIds)); + + const resourcesByPolicy = new Map(); + const resourceLevelIds: number[] = []; + + for (const row of rows) { + // If a shared policy is assigned, use only its values and do not + // fall back to the default policy. + const hasSharedPolicy = row.sharedPolicyId !== null; + const policyId = hasSharedPolicy + ? row.sharedPolicyId + : row.defaultPolicyId; + const policyMtlsEnabled = hasSharedPolicy + ? row.sharedPolicyMtlsEnabled + : row.defaultPolicyMtlsEnabled; + + if (policyId !== null && policyMtlsEnabled) { + const list = resourcesByPolicy.get(policyId) ?? []; + list.push(row.resourceId); + resourcesByPolicy.set(policyId, list); + } else if (row.resourceMtlsEnabled) { + resourceLevelIds.push(row.resourceId); + } + } + + const [policyCerts, resourceCerts] = await Promise.all([ + resourcesByPolicy.size > 0 + ? db + .select({ + resourcePolicyId: + resourcePolicyMtlsCertificates.resourcePolicyId, + certificate: resourcePolicyMtlsCertificates.certificate + }) + .from(resourcePolicyMtlsCertificates) + .where( + inArray(resourcePolicyMtlsCertificates.resourcePolicyId, [ + ...resourcesByPolicy.keys() + ]) + ) + .orderBy(resourcePolicyMtlsCertificates.mtlsCertificateId) + : [], + resourceLevelIds.length > 0 + ? db + .select({ + resourceId: resourceMtlsCertificates.resourceId, + certificate: resourceMtlsCertificates.certificate + }) + .from(resourceMtlsCertificates) + .where( + inArray( + resourceMtlsCertificates.resourceId, + resourceLevelIds + ) + ) + .orderBy(resourceMtlsCertificates.mtlsCertificateId) + : [] + ]); + + const append = (resourceId: number, certificate: string) => { + const list = byResource.get(resourceId) ?? []; + list.push(certificate); + byResource.set(resourceId, list); + }; + + for (const row of policyCerts) { + for (const resourceId of resourcesByPolicy.get(row.resourcePolicyId) ?? + []) { + append(resourceId, row.certificate); + } + } + for (const row of resourceCerts) { + append(row.resourceId, row.certificate); + } + + return byResource; +} + +/** + * Adds the TLS options and client-cert headers middleware that enforce mTLS + * for a resource and returns the TLS options name for its routers, or + * undefined when the resource does not require mTLS. + * + * Traefik resolves TLS options per hostname, and routers on the same host + * that reference different options make it fall back to the defaults, so the + * options are keyed on the resource (not the per-path router key) and every + * TLS router of the resource must reference the same name. + */ +export function applyMtlsConfig( + config_output: any, + resourceId: number, + caCerts: string[] | undefined, + routerMiddlewares: string[] +): string | undefined { + if (!caCerts || caCerts.length === 0) { + return undefined; + } + + const mtlsOptionsName = `resource-${resourceId}-mtls`; + const mtlsHeadersMiddlewareName = `resource-${resourceId}-mtls-headers`; + + if (!config_output.tls) { + config_output.tls = {}; + } + if (!config_output.tls.options) { + config_output.tls.options = {}; + } + // caFiles entries are Traefik FileOrContent values: anything that isn't + // an existing file path is read as inline PEM, so the CA certs travel in + // this config to any exit node. + config_output.tls.options[mtlsOptionsName] = { + clientAuth: { + caFiles: caCerts, + clientAuthType: "RequireAndVerifyClientCert" + } + }; + + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + config_output.http.middlewares[mtlsHeadersMiddlewareName] = { + passTLSClientCert: { + pem: false, + info: { + notAfter: true, + notBefore: true, + serialNumber: true, + sans: true, + subject: { + commonName: true, + organization: true + }, + issuer: { + commonName: true, + organization: true + } + } + } + }; + routerMiddlewares.unshift(mtlsHeadersMiddlewareName); + + return mtlsOptionsName; +} diff --git a/server/lib/traefik/types.ts b/server/lib/traefik/types.ts index cec77aad6..07f3e4d5c 100644 --- a/server/lib/traefik/types.ts +++ b/server/lib/traefik/types.ts @@ -34,7 +34,6 @@ export type ResourceWithTargets = Pick< | "enabled" | "stickySession" | "loadBalancingStrategy" - | "mtlsEnabled" | "tlsServerName" | "setHostHeader" | "enableProxy" diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 9e57c9295..af6b8de59 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -22,7 +22,6 @@ import { domains, exitNodes, loginPage, - resourceMtlsCertificates, redirects, resources, siteNetworks, @@ -53,6 +52,10 @@ import { } from "@server/lib/traefik/browserGateway"; import { buildWildcardTls } from "@server/lib/traefik/certResolver"; import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware"; +import { + applyMtlsConfig, + getMtlsCaCertificatesByResource +} from "@server/lib/traefik/mtls"; import { buildHttpLoadBalancerServers, buildLoadBalancingStrategy, @@ -151,8 +154,6 @@ export async function getTraefikConfig( maintenanceMessage: resources.maintenanceMessage, maintenanceEstimatedTime: resources.maintenanceEstimatedTime, - mtlsEnabled: resources.mtlsEnabled, - // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -301,9 +302,7 @@ export async function getTraefikConfig( maintenanceModeType: row.maintenanceModeType, maintenanceTitle: row.maintenanceTitle, maintenanceMessage: row.maintenanceMessage, - maintenanceEstimatedTime: row.maintenanceEstimatedTime, - - mtlsEnabled: row.mtlsEnabled + maintenanceEstimatedTime: row.maintenanceEstimatedTime }); } @@ -328,31 +327,11 @@ export async function getTraefikConfig( } // Trusted client-CA certificates for resources that require mTLS. - const mtlsResourceIds = [ - ...new Set( - [...resourcesMap.values()] - .filter((r) => r.mode === "http" && r.mtlsEnabled) - .map((r) => r.resourceId as number) - ) - ]; - const mtlsCaCertsByResource = new Map(); - if (mtlsResourceIds.length > 0) { - const caRows = await db - .select({ - resourceId: resourceMtlsCertificates.resourceId, - certificate: resourceMtlsCertificates.certificate - }) - .from(resourceMtlsCertificates) - .where( - inArray(resourceMtlsCertificates.resourceId, mtlsResourceIds) - ) - .orderBy(resourceMtlsCertificates.mtlsCertificateId); - for (const row of caRows) { - const list = mtlsCaCertsByResource.get(row.resourceId) ?? []; - list.push(row.certificate); - mtlsCaCertsByResource.set(row.resourceId, list); - } - } + const mtlsCaCertsByResource = await getMtlsCaCertificatesByResource( + [...resourcesMap.values()] + .filter((r) => r.mode === "http" && r.ssl) + .map((r) => r.resourceId) + ); // Group browser gateway targets by resource const browserGatewayResourcesMap = browserGatewayUiUrl @@ -685,56 +664,17 @@ export async function getTraefikConfig( } } - // mTLS: Traefik resolves TLS options per hostname, and routers on - // the same host that reference different options make it fall back - // to the defaults, so the options are keyed on the resource (not - // the per-path router key) and every TLS router below (including - // the maintenance ones) references the same name. - const mtlsCaCerts = - resource.mtlsEnabled && resource.ssl - ? mtlsCaCertsByResource.get(resource.resourceId) - : undefined; - if (mtlsCaCerts && mtlsCaCerts.length > 0) { - const mtlsOptionsName = `resource-${resource.resourceId}-mtls`; - const mtlsHeadersMiddlewareName = `resource-${resource.resourceId}-mtls-headers`; - - if (!config_output.tls) { - config_output.tls = {}; - } - if (!config_output.tls.options) { - config_output.tls.options = {}; - } - // caFiles entries are Traefik FileOrContent values: anything - // that isn't an existing file path is read as inline PEM, so - // the CA certs travel in this config to any exit node. - config_output.tls.options[mtlsOptionsName] = { - clientAuth: { - caFiles: mtlsCaCerts, - clientAuthType: "RequireAndVerifyClientCert" - } - }; - - config_output.http.middlewares[mtlsHeadersMiddlewareName] = { - passTLSClientCert: { - pem: false, - info: { - notAfter: true, - notBefore: true, - serialNumber: true, - sans: true, - subject: { - commonName: true, - organization: true - }, - issuer: { - commonName: true, - organization: true - } - } - } - }; - routerMiddlewares.unshift(mtlsHeadersMiddlewareName); - + const mtlsOptionsName = resource.ssl + ? applyMtlsConfig( + config_output, + resource.resourceId, + mtlsCaCertsByResource.get(resource.resourceId), + routerMiddlewares + ) + : undefined; + if (mtlsOptionsName) { + // every TLS router below (including the maintenance ones) + // must reference the same options tls = { ...tls, options: mtlsOptionsName }; } diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index a7bf59bf0..84ee0eba0 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -24,6 +24,7 @@ import { loginPageBrandingOrg, loginPageOrg, orgs, + resourcePolicies, resources, roles, siteResources, @@ -524,7 +525,16 @@ async function disableMtls(orgId: string): Promise { }) .where(eq(resources.orgId, orgId)); - logger.info(`Disabled mTLS on all resources for org ${orgId}`); + await db + .update(resourcePolicies) + .set({ + mtlsEnabled: false + }) + .where(eq(resourcePolicies.orgId, orgId)); + + logger.info( + `Disabled mTLS on all resources and resource policies for org ${orgId}` + ); } async function disableDevicePosture(orgId: string): Promise {} diff --git a/server/routers/external.ts b/server/routers/external.ts index 7640e98da..078863e6c 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1078,6 +1078,28 @@ authenticated.put( policy.setResourcePolicyRules ); +authenticated.put( + "/resource-policy/:resourcePolicyId/mtls/certificate", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.updateResourcePolicy), + logActionAudit(ActionsEnum.updateResourcePolicy), + policy.createResourcePolicyMtlsCertificate +); +authenticated.get( + "/resource-policy/:resourcePolicyId/mtls/certificates", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicy), + policy.listResourcePolicyMtlsCertificates +); +authenticated.delete( + "/resource-policy/:resourcePolicyId/mtls/certificate/:mtlsCertificateId", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.updateResourcePolicy), + logActionAudit(ActionsEnum.updateResourcePolicy), + policy.deleteResourcePolicyMtlsCertificate +); + authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 424bbd213..bab8e412e 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1031,6 +1031,39 @@ authenticated.put( policy.setResourcePolicyRules ); +authenticated.put( + [ + "/resource-policy/:resourcePolicyId/mtls/certificate", + "/public-resource-policy/:resourcePolicyId/mtls/certificate" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy), + logActionAudit(ActionsEnum.updateResourcePolicy), + policy.createResourcePolicyMtlsCertificate +); + +authenticated.get( + [ + "/resource-policy/:resourcePolicyId/mtls/certificates", + "/public-resource-policy/:resourcePolicyId/mtls/certificates" + ], + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), + policy.listResourcePolicyMtlsCertificates +); + +authenticated.delete( + [ + "/resource-policy/:resourcePolicyId/mtls/certificate/:mtlsCertificateId", + "/public-resource-policy/:resourcePolicyId/mtls/certificate/:mtlsCertificateId" + ], + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy), + logActionAudit(ActionsEnum.updateResourcePolicy), + policy.deleteResourcePolicyMtlsCertificate +); + authenticated.post( [ "/resource/:resourceId/roles/add", diff --git a/server/routers/policy/createResourcePolicyMtlsCertificate.ts b/server/routers/policy/createResourcePolicyMtlsCertificate.ts new file mode 100644 index 000000000..b2b274c15 --- /dev/null +++ b/server/routers/policy/createResourcePolicyMtlsCertificate.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourcePolicies } 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 { + addMtlsCertificates, + type MtlsCertificateInfo +} from "@server/lib/mtls"; + +const createResourcePolicyMtlsCertificateParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const createResourcePolicyMtlsCertificateBodySchema = z.strictObject({ + name: z.string().min(1).max(255).optional(), + certificate: z.string().min(1).max(100_000) +}); + +export type CreateResourcePolicyMtlsCertificateResponse = { + certificates: MtlsCertificateInfo[]; +}; + +const description = + "Add a trusted CA certificate that clients must present a certificate signed by when mTLS is enabled on a resource policy. Accepts a PEM bundle; each certificate in the bundle is stored separately."; + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/mtls/certificate", + description, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: createResourcePolicyMtlsCertificateParamsSchema, + body: { + content: { + "application/json": { + schema: createResourcePolicyMtlsCertificateBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/mtls/certificate", + description, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: createResourcePolicyMtlsCertificateParamsSchema, + body: { + content: { + "application/json": { + schema: createResourcePolicyMtlsCertificateBodySchema + } + } + } + }, + responses: {} +}); + +export async function createResourcePolicyMtlsCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + createResourcePolicyMtlsCertificateParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = + createResourcePolicyMtlsCertificateBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + const { name, certificate } = parsedBody.data; + + const [policy] = await db + .select({ resourcePolicyId: resourcePolicies.resourcePolicyId }) + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + const certificates = await addMtlsCertificates( + { type: "policy", resourcePolicyId }, + certificate, + name + ); + + return response(res, { + data: { certificates }, + success: true, + error: false, + message: "CA certificate added successfully", + status: HttpCode.CREATED + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/deleteResourcePolicyMtlsCertificate.ts b/server/routers/policy/deleteResourcePolicyMtlsCertificate.ts new file mode 100644 index 000000000..dbbc184be --- /dev/null +++ b/server/routers/policy/deleteResourcePolicyMtlsCertificate.ts @@ -0,0 +1,93 @@ +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 { removeMtlsCertificate } from "@server/lib/mtls"; + +const deleteResourcePolicyMtlsCertificateParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()), + mtlsCertificateId: z.coerce.number().int().positive() +}); + +export type DeleteResourcePolicyMtlsCertificateResponse = { + // true when removing the last CA certificate forced mTLS off + mtlsDisabled: boolean; +}; + +const description = + "Remove a trusted CA certificate from a resource policy. If it is the last one, mTLS is disabled on the policy since it can no longer verify any client."; + +registry.registerPath({ + method: "delete", + path: "/resource-policy/{resourcePolicyId}/mtls/certificate/{mtlsCertificateId}", + description, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: deleteResourcePolicyMtlsCertificateParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "delete", + path: "/public-resource-policy/{resourcePolicyId}/mtls/certificate/{mtlsCertificateId}", + description, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: deleteResourcePolicyMtlsCertificateParamsSchema + }, + responses: {} +}); + +export async function deleteResourcePolicyMtlsCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + deleteResourcePolicyMtlsCertificateParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { resourcePolicyId, mtlsCertificateId } = parsedParams.data; + + const result = await removeMtlsCertificate( + { type: "policy", resourcePolicyId }, + mtlsCertificateId + ); + + if (!result) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `CA certificate with ID ${mtlsCertificateId} not found` + ) + ); + } + + return response(res, { + data: result, + 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") + ); + } +} diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index fafa31d7d..e2103557c 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -61,6 +61,7 @@ export async function queryResourcePolicy( sso: resourcePolicies.sso, applyRules: resourcePolicies.applyRules, emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled, + mtlsEnabled: resourcePolicies.mtlsEnabled, idpId: resourcePolicies.idpId, niceId: resourcePolicies.niceId, name: resourcePolicies.name, diff --git a/server/routers/policy/index.ts b/server/routers/policy/index.ts index 2ebe6da7e..2549d2a69 100644 --- a/server/routers/policy/index.ts +++ b/server/routers/policy/index.ts @@ -6,3 +6,6 @@ export * from "./setResourcePolicyPincode"; export * from "./setResourcePolicyHeaderAuth"; export * from "./setResourcePolicyWhitelist"; export * from "./setResourcePolicyRules"; +export * from "./createResourcePolicyMtlsCertificate"; +export * from "./listResourcePolicyMtlsCertificates"; +export * from "./deleteResourcePolicyMtlsCertificate"; diff --git a/server/routers/policy/listResourcePolicyMtlsCertificates.ts b/server/routers/policy/listResourcePolicyMtlsCertificates.ts new file mode 100644 index 000000000..2cae495d9 --- /dev/null +++ b/server/routers/policy/listResourcePolicyMtlsCertificates.ts @@ -0,0 +1,99 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourcePolicies } 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 { + listMtlsCertificates, + type MtlsCertificateInfo +} from "@server/lib/mtls"; + +const listResourcePolicyMtlsCertificatesParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +export type ListResourcePolicyMtlsCertificatesResponse = { + certificates: MtlsCertificateInfo[]; +}; + +const description = + "List the trusted CA certificates for a resource policy's mTLS."; + +registry.registerPath({ + method: "get", + path: "/resource-policy/{resourcePolicyId}/mtls/certificates", + description, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: listResourcePolicyMtlsCertificatesParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/public-resource-policy/{resourcePolicyId}/mtls/certificates", + description, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: listResourcePolicyMtlsCertificatesParamsSchema + }, + responses: {} +}); + +export async function listResourcePolicyMtlsCertificates( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + listResourcePolicyMtlsCertificatesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { resourcePolicyId } = parsedParams.data; + + const [policy] = await db + .select({ resourcePolicyId: resourcePolicies.resourcePolicyId }) + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + const certificates = await listMtlsCertificates({ + type: "policy", + resourcePolicyId + }); + + return response(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") + ); + } +} diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts index f42f1bc74..9cdf3ca5e 100644 --- a/server/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -8,6 +8,7 @@ import { db, orgs, resourcePolicies, type ResourcePolicy } from "@server/db"; import { and, eq } from "drizzle-orm"; import logger from "@server/logger"; import response from "@server/lib/response"; +import { countMtlsCertificates } from "@server/lib/mtls"; const updateResourcePolicyParamsSchema = z.strictObject({ resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) @@ -15,7 +16,13 @@ const updateResourcePolicyParamsSchema = z.strictObject({ const updateResourcePolicyBodySchema = z.strictObject({ name: z.string().min(1).max(255).optional(), - niceId: z.string().min(1).max(255).optional() + niceId: z.string().min(1).max(255).optional(), + mtlsEnabled: z + .boolean() + .optional() + .describe( + "Require clients to present a certificate signed by one of the policy's trusted CA certificates. Requires at least one CA certificate, and only takes effect on HTTP resources with SSL enabled." + ) }); registry.registerPath({ @@ -171,6 +178,21 @@ export async function updateResourcePolicy( } } + if (updateData.mtlsEnabled === true) { + const certificateCount = await countMtlsCertificates({ + type: "policy", + resourcePolicyId: policy.resourcePolicyId + }); + if (certificateCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Add at least one trusted CA certificate before enabling mTLS." + ) + ); + } + } + const updatedPolicy = await db.transaction(async (trx) => { const [updated] = await trx .update(resourcePolicies) diff --git a/server/routers/resource/createResourceMtlsCertificate.ts b/server/routers/resource/createResourceMtlsCertificate.ts index 2f32ed89d..bdeff996a 100644 --- a/server/routers/resource/createResourceMtlsCertificate.ts +++ b/server/routers/resource/createResourceMtlsCertificate.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { db, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -8,10 +8,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { - MAX_MTLS_CERTIFICATES_PER_RESOURCE, - parseCaCertificates -} from "@server/lib/mtlsCertificate"; +import { addMtlsCertificates, getResourceMtlsOwner } from "@server/lib/mtls"; import { CreateResourceMtlsCertificateResponse } from "@server/routers/resource/types"; const createResourceMtlsCertificateParamsSchema = z.strictObject({ @@ -24,7 +21,7 @@ const createResourceMtlsCertificateBodySchema = z.strictObject({ }); 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."; + "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. When no shared resource policy is assigned, this updates the resource's inline policy."; registry.registerPath({ method: "put", @@ -118,92 +115,25 @@ export async function createResourceMtlsCertificate( ); } - 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 inserted = await addMtlsCertificates( + getResourceMtlsOwner(resource), + certificate, + name ); - 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(res, { - data: { certificates: inserted }, + data: { + certificates: inserted.map((c) => ({ ...c, resourceId })) + }, success: true, error: false, message: "CA certificate added successfully", status: HttpCode.CREATED }); } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") diff --git a/server/routers/resource/deleteResourceMtlsCertificate.ts b/server/routers/resource/deleteResourceMtlsCertificate.ts index f9ee3c2f6..758deba62 100644 --- a/server/routers/resource/deleteResourceMtlsCertificate.ts +++ b/server/routers/resource/deleteResourceMtlsCertificate.ts @@ -1,13 +1,14 @@ 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 { db, 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 { getResourceMtlsOwner, removeMtlsCertificate } from "@server/lib/mtls"; import { DeleteResourceMtlsCertificateResponse } from "@server/routers/resource/types"; const deleteResourceMtlsCertificateParamsSchema = z.strictObject({ @@ -16,7 +17,7 @@ const deleteResourceMtlsCertificateParamsSchema = z.strictObject({ }); 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."; + "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. When no shared resource policy is assigned, this updates the resource's inline policy."; registry.registerPath({ method: "delete", @@ -58,21 +59,31 @@ export async function deleteResourceMtlsCertificate( } const { resourceId, mtlsCertificateId } = parsedParams.data; - const [existing] = await db - .select() - .from(resourceMtlsCertificates) - .where( - and( - eq( - resourceMtlsCertificates.mtlsCertificateId, - mtlsCertificateId - ), - eq(resourceMtlsCertificates.resourceId, resourceId) - ) - ) + const [resource] = await db + .select({ + resourceId: resources.resourceId, + resourcePolicyId: resources.resourcePolicyId, + defaultResourcePolicyId: resources.defaultResourcePolicyId + }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) .limit(1); - if (!existing) { + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + const result = await removeMtlsCertificate( + getResourceMtlsOwner(resource), + mtlsCertificateId + ); + + if (!result) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -81,39 +92,7 @@ export async function deleteResourceMtlsCertificate( ); } - 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; - } - }); + const { mtlsDisabled } = result; return response(res, { data: { mtlsDisabled }, diff --git a/server/routers/resource/inlinePolicyFields.ts b/server/routers/resource/inlinePolicyFields.ts index 9c006beda..5282a8e9a 100644 --- a/server/routers/resource/inlinePolicyFields.ts +++ b/server/routers/resource/inlinePolicyFields.ts @@ -3,7 +3,8 @@ import type { Resource, ResourcePolicy } from "@server/db"; type InlinePolicyFields = Pick< ResourcePolicy, "sso" | "emailWhitelistEnabled" | "applyRules" | "idpId" ->; +> & + Partial>; export function applyInlinePolicyFields( resource: T, @@ -14,6 +15,7 @@ export function applyInlinePolicyFields( sso: policy?.sso ?? null, emailWhitelistEnabled: policy?.emailWhitelistEnabled ?? null, applyRules: policy?.applyRules ?? null, - skipToIdpId: policy?.idpId ?? null + skipToIdpId: policy?.idpId ?? null, + mtlsEnabled: policy?.mtlsEnabled ?? resource.mtlsEnabled }; } diff --git a/server/routers/resource/listResourceMtlsCertificates.ts b/server/routers/resource/listResourceMtlsCertificates.ts index 142d225b7..0df07bbb4 100644 --- a/server/routers/resource/listResourceMtlsCertificates.ts +++ b/server/routers/resource/listResourceMtlsCertificates.ts @@ -1,39 +1,22 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resourceMtlsCertificates, resources } from "@server/db"; -import { asc, eq } from "drizzle-orm"; +import { db, 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 { getResourceMtlsOwner, listMtlsCertificates } from "@server/lib/mtls"; import { ListResourceMtlsCertificatesResponse } from "@server/routers/resource/types"; 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)); -} - -const description = "List the trusted CA certificates for a resource's mTLS."; +const description = + "List the trusted CA certificates for a resource's mTLS. When no shared resource policy is assigned, these are the resource's inline policy certificates."; registry.registerPath({ method: "get", @@ -77,7 +60,11 @@ export async function listResourceMtlsCertificates( const { resourceId } = parsedParams.data; const [resource] = await db - .select({ resourceId: resources.resourceId }) + .select({ + resourceId: resources.resourceId, + resourcePolicyId: resources.resourcePolicyId, + defaultResourcePolicyId: resources.defaultResourcePolicyId + }) .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); @@ -91,7 +78,9 @@ export async function listResourceMtlsCertificates( ); } - const certificates = await queryResourceMtlsCertificates(resourceId); + const certificates = ( + await listMtlsCertificates(getResourceMtlsOwner(resource)) + ).map((c) => ({ ...c, resourceId })); return response(res, { data: { certificates }, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index a55b08ec4..0fa5fbf0b 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -6,7 +6,6 @@ import { loginPage, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, - resourceMtlsCertificates, resourcePassword, resourcePincode, resourceRules, @@ -25,7 +24,7 @@ import { resourcePolicies, resources } from "@server/db"; -import { eq, and, ne, count } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -49,6 +48,12 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { applyInlinePolicyFields } from "./inlinePolicyFields"; +import { + clearMtls, + countMtlsCertificates, + getResourceMtlsOwner, + isMtlsEnabled +} from "@server/lib/mtls"; const updateResourceParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() @@ -98,6 +103,12 @@ const updateHttpResourceBodySchema = z ), tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), + forwardIdentityHeaders: z + .boolean() + .optional() + .describe( + "Whether to forward the authenticated user's identity to the backend in the Remote-User, Remote-Email, Remote-Name, Remote-Role, and Remote-User-Id headers." + ), skipToIdpId: z .int() .positive() @@ -128,7 +139,7 @@ const updateHttpResourceBodySchema = 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." + "Require clients to present a certificate signed by one of the resource's trusted CA certificates. Requires SSL and at least one CA certificate. When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this applies to this resource only if the shared policy does not enable mTLS itself." ), postAuthPath: z.string().nullable().optional(), // SSH settings @@ -418,7 +429,7 @@ async function clearResourceSpecificSettings( if (adminRole.length === 0) { throw new Error(`Admin role not found for org ${orgId}`); } - // remove the resource specific pincode, password, header auth, rules, nad whitelist entries so that the resource will fall back to the policy settings + // remove the resource specific pincode, password, header auth, rules, whitelist entries and mTLS so that the resource will fall back to the policy settings await Promise.all([ trx .delete(resourcePassword) @@ -443,6 +454,7 @@ async function clearResourceSpecificSettings( trx .delete(resourceRules) .where(eq(resourceRules.resourceId, resourceId)), + clearMtls({ type: "resource", resourceId }, trx), // delete the roles and the users as well trx .delete(userResources) @@ -521,8 +533,10 @@ async function updateHttpResource( } } + const mtlsOwner = getResourceMtlsOwner(resource); const effectiveSsl = updateData.ssl ?? resource.ssl; - const effectiveMtlsEnabled = updateData.mtlsEnabled ?? resource.mtlsEnabled; + const effectiveMtlsEnabled = + updateData.mtlsEnabled ?? (await isMtlsEnabled(mtlsOwner)); if (updateData.mtlsEnabled === true) { if (resource.mode !== "http") { @@ -534,14 +548,7 @@ async function updateHttpResource( ); } - const [{ certificateCount }] = await db - .select({ certificateCount: count() }) - .from(resourceMtlsCertificates) - .where( - eq(resourceMtlsCertificates.resourceId, resource.resourceId) - ); - - if (certificateCount === 0) { + if ((await countMtlsCertificates(mtlsOwner)) === 0) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -804,6 +811,7 @@ async function updateHttpResource( emailWhitelistEnabled, applyRules, skipToIdpId, + mtlsEnabled, ...resourceOnlyDataRest } = updateData; @@ -821,6 +829,7 @@ async function updateHttpResource( policyUpdate.emailWhitelistEnabled = emailWhitelistEnabled; if (applyRules !== undefined) policyUpdate.applyRules = applyRules; if (skipToIdpId !== undefined) policyUpdate.idpId = skipToIdpId; + if (mtlsEnabled !== undefined) policyUpdate.mtlsEnabled = mtlsEnabled; if (Object.keys(policyUpdate).length > 0) { await db diff --git a/server/setup/scriptsPg/1.25.0.ts b/server/setup/scriptsPg/1.25.0.ts index 28c6e3563..14ec3d3a2 100644 --- a/server/setup/scriptsPg/1.25.0.ts +++ b/server/setup/scriptsPg/1.25.0.ts @@ -30,6 +30,21 @@ export default async function migration() { "createdAt" bigint NOT NULL ); `); + await db.execute(sql` + CREATE TABLE "resourcePolicyMtlsCertificates" ( + "mtlsCertificateId" serial PRIMARY KEY NOT NULL, + "resourcePolicyId" integer NOT NULL, + "name" varchar, + "certificate" text NOT NULL, + "subject" varchar, + "issuer" varchar, + "serialNumber" varchar, + "fingerprint" varchar, + "notBefore" bigint, + "notAfter" bigint, + "createdAt" bigint NOT NULL + ); + `); await db.execute(sql` CREATE TABLE "clientOrgRoles" ( "clientId" integer NOT NULL, @@ -67,6 +82,15 @@ export default async function migration() { await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "mtlsEnabled" boolean DEFAULT false NOT NULL;` ); + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "forwardIdentityHeaders" boolean DEFAULT true NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "resourcePolicies" ADD COLUMN "mtlsEnabled" boolean DEFAULT false NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "resourcePolicyMtlsCertificates" ADD CONSTRAINT "resourcePolicyMtlsCertificates_resourcePolicyId_resourcePolicies_resourcePolicyId_fk" FOREIGN KEY ("resourcePolicyId") REFERENCES "public"."resourcePolicies"("resourcePolicyId") ON DELETE cascade ON UPDATE no action;` + ); await db.execute( sql`ALTER TABLE "resourceMtlsCertificates" ADD CONSTRAINT "resourceMtlsCertificates_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;` ); diff --git a/server/setup/scriptsSqlite/1.25.0.ts b/server/setup/scriptsSqlite/1.25.0.ts index db6676554..f0279179a 100644 --- a/server/setup/scriptsSqlite/1.25.0.ts +++ b/server/setup/scriptsSqlite/1.25.0.ts @@ -36,6 +36,24 @@ export default async function migration() { ); ` ).run(); + db.prepare( + ` + CREATE TABLE 'resourcePolicyMtlsCertificates' ( + 'mtlsCertificateId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'resourcePolicyId' integer NOT NULL, + 'name' text, + 'certificate' text NOT NULL, + 'subject' text, + 'issuer' text, + 'serialNumber' text, + 'fingerprint' text, + 'notBefore' integer, + 'notAfter' integer, + 'createdAt' integer NOT NULL, + FOREIGN KEY ('resourcePolicyId') REFERENCES 'resourcePolicies'('resourcePolicyId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); db.prepare( ` CREATE TABLE 'clientOrgRoles' ( @@ -98,6 +116,16 @@ export default async function migration() { ALTER TABLE 'resources' ADD 'mtlsEnabled' integer DEFAULT false NOT NULL; ` ).run(); + db.prepare( + ` + ALTER TABLE 'resources' ADD 'forwardIdentityHeaders' integer DEFAULT true NOT NULL; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'resourcePolicies' ADD 'mtlsEnabled' integer DEFAULT false NOT NULL; + ` + ).run(); })(); db.pragma("foreign_keys = ON"); diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx index 9371307b6..9fc0dbb37 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -14,12 +14,11 @@ import { type SelectedRole } from "@app/components/roles-selector"; import { UsersSelector } from "@app/components/users-selector"; -import { ResourceMtlsCertificatesCredenza } from "@app/components/ResourceMtlsCertificatesCredenza"; import { Button } from "@app/components/ui/button"; import { Form, FormField } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useResourceMtls } from "@app/hooks/useResourceMtls"; +import { usePolicyMtls } from "@app/hooks/usePolicyMtls"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { resourceQueries } from "@app/lib/queries"; @@ -33,13 +32,7 @@ import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { toUnicode } from "punycode"; -import { - useContext, - useEffect, - useMemo, - useRef, - useState -} from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useForm, useWatch } from "react-hook-form"; import { createPolicySchema } from "."; import { @@ -50,6 +43,7 @@ import { } from "./PolicyAuthMethodCredenzas"; import { PolicyAuthOtherMethodsSection } from "./PolicyAuthOtherMethodsSection"; import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection"; +import { PolicyMtlsCertificatesCredenza } from "./PolicyMtlsCertificatesCredenza"; import type { PolicyAuthMethodId } from "./policy-auth-method-id"; import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice"; import z from "zod"; @@ -259,9 +253,7 @@ export function PolicyAuthStackSectionEdit({ const [editingMethod, setEditingMethod] = useState(null); - const mtls = useResourceMtls(); - // mTLS is resource-level, so it stays editable when the policy is shared. - const mtlsReadonly = readonly; + const mtls = usePolicyMtls(); const canSaveMtls = mtls.isDirty && !(mtls.enabled && !mtls.hasCertificates); @@ -513,8 +505,7 @@ export function PolicyAuthStackSectionEdit({ }), api.post(`/resource/${resourceId}/users`, { userIds: currentResourceUserIds - }), - ...(canSaveMtls ? [mtls.save()] : []) + }) ]); toast({ @@ -725,7 +716,7 @@ export function PolicyAuthStackSectionEdit({ certificateCount: mtls.certificates.length, sslEnabled: mtls.sslEnabled, - disabled: mtlsReadonly, + disabled: authReadonly, onConfigure: () => openMethodEditor("mtls"), onToggle: (active) => @@ -824,7 +815,7 @@ export function PolicyAuthStackSectionEdit({ /> {mtls.supported && ( - { if (open) return; @@ -834,8 +825,14 @@ export function PolicyAuthStackSectionEdit({ mtls.setEnabled(false); } }} + resourcePolicyId={ + policy.resourcePolicyId + } certificates={mtls.certificates} isLoading={mtls.isLoadingCertificates} + onMtlsDisabled={() => + updatePolicy({ mtlsEnabled: false }) + } /> )} diff --git a/src/components/ResourceMtlsCertificatesCredenza.tsx b/src/components/resource-policy/PolicyMtlsCertificatesCredenza.tsx similarity index 92% rename from src/components/ResourceMtlsCertificatesCredenza.tsx rename to src/components/resource-policy/PolicyMtlsCertificatesCredenza.tsx index b831c83bf..5dc0189cc 100644 --- a/src/components/ResourceMtlsCertificatesCredenza.tsx +++ b/src/components/resource-policy/PolicyMtlsCertificatesCredenza.tsx @@ -16,15 +16,14 @@ import { Input } from "@app/components/ui/input"; import { Label } from "@app/components/ui/label"; import { Textarea } from "@app/components/ui/textarea"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useResourceContext } from "@app/hooks/useResourceContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { resourceQueries } from "@app/lib/queries"; +import { orgQueries } from "@app/lib/queries"; import type { - CreateResourceMtlsCertificateResponse, - DeleteResourceMtlsCertificateResponse, - ListResourceMtlsCertificatesResponse -} from "@server/routers/resource"; + CreateResourcePolicyMtlsCertificateResponse, + DeleteResourcePolicyMtlsCertificateResponse, + ListResourcePolicyMtlsCertificatesResponse +} from "@server/routers/policy"; import { useQueryClient } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { FileKey2, Trash2 } from "lucide-react"; @@ -34,22 +33,26 @@ import { useRef, useState } from "react"; const DAY_MS = 24 * 60 * 60 * 1000; const EXPIRES_SOON_MS = 30 * DAY_MS; -type ResourceMtlsCertificatesCredenzaProps = { +type PolicyMtlsCertificatesCredenzaProps = { open: boolean; onOpenChange: (open: boolean) => void; - certificates: ListResourceMtlsCertificatesResponse["certificates"]; + resourcePolicyId: number; + certificates: ListResourcePolicyMtlsCertificatesResponse["certificates"]; isLoading: boolean; + // called when removing the last CA certificate switched mTLS off + onMtlsDisabled: () => void; }; -export function ResourceMtlsCertificatesCredenza({ +export function PolicyMtlsCertificatesCredenza({ open, onOpenChange, + resourcePolicyId, certificates, - isLoading -}: ResourceMtlsCertificatesCredenzaProps) { + isLoading, + onMtlsDisabled +}: PolicyMtlsCertificatesCredenzaProps) { const t = useTranslations(); const { env } = useEnvContext(); - const { resource, updateResource } = useResourceContext(); const api = createApiClient({ env }); const queryClient = useQueryClient(); @@ -61,9 +64,7 @@ export function ResourceMtlsCertificatesCredenza({ async function invalidateCertificates() { await queryClient.invalidateQueries( - resourceQueries.resourceMtlsCertificates({ - resourceId: resource.resourceId - }) + orgQueries.resourcePolicyMtlsCertificates({ resourcePolicyId }) ); } @@ -82,13 +83,12 @@ export function ResourceMtlsCertificatesCredenza({ if (!pem.trim()) return; setAdding(true); try { - await api.put>( - `/resource/${resource.resourceId}/mtls/certificate`, - { - certificate: pem, - ...(name.trim() ? { name: name.trim() } : {}) - } - ); + await api.put< + AxiosResponse + >(`/resource-policy/${resourcePolicyId}/mtls/certificate`, { + certificate: pem, + ...(name.trim() ? { name: name.trim() } : {}) + }); setPem(""); setName(""); await invalidateCertificates(); @@ -114,14 +114,14 @@ export function ResourceMtlsCertificatesCredenza({ setRemovingId(mtlsCertificateId); try { const res = await api.delete< - AxiosResponse + AxiosResponse >( - `/resource/${resource.resourceId}/mtls/certificate/${mtlsCertificateId}` + `/resource-policy/${resourcePolicyId}/mtls/certificate/${mtlsCertificateId}` ); await invalidateCertificates(); if (res.data.data.mtlsDisabled) { - updateResource({ mtlsEnabled: false }); + onMtlsDisabled(); toast({ title: t("mtlsCertificateRemoved"), description: t("mtlsLastCertificateRemoved") diff --git a/src/hooks/useResourceMtls.ts b/src/hooks/usePolicyMtls.ts similarity index 53% rename from src/hooks/useResourceMtls.ts rename to src/hooks/usePolicyMtls.ts index 94866b56e..2ff7152ca 100644 --- a/src/hooks/useResourceMtls.ts +++ b/src/hooks/usePolicyMtls.ts @@ -1,56 +1,58 @@ import ResourceContext from "@app/contexts/resourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; -import { resourceQueries } from "@app/lib/queries"; -import type { UpdateResourceResponse } from "@server/routers/resource"; +import { orgQueries } from "@app/lib/queries"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { useContext, useEffect, useState } from "react"; -// mTLS is a resource-level setting, so it is only available when editing -// authentication in the context of an HTTP resource. -export function useResourceMtls() { +// mTLS is set on the policy being edited, like the other auth methods. When +// editing in the context of a resource it only applies to HTTP resources. +export function usePolicyMtls() { + const { policy, updatePolicy } = useResourcePolicyContext(); const resourceContext = useContext(ResourceContext); const { env } = useEnvContext(); const api = createApiClient({ env }); const resource = resourceContext?.resource; const supported = - !!resource && !env.flags.disableEnterpriseFeatures && - resource.mode === "http"; + (!resource || resource.mode === "http"); const { data: certificates = [], isLoading: isLoadingCertificates } = useQuery({ - ...resourceQueries.resourceMtlsCertificates({ - resourceId: resource?.resourceId ?? 0 + ...orgQueries.resourcePolicyMtlsCertificates({ + resourcePolicyId: policy.resourcePolicyId }), enabled: supported }); - const [enabled, setEnabled] = useState(Boolean(resource?.mtlsEnabled)); + const [enabled, setEnabled] = useState(policy.mtlsEnabled); // The server switches mTLS off when the last CA is removed. useEffect(() => { - setEnabled(Boolean(resource?.mtlsEnabled)); - }, [resource?.mtlsEnabled]); + setEnabled(policy.mtlsEnabled); + }, [policy.mtlsEnabled]); const hasCertificates = certificates.length > 0; - const isDirty = supported && enabled !== Boolean(resource?.mtlsEnabled); + const isDirty = supported && enabled !== policy.mtlsEnabled; async function save(): Promise { if (!supported || !isDirty) return; - const res = await api.post>( - `/resource/${resource!.resourceId}`, + const res = await api.put( + `/resource-policy/${policy.resourcePolicyId}`, { mtlsEnabled: enabled } ); - resourceContext!.updateResource({ mtlsEnabled: enabled }); + updatePolicy({ mtlsEnabled: enabled }); return res; } return { supported, - sslEnabled: Boolean(resource?.ssl), + // A policy page has no single resource; there it applies to the + // policy's resources that have SSL enabled. + sslEnabled: resource ? Boolean(resource.ssl) : true, enabled, setEnabled, certificates, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index bc1e9fd74..83413b778 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -55,13 +55,15 @@ import type { ListLauncherSitesResponse, ListLauncherViewsResponse } from "@server/routers/launcher/types"; -import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { + GetResourcePolicyResponse, + ListResourcePolicyMtlsCertificatesResponse +} from "@server/routers/policy"; import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import type { GetResourcePoliciesResponse, GetResourceWhitelistResponse, ListResourceAiModelsResponse, - ListResourceMtlsCertificatesResponse, ListResourceNamesResponse, ListResourceRolesResponse, ListResourceRulesResponse, @@ -927,6 +929,27 @@ export const orgQueries = { return res.data.data; } + }), + resourcePolicyMtlsCertificates: ({ + resourcePolicyId + }: { + resourcePolicyId: number; + }) => + queryOptions({ + queryKey: [ + "RESOURCE_POLICY", + resourcePolicyId, + "MTLS_CERTIFICATES" + ] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource-policy/${resourcePolicyId}/mtls/certificates`, { + signal + }); + + return res.data.data.certificates; + } }) }; @@ -1657,17 +1680,6 @@ export const resourceQueries = { return res.data.data.whitelist; } }), - resourceMtlsCertificates: ({ resourceId }: { resourceId: number }) => - queryOptions({ - queryKey: ["RESOURCES", resourceId, "MTLS_CERTIFICATES"] as const, - queryFn: async ({ signal, meta }) => { - const res = await meta!.api.get< - AxiosResponse - >(`/resource/${resourceId}/mtls/certificates`, { signal }); - - return res.data.data.certificates; - } - }), policies: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "POLICIES"] as const,