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 certificate metadata as HTTP headers. - 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-Client-Cert-* headers).
Headers forwarded by Cloudflare¶
| Header | Description |
|---|---|
Cf-Client-Cert-Subject-Dn |
Subject Distinguished Name |
Cf-Client-Cert-Issuer-Dn |
Issuer Distinguished Name (the CA) |
Cf-Client-Cert-Serial-Number |
Certificate serial number |
Cf-Client-Cert-Der-Base64 |
Full certificate in DER format, base64-encoded |
Cf-Client-Cert-Sha256 |
SHA-256 fingerprint |
Cloudflare strips these headers from requests without a valid client certificate.
Backend authentication¶
Two layers of defense:
1. Cloudflare validates the client cert at the edge (ZeroTrust Access policy)
2. Backend checks Cf-Client-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-Client-Cert-Subject-Dnheader is present (Cloudflare strips it if no valid cert) - Check
Cf-Client-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 cert forwarding per hostnamecloudflare_zero_trust_access_group+policy+applicationβ mTLS enforcement
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 β§.