| 1 | import * as mtls from "@distilled.cloud/cloudflare/mtls-certificates"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Redacted from "effect/Redacted"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | |
| 14 | const TypeId = "Cloudflare.MtlsCertificate.MtlsCertificate" as const; |
| 15 | type TypeId = typeof TypeId; |
| 16 | |
| 17 | /** |
| 18 | * How the certificate was created and who manages it. Certificates uploaded |
| 19 | * through this resource are always `"custom"`. |
| 20 | */ |
| 21 | export type Type = |
| 22 | | "custom" |
| 23 | | "gateway_managed" |
| 24 | | "access_managed" |
| 25 | // Keep the union open so new Cloudflare types aren't blocked by stale types. |
| 26 | | (string & {}); |
| 27 | |
| 28 | export type Props = { |
| 29 | /** |
| 30 | * Optional human-readable name for the certificate. If omitted, a unique |
| 31 | * name will be generated. There is no update API, so changing the name |
| 32 | * triggers a replacement. |
| 33 | * @default ${app}-${stage}-${id} |
| 34 | */ |
| 35 | name?: string; |
| 36 | /** |
| 37 | * Indicates whether the certificate is a CA certificate (`true`) used to |
| 38 | * validate client certificates, or a leaf certificate (`false`) presented |
| 39 | * by Cloudflare to your origin. Cannot be changed after upload — updating |
| 40 | * this property triggers a replacement. |
| 41 | */ |
| 42 | ca: boolean; |
| 43 | /** |
| 44 | * The certificate in PEM format. A root CA certificate when `ca: true`, |
| 45 | * or a leaf certificate (chain) when `ca: false`. Cannot be changed after |
| 46 | * upload — updating this property triggers a replacement. |
| 47 | */ |
| 48 | certificates: string; |
| 49 | /** |
| 50 | * The private key for the certificate in PEM format. Required when |
| 51 | * uploading a leaf certificate (`ca: false`) that Cloudflare must present |
| 52 | * to your origin. Cannot be changed after upload — updating this property |
| 53 | * triggers a replacement. |
| 54 | */ |
| 55 | privateKey?: Redacted.Redacted<string>; |
| 56 | }; |
| 57 | |
| 58 | export type Attributes = { |
| 59 | /** |
| 60 | * Unique identifier of the uploaded certificate. |
| 61 | */ |
| 62 | mtlsCertificateId: string; |
| 63 | /** |
| 64 | * The Cloudflare account the certificate was uploaded to. |
| 65 | */ |
| 66 | accountId: string; |
| 67 | /** |
| 68 | * Human-readable name of the certificate. |
| 69 | */ |
| 70 | name: string | undefined; |
| 71 | /** |
| 72 | * Whether the certificate is a CA (`true`) or leaf (`false`) certificate. |
| 73 | */ |
| 74 | ca: boolean; |
| 75 | /** |
| 76 | * The certificate authority that issued the certificate. |
| 77 | */ |
| 78 | issuer: string | undefined; |
| 79 | /** |
| 80 | * The certificate serial number. |
| 81 | */ |
| 82 | serialNumber: string | undefined; |
| 83 | /** |
| 84 | * The type of hash used for the certificate signature. |
| 85 | */ |
| 86 | signature: string | undefined; |
| 87 | /** |
| 88 | * When the certificate expires. |
| 89 | */ |
| 90 | expiresOn: string | undefined; |
| 91 | /** |
| 92 | * When the certificate was uploaded to Cloudflare. |
| 93 | */ |
| 94 | uploadedOn: string | undefined; |
| 95 | /** |
| 96 | * How the certificate was created and who manages it. |
| 97 | */ |
| 98 | type: Type | undefined; |
| 99 | }; |
| 100 | |
| 101 | export type MtlsCertificate = Resource< |
| 102 | TypeId, |
| 103 | Props, |
| 104 | Attributes, |
| 105 | never, |
| 106 | Providers |
| 107 | >; |
| 108 | |
| 109 | /** |
| 110 | * An account-level Cloudflare mTLS certificate. |
| 111 | * |
| 112 | * Uploads a certificate to the account-level mTLS certificate store. Upload a |
| 113 | * CA certificate (`ca: true`) to validate client certificates (referenced by |
| 114 | * certificate-authority hostname associations and Hyperdrive |
| 115 | * `caCertificateId`), or a leaf certificate plus private key (`ca: false`) |
| 116 | * that Cloudflare presents to your origin (referenced by Worker |
| 117 | * `mtls_certificate` bindings and Hyperdrive `mtlsCertificateId`). |
| 118 | * |
| 119 | * Certificates are immutable: there is no update API, so changing any |
| 120 | * property triggers a replacement. |
| 121 | * ### Uploading Certificates |
| 122 | * **Example:** CA certificate |
| 123 | * ```typescript |
| 124 | * const ca = yield* Cloudflare.MtlsCertificate.MtlsCertificate("client-ca", { |
| 125 | * ca: true, |
| 126 | * certificates: caPem, |
| 127 | * }); |
| 128 | * ``` |
| 129 | * |
| 130 | * **Example:** Leaf certificate with private key |
| 131 | * ```typescript |
| 132 | * const cert = yield* Cloudflare.MtlsCertificate.MtlsCertificate("origin-client-cert", { |
| 133 | * ca: false, |
| 134 | * certificates: leafPem, |
| 135 | * privateKey: yield* Config.Redacted("ORIGIN_CLIENT_KEY"), |
| 136 | * }); |
| 137 | * ``` |
| 138 | * |
| 139 | * **Example:** Named certificate |
| 140 | * ```typescript |
| 141 | * const ca = yield* Cloudflare.MtlsCertificate.MtlsCertificate("client-ca", { |
| 142 | * name: "my-client-ca", |
| 143 | * ca: true, |
| 144 | * certificates: caPem, |
| 145 | * }); |
| 146 | * ``` |
| 147 | * |
| 148 | * ### Referencing from Hyperdrive |
| 149 | * **Example:** Verify the origin with an uploaded CA |
| 150 | * ```typescript |
| 151 | * const ca = yield* Cloudflare.MtlsCertificate.MtlsCertificate("db-ca", { |
| 152 | * ca: true, |
| 153 | * certificates: caPem, |
| 154 | * }); |
| 155 | * |
| 156 | * const hd = yield* Cloudflare.Hyperdrive.Connection("my-db", { |
| 157 | * origin: { ... }, |
| 158 | * mtls: { |
| 159 | * caCertificateId: ca.mtlsCertificateId, |
| 160 | * sslmode: "verify-full", |
| 161 | * }, |
| 162 | * }); |
| 163 | * ``` |
| 164 | * |
| 165 | * @see https://developers.cloudflare.com/ssl/client-certificates/ |
| 166 | * |
| 167 | * @resource |
| 168 | * @product mTLS Certificates |
| 169 | * @category SSL/TLS & Certificates |
| 170 | */ |
| 171 | export const MtlsCertificate = Resource<MtlsCertificate>(TypeId, { |
| 172 | aliases: ["Cloudflare.MtlsCertificate"], |
| 173 | }); |
| 174 | |
| 175 | /** |
| 176 | * Returns true if the given value is a MtlsCertificate resource. |
| 177 | */ |
| 178 | export const isMtlsCertificate = (value: unknown): value is MtlsCertificate => |
| 179 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 180 | |
| 181 | export const MtlsCertificateProvider = () => |
| 182 | Provider.succeed(MtlsCertificate, { |
| 183 | stables: ["mtlsCertificateId", "accountId"], |
| 184 | diff: Effect.fn(function* ({ id, olds, news, output }) { |
| 185 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 186 | if (!isResolved(news)) return undefined; |
| 187 | if ((output?.accountId ?? accountId) !== accountId) { |
| 188 | return { action: "replace" } as const; |
| 189 | } |
| 190 | const oldName = |
| 191 | output?.name ?? (yield* createCertificateName(id, olds.name)); |
| 192 | // Auto-generated names are engine-owned: the deployed name stays |
| 193 | // authoritative even if the generator would name this id differently |
| 194 | // today. Only an explicit user-provided name can force a replace. |
| 195 | const name = news.name ?? oldName; |
| 196 | if ( |
| 197 | oldName !== name || |
| 198 | (news.ca ?? undefined) !== (olds.ca ?? undefined) || |
| 199 | (news.certificates ?? undefined) !== (olds.certificates ?? undefined) || |
| 200 | unwrap(news.privateKey) !== unwrap(olds.privateKey) |
| 201 | ) { |
| 202 | // There is no update API for mTLS certificates — every change is a |
| 203 | // replacement. |
| 204 | return { action: "replace" } as const; |
| 205 | } |
| 206 | return undefined; |
| 207 | }), |
| 208 | read: Effect.fn(function* ({ id, output, olds }) { |
| 209 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 210 | const acct = output?.accountId ?? accountId; |
| 211 | if (output?.mtlsCertificateId) { |
| 212 | return yield* mtls |
| 213 | .getMtlsCertificate({ |
| 214 | accountId: acct, |
| 215 | mtlsCertificateId: output.mtlsCertificateId, |
| 216 | }) |
| 217 | .pipe( |
| 218 | Effect.map((cert) => toAttributes(cert, acct)), |
| 219 | Effect.catchTag("CertificateNotFound", () => |
| 220 | Effect.succeed(undefined), |
| 221 | ), |
| 222 | ); |
| 223 | } |
| 224 | // Cold read — recover by listing and matching on the deterministic |
| 225 | // physical name (the only brand available; certificates have no tags). |
| 226 | const name = yield* createCertificateName(id, olds?.name); |
| 227 | const match = yield* findByName(acct, name); |
| 228 | return match ? toAttributes(match, acct) : undefined; |
| 229 | }), |
| 230 | list: Effect.fn(function* () { |
| 231 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 232 | // Account-scoped collection — exhaustively paginate the mTLS |
| 233 | // certificate store and hydrate each item into the read Attributes |
| 234 | // shape (the private key is write-only and never returned). |
| 235 | return yield* mtls.listMtlsCertificates.pages({ accountId }).pipe( |
| 236 | Stream.runCollect, |
| 237 | Effect.map((chunk) => |
| 238 | Array.from(chunk).flatMap((page) => |
| 239 | (page.result ?? []) |
| 240 | // Cloudflare-managed certificates (e.g. the gateway/access |
| 241 | // managed CAs) reject deletion with `Unauthorized`; only |
| 242 | // enumerate user-uploaded `custom` certificates for teardown. |
| 243 | .filter( |
| 244 | (cert) => |
| 245 | cert.type !== "gateway_managed" && |
| 246 | cert.type !== "access_managed", |
| 247 | ) |
| 248 | .map((cert) => toAttributes(cert, accountId)), |
| 249 | ), |
| 250 | ), |
| 251 | ); |
| 252 | }), |
| 253 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 254 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 255 | const name = |
| 256 | output?.name ?? (yield* createCertificateName(id, news.name)); |
| 257 | |
| 258 | // Observe — the certificate id is the stable identifier; fall through |
| 259 | // a CertificateNotFound to the list+name match so we recover from |
| 260 | // out-of-band deletes and partial state persistence. |
| 261 | let observed = output?.mtlsCertificateId |
| 262 | ? yield* mtls |
| 263 | .getMtlsCertificate({ |
| 264 | accountId, |
| 265 | mtlsCertificateId: output.mtlsCertificateId, |
| 266 | }) |
| 267 | .pipe( |
| 268 | Effect.catchTag("CertificateNotFound", () => |
| 269 | Effect.succeed(undefined), |
| 270 | ), |
| 271 | ) |
| 272 | : yield* findByName(accountId, name); |
| 273 | |
| 274 | // Ensure — upload if missing. Cloudflare rejects uploading a |
| 275 | // certificate with identical content twice (code 1471); tolerate the |
| 276 | // race (or an orphaned prior upload) by re-listing and adopting the |
| 277 | // certificate with identical PEM content. |
| 278 | if (!observed) { |
| 279 | observed = yield* mtls |
| 280 | .createMtlsCertificate({ |
| 281 | accountId, |
| 282 | ca: news.ca, |
| 283 | certificates: news.certificates, |
| 284 | name, |
| 285 | privateKey: unwrap(news.privateKey), |
| 286 | }) |
| 287 | .pipe( |
| 288 | Effect.catchTag("CertificateAlreadyExists", (originalError) => |
| 289 | Effect.gen(function* () { |
| 290 | const match = yield* findByContent( |
| 291 | accountId, |
| 292 | news.certificates, |
| 293 | ); |
| 294 | if (!match) return yield* Effect.fail(originalError); |
| 295 | return match; |
| 296 | }), |
| 297 | ), |
| 298 | ); |
| 299 | } |
| 300 | |
| 301 | return toAttributes(observed, accountId, news); |
| 302 | }), |
| 303 | delete: Effect.fn(function* ({ output }) { |
| 304 | yield* mtls |
| 305 | .deleteMtlsCertificate({ |
| 306 | accountId: output.accountId, |
| 307 | mtlsCertificateId: output.mtlsCertificateId, |
| 308 | }) |
| 309 | .pipe( |
| 310 | Effect.catchTag( |
| 311 | ["CertificateNotFound", "CertificateAlreadyDeleted"], |
| 312 | () => Effect.void, |
| 313 | ), |
| 314 | ); |
| 315 | }), |
| 316 | }); |
| 317 | |
| 318 | const createCertificateName = (id: string, name: string | undefined) => |
| 319 | Effect.gen(function* () { |
| 320 | return name ?? (yield* createPhysicalName({ id, lowercase: true })); |
| 321 | }); |
| 322 | |
| 323 | const findByName = (accountId: string, name: string) => |
| 324 | Effect.gen(function* () { |
| 325 | const list = yield* mtls.listMtlsCertificates({ accountId }); |
| 326 | return list.result.find((c) => c.name === name); |
| 327 | }); |
| 328 | |
| 329 | const findByContent = (accountId: string, certificates: string) => |
| 330 | Effect.gen(function* () { |
| 331 | const list = yield* mtls.listMtlsCertificates({ accountId }); |
| 332 | return list.result.find( |
| 333 | (c) => c.certificates?.trim() === certificates.trim(), |
| 334 | ); |
| 335 | }); |
| 336 | |
| 337 | const unwrap = ( |
| 338 | value: Redacted.Redacted<string> | undefined, |
| 339 | ): string | undefined => |
| 340 | value === undefined |
| 341 | ? undefined |
| 342 | : Redacted.isRedacted(value) |
| 343 | ? Redacted.value(value) |
| 344 | : value; |
| 345 | |
| 346 | type CertificateShape = { |
| 347 | id?: string | null; |
| 348 | ca?: boolean | null; |
| 349 | name?: string | null; |
| 350 | issuer?: string | null; |
| 351 | serialNumber?: string | null; |
| 352 | signature?: string | null; |
| 353 | expiresOn?: string | null; |
| 354 | uploadedOn?: string | null; |
| 355 | type?: Type | null; |
| 356 | }; |
| 357 | |
| 358 | const toAttributes = ( |
| 359 | cert: CertificateShape, |
| 360 | accountId: string, |
| 361 | news?: Props, |
| 362 | ): Attributes => ({ |
| 363 | mtlsCertificateId: cert.id!, |
| 364 | accountId, |
| 365 | name: cert.name ?? undefined, |
| 366 | ca: cert.ca ?? news?.ca ?? false, |
| 367 | issuer: cert.issuer ?? undefined, |
| 368 | serialNumber: cert.serialNumber ?? undefined, |
| 369 | signature: cert.signature ?? undefined, |
| 370 | expiresOn: cert.expiresOn ?? undefined, |
| 371 | uploadedOn: cert.uploadedOn ?? undefined, |
| 372 | type: cert.type ?? undefined, |
| 373 | }); |