Skip to content

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

  1. Cloudflare presents the GIE-signed server certificate (uploaded as custom SSL cert).
  2. Partner sends a request with a client certificate signed by the GIE Sesam-Vitale CA.
  3. Cloudflare validates the client cert against the uploaded CA.
  4. The ZeroTrust Access Application enforces a non_identity policy requiring a valid certificate.
  5. With client_certificate_forwarding = true, Cloudflare forwards certificate metadata as HTTP headers.
  6. 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:

  1. cloudflare_zero_trust_access_mtls_certificate β€” uploads the GIE CA ("here's which CA to trust")
  2. cloudflare_zero_trust_access_group β€” defines a group restricted to certs signed by the GIE CA specifically (certificate = { id = ... })
  3. cloudflare_zero_trust_access_policy β€” non_identity decision allowing that group (cert-based auth, no login)
  4. 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 + logging
  • require_mtls_client_cert(expected_issuer_dn) β€” Flask decorator factory
  • MtlsWsgiMiddleware(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

  1. Check Cf-Client-Cert-Subject-Dn header is present (Cloudflare strips it if no valid cert)
  2. Check Cf-Client-Cert-Issuer-Dn matches GIE_SESAM_VITALE_ISSUER_DN
  3. Log cert metadata (subject, issuer, serial) for audit
  4. 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 CA
  • cloudflare_custom_ssl β€” uploads the GIE-signed server certs (one per env)
  • cloudflare_zero_trust_access_mtls_hostname_settings β€” enables cert forwarding per hostname
  • cloudflare_zero_trust_access_group + policy + application β€” mTLS enforcement

Hostnames added to alternate_hostnames in:

  • infra/src/stacks/aws/qovery-env-backend-fr--prod/locals.tf
  • infra/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:

infra/scripts/test-mtls-staging.sh      # pass -v for verbose curl output

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:

  1. CA cert β€” Extracted bundle β†’ uploaded to ca_certificate field in Secrets Manager. Used by cloudflare_zero_trust_access_mtls_certificate to validate incoming client certs.
  2. Server certs β€” Extracted leaf + intermediate + root (in correct chain order) β†’ uploaded to certificate field in Secrets Manager.
  3. Server + CA certs β€” Terraform reads all cert material from AWS Secrets Manager (data "aws_secretsmanager_secret_version"), no local PEM files in the repo.
  4. Client certs β€” Uploaded to Secrets Manager (certificate and ca_certificate fields updated) via tmp/update_secrets.sh. Private keys were already there from CSR generation.
  5. Prod server cert uploaded to Cloudflare β€” cloudflare_custom_ssl.interamc_prod created successfully with bundle_method = "force" and type = "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 β§‰.