mTLS for TP Internalization (inter-AMC)¶
Context¶
TP internalization requires interfacing with inter-AMC (hosted by ProBTP) via SOAP services secured by mutual TLS, using certificates issued by GIE Sesam-Vitale.
This is the top priority blocker for the HP Payment crew: Alan cannot be listed as a TP operator in HP software until these SOAP exchanges are in place.
Scope: Only edge mTLS (inter-AMC -> Cloudflare). The Cloudflare->Qovery mTLS hardening is a separate initiative.
Architecture¶
Open interactive schema in full page β
Hostnames¶
| Environment | Hostname |
|---|---|
| Production | api-interamc-noar.alan.com |
| Staging | api-interamc-noar-staging.alan.com |
The -noar suffix means "No Automated Renewal" β the GIE-signed server certificates have fixed validity periods and must be manually renewed.
These hostnames CNAME to the same Qovery ALB as api.alan.com. The hostname acts as an mTLS boundary β Cloudflare Access policies enforce client certificate validation only on these hostnames.
Certificates¶
All certificates are issued by the GIE Sesam-Vitale CA. Two pairs per environment:
| Purpose | Secrets Manager path (prod) | Secrets Manager path (staging) |
|---|---|---|
| Server cert (TLS termination) | fr-api/env/interamc-server-keys |
fr-api-staging/env/interamc-server-keys |
| Client cert (outbound calls) | fr-api/env/interamc-client-keys |
fr-api-staging/env/interamc-client-keys |
Each secret is raw JSON with certificate, private_key, ca_certificate properties (+ remote_service_url for client secrets). Note: ca_certificate in client secrets is unused β the portal's server cert is Sectigo-signed (public CA), validated via system CAs.
How mTLS works at the Cloudflare edge¶
- Cloudflare presents the GIE-signed server certificate (uploaded as custom SSL cert).
- Partner sends a request with a client certificate signed by the GIE Sesam-Vitale CA.
- Cloudflare validates the client cert against the uploaded CA.
- The ZeroTrust Access Application enforces a
non_identitypolicy requiring a valid certificate. - With
client_certificate_forwarding = true, Cloudflare forwards raw cert data (Cf-Client-Cert-Der-Base64,Cf-Client-Cert-Sha256). - The "Add TLS client auth headers" managed transform (
cloudflare_managed_transforms.alan_comin main.tf) addscf-cert-subject-dn,cf-cert-issuer-dn,cf-cert-serialheaders fromcf.tls_client_auth.*fields. - The Flask backend reads those headers to identify the caller.
ZeroTrust Access policy chain¶
The CA upload alone doesn't enforce anything. Four resources work together to block unauthenticated requests:
cloudflare_zero_trust_access_mtls_certificateβ uploads the GIE CA ("here's which CA to trust")cloudflare_zero_trust_access_groupβ defines a group restricted to certs signed by the GIE CA specifically (certificate = { id = ... })cloudflare_zero_trust_access_policyβnon_identitydecision allowing that group (cert-based auth, no login)cloudflare_zero_trust_access_applicationβ applies the policy to each hostname
Without the Access Application + Policy, requests without a client cert would pass through (Cloudflare would just omit the cf-cert-* headers).
Headers forwarded by Cloudflare¶
Via "Add TLS client auth headers" managed transform¶
These headers are set by Cloudflare's managed transform (cloudflare_managed_transforms.alan_com in main.tf). Zone-wide, but fields are empty for hostnames without mTLS configured. Ref β§.
| Header | Expression field | Used by backend |
|---|---|---|
cf-cert-subject-dn |
cf.tls_client_auth.cert_subject_dn |
Yes |
cf-cert-issuer-dn |
cf.tls_client_auth.cert_issuer_dn |
Yes |
cf-cert-serial |
cf.tls_client_auth.cert_serial |
Yes (audit log) |
cf-cert-verified |
cf.tls_client_auth.cert_verified |
No (Access enforces) |
| ... | (16 headers total) |
Via client_certificate_forwarding = true¶
| Header | Description |
|---|---|
Cf-Client-Cert-Der-Base64 |
Full certificate in DER format, base64-encoded |
Cf-Client-Cert-Sha256 |
SHA-256 fingerprint |
Backend authentication¶
Two layers of defense:
1. Cloudflare validates the client cert at the edge (ZeroTrust Access policy)
2. Backend checks cf-cert-* headers as defense in depth β verifies the issuer DN matches the GIE Sesam-Vitale CA
Generic utilities (shared/iam/mtls.py)¶
Reusable mTLS auth building blocks, not tied to inter-AMC:
check_mtls_cert(subject_dn, issuer_dn, serial_number, expected_issuer_dn)β core validation + loggingrequire_mtls_client_cert(expected_issuer_dn)β Flask decorator factoryMtlsWsgiMiddleware(app, expected_issuer_dn)β WSGI middleware for non-Flask services (e.g. Spyne SOAP)
Inter-AMC wiring (components/fr/internal/interamc_api/auth.py)¶
Pre-configured for the GIE Sesam-Vitale CA:
GIE_SESAM_VITALE_ISSUER_DN = "C=FR, O=GIE SESAM-VITALE, OU=0002 391722881, CN=AC SERVICES APPLICATIFS"
# Flask decorator for /health and future Flask endpoints
require_interamc_mtls = require_mtls_client_cert(expected_issuer_dn=GIE_SESAM_VITALE_ISSUER_DN)
# WSGI middleware for the SOAP service (bypasses Flask)
class InterAmcMtlsWsgiMiddleware(MtlsWsgiMiddleware):
def __init__(self, app):
super().__init__(app, expected_issuer_dn=GIE_SESAM_VITALE_ISSUER_DN)
Protected endpoints¶
| Endpoint | Type | Protection |
|---|---|---|
/ws/inter-amc/health |
Flask | @require_interamc_mtls decorator |
/ws/inter-amc/ota-service |
Spyne SOAP (WSGI) | InterAmcMtlsWsgiMiddleware wrapper |
What happens on each request¶
- Check
cf-cert-subject-dnheader is present (empty when no valid cert) - Check
cf-cert-issuer-dnmatchesGIE_SESAM_VITALE_ISSUER_DN - Log cert metadata (subject, issuer, serial) for audit
- If either check fails β 403
Infrastructure (Terraform)¶
Resources in infra/src/stacks/aws/cloudflare-core/zero_trust.tf:
cloudflare_zero_trust_access_mtls_certificateβ uploads the GIE Sesam-Vitale CAcloudflare_custom_sslβ uploads the GIE-signed server certs (one per env)cloudflare_zero_trust_access_mtls_hostname_settingsβ enables raw cert forwarding per hostnamecloudflare_managed_transforms.alan_com(main.tf) β "Add TLS client auth headers" managed transformcloudflare_zero_trust_access_group+policy+applicationβ mTLS enforcement
Staging accepts 4 client-cert trust anchors: GIE Sesam-Vitale (also prod), Sectigo RSA OV intermediate (ProBTP), Sectigo Public Server Auth R46 (modern Sectigo chain, ProBTP), and USERTrust RSA CA (legacy Sectigo root, ProBTP). Prod accepts only GIE. Each anchor is a separate cloudflare_zero_trust_access_mtls_certificate resource because Cloudflare treats only the first cert in a bundle as the trust anchor.
Hostnames added to alternate_hostnames in:
infra/src/stacks/aws/qovery-env-backend-fr--prod/locals.tfinfra/src/stacks/aws/qovery-env-backend-fr--staging/locals.tf
Testing on staging and prod¶
Use the mTLS test script to verify all scenarios (no cert, self-signed cert, real GIE-signed cert) against staging and production:
Certificate deployment status (2026-04-15)¶
What was done¶
GIE Sesam-Vitale delivered 4 signed certificate zip files on Apr 10 (to Thomas Quinot's email). Each zip contains: leaf cert (.pem), CA bundle (-bundle.pem with root ACR-A SESAM-VITALE + intermediate AC SERVICES APPLICATIFS), plus .der and .p7b formats.
Zip β purpose mapping:
| Zip file | Purpose |
|---|---|
api-interamc-noar.alan.com.zip |
Prod server cert (Cloudflare custom SSL) |
api-interamc-noar-staging.alan.com.zip |
Staging server cert (Cloudflare custom SSL) |
interamc-ota-client.alan.com.zip |
Prod client cert (outbound calls to inter-AMC) |
interamc-ota-staging-client.alan.com.zip |
Staging client cert (outbound calls to inter-AMC) |
Completed:
- CA cert β Extracted bundle β uploaded to
ca_certificatefield in Secrets Manager. Used bycloudflare_zero_trust_access_mtls_certificateto validate incoming client certs. - Server certs β Extracted leaf + intermediate + root (in correct chain order) β uploaded to
certificatefield in Secrets Manager. - Server + CA certs β Terraform reads all cert material from AWS Secrets Manager (
data "aws_secretsmanager_secret_version"), no local PEM files in the repo. - Client certs β Uploaded to Secrets Manager (
certificateandca_certificatefields updated) viatmp/update_secrets.sh. Private keys were already there from CSR generation. - Prod server cert uploaded to Cloudflare β
cloudflare_custom_ssl.interamc_prodcreated successfully withbundle_method = "force"andtype = "legacy_custom".
Cloudflare custom SSL β key learnings¶
Cloudflare's cloudflare_custom_ssl rejects certificates from CAs not in their public trust store. GIE Sesam-Vitale is a private CA, so all three bundle methods (ubiquitous, optimal, force) initially failed with error 2100 "cannot be bundled".
The fix: Provide the full chain in correct order (leaf β intermediate β root) with bundle_method = "force". The Cloudflare docs confirm that force = "User Defined" mode, which explicitly exempts from the public trust requirement. The initial failures were caused by wrong chain order (root before intermediate in the bundle file).
Outbound mTLS (Alan β inter-AMC portal)¶
No Cloudflare involved. The backend makes HTTPS requests presenting Alan's GIE-signed client cert. Certs are in Secrets Manager, read via INTER_AMC_MTLS_CONFIG_SECRET_NAME config. The portal's server cert is Sectigo-signed (public CA) β validated via system CAs, no custom CA needed. The portal validates Alan's client cert against GIE CA. This path is ready.
Inbound mTLS (inter-AMC β Alan)¶
ProBTP connects to api-interamc-noar.alan.com. GIE/ProBTP validates that our server cert is GIE-signed (per the GIE spec: "la liste des autoritΓ©s temporaires autorisΓ©es est fournie dans le guide d'accrochage AMC"). The GIE doc with cert requirements is here β§.