Add mTLS RBAC with path-based access control, role/cert trust model, and comprehensive tests
Implements full role-based access control for mTLS client certificates: - Database: mtls_roles, mtls_certificate_roles, mtls_access_rules tables with migration - Models: CRUD for roles, cert-role assignments, path-based access rules - Caddy config: HTTP-layer RBAC enforcement via CEL fingerprint matching in subroutes - New trust model: select individual certs or entire roles instead of CAs (derives CAs automatically) - REST API: /api/v1/mtls-roles, cert assignments, proxy-host access rules endpoints - UI: Roles management tab (card-based), cert/role trust picker, inline RBAC rule editor - Fix: dialog autoclose bug after creating proxy host (key-based remount) - Tests: 85 new tests (785 total) covering models, schema, RBAC route generation, leaf override, edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+191
-18
@@ -1,9 +1,32 @@
|
||||
/**
|
||||
* mTLS helper functions for building Caddy TLS connection policies.
|
||||
* mTLS helper functions for building Caddy TLS connection policies
|
||||
* and HTTP-layer RBAC route enforcement.
|
||||
*
|
||||
* Extracted from caddy.ts so they can be unit-tested independently.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalise a fingerprint to the format Caddy uses:
|
||||
* lowercase hex without colons.
|
||||
*
|
||||
* Node's X509Certificate.fingerprint256 returns "AB:CD:EF:..." (uppercase, colons).
|
||||
* Caddy's {http.request.tls.client.fingerprint} returns "abcdef..." (lowercase, no colons).
|
||||
*/
|
||||
export function normalizeFingerprint(fp: string): string {
|
||||
return fp.replace(/:/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal type matching MtlsAccessRule from the models layer.
|
||||
* Defined here to avoid importing from models (which pulls in db.ts).
|
||||
*/
|
||||
export type MtlsAccessRuleLike = {
|
||||
path_pattern: string;
|
||||
allowed_role_ids: number[];
|
||||
allowed_cert_ids: number[];
|
||||
deny_all: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a PEM certificate to base64-encoded DER format expected by Caddy's
|
||||
* `trusted_ca_certs` and `trusted_leaf_certs` fields.
|
||||
@@ -36,7 +59,8 @@ export function buildClientAuthentication(
|
||||
mTlsDomainMap: Map<string, number[]>,
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>,
|
||||
issuedClientCertMap: Map<number, string[]>,
|
||||
cAsWithAnyIssuedCerts: Set<number>
|
||||
cAsWithAnyIssuedCerts: Set<number>,
|
||||
mTlsDomainLeafOverride?: Map<string, string[]>
|
||||
): Record<string, unknown> | null {
|
||||
const caCertIds = new Set<number>();
|
||||
for (const domain of domains) {
|
||||
@@ -47,27 +71,50 @@ export function buildClientAuthentication(
|
||||
}
|
||||
if (caCertIds.size === 0) return null;
|
||||
|
||||
// Check if any domain in this group uses the new cert-based model (has leaf override)
|
||||
const leafOverridePems = new Set<string>();
|
||||
let hasLeafOverride = false;
|
||||
if (mTlsDomainLeafOverride) {
|
||||
for (const domain of domains) {
|
||||
const pems = mTlsDomainLeafOverride.get(domain.toLowerCase());
|
||||
if (pems) {
|
||||
hasLeafOverride = true;
|
||||
for (const pem of pems) leafOverridePems.add(pem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trustedCaCerts: string[] = [];
|
||||
const trustedLeafCerts: string[] = [];
|
||||
|
||||
for (const id of caCertIds) {
|
||||
const ca = caCertMap.get(id);
|
||||
if (!ca) continue;
|
||||
if (hasLeafOverride) {
|
||||
// New cert-based model: CAs were derived from selected certs.
|
||||
// Add CAs for chain validation, pin to only the explicitly selected leaf certs.
|
||||
for (const id of caCertIds) {
|
||||
const ca = caCertMap.get(id);
|
||||
if (ca) trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
}
|
||||
for (const pem of leafOverridePems) {
|
||||
trustedLeafCerts.push(pemToBase64Der(pem));
|
||||
}
|
||||
} else {
|
||||
// Legacy CA-based model
|
||||
for (const id of caCertIds) {
|
||||
const ca = caCertMap.get(id);
|
||||
if (!ca) continue;
|
||||
|
||||
if (cAsWithAnyIssuedCerts.has(id)) {
|
||||
// Managed CA: enforce revocation via leaf pinning
|
||||
const activeLeafCerts = issuedClientCertMap.get(id) ?? [];
|
||||
if (activeLeafCerts.length === 0) {
|
||||
// All certs revoked: exclude CA so chain validation fails for its certs
|
||||
continue;
|
||||
if (cAsWithAnyIssuedCerts.has(id)) {
|
||||
const activeLeafCerts = issuedClientCertMap.get(id) ?? [];
|
||||
if (activeLeafCerts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
for (const certPem of activeLeafCerts) {
|
||||
trustedLeafCerts.push(pemToBase64Der(certPem));
|
||||
}
|
||||
} else {
|
||||
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
}
|
||||
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
for (const certPem of activeLeafCerts) {
|
||||
trustedLeafCerts.push(pemToBase64Der(certPem));
|
||||
}
|
||||
} else {
|
||||
// Unmanaged CA: trust any cert in the chain
|
||||
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,3 +155,129 @@ export function groupMtlsDomainsByCaSet(
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ── mTLS RBAC HTTP-layer route enforcement ───────────────────────────
|
||||
|
||||
/**
|
||||
* For a single access rule, resolve the set of allowed fingerprints by unioning:
|
||||
* - All active cert fingerprints from certs that hold any of the allowed roles
|
||||
* - All active cert fingerprints from directly-allowed cert IDs
|
||||
*/
|
||||
export function resolveAllowedFingerprints(
|
||||
rule: MtlsAccessRuleLike,
|
||||
roleFingerprintMap: Map<number, Set<string>>,
|
||||
certFingerprintMap: Map<number, string>
|
||||
): Set<string> {
|
||||
const allowed = new Set<string>();
|
||||
|
||||
for (const roleId of rule.allowed_role_ids) {
|
||||
const fps = roleFingerprintMap.get(roleId);
|
||||
if (fps) {
|
||||
for (const fp of fps) allowed.add(fp);
|
||||
}
|
||||
}
|
||||
|
||||
for (const certId of rule.allowed_cert_ids) {
|
||||
const fp = certFingerprintMap.get(certId);
|
||||
if (fp) allowed.add(fp);
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CEL expression that checks whether the client certificate's
|
||||
* fingerprint is in the given set of allowed fingerprints.
|
||||
*
|
||||
* Uses Caddy's `{http.request.tls.client.fingerprint}` placeholder.
|
||||
*/
|
||||
export function buildFingerprintCelExpression(fingerprints: Set<string>): string {
|
||||
const fps = Array.from(fingerprints).sort();
|
||||
const quoted = fps.map((fp) => `'${fp}'`).join(", ");
|
||||
return `{http.request.tls.client.fingerprint} in [${quoted}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a proxy host's mTLS access rules, builds subroutes that enforce
|
||||
* path-based RBAC at the HTTP layer (after TLS handshake).
|
||||
*
|
||||
* Returns null if there are no access rules (caller should use normal routing).
|
||||
*
|
||||
* The returned subroutes:
|
||||
* - For each rule (ordered by priority desc), emit a path+fingerprint match
|
||||
* route (allow) followed by a path-only route (deny 403).
|
||||
* - After all rules, a catch-all route allows any valid cert (preserving
|
||||
* backwards-compatible behavior for paths without rules).
|
||||
*/
|
||||
export function buildMtlsRbacSubroutes(
|
||||
accessRules: MtlsAccessRuleLike[],
|
||||
roleFingerprintMap: Map<number, Set<string>>,
|
||||
certFingerprintMap: Map<number, string>,
|
||||
baseHandlers: Record<string, unknown>[],
|
||||
reverseProxyHandler: Record<string, unknown>
|
||||
): Record<string, unknown>[] | null {
|
||||
if (accessRules.length === 0) return null;
|
||||
|
||||
const subroutes: Record<string, unknown>[] = [];
|
||||
|
||||
// Rules are already sorted by priority desc, path asc
|
||||
for (const rule of accessRules) {
|
||||
if (rule.deny_all) {
|
||||
// Explicit deny: any request matching this path gets 403
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
body: "mTLS access denied",
|
||||
}],
|
||||
terminal: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowedFps = resolveAllowedFingerprints(rule, roleFingerprintMap, certFingerprintMap);
|
||||
|
||||
if (allowedFps.size === 0) {
|
||||
// Rule exists but no certs match → deny all for this path
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
body: "mTLS access denied",
|
||||
}],
|
||||
terminal: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow route: path + fingerprint CEL match
|
||||
const celExpr = buildFingerprintCelExpression(allowedFps);
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern], expression: celExpr }],
|
||||
handle: [...baseHandlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
// Deny route: path matches but fingerprint didn't → 403
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
body: "mTLS access denied",
|
||||
}],
|
||||
terminal: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Catch-all: paths without explicit rules → any valid cert gets through
|
||||
subroutes.push({
|
||||
handle: [...baseHandlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
return subroutes;
|
||||
}
|
||||
|
||||
|
||||
+118
-15
@@ -51,7 +51,9 @@ import {
|
||||
l4ProxyHosts
|
||||
} from "./db/schema";
|
||||
import { type GeoBlockMode, type WafHostConfig, type MtlsConfig, type RedirectRule, type RewriteConfig, type LocationRule } from "./models/proxy-hosts";
|
||||
import { buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls";
|
||||
import { buildClientAuthentication, groupMtlsDomainsByCaSet, buildMtlsRbacSubroutes, type MtlsAccessRuleLike } from "./caddy-mtls";
|
||||
import { buildRoleFingerprintMap, buildCertFingerprintMap, buildRoleCertIdMap } from "./models/mtls-roles";
|
||||
import { getAccessRulesForHosts } from "./models/mtls-access-rules";
|
||||
import { buildWafHandler, resolveEffectiveWaf } from "./caddy-waf";
|
||||
|
||||
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
|
||||
@@ -621,6 +623,11 @@ type BuildProxyRoutesOptions = {
|
||||
globalUpstreamDnsResolutionSettings: UpstreamDnsResolutionSettings | null;
|
||||
globalGeoBlock?: GeoBlockSettings | null;
|
||||
globalWaf?: WafSettings | null;
|
||||
mtlsRbac?: {
|
||||
roleFingerprintMap: Map<number, Set<string>>;
|
||||
certFingerprintMap: Map<number, string>;
|
||||
accessRulesByHost: Map<number, MtlsAccessRuleLike[]>;
|
||||
};
|
||||
};
|
||||
|
||||
export function buildLocationReverseProxy(
|
||||
@@ -1106,6 +1113,12 @@ async function buildProxyRoutes(
|
||||
}
|
||||
} else {
|
||||
const locationRules = meta.location_rules ?? [];
|
||||
|
||||
// Check for mTLS RBAC access rules for this proxy host
|
||||
const hostAccessRules = options.mtlsRbac?.accessRulesByHost.get(row.id);
|
||||
const hasMtlsRbac = hostAccessRules && hostAccessRules.length > 0
|
||||
&& options.mtlsRbac?.roleFingerprintMap && options.mtlsRbac?.certFingerprintMap;
|
||||
|
||||
for (const domainGroup of domainGroups) {
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
@@ -1120,12 +1133,41 @@ async function buildProxyRoutes(
|
||||
terminal: true,
|
||||
});
|
||||
}
|
||||
const route: CaddyHttpRoute = {
|
||||
match: [{ host: domainGroup }],
|
||||
handle: [...handlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
};
|
||||
hostRoutes.push(route);
|
||||
|
||||
if (hasMtlsRbac) {
|
||||
// mTLS RBAC: wrap in subroute with path-based fingerprint enforcement
|
||||
const rbacSubroutes = buildMtlsRbacSubroutes(
|
||||
hostAccessRules,
|
||||
options.mtlsRbac!.roleFingerprintMap,
|
||||
options.mtlsRbac!.certFingerprintMap,
|
||||
handlers,
|
||||
reverseProxyHandler
|
||||
);
|
||||
if (rbacSubroutes) {
|
||||
hostRoutes.push({
|
||||
match: [{ host: domainGroup }],
|
||||
handle: [{
|
||||
handler: "subroute",
|
||||
routes: rbacSubroutes,
|
||||
}],
|
||||
terminal: true,
|
||||
});
|
||||
} else {
|
||||
// Fallback: no subroutes generated, use normal routing
|
||||
hostRoutes.push({
|
||||
match: [{ host: domainGroup }],
|
||||
handle: [...handlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const route: CaddyHttpRoute = {
|
||||
match: [{ host: domainGroup }],
|
||||
handle: [...handlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
};
|
||||
hostRoutes.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,14 +1184,15 @@ function buildTlsConnectionPolicies(
|
||||
mTlsDomainMap: Map<string, number[]>,
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>,
|
||||
issuedClientCertMap: Map<number, string[]>,
|
||||
cAsWithAnyIssuedCerts: Set<number>
|
||||
cAsWithAnyIssuedCerts: Set<number>,
|
||||
mTlsDomainLeafOverride: Map<string, string[]>
|
||||
) {
|
||||
const policies: Record<string, unknown>[] = [];
|
||||
const readyCertificates = new Set<number>();
|
||||
const importedCertPems: { certificate: string; key: string }[] = [];
|
||||
|
||||
const buildAuth = (domains: string[]) =>
|
||||
buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts);
|
||||
buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts, mTlsDomainLeafOverride);
|
||||
|
||||
/**
|
||||
* Pushes one TLS policy per unique CA set found in `mTlsDomains`.
|
||||
@@ -1628,6 +1671,7 @@ async function buildCaddyDocument() {
|
||||
.from(caCertificates),
|
||||
db
|
||||
.select({
|
||||
id: issuedClientCertificates.id,
|
||||
caCertificateId: issuedClientCertificates.caCertificateId,
|
||||
certificatePem: issuedClientCertificates.certificatePem
|
||||
})
|
||||
@@ -1692,18 +1736,71 @@ async function buildCaddyDocument() {
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
// Build domain → CA cert IDs map for mTLS-enabled hosts
|
||||
// Build a lookup: issued cert ID → { id, caCertificateId, certificatePem }
|
||||
const issuedCertById = new Map(issuedClientCertRows.map(r => [r.id, r]));
|
||||
|
||||
// Resolve role IDs → cert IDs for trusted_role_ids in mTLS config
|
||||
const roleCertIdMap = await buildRoleCertIdMap();
|
||||
|
||||
// Build domain → CA cert IDs map for mTLS-enabled hosts.
|
||||
// New model (trusted_client_cert_ids + trusted_role_ids): derive CAs from selected certs and pin to those certs.
|
||||
// Old model (ca_certificate_ids): trust entire CAs as before.
|
||||
const mTlsDomainMap = new Map<string, number[]>();
|
||||
// Per-domain override: which specific leaf cert PEMs to pin (new model only)
|
||||
const mTlsDomainLeafOverride = new Map<string, string[]>();
|
||||
for (const row of proxyHostRows) {
|
||||
if (!row.enabled) continue;
|
||||
const meta = parseJson<{ mtls?: MtlsConfig }>(row.meta, {});
|
||||
if (!meta.mtls?.enabled || !meta.mtls.ca_certificate_ids?.length) continue;
|
||||
if (!meta.mtls?.enabled) continue;
|
||||
|
||||
const domains = parseJson<string[]>(row.domains, []).map(d => d.trim().toLowerCase()).filter(Boolean);
|
||||
for (const domain of domains) {
|
||||
mTlsDomainMap.set(domain, meta.mtls.ca_certificate_ids);
|
||||
if (domains.length === 0) continue;
|
||||
|
||||
// Collect all trusted cert IDs from both direct selection and roles
|
||||
const allCertIds = new Set<number>();
|
||||
if (meta.mtls.trusted_client_cert_ids) {
|
||||
for (const id of meta.mtls.trusted_client_cert_ids) allCertIds.add(id);
|
||||
}
|
||||
if (meta.mtls.trusted_role_ids) {
|
||||
for (const roleId of meta.mtls.trusted_role_ids) {
|
||||
const certIds = roleCertIdMap.get(roleId);
|
||||
if (certIds) for (const id of certIds) allCertIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (allCertIds.size > 0) {
|
||||
// New model: derive CAs from resolved cert IDs and collect leaf PEMs
|
||||
const derivedCaIds = new Set<number>();
|
||||
const leafPems: string[] = [];
|
||||
for (const certId of allCertIds) {
|
||||
const cert = issuedCertById.get(certId);
|
||||
if (cert) {
|
||||
derivedCaIds.add(cert.caCertificateId);
|
||||
leafPems.push(cert.certificatePem);
|
||||
}
|
||||
}
|
||||
if (derivedCaIds.size === 0) continue;
|
||||
const caIdArr = Array.from(derivedCaIds);
|
||||
for (const domain of domains) {
|
||||
mTlsDomainMap.set(domain, caIdArr);
|
||||
mTlsDomainLeafOverride.set(domain, leafPems);
|
||||
}
|
||||
} else if (meta.mtls.ca_certificate_ids?.length) {
|
||||
// Legacy model: trust entire CAs (backward compat)
|
||||
for (const domain of domains) {
|
||||
mTlsDomainMap.set(domain, meta.mtls.ca_certificate_ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build mTLS RBAC data for HTTP-layer enforcement
|
||||
const enabledProxyHostIds = proxyHostRows.filter((r) => r.enabled).map((r) => r.id);
|
||||
const [roleFingerprintMap, certFingerprintMap, accessRulesByHost] = await Promise.all([
|
||||
buildRoleFingerprintMap(),
|
||||
buildCertFingerprintMap(),
|
||||
getAccessRulesForHosts(enabledProxyHostIds),
|
||||
]);
|
||||
|
||||
const { usage: certificateUsage, autoManagedDomains } = collectCertificateUsage(proxyHostRows, certificateMap);
|
||||
const [generalSettings, dnsSettings, upstreamDnsResolutionSettings, globalGeoBlock, globalWaf] = await Promise.all([
|
||||
getGeneralSettings(),
|
||||
@@ -1723,7 +1820,8 @@ async function buildCaddyDocument() {
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
issuedClientCertMap,
|
||||
cAsWithAnyIssuedCerts
|
||||
cAsWithAnyIssuedCerts,
|
||||
mTlsDomainLeafOverride
|
||||
);
|
||||
|
||||
const httpRoutes: CaddyHttpRoute[] = await buildProxyRoutes(
|
||||
@@ -1734,7 +1832,12 @@ async function buildCaddyDocument() {
|
||||
globalDnsSettings: dnsSettings,
|
||||
globalUpstreamDnsResolutionSettings: upstreamDnsResolutionSettings,
|
||||
globalGeoBlock,
|
||||
globalWaf
|
||||
globalWaf,
|
||||
mtlsRbac: {
|
||||
roleFingerprintMap,
|
||||
certFingerprintMap,
|
||||
accessRulesByHost,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -274,6 +274,70 @@ export const wafLogParseState = sqliteTable('waf_log_parse_state', {
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
// ── mTLS RBAC ──────────────────────────────────────────────────────────
|
||||
|
||||
export const mtlsRoles = sqliteTable(
|
||||
"mtls_roles",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
nameUnique: uniqueIndex("mtls_roles_name_unique").on(table.name)
|
||||
})
|
||||
);
|
||||
|
||||
export const mtlsCertificateRoles = sqliteTable(
|
||||
"mtls_certificate_roles",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
issuedClientCertificateId: integer("issued_client_certificate_id")
|
||||
.references(() => issuedClientCertificates.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
mtlsRoleId: integer("mtls_role_id")
|
||||
.references(() => mtlsRoles.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
certRoleUnique: uniqueIndex("mtls_cert_role_unique").on(
|
||||
table.issuedClientCertificateId,
|
||||
table.mtlsRoleId
|
||||
),
|
||||
roleIdx: index("mtls_certificate_roles_role_idx").on(table.mtlsRoleId)
|
||||
})
|
||||
);
|
||||
|
||||
export const mtlsAccessRules = sqliteTable(
|
||||
"mtls_access_rules",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
proxyHostId: integer("proxy_host_id")
|
||||
.references(() => proxyHosts.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
pathPattern: text("path_pattern").notNull(),
|
||||
allowedRoleIds: text("allowed_role_ids").notNull().default("[]"),
|
||||
allowedCertIds: text("allowed_cert_ids").notNull().default("[]"),
|
||||
denyAll: integer("deny_all", { mode: "boolean" }).notNull().default(false),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
proxyHostIdx: index("mtls_access_rules_proxy_host_idx").on(table.proxyHostId),
|
||||
hostPathUnique: uniqueIndex("mtls_access_rules_host_path_unique").on(
|
||||
table.proxyHostId,
|
||||
table.pathPattern
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { mtlsAccessRules } from "../db/schema";
|
||||
import { asc, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type MtlsAccessRule = {
|
||||
id: number;
|
||||
proxy_host_id: number;
|
||||
path_pattern: string;
|
||||
allowed_role_ids: number[];
|
||||
allowed_cert_ids: number[];
|
||||
deny_all: boolean;
|
||||
priority: number;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MtlsAccessRuleInput = {
|
||||
proxy_host_id: number;
|
||||
path_pattern: string;
|
||||
allowed_role_ids?: number[];
|
||||
allowed_cert_ids?: number[];
|
||||
deny_all?: boolean;
|
||||
priority?: number;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type RuleRow = typeof mtlsAccessRules.$inferSelect;
|
||||
|
||||
function parseJsonIds(raw: string): number[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed.filter((n: unknown) => typeof n === "number" && Number.isFinite(n));
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function toMtlsAccessRule(row: RuleRow): MtlsAccessRule {
|
||||
return {
|
||||
id: row.id,
|
||||
proxy_host_id: row.proxyHostId,
|
||||
path_pattern: row.pathPattern,
|
||||
allowed_role_ids: parseJsonIds(row.allowedRoleIds),
|
||||
allowed_cert_ids: parseJsonIds(row.allowedCertIds),
|
||||
deny_all: row.denyAll,
|
||||
priority: row.priority,
|
||||
description: row.description,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
// ── CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listMtlsAccessRules(proxyHostId: number): Promise<MtlsAccessRule[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mtlsAccessRules)
|
||||
.where(eq(mtlsAccessRules.proxyHostId, proxyHostId))
|
||||
.orderBy(desc(mtlsAccessRules.priority), asc(mtlsAccessRules.pathPattern));
|
||||
return rows.map(toMtlsAccessRule);
|
||||
}
|
||||
|
||||
export async function getMtlsAccessRule(id: number): Promise<MtlsAccessRule | null> {
|
||||
const row = await db.query.mtlsAccessRules.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
return row ? toMtlsAccessRule(row) : null;
|
||||
}
|
||||
|
||||
export async function createMtlsAccessRule(
|
||||
input: MtlsAccessRuleInput,
|
||||
actorUserId: number
|
||||
): Promise<MtlsAccessRule> {
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(mtlsAccessRules)
|
||||
.values({
|
||||
proxyHostId: input.proxy_host_id,
|
||||
pathPattern: input.path_pattern.trim(),
|
||||
allowedRoleIds: JSON.stringify(input.allowed_role_ids ?? []),
|
||||
allowedCertIds: JSON.stringify(input.allowed_cert_ids ?? []),
|
||||
denyAll: input.deny_all ?? false,
|
||||
priority: input.priority ?? 0,
|
||||
description: input.description ?? null,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) throw new Error("Failed to create mTLS access rule");
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: record.id,
|
||||
summary: `Created mTLS access rule for path ${input.path_pattern} on proxy host ${input.proxy_host_id}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
return toMtlsAccessRule(record);
|
||||
}
|
||||
|
||||
export async function updateMtlsAccessRule(
|
||||
id: number,
|
||||
input: Partial<Omit<MtlsAccessRuleInput, "proxy_host_id">>,
|
||||
actorUserId: number
|
||||
): Promise<MtlsAccessRule> {
|
||||
const existing = await db.query.mtlsAccessRules.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!existing) throw new Error("mTLS access rule not found");
|
||||
|
||||
const now = nowIso();
|
||||
const updates: Partial<typeof mtlsAccessRules.$inferInsert> = { updatedAt: now };
|
||||
|
||||
if (input.path_pattern !== undefined) updates.pathPattern = input.path_pattern.trim();
|
||||
if (input.allowed_role_ids !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowed_role_ids);
|
||||
if (input.allowed_cert_ids !== undefined) updates.allowedCertIds = JSON.stringify(input.allowed_cert_ids);
|
||||
if (input.deny_all !== undefined) updates.denyAll = input.deny_all;
|
||||
if (input.priority !== undefined) updates.priority = input.priority;
|
||||
if (input.description !== undefined) updates.description = input.description ?? null;
|
||||
|
||||
await db.update(mtlsAccessRules).set(updates).where(eq(mtlsAccessRules.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: id,
|
||||
summary: `Updated mTLS access rule for path ${input.path_pattern ?? existing.pathPattern}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
return (await getMtlsAccessRule(id))!;
|
||||
}
|
||||
|
||||
export async function deleteMtlsAccessRule(
|
||||
id: number,
|
||||
actorUserId: number
|
||||
): Promise<void> {
|
||||
const existing = await db.query.mtlsAccessRules.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!existing) throw new Error("mTLS access rule not found");
|
||||
|
||||
await db.delete(mtlsAccessRules).where(eq(mtlsAccessRules.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: id,
|
||||
summary: `Deleted mTLS access rule for path ${existing.pathPattern}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-query access rules for multiple proxy hosts at once.
|
||||
* Used during Caddy config generation.
|
||||
*/
|
||||
export async function getAccessRulesForHosts(
|
||||
proxyHostIds: number[]
|
||||
): Promise<Map<number, MtlsAccessRule[]>> {
|
||||
if (proxyHostIds.length === 0) return new Map();
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mtlsAccessRules)
|
||||
.where(inArray(mtlsAccessRules.proxyHostId, proxyHostIds))
|
||||
.orderBy(desc(mtlsAccessRules.priority), asc(mtlsAccessRules.pathPattern));
|
||||
|
||||
const map = new Map<number, MtlsAccessRule[]>();
|
||||
for (const row of rows) {
|
||||
const parsed = toMtlsAccessRule(row);
|
||||
let bucket = map.get(parsed.proxy_host_id);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
map.set(parsed.proxy_host_id, bucket);
|
||||
}
|
||||
bucket.push(parsed);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import {
|
||||
mtlsRoles,
|
||||
mtlsCertificateRoles,
|
||||
issuedClientCertificates,
|
||||
} from "../db/schema";
|
||||
import { asc, eq, inArray, count, and, isNull } from "drizzle-orm";
|
||||
import { normalizeFingerprint } from "../caddy-mtls";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type MtlsRole = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
certificate_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MtlsRoleInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type MtlsRoleWithCertificates = MtlsRole & {
|
||||
certificate_ids: number[];
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type RoleRow = typeof mtlsRoles.$inferSelect;
|
||||
|
||||
async function countCertsForRole(roleId: number): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(mtlsCertificateRoles)
|
||||
.where(eq(mtlsCertificateRoles.mtlsRoleId, roleId));
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
function toMtlsRole(row: RoleRow, certCount: number): MtlsRole {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
certificate_count: certCount,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
// ── CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listMtlsRoles(): Promise<MtlsRole[]> {
|
||||
const rows = await db.query.mtlsRoles.findMany({
|
||||
orderBy: (table) => asc(table.name),
|
||||
});
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const roleIds = rows.map((r) => r.id);
|
||||
const counts = await db
|
||||
.select({
|
||||
roleId: mtlsCertificateRoles.mtlsRoleId,
|
||||
cnt: count(),
|
||||
})
|
||||
.from(mtlsCertificateRoles)
|
||||
.where(inArray(mtlsCertificateRoles.mtlsRoleId, roleIds))
|
||||
.groupBy(mtlsCertificateRoles.mtlsRoleId);
|
||||
|
||||
const countMap = new Map(counts.map((c) => [c.roleId, c.cnt]));
|
||||
return rows.map((r) => toMtlsRole(r, countMap.get(r.id) ?? 0));
|
||||
}
|
||||
|
||||
export async function getMtlsRole(id: number): Promise<MtlsRoleWithCertificates | null> {
|
||||
const row = await db.query.mtlsRoles.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!row) return null;
|
||||
|
||||
const assignments = await db
|
||||
.select({ certId: mtlsCertificateRoles.issuedClientCertificateId })
|
||||
.from(mtlsCertificateRoles)
|
||||
.where(eq(mtlsCertificateRoles.mtlsRoleId, id));
|
||||
|
||||
return {
|
||||
...toMtlsRole(row, assignments.length),
|
||||
certificate_ids: assignments.map((a) => a.certId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMtlsRole(
|
||||
input: MtlsRoleInput,
|
||||
actorUserId: number
|
||||
): Promise<MtlsRole> {
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(mtlsRoles)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
description: input.description ?? null,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) throw new Error("Failed to create mTLS role");
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "mtls_role",
|
||||
entityId: record.id,
|
||||
summary: `Created mTLS role ${input.name}`,
|
||||
});
|
||||
|
||||
return toMtlsRole(record, 0);
|
||||
}
|
||||
|
||||
export async function updateMtlsRole(
|
||||
id: number,
|
||||
input: Partial<MtlsRoleInput>,
|
||||
actorUserId: number
|
||||
): Promise<MtlsRole> {
|
||||
const existing = await db.query.mtlsRoles.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!existing) throw new Error("mTLS role not found");
|
||||
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(mtlsRoles)
|
||||
.set({
|
||||
name: input.name?.trim() ?? existing.name,
|
||||
description: input.description !== undefined ? (input.description ?? null) : existing.description,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(mtlsRoles.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "mtls_role",
|
||||
entityId: id,
|
||||
summary: `Updated mTLS role ${input.name?.trim() ?? existing.name}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
const certCount = await countCertsForRole(id);
|
||||
const updated = await db.query.mtlsRoles.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
return toMtlsRole(updated!, certCount);
|
||||
}
|
||||
|
||||
export async function deleteMtlsRole(id: number, actorUserId: number): Promise<void> {
|
||||
const existing = await db.query.mtlsRoles.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!existing) throw new Error("mTLS role not found");
|
||||
|
||||
await db.delete(mtlsRoles).where(eq(mtlsRoles.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "mtls_role",
|
||||
entityId: id,
|
||||
summary: `Deleted mTLS role ${existing.name}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
|
||||
// ── Certificate ↔ Role assignments ───────────────────────────────────
|
||||
|
||||
export async function assignRoleToCertificate(
|
||||
roleId: number,
|
||||
certId: number,
|
||||
actorUserId: number
|
||||
): Promise<void> {
|
||||
const role = await db.query.mtlsRoles.findFirst({
|
||||
where: (t, { eq: cmpEq }) => cmpEq(t.id, roleId),
|
||||
});
|
||||
if (!role) throw new Error("mTLS role not found");
|
||||
|
||||
const cert = await db.query.issuedClientCertificates.findFirst({
|
||||
where: (t, { eq: cmpEq }) => cmpEq(t.id, certId),
|
||||
});
|
||||
if (!cert) throw new Error("Issued client certificate not found");
|
||||
|
||||
const now = nowIso();
|
||||
await db
|
||||
.insert(mtlsCertificateRoles)
|
||||
.values({
|
||||
issuedClientCertificateId: certId,
|
||||
mtlsRoleId: roleId,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "assign",
|
||||
entityType: "mtls_certificate_role",
|
||||
entityId: roleId,
|
||||
summary: `Assigned cert ${cert.commonName} to role ${role.name}`,
|
||||
data: { roleId, certId },
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
|
||||
export async function removeRoleFromCertificate(
|
||||
roleId: number,
|
||||
certId: number,
|
||||
actorUserId: number
|
||||
): Promise<void> {
|
||||
const role = await db.query.mtlsRoles.findFirst({
|
||||
where: (t, { eq: cmpEq }) => cmpEq(t.id, roleId),
|
||||
});
|
||||
if (!role) throw new Error("mTLS role not found");
|
||||
|
||||
await db
|
||||
.delete(mtlsCertificateRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(mtlsCertificateRoles.mtlsRoleId, roleId),
|
||||
eq(mtlsCertificateRoles.issuedClientCertificateId, certId)
|
||||
)
|
||||
);
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "unassign",
|
||||
entityType: "mtls_certificate_role",
|
||||
entityId: roleId,
|
||||
summary: `Removed cert from role ${role.name}`,
|
||||
data: { roleId, certId },
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
|
||||
export async function getCertificateRoles(certId: number): Promise<MtlsRole[]> {
|
||||
const assignments = await db
|
||||
.select({ roleId: mtlsCertificateRoles.mtlsRoleId })
|
||||
.from(mtlsCertificateRoles)
|
||||
.where(eq(mtlsCertificateRoles.issuedClientCertificateId, certId));
|
||||
|
||||
if (assignments.length === 0) return [];
|
||||
|
||||
const roleIds = assignments.map((a) => a.roleId);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mtlsRoles)
|
||||
.where(inArray(mtlsRoles.id, roleIds))
|
||||
.orderBy(asc(mtlsRoles.name));
|
||||
|
||||
return rows.map((r) => toMtlsRole(r, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of roleId → Set<normalizedFingerprint> for all active (non-revoked) certs.
|
||||
* Used during Caddy config generation.
|
||||
*/
|
||||
export async function buildRoleFingerprintMap(): Promise<Map<number, Set<string>>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
roleId: mtlsCertificateRoles.mtlsRoleId,
|
||||
fingerprint: issuedClientCertificates.fingerprintSha256,
|
||||
})
|
||||
.from(mtlsCertificateRoles)
|
||||
.innerJoin(
|
||||
issuedClientCertificates,
|
||||
eq(mtlsCertificateRoles.issuedClientCertificateId, issuedClientCertificates.id)
|
||||
)
|
||||
.where(isNull(issuedClientCertificates.revokedAt));
|
||||
|
||||
const map = new Map<number, Set<string>>();
|
||||
for (const row of rows) {
|
||||
let set = map.get(row.roleId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(row.roleId, set);
|
||||
}
|
||||
set.add(normalizeFingerprint(row.fingerprint));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of certId → normalizedFingerprint for all active (non-revoked) certs.
|
||||
* Used during Caddy config generation for direct cert overrides.
|
||||
*/
|
||||
export async function buildCertFingerprintMap(): Promise<Map<number, string>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issuedClientCertificates.id,
|
||||
fingerprint: issuedClientCertificates.fingerprintSha256,
|
||||
})
|
||||
.from(issuedClientCertificates)
|
||||
.where(isNull(issuedClientCertificates.revokedAt));
|
||||
|
||||
const map = new Map<number, string>();
|
||||
for (const row of rows) {
|
||||
map.set(row.id, normalizeFingerprint(row.fingerprint));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of roleId → Set<certId> for all active (non-revoked) certs.
|
||||
* Used during Caddy config generation to resolve trusted_role_ids → cert IDs.
|
||||
*/
|
||||
export async function buildRoleCertIdMap(): Promise<Map<number, Set<number>>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
roleId: mtlsCertificateRoles.mtlsRoleId,
|
||||
certId: mtlsCertificateRoles.issuedClientCertificateId,
|
||||
})
|
||||
.from(mtlsCertificateRoles)
|
||||
.innerJoin(
|
||||
issuedClientCertificates,
|
||||
eq(mtlsCertificateRoles.issuedClientCertificateId, issuedClientCertificates.id)
|
||||
)
|
||||
.where(isNull(issuedClientCertificates.revokedAt));
|
||||
|
||||
const map = new Map<number, Set<number>>();
|
||||
for (const row of rows) {
|
||||
let set = map.get(row.roleId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(row.roleId, set);
|
||||
}
|
||||
set.add(row.certId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// normalizeFingerprint is imported from caddy-mtls.ts (the canonical location)
|
||||
// and re-exported for convenience.
|
||||
export { normalizeFingerprint } from "../caddy-mtls";
|
||||
@@ -218,7 +218,12 @@ type ProxyHostAuthentikMeta = {
|
||||
|
||||
export type MtlsConfig = {
|
||||
enabled: boolean;
|
||||
ca_certificate_ids: number[];
|
||||
/** Trust specific issued client certificates (derives CAs automatically) */
|
||||
trusted_client_cert_ids?: number[];
|
||||
/** Trust all certificates belonging to these roles */
|
||||
trusted_role_ids?: number[];
|
||||
/** @deprecated Old model: trust entire CAs. Kept for backward compat migration. */
|
||||
ca_certificate_ids?: number[];
|
||||
};
|
||||
|
||||
type ProxyHostMeta = {
|
||||
|
||||
Reference in New Issue
Block a user