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:
fuomag9
2026-04-05 18:40:21 +02:00
parent a2b8d69aa6
commit 277ae6e79c
28 changed files with 3484 additions and 86 deletions
+191 -18
View File
@@ -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
View File
@@ -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,
},
}
);
+64
View File
@@ -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(),
+194
View File
@@ -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;
}
+345
View File
@@ -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";
+6 -1
View File
@@ -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 = {