diff --git a/Access_Controls-Applications-Infrastructure.tf b/Access_Controls-Applications-Infrastructure.tf index a85490d..5a0f53c 100644 --- a/Access_Controls-Applications-Infrastructure.tf +++ b/Access_Controls-Applications-Infrastructure.tf @@ -98,7 +98,7 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_aws_app_ssh_infr require = [ { device_posture = { - integration_uid = var.cloudflare_gateway_posture_id + integration_uid = cloudflare_zero_trust_device_posture_rule.gateway_connected.id } }, { @@ -210,7 +210,7 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_gcp_app_ssh_infr require = [ { device_posture = { - integration_uid = var.cloudflare_gateway_posture_id + integration_uid = cloudflare_zero_trust_device_posture_rule.gateway_connected.id } }, { diff --git a/Access_Controls-Applications-ssh.tf b/Access_Controls-Applications-ssh.tf index ce6dd9e..de87583 100644 --- a/Access_Controls-Applications-ssh.tf +++ b/Access_Controls-Applications-ssh.tf @@ -24,10 +24,10 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_aws_app_ssh_brow uri = var.cloudflare_aws_subdomain_ssh }] - allowed_idps = [ - cloudflare_zero_trust_access_identity_provider.gmail.id, + # Google proposé seulement si la brique IdP Google est activée + allowed_idps = concat(local.google_idp_ids, [ cloudflare_zero_trust_access_identity_provider.authentik_oidc.id, - ] + ]) auto_redirect_to_identity = false allow_authenticate_via_warp = false @@ -63,10 +63,10 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_gcp_app_ssh_brow uri = var.cloudflare_gcp_subdomain_ssh }] - allowed_idps = [ - cloudflare_zero_trust_access_identity_provider.gmail.id, + # Google proposé seulement si la brique IdP Google est activée + allowed_idps = concat(local.google_idp_ids, [ cloudflare_zero_trust_access_identity_provider.authentik_oidc.id, - ] + ]) auto_redirect_to_identity = false allow_authenticate_via_warp = false diff --git a/Access_Controls-Applications-vnc.tf b/Access_Controls-Applications-vnc.tf index f9e7fbf..45173f2 100644 --- a/Access_Controls-Applications-vnc.tf +++ b/Access_Controls-Applications-vnc.tf @@ -24,10 +24,10 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_aws_app_vnc_brow uri = var.cloudflare_aws_subdomain_vnc }] - allowed_idps = [ - cloudflare_zero_trust_access_identity_provider.gmail.id, + # Google proposé seulement si la brique IdP Google est activée + allowed_idps = concat(local.google_idp_ids, [ cloudflare_zero_trust_access_identity_provider.authentik_oidc.id, - ] + ]) auto_redirect_to_identity = false allow_authenticate_via_warp = false @@ -58,10 +58,10 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_gcp_app_vnc_brow uri = var.cloudflare_gcp_subdomain_vnc }] - allowed_idps = [ - cloudflare_zero_trust_access_identity_provider.gmail.id, + # Google proposé seulement si la brique IdP Google est activée + allowed_idps = concat(local.google_idp_ids, [ cloudflare_zero_trust_access_identity_provider.authentik_oidc.id, - ] + ]) auto_redirect_to_identity = false allow_authenticate_via_warp = false diff --git a/Access_Controls-Policies-Rule_Groups.tf b/Access_Controls-Policies-Rule_Groups.tf index 0c02aa8..9f59309 100644 --- a/Access_Controls-Policies-Rule_Groups.tf +++ b/Access_Controls-Policies-Rule_Groups.tf @@ -100,11 +100,13 @@ locals { (contains(local.other_countries, code) ? null : code) ]) - # OS posture checks + # OS posture checks — MIGRÉ : ces règles sont désormais des ressources + # Terraform (Team_Resources-Devices-Device_posture.tf), plus aucun ID + # à recopier depuis le dashboard. os_posture_checks = [ - var.cloudflare_linux_posture_id, - var.cloudflare_macos_posture_id, - var.cloudflare_windows_posture_id + cloudflare_zero_trust_device_posture_rule.linux_kernel_version.id, + cloudflare_zero_trust_device_posture_rule.macos_os_version.id, + cloudflare_zero_trust_device_posture_rule.windows_os_version.id, ] policy_groups = { @@ -595,10 +597,11 @@ resource "cloudflare_zero_trust_access_policy" "policies" { # Require conditions require = concat( - # Device posture (always required if specified) + # Device posture (always required if specified) — MIGRÉ : la posture + # "connecté via Gateway" est gérée par Terraform, plus d'ID manuel try(each.value.require_posture, false) ? [{ device_posture = { - integration_uid = var.cloudflare_gateway_posture_id + integration_uid = cloudflare_zero_trust_device_posture_rule.gateway_connected.id } }] : [], # MFA requirement diff --git a/Access_Controls-Service_Auth-mTLS_Certificates.tf b/Access_Controls-Service_Auth-mTLS_Certificates.tf new file mode 100644 index 0000000..d7c8c4e --- /dev/null +++ b/Access_Controls-Service_Auth-mTLS_Certificates.tf @@ -0,0 +1,54 @@ +# ============================================================================= +# CLOUDFLARE : Access Controls : Service Auth : mTLS Certificates +# ============================================================================= +# BRIQUE mTLS : en plus de l'identité (IdP), exiger un CERTIFICAT CLIENT +# installé sur le poste pour accéder aux applications sensibles. Même un +# compte compromis ne suffit plus : il faut aussi la machine. +# +# 1. Une autorité de certification (CA) est déposée dans Vault (secret/mtls, +# clé ca_pem — le certificat PUBLIC de la CA, pas sa clé privée !) +# 2. Cloudflare vérifie que le client présente un certificat signé par +# cette CA sur les hostnames associés +# 3. La politique réutilisable ci-dessous ("Allow valid client certificate") +# peut être ajoutée aux applications à durcir +# +# La brique est inerte tant que mtls_enabled = false. +# ACTIONS EXTERNES (voir ACTIONS_EXTERNES.md) : générer la CA, distribuer +# les certificats clients sur les postes, pousser le PEM dans Vault. +# ============================================================================= + +data "vault_generic_secret" "mtls" { + count = var.mtls_enabled ? 1 : 0 + path = var.vault_mtls_path +} + +#====================================================== +# La CA de confiance, associée aux hostnames à protéger +#====================================================== +resource "cloudflare_zero_trust_access_mtls_certificate" "home_ca" { + count = var.mtls_enabled ? 1 : 0 + account_id = local.cloudflare_account_id + + name = "Home mTLS CA" + certificate = data.vault_generic_secret.mtls[0].data["ca_pem"] + + associated_hostnames = [ + "home.${var.cloudflare_zone}", + ] +} + +#====================================================== +# Politique réutilisable : certificat client valide exigé +#====================================================== +resource "cloudflare_zero_trust_access_policy" "allow_valid_client_certificate" { + count = var.mtls_enabled ? 1 : 0 + account_id = local.cloudflare_account_id + + name = "Allow valid client certificate" + decision = "non_identity" + session_duration = "24h" + + include = [{ + certificate = {} + }] +} diff --git a/Integrations-Identity_providers.tf b/Integrations-Identity_providers.tf index e045d07..be0aade 100644 --- a/Integrations-Identity_providers.tf +++ b/Integrations-Identity_providers.tf @@ -2,16 +2,29 @@ # CLOUDFLARE : Integrations : Identity Providers # ============================================================================= +#====================================================== +# BRIQUE IdP n°3 : Google (OAuth) +# Secrets lus depuis Vault : secret/google +# - client_id_cloudflare : Client ID OAuth (Google Cloud Console) +# - secret_cloudflare : Client secret OAuth # +# CORRECTIF : l'ancienne version contenait des placeholders en dur +# ("") -> le bouton Google de la page de login échouait +# systématiquement. Les identifiants viennent désormais de Vault, et la +# brique est inerte (bouton absent) tant que google_idp_enabled = false. +# ACTIONS EXTERNES : créer le client OAuth Google + le secret Vault, +# voir ACTIONS_EXTERNES.md. +#====================================================== resource "cloudflare_zero_trust_access_identity_provider" "gmail" { + count = var.google_idp_enabled ? 1 : 0 + account_id = local.cloudflare_account_id name = "Gmail" type = "google" - zone_id = local.cloudflare_zone_id config = { - client_id = "" - client_secret = "" + client_id = local.google_oidc_client_id_cloudflare + client_secret = local.google_oidc_secret_cloudflare email_claim_name = "email" } } diff --git a/README.md b/README.md index 6327605..c6d76ed 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,195 @@ Inspiré de [macharpe/terraform-cloudflare-zero-trust-demo](https://github.com/m > 📋 **Avant tout apply : lire [ACTIONS_EXTERNES.md](ACTIONS_EXTERNES.md)** — tout ce que Terraform ne peut pas faire (secrets Vault à créer, sshd à configurer, connecteurs à installer...) y est détaillé brique par brique. +--- + +# 🎯 Ce que cette plateforme fait + +## Le principe : Zero Trust au lieu d'un VPN + +Dans un modèle classique, on ouvre des ports sur sa box ou on installe un VPN : une fois « dedans », on accède à tout. Ici, **rien n'est exposé** : aucun port ouvert, aucune IP publique ne pointe vers le réseau domestique. À la place : + +- Un connecteur **cloudflared** établit un tunnel **sortant** vers le réseau Cloudflare — c'est lui qui appelle Cloudflare, jamais l'inverse ; +- Chaque application (Home Assistant, apps web, SSH...) est publiée sur un sous-domaine public (`home.tips-of-mine.org`...), mais **chaque requête** doit d'abord prouver **qui** elle est (identité via Authentik/Azure/Google), **depuis quoi** (posture du poste : WARP actif, disque chiffré, pare-feu...) et **d'où** (pays, réseau) ; +- Les postes de travail embarquent l'agent **WARP** : tout leur trafic passe par **Gateway**, qui filtre DNS, HTTP et réseau (publicités, malware, mouvements latéraux...) ; +- Plusieurs **sites distants** (agences d'entreprise) sont raccordés au même réseau privé virtuel, même avec des plans d'adressage identiques. + +## Vue d'ensemble + +```mermaid +flowchart LR + subgraph POSTES["🖥️ Postes de travail"] + NAV["Navigateur"] + WARP["Agent WARP
multi-OS"] + end + + subgraph EDGE["☁️ Réseau Cloudflare"] + direction TB + ACCESS["🔐 Access
authentification & politiques"] + GW["🛡️ Gateway
filtrage DNS / HTTP / réseau"] + DENY["📄 Worker
page de refus"] + end + + subgraph IDP["🪪 Fournisseurs d'identité"] + AUTHENTIK["Authentik OIDC"] + AZUREAD["Azure Entra ID"] + GOOGLE["Google OAuth"] + end + + subgraph HOME["🏠 Réseau domestique - 10.0.x.x"] + CFD["connecteur cloudflared"] + HA["Home Assistant"] + WEB["Apps web internes"] + SSHSRV["Serveur SSH"] + end + + subgraph SITES["🏢 Sites distants d'entreprise"] + PARIS["Site Paris
10.100.0.0/16"] + LYON["Site Lyon
10.100.0.0/16
même CIDR !"] + end + + NAV -- "https://home.tips-of-mine.org" --> ACCESS + WARP -- "tout le trafic du poste" --> GW + ACCESS <-->|"vérification d'identité"| IDP + ACCESS -- "refus ➜ explication" --> DENY + ACCESS -- "tunnel (connexion sortante)" --> CFD + CFD --> HA & WEB & SSHSRV + GW -- "réseau privé virtuel" --> SITES + PARIS -."vnet-site-paris".- GW + LYON -."vnet-site-lyon".- GW +``` + +Point clé : les flèches vers le réseau domestique et les sites passent par des tunnels **initiés depuis l'intérieur**. Couper cloudflared = plus aucun accès possible, il n'existe pas de « porte » à attaquer. + +## Parcours d'un accès utilisateur + +Ce qui se passe entre « je tape l'URL » et « je vois l'application » : + +```mermaid +sequenceDiagram + autonumber + actor U as Utilisateur + participant CF as Edge Cloudflare + participant AC as Access + participant IdP as Authentik (ou Azure/Google) + participant T as cloudflared (tunnel) + participant App as Home Assistant + + U->>CF: https://home.tips-of-mine.org + CF->>AC: session Access valide ? + AC-->>U: page de login (choix de l'IdP) + U->>IdP: authentification + MFA + IdP-->>AC: identité confirmée (OIDC) + Note over AC: Évaluation des politiques :
groupe autorisé ? posture du poste OK ?
pays autorisé ? MFA (pas SMS) ? + alt Accès autorisé + AC-->>U: cookie de session (durée limitée) + CF->>T: requête transmise dans le tunnel + T->>App: http://10.0.4.135:8123 (réseau local) + App-->>U: l'application s'affiche + else Accès refusé + AC-->>U: redirection ➜ denied.tips-of-mine.org
(page Worker : raison + contact) + end +``` + +À noter : l'application d'origine (Home Assistant...) ne voit **jamais** de trafic non authentifié — les scans, bots et tentatives de force brute meurent sur l'edge Cloudflare, avant le tunnel. + +## Cycle de vie d'un poste de travail (agent WARP) + +```mermaid +flowchart TB + A["1 - Installation du client WARP
(manuelle ou MDM)"] --> B["2 - Login with Cloudflare Zero Trust
équipe : tips-of-mine"] + B --> C{"Politique d'enrôlement :
email du domaine ?
MFA validée ?"} + C -- non --> X["❌ Poste refusé"] + C -- oui --> D["3 - Poste enrôlé dans l'organisation"] + D --> E["Profil appareil appliqué selon l'OS
(Windows / macOS / Linux)"] + D --> F["Vérifications de posture en continu :
WARP actif · pare-feu · disque chiffré · OS à jour"] + D --> G["Tout le trafic passe par Gateway :
filtrage DNS (pubs, malware)
filtrage HTTP (catégories, IA, DLP)
filtrage réseau (anti-mouvement latéral)"] + D --> H["Tests DEX : mesure de latence
vers les apps depuis le poste"] + F -- "posture KO" --> I["⛔ Les applications exigeant
la posture deviennent inaccessibles"] +``` + +La posture n'est pas vérifiée qu'à la connexion : un poste qui désactive son pare-feu **perd l'accès** aux applications qui l'exigent, même avec une session valide. + +## Sites distants : un réseau d'entreprise multi-agences + +Le scénario simulé : une entreprise dont les agences ont été déployées sur le **même template réseau** (ou issues d'une fusion) — donc des plages IP identiques, normalement impossibles à interconnecter : + +```mermaid +flowchart TB + subgraph CF["☁️ Réseau privé virtuel Cloudflare"] + VNP["vnet-site-paris"] + VNL["vnet-site-lyon"] + end + + subgraph P["🏢 Site Paris - siège"] + CP["cloudflared"] --- LANP["LAN 10.100.0.0/16
serveurs, imprimantes, AD"] + end + + subgraph L["🏢 Site Lyon - agence"] + CL["cloudflared"] --- LANL["LAN 10.100.0.0/16
⚠️ même plage que Paris"] + end + + U["💻 Poste WARP
warp-cli vnet ..."] + + CP -- "tunnel sortant" --> VNP + CL -- "tunnel sortant" --> VNL + U -- "vnet actif = paris ➜ joint Paris" --> VNP + U -. "vnet actif = lyon ➜ joint Lyon" .-> VNL +``` + +Chaque site vit dans son **réseau virtuel** : les deux `10.100.0.0/16` cohabitent sans conflit, et le poste WARP choisit à quel site « se téléporter » en changeant de réseau virtuel actif. La variante **WARP Connector** (bidirectionnelle : le site peut aussi initier des connexions vers le reste du réseau) est prête dans la brique correspondante. + +## Accès SSH : deux philosophies comparées + +Le repo implémente volontairement **les deux** pour montrer la différence : + +```mermaid +flowchart LR + subgraph MODERNE["✅ Certificats éphémères (recommandé)"] + direction LR + U1["Utilisateur"] -- "1 - auth Access
(IdP + MFA + posture)" --> CA["CA Cloudflare"] + CA -- "2 - certificat SSH
valable quelques secondes" --> U1 + U1 -- "3 - connexion SSH" --> S1["sshd
TrustedUserCAKeys"] + end + + subgraph CLASSIQUE["⚠️ Clé SSH classique (pédagogique)"] + direction LR + U2["Utilisateur"] -- "clé privée permanente
(stockée, à faire tourner)" --> S2["sshd
authorized_keys"] + end +``` + +| | Certificats éphémères | Clé classique | +|---|---|---| +| Durée de vie du secret | quelques secondes | des années | +| Vol de la clé | inutilisable ensuite | compromission totale | +| Rotation | automatique | manuelle, souvent oubliée | +| Révocation d'un utilisateur | retirer du groupe IdP | retirer la clé de chaque serveur | +| Traçabilité | chaque certificat est nominatif | une clé peut être partagée | + +## Le pipeline de filtrage Gateway + +Chaque requête d'un poste WARP traverse les politiques dans cet ordre (la précédence numérique fait foi) : + +```mermaid +flowchart LR + REQ["Requête du poste"] --> DNS["1 - Politiques DNS
· blocage malware
· liste pubs Pi-hole
(mise à jour quotidienne)"] + DNS --> L4["2 - Politiques réseau L4
· anti-mouvement latéral
SSH RDP SMB WinRM DB
· contrôle RDP par identité"] + L4 --> DNI["3 - Do Not Inspect
banque & santé :
jamais déchiffrés"] + DNI --> HTTP["4 - Politiques HTTP
· gouvernance IA
· blocage catégories
· DLP (IBAN)
· isolation RBI"] + HTTP --> OK["✅ Autorisé"] + DNS -- "domaine bloqué" --> BLOCK["⛔ Page de blocage"] + L4 -- "latéral détecté" --> BLOCK + HTTP -- "contenu interdit" --> BLOCK +``` + +L'anti-mouvement latéral mérite une explication : même **à l'intérieur** du réseau, une machine compromise ne peut pas rebondir en SSH/RDP/SMB vers ses voisines — seuls les clients WARP (identifiés, conformes) le peuvent. C'est le « Zero Trust » appliqué au trafic est-ouest, pas seulement à l'entrée. + +## Accès machine-to-machine + +Les robots (CI, supervision) n'ont pas d'IdP : ils présentent un **service token** (paire ID/secret dans deux en-têtes HTTP) évalué par une politique dédiée `non_identity`. Même modèle que les humains — identité vérifiable, durée limitée, révocable — mais sans navigateur. + +--- + # Les briques Les fichiers suivent la navigation du dashboard Cloudflare Zero Trust (`Section-SousSection-Nom.tf`). @@ -20,14 +209,15 @@ Les fichiers suivent la navigation du dashboard Cloudflare Zero Trust (`Section- | `Networks-Routes-Virtual_Networks.tf` | Réseaux virtuels par cloud | | `variables.auto.tfvars` → `applications` | La map des applications exposées : ajouter une entrée = DNS + ingress créés au prochain apply | -## Identité — deux briques IdP interchangeables +## Identité — trois briques IdP interchangeables | Brique | Fichiers | Activation | |---|---|---| | **Authentik (OIDC)** | `Integrations-Identity_providers.tf`, `Access_Controls-Policies-Rule_Groups.tf` | toujours active | | **Azure Entra ID** | `Integrations-Identity_providers.tf`, `Access_Controls-Policies-Rule_Groups-Azure.tf` | `azure_idp_enabled = true` + secret Vault `secret/azure` | +| **Google (OAuth)** | `Integrations-Identity_providers.tf` | `google_idp_enabled = true` + secret Vault `secret/google` | -Les deux alimentent les mêmes concepts : rule groups, politiques réutilisables, enrôlement WARP. +Toutes alimentent les mêmes concepts : rule groups, politiques réutilisables, enrôlement WARP. ## Accès SSH — deux approches comparées @@ -64,9 +254,11 @@ Les deux alimentent les mêmes concepts : rule groups, politiques réutilisables | Fichier | Contenu | |---|---| -| `Insights-Digital_Experience-Tests.tf` | Tests DEX (HTTP + traceroute) exécutés par les postes WARP | +| `Insights-Digital_Experience-Tests.tf` | Tests DEX (HTTP + traceroute) exécutés par les postes WARP (`dex_enabled`) | | `Insights-Logs-Logpush.tf` | Export des logs Gateway vers S3/R2/SIEM (`logpush_enabled`) | | `Access_Controls-Service_Auth-Service_Tokens.tf` | Accès machine-to-machine (CI) par service token | +| `Access_Controls-Service_Auth-mTLS_Certificates.tf` | Certificat client exigé en plus de l'identité (`mtls_enabled`) | +| `Workers-Deny_Page.tf` + `workers/deny-page.js` | Page de refus d'accès personnalisée sur denied.tips-of-mine.org | # Prérequis @@ -117,11 +309,12 @@ terraform plan | Workflow | Déclencheur | Rôle | |---|---|---| -| `validate.yml` | pull request | fmt + validate + **plan** | +| `validate.yml` | pull request | fmt + validate + **terraform test** + tflint/trivy + **plan** | | `cicd.yml` | push sur `main` | plan + **apply** | -| `action-update-lists.yml` | cron quotidien | met à jour la liste Pi-hole et ouvre une PR | +| `drift.yml` | cron quotidien 16h00 | **détection de dérive** (plan seul, échoue si l'infra a divergé du code) | +| `action-update-lists.yml` | cron quotidien 14h00 | met à jour la liste Pi-hole et ouvre une PR | -Secrets Gitea nécessaires : `VAULT_TOKEN`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`. +Secrets Gitea nécessaires : `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, et pour Vault : `VAULT_ROLE_ID` + `VAULT_SECRET_ID` (AppRole, recommandé) ou `VAULT_TOKEN` (token statique). Le state S3 est verrouillé nativement (`use_lockfile`). # Buy me a coffe Buy Me a Coffee at ko-fi.com diff --git a/Team_Resources-Devices-Device_posture.tf b/Team_Resources-Devices-Device_posture.tf index c8ae1d0..2aef10a 100644 --- a/Team_Resources-Devices-Device_posture.tf +++ b/Team_Resources-Devices-Device_posture.tf @@ -73,6 +73,24 @@ resource "cloudflare_zero_trust_device_posture_rule" "disk_encrypted" { } } +#====================================================== +# Le trafic du poste passe par Gateway +# (remplace l'ancien ID manuel cloudflare_gateway_posture_id) +#====================================================== +resource "cloudflare_zero_trust_device_posture_rule" "gateway_connected" { + account_id = local.cloudflare_account_id + + name = "TF - Connecte via Gateway" + description = "Le trafic du poste transite par Cloudflare Gateway" + type = "gateway" + + match = [ + { platform = "windows" }, + { platform = "mac" }, + { platform = "linux" }, + ] +} + #====================================================== # Version d'OS minimale (exemple Windows 11 23H2) #====================================================== @@ -94,6 +112,50 @@ resource "cloudflare_zero_trust_device_posture_rule" "windows_os_version" { } } +#====================================================== +# Version d'OS minimale macOS +# (remplace l'ancien ID manuel cloudflare_macos_posture_id) +#====================================================== +resource "cloudflare_zero_trust_device_posture_rule" "macos_os_version" { + account_id = local.cloudflare_account_id + + name = "TF - macOS a jour" + description = "Version minimale de macOS exigee" + type = "os_version" + schedule = "5m" + + match = [ + { platform = "mac" }, + ] + + input = { + version = "14.0.0" + operator = ">=" + } +} + +#====================================================== +# Version minimale du noyau Linux +# (remplace l'ancien ID manuel cloudflare_linux_posture_id) +#====================================================== +resource "cloudflare_zero_trust_device_posture_rule" "linux_kernel_version" { + account_id = local.cloudflare_account_id + + name = "TF - Noyau Linux a jour" + description = "Version minimale du noyau Linux exigee" + type = "os_version" + schedule = "5m" + + match = [ + { platform = "linux" }, + ] + + input = { + version = "5.15.0" + operator = ">=" + } +} + #====================================================== # Rule group Access s'appuyant sur ces postures Terraform # (équivalent "new generation" de latest_os_version_requirements_rule_group) diff --git a/Traffic_Policies-Firewall_Policies-HTTP.tf b/Traffic_Policies-Firewall_Policies-HTTP.tf index 7a69678..a17be84 100644 --- a/Traffic_Policies-Firewall_Policies-HTTP.tf +++ b/Traffic_Policies-Firewall_Policies-HTTP.tf @@ -17,6 +17,9 @@ locals { # HTTP (L7) Policies - Remote Browser Isolation webmail_isolate = 25300 # Isolate personal webmail in a remote browser (RBI) + + # HTTP (L7) Policies - Do Not Inspect (évaluées avant l'inspection) + dni_privacy = 12500 # Ne pas déchiffrer banque/santé (confidentialité) } # Common rule settings for block policies @@ -40,6 +43,25 @@ locals { # Note: Dashboard Do-Not-Inspect policies (13000-15000) evaluated before Terraform HTTP policies #========================================================== + # Do Not Inspect - Confidentialité (Precedence: 12500) + # Bonne pratique : NE PAS déchiffrer le trafic vers les sites bancaires + # et de santé (obligations de confidentialité + certains sites cassent + # avec l'inspection TLS : certificate pinning). action = "off" est le + # "Do Not Inspect" du dashboard ; seuls les champs http.conn.* (basés + # sur le SNI, sans déchiffrement) sont utilisables ici. + # NOTE : vérifier les IDs de catégories dans le dashboard (Gateway > + # politiques > sélecteur Content Categories) et ajuster si besoin. + do_not_inspect_privacy = { + name = "HTTP - Do Not Inspect: Banque & Sante" + description = "Ne pas dechiffrer le trafic vers les sites bancaires et de sante (confidentialite)" + enabled = true + action = "off" + precedence = local.precedence_http.dni_privacy + filters = ["http"] + traffic = "any(http.conn.content_category[*] in {113 156})" + notification_enabled = false + } + # AI Application Governance (Precedence: 24000-24100) redirect_ai_to_claude = { name = "HTTP - Redirect: Redirect users to claude.ai" diff --git a/Traffic_Policies-Firewall_Policies-Network.tf b/Traffic_Policies-Firewall_Policies-Network.tf index b218d9b..887b48a 100644 --- a/Traffic_Policies-Firewall_Policies-Network.tf +++ b/Traffic_Policies-Firewall_Policies-Network.tf @@ -67,7 +67,7 @@ locals { filters = ["l4"] traffic = "net.dst.ip == ${var.gcp_windows_vm_internal_ip} and net.dst.port == ${var.cloudflare_domain_controller_rdp_port} and net.protocol == \"tcp\"" identity = "any(identity.saml_attributes[*] == \"groups=${var.okta_itadmin_saml_group_name}\") or any(identity.saml_attributes[*] == \"groups=${var.okta_infra_admin_saml_group_name}\")" - device_posture = "any(device_posture.checks.passed[*] == \"${var.cloudflare_macos_posture_id}\") or any(device_posture.checks.passed[*] == \"${var.cloudflare_windows_posture_id}\") or any(device_posture.checks.passed[*] == \"${var.cloudflare_linux_posture_id}\")" + device_posture = "any(device_posture.checks.passed[*] == \"${cloudflare_zero_trust_device_posture_rule.macos_os_version.id}\") or any(device_posture.checks.passed[*] == \"${cloudflare_zero_trust_device_posture_rule.windows_os_version.id}\") or any(device_posture.checks.passed[*] == \"${cloudflare_zero_trust_device_posture_rule.linux_kernel_version.id}\")" notification_enabled = false } diff --git a/Workers-Deny_Page.tf b/Workers-Deny_Page.tf new file mode 100644 index 0000000..01cd573 --- /dev/null +++ b/Workers-Deny_Page.tf @@ -0,0 +1,44 @@ +# ============================================================================= +# CLOUDFLARE : Workers : Deny Page (page de refus d'accès personnalisée) +# ============================================================================= +# BRIQUE DENY PAGE : toutes les applications Access du repo pointent leur +# custom_deny_url vers https://denied.tips-of-mine.org/ — mais ce hostname +# n'existait pas jusqu'ici (idée reprise du projet macharpe). Cette brique +# comble le trou avec un Worker Cloudflare qui sert une vraie page de refus +# (HTML dans workers/deny-page.js), sans aucun serveur à héberger. +# +# Chaîne : DNS denied. (proxifié, IP factice) -> route Workers -> Worker +# ============================================================================= + +#====================================================== +# Le script Worker +#====================================================== +resource "cloudflare_workers_script" "deny_page" { + account_id = local.cloudflare_account_id + script_name = "deny-page" + content = file("${path.module}/workers/deny-page.js") +} + +#====================================================== +# DNS : denied. — enregistrement proxifié vers une adresse réservée +# (100:: = IPv6 discard) : le trafic n'atteint jamais cette IP, le Worker +# intercepte la requête sur l'edge Cloudflare. +#====================================================== +resource "cloudflare_dns_record" "deny_page" { + zone_id = local.cloudflare_zone_id + name = "denied" + type = "AAAA" + content = "100::" + ttl = 1 + proxied = true + comment = "Managed by Terraform - page de refus Access (Worker deny-page)" +} + +#====================================================== +# La route qui lie le hostname au Worker +#====================================================== +resource "cloudflare_workers_route" "deny_page" { + zone_id = local.cloudflare_zone_id + pattern = "denied.${var.cloudflare_zone}/*" + script = cloudflare_workers_script.deny_page.script_name +} diff --git a/main.tf b/main.tf index 15516fb..e60ff64 100644 --- a/main.tf +++ b/main.tf @@ -19,6 +19,15 @@ data "vault_generic_secret" "azure" { path = var.vault_azure_path } +# Secrets Google OAuth — lus uniquement si la brique IdP Google est activée. +# ACTION EXTERNE : créer un client OAuth dans Google Cloud Console, déposer +# le secret "secret/google" dans Vault (clés client_id_cloudflare / +# secret_cloudflare), puis google_idp_enabled = true dans le tfvars. +data "vault_generic_secret" "google" { + count = var.google_idp_enabled ? 1 : 0 + path = var.vault_google_path +} + # ============================================================================= # LOCALS # ============================================================================= @@ -42,6 +51,14 @@ locals { azure_oidc_secret_cloudflare = var.azure_idp_enabled ? data.vault_generic_secret.azure[0].data["secret_cloudflare"] : "" azure_directory_id = var.azure_idp_enabled ? data.vault_generic_secret.azure[0].data["directory_id"] : "" + # Secrets Google OAuth depuis Vault (brique IdP Google, optionnelle) + google_oidc_client_id_cloudflare = var.google_idp_enabled ? data.vault_generic_secret.google[0].data["client_id_cloudflare"] : "" + google_oidc_secret_cloudflare = var.google_idp_enabled ? data.vault_generic_secret.google[0].data["secret_cloudflare"] : "" + + # Liste d'IdP Google utilisable dans les allowed_idps des applications + # (vide si la brique est désactivée -> le bouton Google disparaît) + google_idp_ids = var.google_idp_enabled ? [cloudflare_zero_trust_access_identity_provider.gmail[0].id] : [] + # Construction des ingress rules pour toutes les applications # CORRECTIF : le hostname est construit avec le NOM de la zone # (var.cloudflare_zone = "tips-of-mine.org") et non plus avec l'ID de zone. diff --git a/provider.tf b/provider.tf index 415eb8f..26e9679 100644 --- a/provider.tf +++ b/provider.tf @@ -1,8 +1,11 @@ terraform { required_providers { cloudflare = { + # Cap < 6.0.0 : on accepte les évolutions de la v5 mais jamais un saut + # de version majeure automatique (c'est un provider v5 non épinglé qui + # avait déclenché le crash cty avec Terraform 1.7.5). source = "cloudflare/cloudflare" - version = ">= 5.12.0" + version = ">= 5.12.0, < 6.0.0" } vault = { source = "hashicorp/vault" @@ -39,5 +42,23 @@ provider "vault" { # (Let's Encrypt, CA interne importée sur le runner CI, etc.). # En dépannage uniquement : vault_skip_tls_verify = true dans le tfvars. skip_tls_verify = var.vault_skip_tls_verify - token = var.vault_token + + # Deux modes d'authentification : + # - AppRole (recommandé pour la CI) : renseigner vault_role_id + + # vault_secret_id (secrets Gitea VAULT_ROLE_ID / VAULT_SECRET_ID) -> + # la CI obtient un token COURT à chaque run, plus de token statique + # longue durée qui traîne. Mise en place : voir ACTIONS_EXTERNES.md. + # - Token statique (mode historique, toujours fonctionnel) : vault_token. + token = var.vault_role_id != "" ? null : var.vault_token + + dynamic "auth_login" { + for_each = var.vault_role_id != "" ? [1] : [] + content { + path = "auth/approle/login" + parameters = { + role_id = var.vault_role_id + secret_id = var.vault_secret_id + } + } + } } diff --git a/state.tf b/state.tf index 2851ed4..33704a0 100644 --- a/state.tf +++ b/state.tf @@ -3,5 +3,13 @@ terraform { bucket = "tips-of-mine-terraform-bucket-backend" key = "backend/cloudflare-tunnel-org/terraform.tfstate" region = "eu-north-1" + + # Verrou d'état NATIF S3 (Terraform >= 1.10) : un fichier .tflock est créé + # à côté du state pendant chaque opération -> deux applies simultanés + # deviennent impossibles. 100 % gratuit : aucune table DynamoDB, aucun + # service supplémentaire, juste une écriture conditionnelle dans le bucket + # existant. Si ton fournisseur S3 ne supporte pas les écritures + # conditionnelles (erreur au lock), retirer simplement cette ligne. + use_lockfile = true } -} \ No newline at end of file +} diff --git a/tests/applications.tftest.hcl b/tests/applications.tftest.hcl new file mode 100644 index 0000000..6c3b996 --- /dev/null +++ b/tests/applications.tftest.hcl @@ -0,0 +1,75 @@ +# ============================================================================= +# TESTS TERRAFORM NATIFS (terraform test) +# ============================================================================= +# Exécutés par la CI (validate.yml) avec des providers MOCKÉS : aucun appel +# réel à Cloudflare, Vault, ou autre. On vérifie la logique de construction +# (locals, for_each) — notamment le bug historique "hostname construit avec +# le zone ID au lieu du nom de domaine", qui ne peut plus revenir. +# ============================================================================= + +mock_provider "cloudflare" {} +mock_provider "tls" {} +mock_provider "http" {} + +mock_provider "vault" { + # Les data sources vault_generic_secret renvoient ces valeurs factices + # (les clés couvrent secret/cloudflare ET secret/authentik) + mock_data "vault_generic_secret" { + defaults = { + data = { + api_token = "mock-api-token" + account_id = "mock-account-id" + zone_id_org = "0123456789abcdef0123456789abcdef" + email = "mock@tips-of-mine.org" + client_id_cloudflare = "mock-client-id" + secret_cloudflare = "mock-secret" + directory_id = "mock-directory-id" + } + } + } +} + +variables { + vault_token = "mock-token" +} + +run "hostnames_utilisent_le_nom_de_zone" { + command = plan + + # Les URLs publiques sont construites avec le NOM de la zone… + assert { + condition = output.applications_urls["homeassistant"] == "https://home.tips-of-mine.org" + error_message = "L'URL publique de homeassistant doit être https://home.tips-of-mine.org (nom de zone, pas zone ID)" + } + + # …et les règles d'ingress du tunnel aussi (le bug historique utilisait + # le zone ID hexadécimal ici) + assert { + condition = contains( + [for r in cloudflare_zero_trust_tunnel_cloudflared_config.home_tunnel_config.config.ingress : try(r.hostname, "")], + "home.tips-of-mine.org" + ) + error_message = "L'ingress du tunnel doit contenir le hostname home.tips-of-mine.org" + } + + # Aucun hostname d'ingress ne doit contenir le zone ID mocké + assert { + condition = length([ + for r in cloudflare_zero_trust_tunnel_cloudflared_config.home_tunnel_config.config.ingress : + r if can(regex("0123456789abcdef", try(r.hostname, ""))) + ]) == 0 + error_message = "Un hostname d'ingress contient le zone ID : régression du bug zone ID vs nom de domaine" + } + + # La règle catch-all 404 doit rester la dernière règle d'ingress + assert { + condition = element(cloudflare_zero_trust_tunnel_cloudflared_config.home_tunnel_config.config.ingress, length(cloudflare_zero_trust_tunnel_cloudflared_config.home_tunnel_config.config.ingress) - 1).service == "http_status:404" + error_message = "La dernière règle d'ingress doit être le catch-all http_status:404" + } + + # Chaque application du tfvars a bien son enregistrement DNS + assert { + condition = length(cloudflare_dns_record.applications) == length(var.applications) + error_message = "Chaque application doit avoir exactement un enregistrement DNS" + } +} diff --git a/variables.auto.tfvars b/variables.auto.tfvars index 994ca3b..005f598 100644 --- a/variables.auto.tfvars +++ b/variables.auto.tfvars @@ -122,6 +122,14 @@ remote_sites = { } } +# BRIQUE IdP GOOGLE : passer à true APRÈS avoir créé le client OAuth Google +# et le secret Vault "secret/google" (client_id_cloudflare / secret_cloudflare) +google_idp_enabled = false + +# BRIQUE mTLS : passer à true APRÈS avoir déposé la CA dans Vault +# ("secret/mtls", clé ca_pem) +mtls_enabled = false + # BRIQUE DEX : passer à true APRÈS avoir ajouté la permission # "Digital Experience Monitoring : Edit" au token API Cloudflare (403 sinon) dex_enabled = false @@ -198,12 +206,9 @@ cloudflare_domain_controller_rdp_port = 3389 #cloudflare_azure_admin_rule_group_id = "5f253130-a400-4215-44444444" # Device Posture - Sensitive: manually retrieved from Cloudflare dashboard -cloudflare_gateway_posture_id = "4d8d7499-38c3-4bf0-55555555" -cloudflare_macos_posture_id = "6d64ff80-1308-4462-66666666" -#cloudflare_ios_posture_id = "56454654-1245-8564-77777777" -cloudflare_windows_posture_id = "67b05735-3b9b-4bcc-88888888" -cloudflare_linux_posture_id = "ed5639c7-3305-4a91-9999999" -cloudflare_device_os = "mac" # Options: "linux", "windows", "mac" +# MIGRÉ : les IDs de posture recopiés du dashboard ont été supprimés — les +# postures sont gérées par Terraform (Team_Resources-Devices-Device_posture.tf) +cloudflare_device_os = "mac" # Options: "linux", "windows", "mac" #cloudflare_tunnel_warp_connector_azure_id = "" #cloudflare_tunnel_warp_connector_gcp_id = "" diff --git a/variables.tf b/variables.tf index 285f361..d74fc75 100644 --- a/variables.tf +++ b/variables.tf @@ -9,9 +9,24 @@ variable "vault_url" { } variable "vault_token" { - description = "Token d'authentification Vault (fourni par CI/CD)" + description = "Token d'authentification Vault statique (mode historique — inutile si AppRole configuré)" type = string sensitive = true + default = "" +} + +variable "vault_role_id" { + description = "Role ID AppRole pour l'authentification Vault de la CI (vide = auth par token statique)" + type = string + sensitive = true + default = "" +} + +variable "vault_secret_id" { + description = "Secret ID AppRole pour l'authentification Vault de la CI" + type = string + sensitive = true + default = "" } variable "vault_cloudflare_path" { @@ -32,6 +47,18 @@ variable "vault_azure_path" { default = "secret/azure" } +variable "vault_google_path" { + description = "Chemin vers les secrets Google OAuth dans Vault (brique IdP Google)" + type = string + default = "secret/google" +} + +variable "vault_mtls_path" { + description = "Chemin vers le certificat CA mTLS dans Vault (brique mTLS)" + type = string + default = "secret/mtls" +} + variable "vault_skip_tls_verify" { description = "Désactiver la vérification TLS vers Vault (dépannage uniquement — laisser à false)" type = bool @@ -215,29 +242,10 @@ variable "dns_proxied" { # sensitive = true #} -variable "cloudflare_gateway_posture_id" { - description = "Gateway posture ID in Cloudflare" - type = string - sensitive = true -} - -variable "cloudflare_macos_posture_id" { - description = "Latest macOS version posture ID in Cloudflare" - type = string - sensitive = true -} - -variable "cloudflare_windows_posture_id" { - description = "Latest Windows version posture ID in Cloudflare" - type = string - sensitive = true -} - -variable "cloudflare_linux_posture_id" { - description = "Latest Linux Kernel version posture ID in Cloudflare" - type = string - sensitive = true -} +# MIGRÉ : les 4 variables cloudflare_*_posture_id (IDs recopiés à la main +# depuis le dashboard) ont été supprimées. Les vérifications de posture sont +# désormais des ressources Terraform dans +# Team_Resources-Devices-Device_posture.tf, référencées par leur attribut .id. variable "cloudflare_device_os" { description = "This is the OS you are running on your own client machine" @@ -709,6 +717,26 @@ variable "cloudflare_tunnel_warp_connector_site_b_id" { default = "" } +#====================================================== +# BRIQUE IdP GOOGLE +#====================================================== + +variable "google_idp_enabled" { + description = "Activer la brique IdP Google (nécessite le secret Vault secret/google — voir ACTIONS_EXTERNES.md)" + type = bool + default = false +} + +#====================================================== +# BRIQUE mTLS +#====================================================== + +variable "mtls_enabled" { + description = "Activer la brique mTLS (nécessite le secret Vault secret/mtls avec la clé ca_pem)" + type = bool + default = false +} + #====================================================== # BRIQUE DEX #====================================================== diff --git a/workers/deny-page.js b/workers/deny-page.js new file mode 100644 index 0000000..26d40f0 --- /dev/null +++ b/workers/deny-page.js @@ -0,0 +1,63 @@ +/** + * Page de refus d'accès personnalisée — servie sur denied.tips-of-mine.org + * Toutes les applications Access du repo pointent leur custom_deny_url ici. + * Déployée par Terraform : Workers-Deny_Page.tf + */ +addEventListener('fetch', (event) => { + event.respondWith(handleRequest(event.request)); +}); + +async function handleRequest(request) { + const html = ` + + + + + Accès refusé — Tips-Of-Mine + + + +
+
🚫
+

Accès refusé

+

Votre identité a bien été vérifiée, mais elle ne remplit pas + les conditions d'accès de cette application (groupe, posture du poste, MFA ou localisation).

+
+ Que faire ?
+ • Vérifiez que vous êtes connecté avec le bon compte ;
+ • Vérifiez que le client WARP est actif et votre poste conforme ;
+ • Si le besoin est légitime, contactez l'administrateur : + thedjinhn@gmail.com. +
+
+ +`; + + return new Response(html, { + status: 403, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); +}