New update
Terraform Apply / Terraform Apply (push) Successful in 2m26s

This commit is contained in:
2026-07-31 12:57:49 +02:00
parent c73cefdef9
commit f8201d9d06
18 changed files with 671 additions and 63 deletions
@@ -98,7 +98,7 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_aws_app_ssh_infr
require = [ require = [
{ {
device_posture = { 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 = [ require = [
{ {
device_posture = { device_posture = {
integration_uid = var.cloudflare_gateway_posture_id integration_uid = cloudflare_zero_trust_device_posture_rule.gateway_connected.id
} }
}, },
{ {
+6 -6
View File
@@ -24,10 +24,10 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_aws_app_ssh_brow
uri = var.cloudflare_aws_subdomain_ssh uri = var.cloudflare_aws_subdomain_ssh
}] }]
allowed_idps = [ # Google proposé seulement si la brique IdP Google est activée
cloudflare_zero_trust_access_identity_provider.gmail.id, allowed_idps = concat(local.google_idp_ids, [
cloudflare_zero_trust_access_identity_provider.authentik_oidc.id, cloudflare_zero_trust_access_identity_provider.authentik_oidc.id,
] ])
auto_redirect_to_identity = false auto_redirect_to_identity = false
allow_authenticate_via_warp = 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 uri = var.cloudflare_gcp_subdomain_ssh
}] }]
allowed_idps = [ # Google proposé seulement si la brique IdP Google est activée
cloudflare_zero_trust_access_identity_provider.gmail.id, allowed_idps = concat(local.google_idp_ids, [
cloudflare_zero_trust_access_identity_provider.authentik_oidc.id, cloudflare_zero_trust_access_identity_provider.authentik_oidc.id,
] ])
auto_redirect_to_identity = false auto_redirect_to_identity = false
allow_authenticate_via_warp = false allow_authenticate_via_warp = false
+6 -6
View File
@@ -24,10 +24,10 @@ resource "cloudflare_zero_trust_access_application" "cloudflare_aws_app_vnc_brow
uri = var.cloudflare_aws_subdomain_vnc uri = var.cloudflare_aws_subdomain_vnc
}] }]
allowed_idps = [ # Google proposé seulement si la brique IdP Google est activée
cloudflare_zero_trust_access_identity_provider.gmail.id, allowed_idps = concat(local.google_idp_ids, [
cloudflare_zero_trust_access_identity_provider.authentik_oidc.id, cloudflare_zero_trust_access_identity_provider.authentik_oidc.id,
] ])
auto_redirect_to_identity = false auto_redirect_to_identity = false
allow_authenticate_via_warp = 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 uri = var.cloudflare_gcp_subdomain_vnc
}] }]
allowed_idps = [ # Google proposé seulement si la brique IdP Google est activée
cloudflare_zero_trust_access_identity_provider.gmail.id, allowed_idps = concat(local.google_idp_ids, [
cloudflare_zero_trust_access_identity_provider.authentik_oidc.id, cloudflare_zero_trust_access_identity_provider.authentik_oidc.id,
] ])
auto_redirect_to_identity = false auto_redirect_to_identity = false
allow_authenticate_via_warp = false allow_authenticate_via_warp = false
+9 -6
View File
@@ -100,11 +100,13 @@ locals {
(contains(local.other_countries, code) ? null : code) (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 = [ os_posture_checks = [
var.cloudflare_linux_posture_id, cloudflare_zero_trust_device_posture_rule.linux_kernel_version.id,
var.cloudflare_macos_posture_id, cloudflare_zero_trust_device_posture_rule.macos_os_version.id,
var.cloudflare_windows_posture_id cloudflare_zero_trust_device_posture_rule.windows_os_version.id,
] ]
policy_groups = { policy_groups = {
@@ -595,10 +597,11 @@ resource "cloudflare_zero_trust_access_policy" "policies" {
# Require conditions # Require conditions
require = concat( 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) ? [{ try(each.value.require_posture, false) ? [{
device_posture = { device_posture = {
integration_uid = var.cloudflare_gateway_posture_id integration_uid = cloudflare_zero_trust_device_posture_rule.gateway_connected.id
} }
}] : [], }] : [],
# MFA requirement # MFA requirement
@@ -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 = {}
}]
}
+16 -3
View File
@@ -2,16 +2,29 @@
# CLOUDFLARE : Integrations : Identity Providers # 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
# ("<google_client_id>") -> 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" { resource "cloudflare_zero_trust_access_identity_provider" "gmail" {
count = var.google_idp_enabled ? 1 : 0
account_id = local.cloudflare_account_id account_id = local.cloudflare_account_id
name = "Gmail" name = "Gmail"
type = "google" type = "google"
zone_id = local.cloudflare_zone_id
config = { config = {
client_id = "<google_client_id>" client_id = local.google_oidc_client_id_cloudflare
client_secret = "<google_secret>" client_secret = local.google_oidc_secret_cloudflare
email_claim_name = "email" email_claim_name = "email"
} }
} }
+199 -6
View File
@@ -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. > 📋 **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<br/>multi-OS"]
end
subgraph EDGE["☁️ Réseau Cloudflare"]
direction TB
ACCESS["🔐 Access<br/>authentification & politiques"]
GW["🛡️ Gateway<br/>filtrage DNS / HTTP / réseau"]
DENY["📄 Worker<br/>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<br/>10.100.0.0/16"]
LYON["Site Lyon<br/>10.100.0.0/16<br/>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 :<br/>groupe autorisé ? posture du poste OK ?<br/>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<br/>(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<br/>(manuelle ou MDM)"] --> B["2 - Login with Cloudflare Zero Trust<br/>équipe : tips-of-mine"]
B --> C{"Politique d'enrôlement :<br/>email du domaine ?<br/>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<br/>(Windows / macOS / Linux)"]
D --> F["Vérifications de posture en continu :<br/>WARP actif · pare-feu · disque chiffré · OS à jour"]
D --> G["Tout le trafic passe par Gateway :<br/>filtrage DNS (pubs, malware)<br/>filtrage HTTP (catégories, IA, DLP)<br/>filtrage réseau (anti-mouvement latéral)"]
D --> H["Tests DEX : mesure de latence<br/>vers les apps depuis le poste"]
F -- "posture KO" --> I["⛔ Les applications exigeant<br/>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<br/>serveurs, imprimantes, AD"]
end
subgraph L["🏢 Site Lyon - agence"]
CL["cloudflared"] --- LANL["LAN 10.100.0.0/16<br/>⚠️ même plage que Paris"]
end
U["💻 Poste WARP<br/>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<br/>(IdP + MFA + posture)" --> CA["CA Cloudflare"]
CA -- "2 - certificat SSH<br/>valable quelques secondes" --> U1
U1 -- "3 - connexion SSH" --> S1["sshd<br/>TrustedUserCAKeys"]
end
subgraph CLASSIQUE["⚠️ Clé SSH classique (pédagogique)"]
direction LR
U2["Utilisateur"] -- "clé privée permanente<br/>(stockée, à faire tourner)" --> S2["sshd<br/>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<br/>· blocage malware<br/>· liste pubs Pi-hole<br/>(mise à jour quotidienne)"]
DNS --> L4["2 - Politiques réseau L4<br/>· anti-mouvement latéral<br/>SSH RDP SMB WinRM DB<br/>· contrôle RDP par identité"]
L4 --> DNI["3 - Do Not Inspect<br/>banque & santé :<br/>jamais déchiffrés"]
DNI --> HTTP["4 - Politiques HTTP<br/>· gouvernance IA<br/>· blocage catégories<br/>· DLP (IBAN)<br/>· 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 briques
Les fichiers suivent la navigation du dashboard Cloudflare Zero Trust (`Section-SousSection-Nom.tf`). 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 | | `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 | | `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 | | Brique | Fichiers | Activation |
|---|---|---| |---|---|---|
| **Authentik (OIDC)** | `Integrations-Identity_providers.tf`, `Access_Controls-Policies-Rule_Groups.tf` | toujours active | | **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` | | **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 ## 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 | | 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`) | | `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-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 # Prérequis
@@ -117,11 +309,12 @@ terraform plan
| Workflow | Déclencheur | Rôle | | 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** | | `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 coffe
<a href='https://ko-fi.com/R5R2KNI3N' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi4.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a> <a href='https://ko-fi.com/R5R2KNI3N' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi4.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
+62
View File
@@ -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) # 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 # Rule group Access s'appuyant sur ces postures Terraform
# (équivalent "new generation" de latest_os_version_requirements_rule_group) # (équivalent "new generation" de latest_os_version_requirements_rule_group)
@@ -17,6 +17,9 @@ locals {
# HTTP (L7) Policies - Remote Browser Isolation # HTTP (L7) Policies - Remote Browser Isolation
webmail_isolate = 25300 # Isolate personal webmail in a remote browser (RBI) 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 # Common rule settings for block policies
@@ -40,6 +43,25 @@ locals {
# Note: Dashboard Do-Not-Inspect policies (13000-15000) evaluated before Terraform HTTP policies # 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) # AI Application Governance (Precedence: 24000-24100)
redirect_ai_to_claude = { redirect_ai_to_claude = {
name = "HTTP - Redirect: Redirect users to claude.ai" name = "HTTP - Redirect: Redirect users to claude.ai"
@@ -67,7 +67,7 @@ locals {
filters = ["l4"] 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\"" 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}\")" 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 notification_enabled = false
} }
+44
View File
@@ -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.<zone> (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.<zone> — 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
}
+17
View File
@@ -19,6 +19,15 @@ data "vault_generic_secret" "azure" {
path = var.vault_azure_path 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 # LOCALS
# ============================================================================= # =============================================================================
@@ -42,6 +51,14 @@ locals {
azure_oidc_secret_cloudflare = var.azure_idp_enabled ? data.vault_generic_secret.azure[0].data["secret_cloudflare"] : "" 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"] : "" 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 # Construction des ingress rules pour toutes les applications
# CORRECTIF : le hostname est construit avec le NOM de la zone # 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. # (var.cloudflare_zone = "tips-of-mine.org") et non plus avec l'ID de zone.
+23 -2
View File
@@ -1,8 +1,11 @@
terraform { terraform {
required_providers { required_providers {
cloudflare = { 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" source = "cloudflare/cloudflare"
version = ">= 5.12.0" version = ">= 5.12.0, < 6.0.0"
} }
vault = { vault = {
source = "hashicorp/vault" source = "hashicorp/vault"
@@ -39,5 +42,23 @@ provider "vault" {
# (Let's Encrypt, CA interne importée sur le runner CI, etc.). # (Let's Encrypt, CA interne importée sur le runner CI, etc.).
# En dépannage uniquement : vault_skip_tls_verify = true dans le tfvars. # En dépannage uniquement : vault_skip_tls_verify = true dans le tfvars.
skip_tls_verify = var.vault_skip_tls_verify 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
}
}
}
} }
+9 -1
View File
@@ -3,5 +3,13 @@ terraform {
bucket = "tips-of-mine-terraform-bucket-backend" bucket = "tips-of-mine-terraform-bucket-backend"
key = "backend/cloudflare-tunnel-org/terraform.tfstate" key = "backend/cloudflare-tunnel-org/terraform.tfstate"
region = "eu-north-1" 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
} }
} }
+75
View File
@@ -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 = "[email protected]"
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"
}
}
+11 -6
View File
@@ -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 # BRIQUE DEX : passer à true APRÈS avoir ajouté la permission
# "Digital Experience Monitoring : Edit" au token API Cloudflare (403 sinon) # "Digital Experience Monitoring : Edit" au token API Cloudflare (403 sinon)
dex_enabled = false dex_enabled = false
@@ -198,12 +206,9 @@ cloudflare_domain_controller_rdp_port = 3389
#cloudflare_azure_admin_rule_group_id = "5f253130-a400-4215-44444444" #cloudflare_azure_admin_rule_group_id = "5f253130-a400-4215-44444444"
# Device Posture - Sensitive: manually retrieved from Cloudflare dashboard # Device Posture - Sensitive: manually retrieved from Cloudflare dashboard
cloudflare_gateway_posture_id = "4d8d7499-38c3-4bf0-55555555" # MIGRÉ : les IDs de posture recopiés du dashboard ont été supprimés — les
cloudflare_macos_posture_id = "6d64ff80-1308-4462-66666666" # postures sont gérées par Terraform (Team_Resources-Devices-Device_posture.tf)
#cloudflare_ios_posture_id = "56454654-1245-8564-77777777" cloudflare_device_os = "mac" # Options: "linux", "windows", "mac"
cloudflare_windows_posture_id = "67b05735-3b9b-4bcc-88888888"
cloudflare_linux_posture_id = "ed5639c7-3305-4a91-9999999"
cloudflare_device_os = "mac" # Options: "linux", "windows", "mac"
#cloudflare_tunnel_warp_connector_azure_id = "" #cloudflare_tunnel_warp_connector_azure_id = ""
#cloudflare_tunnel_warp_connector_gcp_id = "" #cloudflare_tunnel_warp_connector_gcp_id = ""
+52 -24
View File
@@ -9,9 +9,24 @@ variable "vault_url" {
} }
variable "vault_token" { 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 type = string
sensitive = true 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" { variable "vault_cloudflare_path" {
@@ -32,6 +47,18 @@ variable "vault_azure_path" {
default = "secret/azure" 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" { variable "vault_skip_tls_verify" {
description = "Désactiver la vérification TLS vers Vault (dépannage uniquement — laisser à false)" description = "Désactiver la vérification TLS vers Vault (dépannage uniquement — laisser à false)"
type = bool type = bool
@@ -215,29 +242,10 @@ variable "dns_proxied" {
# sensitive = true # sensitive = true
#} #}
variable "cloudflare_gateway_posture_id" { # MIGRÉ : les 4 variables cloudflare_*_posture_id (IDs recopiés à la main
description = "Gateway posture ID in Cloudflare" # depuis le dashboard) ont été supprimées. Les vérifications de posture sont
type = string # désormais des ressources Terraform dans
sensitive = true # Team_Resources-Devices-Device_posture.tf, référencées par leur attribut .id.
}
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
}
variable "cloudflare_device_os" { variable "cloudflare_device_os" {
description = "This is the OS you are running on your own client machine" 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 = "" 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 # BRIQUE DEX
#====================================================== #======================================================
+63
View File
@@ -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 = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accès refusé — Tips-Of-Mine</title>
<style>
:root { color-scheme: light dark; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5; color: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
body { background: #16181d; color: #e8e8e8; }
.card { background: #1f232b !important; box-shadow: 0 4px 24px rgba(0,0,0,.4) !important; }
}
.card {
background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.08);
max-width: 520px; margin: 1rem; padding: 2.5rem; text-align: center;
}
.icon { font-size: 3rem; }
h1 { font-size: 1.4rem; margin: .75rem 0 .25rem; }
p { line-height: 1.55; opacity: .85; }
.hint {
text-align: left; font-size: .9rem; border-left: 3px solid #f6821f;
padding: .5rem .9rem; margin-top: 1.25rem; opacity: .9;
}
a { color: #f6821f; }
</style>
</head>
<body>
<main class="card">
<div class="icon">&#128683;</div>
<h1>Acc&egrave;s refus&eacute;</h1>
<p>Votre identit&eacute; a bien &eacute;t&eacute; v&eacute;rifi&eacute;e, mais elle ne remplit pas
les conditions d'acc&egrave;s de cette application (groupe, posture du poste, MFA ou localisation).</p>
<div class="hint">
<strong>Que faire ?</strong><br>
&bull; V&eacute;rifiez que vous &ecirc;tes connect&eacute; avec le bon compte&nbsp;;<br>
&bull; V&eacute;rifiez que le client WARP est actif et votre poste conforme&nbsp;;<br>
&bull; Si le besoin est l&eacute;gitime, contactez l'administrateur&nbsp;:
<a href="mailto:[email protected]">[email protected]</a>.
</div>
</main>
</body>
</html>`;
return new Response(html, {
status: 403,
headers: { 'content-type': 'text/html; charset=utf-8' },
});
}