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