mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-21 20:19:07 +02:00
Add mtls to blueprint, policy, and oss getTraefikConfig
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -760,6 +744,3 @@ export type AlertEmailRecipients = InferSelectModel<
|
||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceMtlsCertificate = InferSelectModel<
|
||||
typeof resourceMtlsCertificates
|
||||
>;
|
||||
|
||||
@@ -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<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -2147,3 +2187,9 @@ export type SiteResourceAiProvider = InferSelectModel<
|
||||
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
||||
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
export type ResourceMtlsCertificate = InferSelectModel<
|
||||
typeof resourceMtlsCertificates
|
||||
>;
|
||||
export type ResourcePolicyMtlsCertificate = InferSelectModel<
|
||||
typeof resourcePolicyMtlsCertificates
|
||||
>;
|
||||
|
||||
@@ -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<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -756,6 +735,3 @@ export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceMtlsCertificate = InferSelectModel<
|
||||
typeof resourceMtlsCertificates
|
||||
>;
|
||||
|
||||
@@ -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<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -2190,3 +2241,9 @@ export type SiteResourceAiProvider = InferSelectModel<
|
||||
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
||||
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
export type ResourceMtlsCertificate = InferSelectModel<
|
||||
typeof resourceMtlsCertificates
|
||||
>;
|
||||
export type ResourcePolicyMtlsCertificate = InferSelectModel<
|
||||
typeof resourcePolicyMtlsCertificates
|
||||
>;
|
||||
|
||||
@@ -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<string, any> | 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 {
|
||||
|
||||
@@ -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<typeof parseCaCertificates>[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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof ResourcePolicySchema>;
|
||||
|
||||
// Schema for the entire configuration object
|
||||
|
||||
@@ -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<MtlsCertificateRow[]> {
|
||||
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<MtlsCertificateInfo[]> {
|
||||
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<number> {
|
||||
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<MtlsCertificateInfo[]> {
|
||||
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<string[]> {
|
||||
const rows = await selectCertificates(owner, trx);
|
||||
return rows.map((r) => r.certificate);
|
||||
}
|
||||
|
||||
export async function countMtlsCertificates(
|
||||
owner: MtlsOwner,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<number> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<MtlsCertificateInfo[]> {
|
||||
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<void> {
|
||||
// A PEM entry may itself be a bundle; flatten and dedupe by fingerprint.
|
||||
const desired = new Map<string, ParsedCaCertificate>();
|
||||
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<void> {
|
||||
await setMtlsEnabled(owner, false, trx);
|
||||
await deleteCertificates(owner, undefined, trx);
|
||||
}
|
||||
@@ -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<string, any> = 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(
|
||||
|
||||
@@ -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<Map<number, string[]>> {
|
||||
const byResource = new Map<number, string[]>();
|
||||
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<number, number[]>();
|
||||
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;
|
||||
}
|
||||
@@ -34,7 +34,6 @@ export type ResourceWithTargets = Pick<
|
||||
| "enabled"
|
||||
| "stickySession"
|
||||
| "loadBalancingStrategy"
|
||||
| "mtlsEnabled"
|
||||
| "tlsServerName"
|
||||
| "setHostHeader"
|
||||
| "enableProxy"
|
||||
|
||||
@@ -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<number, string[]>();
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
loginPageBrandingOrg,
|
||||
loginPageOrg,
|
||||
orgs,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
roles,
|
||||
siteResources,
|
||||
@@ -524,7 +525,16 @@ async function disableMtls(orgId: string): Promise<void> {
|
||||
})
|
||||
.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<void> {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<any> {
|
||||
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<CreateResourcePolicyMtlsCertificateResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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<DeleteResourcePolicyMtlsCertificateResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<any> {
|
||||
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<ListResourcePolicyMtlsCertificatesResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<CreateResourceMtlsCertificateResponse>(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")
|
||||
|
||||
@@ -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<DeleteResourceMtlsCertificateResponse>(res, {
|
||||
data: { mtlsDisabled },
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Resource, ResourcePolicy } from "@server/db";
|
||||
type InlinePolicyFields = Pick<
|
||||
ResourcePolicy,
|
||||
"sso" | "emailWhitelistEnabled" | "applyRules" | "idpId"
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<ResourcePolicy, "mtlsEnabled">>;
|
||||
|
||||
export function applyInlinePolicyFields<T extends Resource>(
|
||||
resource: T,
|
||||
@@ -14,6 +15,7 @@ export function applyInlinePolicyFields<T extends Resource>(
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ListResourceMtlsCertificatesResponse>(res, {
|
||||
data: { certificates },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;`
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<PolicyAuthMethodId | null>(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 && (
|
||||
<ResourceMtlsCertificatesCredenza
|
||||
<PolicyMtlsCertificatesCredenza
|
||||
open={editingMethod === "mtls"}
|
||||
onOpenChange={(open) => {
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
+25
-25
@@ -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<AxiosResponse<CreateResourceMtlsCertificateResponse>>(
|
||||
`/resource/${resource.resourceId}/mtls/certificate`,
|
||||
{
|
||||
certificate: pem,
|
||||
...(name.trim() ? { name: name.trim() } : {})
|
||||
}
|
||||
);
|
||||
await api.put<
|
||||
AxiosResponse<CreateResourcePolicyMtlsCertificateResponse>
|
||||
>(`/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<DeleteResourceMtlsCertificateResponse>
|
||||
AxiosResponse<DeleteResourcePolicyMtlsCertificateResponse>
|
||||
>(
|
||||
`/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")
|
||||
@@ -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<AxiosResponse | void> {
|
||||
if (!supported || !isDirty) return;
|
||||
const res = await api.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
`/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,
|
||||
+25
-13
@@ -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<ListResourcePolicyMtlsCertificatesResponse>
|
||||
>(`/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<ListResourceMtlsCertificatesResponse>
|
||||
>(`/resource/${resourceId}/mtls/certificates`, { signal });
|
||||
|
||||
return res.data.data.certificates;
|
||||
}
|
||||
}),
|
||||
policies: ({ resourceId }: { resourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "POLICIES"] as const,
|
||||
|
||||
Reference in New Issue
Block a user