diff --git a/Cargo.lock b/Cargo.lock index 486a1a1..3a4f607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -318,6 +318,7 @@ dependencies = [ "oe-enroll", "oe-hsm", "oe-raflow", + "oe-s3", "oe-webauthn", "rand 0.8.8", "reqwest", @@ -2226,6 +2227,7 @@ dependencies = [ "oe-enroll", "oe-hsm", "oe-raflow", + "oe-s3", "oe-webauthn", "rand 0.8.8", "reqwest", diff --git a/bin/ca-server/Cargo.toml b/bin/ca-server/Cargo.toml index dab8eb7..f1ee7b6 100644 --- a/bin/ca-server/Cargo.toml +++ b/bin/ca-server/Cargo.toml @@ -18,6 +18,9 @@ path = "src/main.rs" oe-hsm = { path = "../../crates/oe-hsm" } oe-certs = { path = "../../crates/oe-certs" } oe-castore = { path = "../../crates/oe-castore", features = ["postgres"] } +# Copie du journal sur un stockage objet compatible S3, auto-hébergé +# (docs/WEBUI.md §7, §15 étape 2b) : jamais AWS, décision de l'association. +oe-s3 = { path = "../../crates/oe-s3" } oe-ca-core = { path = "../../crates/oe-ca-core" } oe-raflow = { path = "../../crates/oe-raflow" } oe-actions = { path = "../../crates/oe-actions" } diff --git a/bin/ca-server/src/config.rs b/bin/ca-server/src/config.rs index 9512989..335a66a 100644 --- a/bin/ca-server/src/config.rs +++ b/bin/ca-server/src/config.rs @@ -49,6 +49,12 @@ pub struct Config { pub crl_grace: time::Duration, pub audit_file: String, + /// Copie du journal sur un stockage objet compatible S3, auto-hébergé + /// (docs/WEBUI.md §7, §15 étape 2b) : `None` si non configuré, le journal + /// reste alors purement local — rétrocompatible, aucune commande n'exige + /// S3. Configuré, un échec de l'envoi bloque l'écriture qui en dépend + /// (`oe_ca_core::Recorder`/`oe_raflow::Recorder` sont bloquants). + pub s3: Option, /// Adresse du lien interne (`/internal/v1/*`, docs/WEBUI.md §17). Vide : /// pas de lien interne, `ca-server` ne sert que le port public. @@ -67,6 +73,46 @@ pub struct Config { pub registry_check_interval: Duration, } +/// Stockage objet compatible S3, auto-hébergé (MinIO, Garage, Ceph… jamais +/// AWS ni un service géré, décision de l'association — voir `oe-s3`). +pub struct S3Config { + pub endpoint: String, + pub bucket: String, + pub region: String, + pub access_key: String, + pub secret_key: String, + /// Clé de l'objet qui reçoit le journal entier à chaque écriture (S3 ne + /// connaît pas d'ajout partiel), par ex. `ca-server/audit.log`. + pub key: String, +} + +/// `None` si aucune des variables `OPENEIDAS_S3_*` n'est renseignée (le +/// journal reste purement local) ; sinon, toutes sont exigées ensemble — une +/// configuration à moitié faite est plus dangereuse qu'absente. +fn s3_config() -> Result, String> { + let endpoint = env_str("OPENEIDAS_S3_ENDPOINT", ""); + if endpoint.is_empty() { + return Ok(None); + } + let required = |key: &str| -> Result { + let v = env_str(key, ""); + if v.is_empty() { + return Err(format!( + "{key} est obligatoire dès qu'OPENEIDAS_S3_ENDPOINT est configuré" + )); + } + Ok(v) + }; + Ok(Some(S3Config { + endpoint, + bucket: required("OPENEIDAS_S3_BUCKET")?, + region: env_str("OPENEIDAS_S3_REGION", "us-east-1"), + access_key: required("OPENEIDAS_S3_ACCESS_KEY")?, + secret_key: required("OPENEIDAS_S3_SECRET_KEY")?, + key: env_str("OPENEIDAS_S3_KEY", "ca-server/audit.log"), + })) +} + fn env_str(key: &str, fallback: &str) -> String { std::env::var(key) .ok() @@ -245,6 +291,7 @@ impl Config { "OPENEIDAS_AUDIT_FILE", "/var/lib/open-eidas/state/ca-audit.log", ), + s3: s3_config()?, internal_listen: env_str("OPENEIDAS_INTERNAL_LISTEN", ""), webauthn_rp_id: env_str("OPENEIDAS_WEBAUTHN_RP_ID", ""), webauthn_origin: env_str("OPENEIDAS_WEBAUTHN_ORIGIN", ""), diff --git a/bin/ca-server/src/main.rs b/bin/ca-server/src/main.rs index 61f9720..a920264 100644 --- a/bin/ca-server/src/main.rs +++ b/bin/ca-server/src/main.rs @@ -197,7 +197,43 @@ fn bind_addr(listen: &str) -> String { /// Relie le journal d'audit aux deux points d'injection qui en dépendent /// (`oe-ca-core` et `oe-raflow`) — mêmes noms d'événement que le binaire Go, /// qui partage lui aussi un seul journal chaîné entre ces deux sources. -struct AuditRecorder(Arc); +/// +/// Le fichier local reste la source de vérité (verrou, relecture, chaînage) ; +/// s'il est configuré, un stockage S3-compatible auto-hébergé (docs/WEBUI.md +/// §7, §15 étape 2b) en reçoit une copie à chaque écriture — le journal +/// entier, S3 ne connaissant pas d'ajout partiel. Bloquant, comme +/// `oe_ca_core::Recorder`/`oe_raflow::Recorder` : un échec de l'envoi propage +/// une erreur, rien n'est considéré journalisé tant qu'il n'a pas atteint S3. +#[derive(Clone)] +struct AuditRecorder { + log: Arc, + /// Chemin du fichier local : relu en entier après chaque écriture pour + /// l'envoi S3 (le contenu exact qui vient d'être scellé, verrou compris). + path: String, + s3: Option<(Arc, String)>, +} + +/// Construit le journal (local, et S3 si configuré) pour une commande. +fn open_recorder(cfg: &Config) -> AuditRecorder { + let log = Arc::new(open_journal(cfg)); + let s3 = cfg.s3.as_ref().map(|c| { + let client = oe_s3::Client::new(oe_s3::Options { + endpoint: c.endpoint.clone(), + bucket: c.bucket.clone(), + region: c.region.clone(), + access_key: c.access_key.clone(), + secret_key: c.secret_key.clone(), + timeout: std::time::Duration::from_secs(30), + }) + .unwrap_or_else(|e| die("client S3 du journal", e)); + (Arc::new(client), c.key.clone()) + }); + AuditRecorder { + log, + path: cfg.audit_file.clone(), + s3, + } +} fn json_to_audit_data(data: serde_json::Value) -> Option { match data { @@ -211,21 +247,38 @@ fn json_to_audit_data(data: serde_json::Value) -> Option { } } +impl AuditRecorder { + async fn append_and_replicate( + &self, + event: &str, + data: serde_json::Value, + ) -> Result<(), String> { + self.log + .append(event, json_to_audit_data(data)) + .map_err(|e| e.to_string())?; + if let Some((client, key)) = &self.s3 { + let bytes = std::fs::read(&self.path) + .map_err(|e| format!("relecture du journal pour l'envoi S3 : {e}"))?; + client + .put(key, bytes) + .await + .map_err(|e| format!("envoi S3 du journal : {e}"))?; + } + Ok(()) + } +} + #[async_trait::async_trait] impl oe_ca_core::Recorder for AuditRecorder { async fn append(&self, event: &str, data: serde_json::Value) -> Result<(), String> { - self.0 - .append(event, json_to_audit_data(data)) - .map_err(|e| e.to_string()) + self.append_and_replicate(event, data).await } } #[async_trait::async_trait] impl oe_raflow::Recorder for AuditRecorder { async fn append(&self, event: &str, data: serde_json::Value) -> Result<(), String> { - self.0 - .append(event, json_to_audit_data(data)) - .map_err(|e| e.to_string()) + self.append_and_replicate(event, data).await } } @@ -299,7 +352,6 @@ async fn run_ceremony() { } let store = open_store(&cfg).await; - let journal = Arc::new(open_journal(&cfg)); let root_token = open_root_key(&cfg); let root_signer: Arc = @@ -308,7 +360,7 @@ async fn run_ceremony() { let issuing_signer: Arc = Arc::new(oe_hsm::SyncToken::new(issuing_token)); - let recorder: Arc = Arc::new(AuditRecorder(journal)); + let recorder: Arc = Arc::new(open_recorder(&cfg)); let h = oe_ca_core::ceremony::run_ceremony(oe_ca_core::ceremony::CeremonyOptions { root_signer, issuing_signer, @@ -500,12 +552,12 @@ async fn run_serve() { let cfg = Config::load().unwrap_or_else(|e| die("configuration invalide", &e)); let store: Arc = Arc::new(open_store(&cfg).await); - let journal = Arc::new(open_journal(&cfg)); - let recorder: Arc = Arc::new(AuditRecorder(journal.clone())); + let audit = open_recorder(&cfg); + let recorder: Arc = Arc::new(audit.clone()); let issuer = Arc::new(build_issuer(&cfg, store.clone(), recorder).await); - let flow_recorder: Arc = Arc::new(AuditRecorder(journal.clone())); + let flow_recorder: Arc = Arc::new(audit.clone()); let internal_service = if cfg.internal_listen.is_empty() { None } else { @@ -525,29 +577,17 @@ async fn run_serve() { let internal_store = store.clone(); let flow = build_flow(&cfg, store.clone(), issuer.clone(), flow_recorder); - let _ = journal.append( - oe_audit::EVENT_OPENED, - Some(oe_audit::Data::from_iter([ - ( - "version".to_string(), - serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()), - ), - ( - "role".to_string(), - serde_json::Value::String("ca-server".to_string()), - ), - ( - "emettrice".to_string(), - serde_json::Value::String( - issuer.certificate().tbs_certificate().subject().to_string(), - ), - ), - ( - "public_url".to_string(), - serde_json::Value::String(cfg.public_url.clone()), - ), - ])), - ); + let _ = audit + .append_and_replicate( + oe_audit::EVENT_OPENED, + serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "role": "ca-server", + "emettrice": issuer.certificate().tbs_certificate().subject().to_string(), + "public_url": cfg.public_url.clone(), + }), + ) + .await; let mut server = http::Server::new(issuer.clone(), flow, env!("CARGO_PKG_VERSION").to_string()); if let Some((_, guard, _)) = &internal_service { @@ -680,8 +720,7 @@ async fn run_decide( tracing_subscriber::fmt::init(); let cfg = Config::load_without_hsm().unwrap_or_else(|e| die("configuration invalide", &e)); let store: Arc = Arc::new(open_store(&cfg).await); - let journal = Arc::new(open_journal(&cfg)); - let recorder: Arc = Arc::new(AuditRecorder(journal)); + let recorder: Arc = Arc::new(open_recorder(&cfg)); let decider = oe_raflow::Decider::new(oe_raflow::DeciderOptions { store, recorder: Some(recorder), @@ -705,8 +744,7 @@ async fn run_revoke(serial_hex: String, reason: i32, operator: String, comment: .unwrap_or_else(|e| die("numéro de série illisible", e)); let store: Arc = Arc::new(open_store(&cfg).await); - let journal = Arc::new(open_journal(&cfg)); - let recorder: Arc = Arc::new(AuditRecorder(journal)); + let recorder: Arc = Arc::new(open_recorder(&cfg)); let issuer = build_issuer(&cfg, store, recorder).await; let comment = join_comment(&comment); @@ -736,8 +774,7 @@ async fn run_operators_bootstrap_admin(name: String, ttl_minutes: i64) { let registry = oe_actions::Registry::connect(&cfg.dsn) .await .unwrap_or_else(|e| die("ouverture du registre des opérateurs", e)); - let journal = Arc::new(open_journal(&cfg)); - let recorder = AuditRecorder(journal); + let recorder = open_recorder(&cfg); let invite = oe_actions::bootstrap_admin( ®istry, @@ -784,16 +821,9 @@ async fn run_internal_cert_server(dns: String) { .unwrap_or_else(|e| die("demande de certificat", e)); let store: Arc = Arc::new(open_store(&cfg).await); - let journal = Arc::new(open_journal(&cfg)); - let issuer = Arc::new( - build_issuer( - &cfg, - store.clone(), - Arc::new(AuditRecorder(journal.clone())), - ) - .await, - ); - let flow = build_flow(&cfg, store, issuer, Arc::new(AuditRecorder(journal))); + let audit = open_recorder(&cfg); + let issuer = Arc::new(build_issuer(&cfg, store.clone(), Arc::new(audit.clone())).await); + let flow = build_flow(&cfg, store, issuer, Arc::new(audit)); let result = flow .submit( &csr_der, @@ -855,7 +885,7 @@ async fn run_operators_reconcile( std::process::exit(2); }); // Les résolutions s'écrivent dans le même journal que celui qu'on a relu. - let recorder = AuditRecorder(Arc::new(open_journal(&cfg))); + let recorder = open_recorder(&cfg); let outcome = oe_actions::reconcile( ®istry, @@ -1001,8 +1031,7 @@ async fn run_operators_recover_admin( let registry = oe_actions::Registry::connect(&cfg.dsn) .await .unwrap_or_else(|e| die("ouverture du registre des opérateurs", e)); - let journal = Arc::new(open_journal(&cfg)); - let recorder = AuditRecorder(journal); + let recorder = open_recorder(&cfg); if let Err(e) = verify_token_pin(&cfg, &pin) { // Un refus est aussi un événement : quelqu'un a essayé. Best-effort : diff --git a/bin/ca-server/tests/s3_journal.rs b/bin/ca-server/tests/s3_journal.rs new file mode 100644 index 0000000..61d7df8 --- /dev/null +++ b/bin/ca-server/tests/s3_journal.rs @@ -0,0 +1,175 @@ +//! Câblage du journal d'audit sur un stockage S3-compatible auto-hébergé +//! (docs/WEBUI.md §7, §15 étape 2b) : exécute le vrai binaire `ca-server` +//! contre un PostgreSQL et un serveur HTTP qui se comporte comme un service +//! S3-compatible (même contrainte que `crates/oe-s3/tests/client.rs` : pas +//! d'image MinIO disponible dans cet environnement). +//! +//! Deux preuves : +//! - configuré, `OPENEIDAS_S3_*` fait recevoir au stockage objet une copie +//! exacte du journal local à chaque écriture ; +//! - si l'envoi échoue, l'opération qui en dépend échoue aussi : rien n'est +//! commité en base (pas seulement « une erreur est rendue »). +//! +//! DSN dans `OE_CASTORE_TEST_DSN` ; test ignoré si elle n'est pas définie. + +use axum::extract::{Path, State}; +use axum::http::{Method, StatusCode}; +use axum::routing::any; +use axum::Router; +use sqlx::postgres::PgPoolOptions; +use std::collections::HashMap; +use std::process::Command; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct Objects(Mutex>>); + +async fn handle_ok( + State(objects): State>, + method: Method, + Path((bucket, key)): Path<(String, String)>, + uri: axum::http::Uri, + body: axum::body::Bytes, +) -> (StatusCode, Vec) { + assert!( + uri.query().unwrap_or("").contains("X-Amz-Signature"), + "requête non signée : {uri}" + ); + let full_key = format!("{bucket}/{key}"); + match method { + Method::PUT => { + objects.0.lock().unwrap().insert(full_key, body.to_vec()); + (StatusCode::OK, Vec::new()) + } + Method::GET => match objects.0.lock().unwrap().get(&full_key) { + Some(bytes) => (StatusCode::OK, bytes.clone()), + None => (StatusCode::NOT_FOUND, Vec::new()), + }, + _ => (StatusCode::METHOD_NOT_ALLOWED, Vec::new()), + } +} + +/// Un service S3 toujours en panne : exerce le chemin de blocage. +async fn handle_failing() -> StatusCode { + StatusCode::INTERNAL_SERVER_ERROR +} + +async fn serve(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +struct Fixture { + dsn: String, + audit: std::path::PathBuf, +} + +async fn fixture(prefix: &str) -> Option { + let base = std::env::var("OE_CASTORE_TEST_DSN").ok()?; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let name = format!("{prefix}_{nanos}"); + let admin = PgPoolOptions::new().connect(&base).await.unwrap(); + sqlx::query(&format!("CREATE DATABASE {name}")) + .execute(&admin) + .await + .unwrap(); + let dsn = format!("{}/{name}", base.rsplit_once('/').unwrap().0); + Some(Fixture { + audit: std::env::temp_dir().join(format!("{name}.audit.log")), + dsn, + }) +} + +impl Fixture { + /// Lance `operators bootstrap-admin`, avec le stockage S3 donné en plus + /// des variables déjà exigées par `bootstrap_cli.rs`. + fn run_bootstrap(&self, name: &str, s3_endpoint: &str) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_ca-server")) + .args(["operators", "bootstrap-admin", name]) + .env("OPENEIDAS_DB_DSN", &self.dsn) + .env("OPENEIDAS_AUDIT_FILE", &self.audit) + .env("OPENEIDAS_S3_ENDPOINT", s3_endpoint) + .env("OPENEIDAS_S3_BUCKET", "audit") + .env("OPENEIDAS_S3_ACCESS_KEY", "test") + .env("OPENEIDAS_S3_SECRET_KEY", "test-secret-au-moins-16-octets") + .env("OPENEIDAS_S3_KEY", "ca-server/audit.log") + .output() + .expect("lancement de ca-server") + } + + async fn operator_exists(&self, name: &str) -> bool { + let pool = PgPoolOptions::new().connect(&self.dsn).await.unwrap(); + sqlx::query_scalar::<_, bool>("SELECT EXISTS (SELECT 1 FROM operators WHERE name = $1)") + .bind(name) + .fetch_one(&pool) + .await + .unwrap() + } +} + +// Le serveur S3 est une tâche tokio ; `Command::output()` bloque le thread +// qui l'appelle. Un runtime mono-thread figerait cette tâche pendant l'appel +// (elle ne répondrait jamais, et le test « échec » passerait pour la +// mauvaise raison — un délai d'attente, pas un vrai refus du service). +#[tokio::test(flavor = "multi_thread")] +async fn a_configured_s3_receives_an_exact_copy_of_the_local_journal() { + let Some(f) = fixture("s3_ok").await else { + eprintln!("OE_CASTORE_TEST_DSN non définie : test PostgreSQL ignoré"); + return; + }; + let objects = Arc::new(Objects::default()); + let app = Router::new() + .route("/{bucket}/{*key}", any(handle_ok)) + .with_state(objects.clone()); + let endpoint = serve(app).await; + + let out = f.run_bootstrap("alice", &endpoint); + assert!( + out.status.success(), + "bootstrap-admin avec S3 configuré : {}", + String::from_utf8_lossy(&out.stderr) + ); + + let local = std::fs::read(&f.audit).expect("journal local écrit"); + let remote = objects + .0 + .lock() + .unwrap() + .get("audit/ca-server/audit.log") + .cloned() + .expect("le journal a été envoyé à S3"); + assert_eq!( + local, remote, + "le contenu envoyé à S3 doit être une copie exacte du journal local" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_failing_s3_blocks_the_operation_nothing_is_committed() { + let Some(f) = fixture("s3_ko").await else { + eprintln!("OE_CASTORE_TEST_DSN non définie : test PostgreSQL ignoré"); + return; + }; + let app = Router::new().route("/{*path}", any(handle_failing)); + let endpoint = serve(app).await; + + let out = f.run_bootstrap("bob", &endpoint); + assert!( + !out.status.success(), + "un envoi S3 en échec doit faire échouer la commande, pas seulement un avertissement" + ); + assert!( + out.stdout.is_empty(), + "aucun jeton ne doit être émis quand le journal n'a pas pu être scellé sur S3 : {}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + !f.operator_exists("bob").await, + "rien ne doit être commité en base : le journal (avant le commit) a échoué" + ); +} diff --git a/bin/ra-console/Cargo.toml b/bin/ra-console/Cargo.toml index 9532fcb..1a27a82 100644 --- a/bin/ra-console/Cargo.toml +++ b/bin/ra-console/Cargo.toml @@ -32,6 +32,9 @@ oe-webauthn = { path = "../../crates/oe-webauthn" } # Son propre journal chaîné (docs/WEBUI.md §7, §15 étape 2b-A) : jamais celui # de ca-server, une chaîne distincte. oe-audit = { path = "../../crates/oe-audit" } +# Copie best-effort du journal sur un stockage objet compatible S3, auto-hébergé +# (docs/WEBUI.md §7, §15 étape 2b-C) : jamais AWS, décision de l'association. +oe-s3 = { path = "../../crates/oe-s3" } clap = { version = "4", features = ["derive"] } axum = "0.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "time"] } diff --git a/bin/ra-console/src/audit.rs b/bin/ra-console/src/audit.rs index 703a4dc..128ca05 100644 --- a/bin/ra-console/src/audit.rs +++ b/bin/ra-console/src/audit.rs @@ -35,7 +35,26 @@ impl Recorder for NullRecorder { fn append(&self, _event: &str, _data: serde_json::Value) {} } -pub struct AuditRecorder(pub Arc); +/// Copie best-effort du journal sur un stockage objet compatible S3, auto-hébergé +/// (docs/WEBUI.md §7, §15 étape 2b-C) : `None` si `OPENEIDAS_S3_ENDPOINT` n'est pas +/// configuré. À la différence de `ca-server` (preuve légale de la PKI, bloquant), le +/// journal de `ra-console` reste best-effort par décision déjà prise ci-dessus — +/// l'envoi S3 suit la même règle, jamais de blocage de connexion/déconnexion pour lui. +pub struct AuditRecorder { + log: Arc, + path: String, + s3: Option<(Arc, String)>, +} + +impl AuditRecorder { + pub fn new( + log: Arc, + path: String, + s3: Option<(Arc, String)>, + ) -> Self { + Self { log, path, s3 } + } +} impl Recorder for AuditRecorder { fn append(&self, event: &str, data: serde_json::Value) { @@ -48,8 +67,27 @@ impl Recorder for AuditRecorder { Some(m) } }; - if let Err(e) = self.0.append(event, data) { - tracing::error!(erreur = %e, evenement = event, "journal d'audit : échec d'écriture"); + if let Err(e) = self.log.append(event, data) { + tracing::error!(erreur = %e, evenement = event, "journal d'audit : échec d'écriture locale"); + return; + } + // L'envoi S3 n'attend jamais l'appelant (connexion/déconnexion) : détaché, + // son échec ne se voit que dans les logs du service, jamais dans la réponse. + if let Some((client, key)) = self.s3.clone() { + let path = self.path.clone(); + let event = event.to_string(); + tokio::spawn(async move { + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + tracing::error!(erreur = %e, evenement = event, "journal d'audit : relecture pour l'envoi S3 impossible"); + return; + } + }; + if let Err(e) = client.put(&key, bytes).await { + tracing::error!(erreur = %e, evenement = event, "journal d'audit : échec de l'envoi S3"); + } + }); } } } diff --git a/bin/ra-console/src/audit_search.rs b/bin/ra-console/src/audit_search.rs new file mode 100644 index 0000000..b98312f --- /dev/null +++ b/bin/ra-console/src/audit_search.rs @@ -0,0 +1,176 @@ +//! `GET /api/v1/audit/search` (docs/WEBUI.md §7, §15 étape 2b-D) : relit et +//! vérifie **les deux** journaux chaînés — celui de `ca-server` (fait foi +//! pour la PKI) et celui de `ra-console` (§7) — depuis le stockage S3 commun, +//! avant de répondre. Jamais un index qui pourrait avoir divergé des fichiers +//! source : la vérification porte sur l'intégralité de chaque journal relu +//! pour cette requête, pas seulement sur les entrées retournées. +//! +//! `ra-console` n'a aucun accès local au journal de `ca-server` : seul S3 les +//! met en commun (`OPENEIDAS_S3_CA_KEY`), d'où la dépendance stricte au +//! stockage S3 — sans lui, cette route ne peut pas honorer sa propre +//! garantie et refuse plutôt que de ne montrer qu'une moitié de la vérité. + +use crate::config::S3Config; +use serde::Serialize; +use std::sync::Arc; + +/// Le journal d'origine d'une entrée : jamais mélangés silencieusement, un +/// constat identique à `docs/WEBUI.md §7` (« chaque résultat indique de quel +/// journal il provient »). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Source { + CaServer, + RaConsole, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Entry { + pub source: Source, + pub sequence: u64, + pub at: String, + pub event: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Report { + pub chain_verified: bool, + pub entries_checked: u64, + pub results: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct Query { + /// Numéro de série hexadécimal (`data.serie`, insensible à la casse). + pub serial: Option, + pub from: Option, + pub to: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("récupération du journal {0} sur S3 : {1}")] + Fetch(&'static str, #[source] oe_s3::S3Error), +} + +/// Relit `bytes` comme un journal `oe-audit` : `oe_audit::read` exige un +/// chemin, pas un tampon en mémoire, donc un fichier temporaire à usage +/// unique le porte le temps de l'appel. +fn read_journal(bytes: &[u8]) -> Result, oe_audit::AuditError> { + let path = std::env::temp_dir().join(format!( + "ra-console-audit-search-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let result = std::fs::write(&path, bytes) + .map_err(oe_audit::AuditError::from) + .and_then(|()| oe_audit::read(&path)); + let _ = std::fs::remove_file(&path); + result +} + +fn matches(entry: &oe_audit::Record, q: &Query) -> bool { + if let Some(serial) = &q.serial { + let found = entry + .data + .as_ref() + .and_then(|d| d.get("serie")) + .and_then(|v| v.as_str()) + .is_some_and(|s| s.eq_ignore_ascii_case(serial)); + if !found { + return false; + } + } + if q.from.is_some() || q.to.is_some() { + let Ok(at) = time::OffsetDateTime::parse( + &entry.time, + &time::format_description::well_known::Rfc3339, + ) else { + // Une date illisible n'est filtrée dans aucun sens : elle n'est + // exclue que si elle échoue par ailleurs (elle ne le devrait + // jamais, `oe-audit` l'écrit toujours au format RFC 3339). + return true; + }; + if let Some(from) = q.from { + if at < from { + return false; + } + } + if let Some(to) = q.to { + if at > to { + return false; + } + } + } + true +} + +/// Récupère et relit un journal depuis S3. `Ok(None)` signale une chaîne +/// rompue ou illisible (jamais mélangée aux résultats, voir `search`) ; +/// `Err` signale que S3 lui-même n'a pas répondu (indisponibilité, pas un +/// constat d'intégrité). +async fn fetch_and_read( + client: &oe_s3::Client, + key: &str, + source: &'static str, +) -> Result>, Error> { + let bytes = client.get(key).await.map_err(|e| Error::Fetch(source, e))?; + Ok(read_journal(&bytes).ok()) +} + +/// Relit et vérifie les deux journaux, filtre, et rend le tout trié +/// chronologiquement — jamais un tri qui masquerait de quel journal vient +/// quoi (`source` sur chaque entrée). +pub async fn search(s3: &S3Config, query: &Query) -> Result { + let client = Arc::new( + oe_s3::Client::new(oe_s3::Options { + endpoint: s3.endpoint.clone(), + bucket: s3.bucket.clone(), + region: s3.region.clone(), + access_key: s3.access_key.clone(), + secret_key: s3.secret_key.clone(), + timeout: std::time::Duration::from_secs(30), + }) + .map_err(|e| Error::Fetch("ca-server", e))?, + ); + + let ca = fetch_and_read(&client, &s3.ca_key, "ca-server").await?; + let ra = fetch_and_read(&client, &s3.key, "ra-console").await?; + + let chain_verified = ca.is_some() && ra.is_some(); + let mut entries_checked = 0u64; + let mut results = Vec::new(); + + if chain_verified { + for (source, records) in [(Source::CaServer, ca), (Source::RaConsole, ra)] { + let records = records.unwrap_or_default(); + entries_checked += records.len() as u64; + for r in records.into_iter().filter(|r| matches(r, query)) { + results.push(Entry { + source, + sequence: r.seq, + at: r.time, + event: r.event, + data: r + .data + .map(|d| serde_json::Value::Object(d.into_iter().collect())), + }); + } + } + results.sort_by(|a, b| a.at.cmp(&b.at)); + } + + Ok(Report { + chain_verified, + entries_checked, + // Un chaînage rompu bloque l'affichage plutôt que de montrer des + // résultats à côté d'une alerte (docs/WEBUI.md §7) : personne ne + // peut l'ignorer par inattention. + results: if chain_verified { results } else { Vec::new() }, + }) +} diff --git a/bin/ra-console/src/config.rs b/bin/ra-console/src/config.rs index ef0f09f..292a967 100644 --- a/bin/ra-console/src/config.rs +++ b/bin/ra-console/src/config.rs @@ -18,6 +18,47 @@ pub struct Config { /// Journal chaîné propre à `ra-console` (docs/WEBUI.md §7, §15 étape 2b-A) : /// jamais celui de `ca-server`, une chaîne distincte. pub audit_file: String, + /// Copie best-effort du journal sur un stockage objet compatible S3, auto-hébergé + /// (docs/WEBUI.md §7, §15 étape 2b-C) : `None` si non configuré. À la différence de + /// `ca-server`, un échec (local ou S3) ne bloque jamais connexion/déconnexion — + /// décision déjà prise pour ce journal (voir `ra_console::audit::Recorder`), non + /// remise en cause par l'ajout de S3. + pub s3: Option, +} + +/// Mêmes champs que `ca_server::config::S3Config` (même stockage S3-compatible +/// auto-hébergé, mêmes variables `OPENEIDAS_S3_*` — chaque service lit son propre +/// environnement, `OPENEIDAS_S3_KEY` distingue les deux journaux dans le même +/// compartiment) ; dupliqué plutôt que partagé, comme `ra_console::audit::Recorder`. +#[derive(Debug, Clone)] +pub struct S3Config { + pub endpoint: String, + pub bucket: String, + pub region: String, + pub access_key: String, + pub secret_key: String, + pub key: String, + /// Clé du journal **de `ca-server`** dans le même compartiment + /// (docs/WEBUI.md §7, §15 étape 2b-D) : `GET /api/v1/audit/search` relit + /// et vérifie les deux chaînes, `ra-console` n'a aucun accès local au + /// journal de `ca-server`, seul S3 les met en commun. + pub ca_key: String, +} + +fn s3_config() -> Result, String> { + let endpoint = optional("OPENEIDAS_S3_ENDPOINT", ""); + if endpoint.is_empty() { + return Ok(None); + } + Ok(Some(S3Config { + endpoint, + bucket: required("OPENEIDAS_S3_BUCKET")?, + region: optional("OPENEIDAS_S3_REGION", "us-east-1"), + access_key: required("OPENEIDAS_S3_ACCESS_KEY")?, + secret_key: required("OPENEIDAS_S3_SECRET_KEY")?, + key: optional("OPENEIDAS_S3_KEY", "ra-console/audit.log"), + ca_key: optional("OPENEIDAS_S3_CA_KEY", "ca-server/audit.log"), + })) } /// Vérification des connexions (docs/WEBUI.md §15, étape 1c, §16) : `ra-console` @@ -117,6 +158,7 @@ impl Config { "OPENEIDAS_RA_AUDIT_FILE", "/var/lib/open-eidas/state/ra-console-audit.log", ), + s3: s3_config()?, }) } diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 4d468a5..f76c80d 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -14,7 +14,9 @@ use axum::{Json, Router}; use serde::Deserialize; use sqlx::PgPool; +use crate::audit_search; use crate::ca_link::{CaLink, Relayed}; +use crate::config::S3Config; use crate::login::{LoginError, LoginService}; use crate::requests; use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL}; @@ -27,6 +29,9 @@ pub struct AppState { pub link: CaLink, pub login: LoginService, pub sessions: Sessions, + /// `None` si non configuré : `/api/v1/audit/search` (étape 2b-D) refuse + /// alors plutôt que de ne vérifier qu'une moitié des deux journaux. + pub s3: Option, } pub fn router(state: Arc) -> Router { @@ -45,6 +50,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/me", get(handle_me)) .route("/api/v1/logout", post(handle_logout)) .route("/api/v1/requests", get(handle_requests)) + .route("/api/v1/audit/search", get(handle_audit_search)) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -438,6 +444,84 @@ async fn handle_requests( } } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AuditSearchQuery { + serial: Option, + from: Option, + to: Option, +} + +/// `Response` est volontairement l'`Err` (`clippy::result_large_err`) : c'est +/// justement la réponse 400 à renvoyer telle quelle à l'appelant, la boxer +/// n'apporterait rien ici (même raison que `authenticate`, ci-dessus). +#[allow(clippy::result_large_err)] +fn parse_date(raw: &Option, field: &str) -> Result, Response> { + match raw { + None => Ok(None), + Some(v) => time::OffsetDateTime::parse(v, &time::format_description::well_known::Rfc3339) + .map(Some) + .map_err(|_| { + error( + StatusCode::BAD_REQUEST, + "bad_request", + &format!("{field} : date RFC 3339 attendue"), + ) + }), + } +} + +/// `GET /api/v1/audit/search?serial=&from=&to=` (docs/WEBUI.md §7, §15 étape +/// 2b-D) : relit et vérifie les deux journaux chaînés (`ca-server`, +/// `ra-console`) depuis S3. Même rôle minimal documenté qu'ailleurs +/// (`auditeur`), même affichage seulement (§3) : toute session authentifiée +/// suffit, la route ne modifie jamais rien. +/// +/// Sans S3 configuré, `ra-console` n'a aucun moyen d'atteindre le journal de +/// `ca-server` : la route refuse plutôt que de ne vérifier qu'une moitié des +/// deux chaînes (décision explicite, cohérente avec « chain_verified: false +/// doit bloquer l'affichage », §7). +async fn handle_audit_search( + State(state): State>, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + let Some(s3) = &state.s3 else { + return error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "stockage S3 non configuré : audit/search ne peut vérifier les deux journaux sans lui", + ); + }; + let from = match parse_date(&q.from, "from") { + Ok(v) => v, + Err(resp) => return resp, + }; + let to = match parse_date(&q.to, "to") { + Ok(v) => v, + Err(resp) => return resp, + }; + let query = audit_search::Query { + serial: q.serial, + from, + to, + }; + match audit_search::search(s3, &query).await { + Ok(report) => Json(report).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "audit/search : stockage S3 indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + /// `POST /api/v1/logout` : révoque la session sans attendre son expiration. /// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans /// tous les cas, plus aucune session valide n'existe ensuite. diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index ba4a1be..646bc9e 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -13,6 +13,7 @@ //! d'opérateur n'y est encore branchée. pub mod audit; +pub mod audit_search; pub mod ca_link; pub mod config; pub mod db_guard; diff --git a/bin/ra-console/src/main.rs b/bin/ra-console/src/main.rs index 3de65fb..960f2f4 100644 --- a/bin/ra-console/src/main.rs +++ b/bin/ra-console/src/main.rs @@ -40,6 +40,27 @@ fn die(context: &str, err: impl std::fmt::Display) -> ! { std::process::exit(1); } +/// Construit le journal (local, et S3 best-effort si configuré, docs/WEBUI.md +/// §7, §15 étape 2b-C). +fn open_recorder(cfg: &Config) -> AuditRecorder { + let log = Arc::new( + oe_audit::Log::open(&cfg.audit_file).unwrap_or_else(|e| die("journal d'audit", e)), + ); + let s3 = cfg.s3.as_ref().map(|c| { + let client = oe_s3::Client::new(oe_s3::Options { + endpoint: c.endpoint.clone(), + bucket: c.bucket.clone(), + region: c.region.clone(), + access_key: c.access_key.clone(), + secret_key: c.secret_key.clone(), + timeout: std::time::Duration::from_secs(30), + }) + .unwrap_or_else(|e| die("client S3 du journal", e)); + (Arc::new(client), c.key.clone()) + }); + AuditRecorder::new(log, cfg.audit_file.clone(), s3) +} + fn bind_addr(listen: &str) -> String { match listen.strip_prefix(':') { Some(port) => format!("0.0.0.0:{port}"), @@ -81,11 +102,9 @@ async fn run_serve() { oe_webauthn::Verifier::new(&cfg.webauthn.rp_id, &origin, &cfg.webauthn.rp_name, models) .unwrap_or_else(|e| die("configuration WebAuthn", e)); - // Son propre journal chaîné (docs/WEBUI.md §7, §15 étape 2b-A) : jamais - // celui de ca-server, une chaîne distincte. - let journal: Arc = Arc::new(AuditRecorder(Arc::new( - oe_audit::Log::open(&cfg.audit_file).unwrap_or_else(|e| die("journal d'audit", e)), - ))); + // Son propre journal chaîné (docs/WEBUI.md §7, §15 étape 2b-A/2b-C) : jamais + // celui de ca-server, une chaîne distincte, copiée best-effort sur S3 si configuré. + let journal: Arc = Arc::new(open_recorder(&cfg)); let login = LoginService::new( oe_actions::Registry::new(pool.clone()), @@ -104,6 +123,7 @@ async fn run_serve() { link, login, sessions, + s3: cfg.s3.clone(), })); let listener = tokio::net::TcpListener::bind(bind_addr(&cfg.listen)) .await diff --git a/bin/ra-console/tests/audit_search.rs b/bin/ra-console/tests/audit_search.rs new file mode 100644 index 0000000..82eb2a8 --- /dev/null +++ b/bin/ra-console/tests/audit_search.rs @@ -0,0 +1,219 @@ +//! `ra_console::audit_search::search` (docs/WEBUI.md §7, §15 étape 2b-D) : +//! relit et vérifie les deux journaux chaînés depuis S3, avant de répondre. +//! Contre un serveur HTTP qui se comporte comme un service S3-compatible +//! (même contrainte que `crates/oe-s3/tests/client.rs` : pas d'image MinIO +//! accessible dans cet environnement). +//! +//! Ce que ces tests prouvent : les deux journaux sont fusionnés et triés, le +//! filtre par série fonctionne, et surtout — un journal rompu ou illisible +//! **bloque tout l'affichage** (`chain_verified: false`, `results` vide) +//! plutôt que de montrer une moitié de la vérité à côté d'une alerte (§7). + +use axum::extract::{Path, State}; +use axum::http::{Method, StatusCode}; +use axum::routing::any; +use axum::Router; +use ra_console::config::S3Config; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct Objects(Mutex>>); + +async fn handle( + State(objects): State>, + method: Method, + Path((bucket, key)): Path<(String, String)>, + body: axum::body::Bytes, +) -> (StatusCode, Vec) { + let full_key = format!("{bucket}/{key}"); + match method { + Method::PUT => { + objects.0.lock().unwrap().insert(full_key, body.to_vec()); + (StatusCode::OK, Vec::new()) + } + Method::GET => match objects.0.lock().unwrap().get(&full_key) { + Some(bytes) => (StatusCode::OK, bytes.clone()), + None => (StatusCode::NOT_FOUND, Vec::new()), + }, + _ => (StatusCode::METHOD_NOT_ALLOWED, Vec::new()), + } +} + +async fn serve(objects: Arc) -> String { + let app = Router::new() + .route("/{bucket}/{*key}", any(handle)) + .with_state(objects); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +fn s3_config(endpoint: String) -> S3Config { + S3Config { + endpoint, + bucket: "audit".to_string(), + region: "us-east-1".to_string(), + access_key: "test".to_string(), + secret_key: "test-secret-au-moins-16-octets".to_string(), + key: "ra-console/audit.log".to_string(), + ca_key: "ca-server/audit.log".to_string(), + } +} + +/// Écrit un journal chaîné valide (via `oe_audit::Log`) et rend ses octets. +fn write_journal(events: &[(&str, serde_json::Value)]) -> Vec { + let path = std::env::temp_dir().join(format!( + "ra-console-audit-search-test-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let log = oe_audit::Log::open(&path).unwrap(); + for (event, data) in events { + let data = data + .as_object() + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()); + log.append(event, data).unwrap(); + } + let bytes = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + bytes +} + +async fn put(endpoint: &str, bucket_key: &str, bytes: Vec) { + let client = oe_s3::Client::new(oe_s3::Options { + endpoint: endpoint.to_string(), + bucket: "audit".to_string(), + region: "us-east-1".to_string(), + access_key: "test".to_string(), + secret_key: "test-secret-au-moins-16-octets".to_string(), + timeout: std::time::Duration::from_secs(5), + }) + .unwrap(); + client.put(bucket_key, bytes).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn merges_and_sorts_both_journals() { + let objects = Arc::new(Objects::default()); + let endpoint = serve(objects).await; + + let ca = write_journal(&[( + "ca.certificate_issued", + serde_json::json!({ "serie": "aabbcc" }), + )]); + let ra = write_journal(&[( + "ra.login_succeeded", + serde_json::json!({ "operateur": "alice" }), + )]); + put(&endpoint, "ca-server/audit.log", ca).await; + put(&endpoint, "ra-console/audit.log", ra).await; + + let report = ra_console::audit_search::search( + &s3_config(endpoint), + &ra_console::audit_search::Query::default(), + ) + .await + .unwrap(); + + assert!(report.chain_verified); + assert_eq!(report.entries_checked, 2); + assert_eq!(report.results.len(), 2); + let sources: Vec<_> = report.results.iter().map(|e| e.source).collect(); + assert!(sources.contains(&ra_console::audit_search::Source::CaServer)); + assert!(sources.contains(&ra_console::audit_search::Source::RaConsole)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn filters_by_serial() { + let objects = Arc::new(Objects::default()); + let endpoint = serve(objects).await; + + let ca = write_journal(&[ + ( + "ca.certificate_issued", + serde_json::json!({ "serie": "aabbcc" }), + ), + ( + "ca.certificate_issued", + serde_json::json!({ "serie": "ddeeff" }), + ), + ]); + let ra = write_journal(&[]); + put(&endpoint, "ca-server/audit.log", ca).await; + put(&endpoint, "ra-console/audit.log", ra).await; + + let report = ra_console::audit_search::search( + &s3_config(endpoint), + &ra_console::audit_search::Query { + serial: Some("AABBCC".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert!(report.chain_verified); + assert_eq!(report.entries_checked, 2, "les deux entrées sont relues"); + assert_eq!(report.results.len(), 1, "une seule correspond au filtre"); + assert_eq!(report.results[0].data.as_ref().unwrap()["serie"], "aabbcc"); +} + +/// La preuve décisive (docs/WEBUI.md §7) : un journal rompu ne doit **jamais** +/// se voir mélangé à des résultats par ailleurs valides. Testé par mutation +/// (voir le commit) : sans le blocage, ce test échoue. +#[tokio::test(flavor = "multi_thread")] +async fn a_broken_chain_blocks_the_entire_response() { + let objects = Arc::new(Objects::default()); + let endpoint = serve(objects).await; + + let ca = write_journal(&[( + "ca.certificate_issued", + serde_json::json!({ "serie": "aabbcc" }), + )]); + let ra = write_journal(&[( + "ra.login_succeeded", + serde_json::json!({ "operateur": "alice" }), + )]); + // Corrompt le journal de ra-console : une chaîne rompue, comme une + // altération malveillante ou une copie partielle sur S3. + let mut corrupted = ra.clone(); + if let Some(byte) = corrupted.last_mut() { + *byte ^= 0xFF; + } + put(&endpoint, "ca-server/audit.log", ca).await; + put(&endpoint, "ra-console/audit.log", corrupted).await; + + let report = ra_console::audit_search::search( + &s3_config(endpoint), + &ra_console::audit_search::Query::default(), + ) + .await + .unwrap(); + + assert!( + !report.chain_verified, + "un journal rompu ne doit jamais être déclaré vérifié" + ); + assert!( + report.results.is_empty(), + "aucun résultat ne doit être montré quand une chaîne est rompue, même l'autre valide" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unreachable_s3_is_reported_as_an_error_not_a_broken_chain() { + // Aucun serveur : le port n'écoute pas. + let endpoint = "http://127.0.0.1:1".to_string(); + let err = ra_console::audit_search::search( + &s3_config(endpoint), + &ra_console::audit_search::Query::default(), + ) + .await + .unwrap_err(); + assert!(matches!(err, ra_console::audit_search::Error::Fetch(_, _))); +} diff --git a/bin/ra-console/tests/healthz.rs b/bin/ra-console/tests/healthz.rs index dc12902..92dee55 100644 --- a/bin/ra-console/tests/healthz.rs +++ b/bin/ra-console/tests/healthz.rs @@ -49,6 +49,7 @@ async fn healthz_needs_both_the_database_and_the_link() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool.clone()), + s3: None, })); let (status, body) = get(&app).await; assert_eq!(status, axum::http::StatusCode::OK, "{body}"); @@ -67,6 +68,7 @@ async fn healthz_needs_both_the_database_and_the_link() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + s3: None, })); let (status, body) = get(&app).await; assert_eq!( diff --git a/bin/ra-console/tests/login.rs b/bin/ra-console/tests/login.rs index 1cc7beb..e1205bd 100644 --- a/bin/ra-console/tests/login.rs +++ b/bin/ra-console/tests/login.rs @@ -79,9 +79,12 @@ impl Env { }; let verifier = Verifier::new("console.example.com", &origin(), "test", models()).unwrap(); let audit_file = std::env::temp_dir().join(format!("{name}.ra-audit.log")); - let journal: Arc = Arc::new( - ra_console::audit::AuditRecorder(Arc::new(oe_audit::Log::open(&audit_file).unwrap())), - ); + let journal: Arc = + Arc::new(ra_console::audit::AuditRecorder::new( + Arc::new(oe_audit::Log::open(&audit_file).unwrap()), + audit_file.to_string_lossy().into_owned(), + None, + )); let login = LoginService::new( registry.clone(), Verifier::new("console.example.com", &origin(), "test", models()).unwrap(), @@ -111,6 +114,7 @@ impl Env { pool, link, login, + s3: None, })); Some(Env { console, diff --git a/bin/ra-console/tests/register_relay.rs b/bin/ra-console/tests/register_relay.rs index c988f19..f1324e9 100644 --- a/bin/ra-console/tests/register_relay.rs +++ b/bin/ra-console/tests/register_relay.rs @@ -112,6 +112,7 @@ impl Env { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + s3: None, })); Some(Env { @@ -353,6 +354,7 @@ async fn an_unreachable_ca_server_gives_a_generic_bad_gateway() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + s3: None, })); let res = console diff --git a/bin/ra-console/tests/requests.rs b/bin/ra-console/tests/requests.rs index ccd1fbd..c153f22 100644 --- a/bin/ra-console/tests/requests.rs +++ b/bin/ra-console/tests/requests.rs @@ -90,6 +90,7 @@ impl Env { pool: pool.clone(), link, login, + s3: None, })); Some(Env { console, diff --git a/bin/ra-console/tests/s3_journal.rs b/bin/ra-console/tests/s3_journal.rs new file mode 100644 index 0000000..5a4eca2 --- /dev/null +++ b/bin/ra-console/tests/s3_journal.rs @@ -0,0 +1,156 @@ +//! Copie best-effort du journal de `ra-console` sur un stockage S3-compatible +//! auto-hébergé (docs/WEBUI.md §7, §15 étape 2b-C) : contre un serveur HTTP +//! qui se comporte comme un service S3-compatible (même contrainte que +//! `crates/oe-s3/tests/client.rs` : pas d'image MinIO accessible dans cet +//! environnement). +//! +//! Ce qui distingue ce journal de celui de `ca-server` : un échec, local ou +//! S3, ne doit **jamais** se voir de l'appelant — `Recorder::append` ne rend +//! rien, à la différence des `Recorder` async/bloquants du reste du dépôt +//! (décision déjà prise, `bin/ra-console/src/audit.rs`). L'envoi S3 est donc +//! testé pour ce qu'il est : une copie qui arrive quand tout va bien, sans +//! jamais bloquer ni faire paniquer quand le service S3 est en panne. + +use axum::extract::{Path, State}; +use axum::http::{Method, StatusCode}; +use axum::routing::any; +use axum::Router; +use ra_console::audit::AuditRecorder; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[derive(Default)] +struct Objects(Mutex>>); + +async fn handle_ok( + State(objects): State>, + method: Method, + Path((bucket, key)): Path<(String, String)>, + body: axum::body::Bytes, +) -> StatusCode { + if method == Method::PUT { + objects + .0 + .lock() + .unwrap() + .insert(format!("{bucket}/{key}"), body.to_vec()); + } + StatusCode::OK +} + +async fn handle_failing() -> StatusCode { + StatusCode::INTERNAL_SERVER_ERROR +} + +async fn serve(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +fn s3_client(endpoint: String) -> Arc { + Arc::new( + oe_s3::Client::new(oe_s3::Options { + endpoint, + bucket: "audit".to_string(), + region: "us-east-1".to_string(), + access_key: "test".to_string(), + secret_key: "test-secret-au-moins-16-octets".to_string(), + timeout: Duration::from_secs(5), + }) + .unwrap(), + ) +} + +/// Laisse le temps à la tâche détachée (`tokio::spawn` dans `append`) de +/// s'exécuter : `append` rend la main avant que l'envoi S3 ne soit terminé, +/// par construction (best-effort, non bloquant). +async fn settle() { + tokio::time::sleep(Duration::from_millis(200)).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_configured_s3_eventually_receives_a_copy_of_the_local_journal() { + let objects = Arc::new(Objects::default()); + let app = Router::new() + .route("/{bucket}/{*key}", any(handle_ok)) + .with_state(objects.clone()); + let endpoint = serve(app).await; + + let dir = std::env::temp_dir().join(format!( + "ra-console-s3-ok-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let audit_file = dir.join("audit.log"); + + let log = Arc::new(oe_audit::Log::open(&audit_file).unwrap()); + let recorder = AuditRecorder::new( + log, + audit_file.to_string_lossy().into_owned(), + Some((s3_client(endpoint), "ra-console/audit.log".to_string())), + ); + + ra_console::audit::Recorder::append( + &recorder, + "ra.login_succeeded", + serde_json::json!({ "operateur": "alice" }), + ); + settle().await; + + let local = std::fs::read(&audit_file).unwrap(); + let remote = objects + .0 + .lock() + .unwrap() + .get("audit/ra-console/audit.log") + .cloned() + .expect("le journal a été envoyé à S3"); + assert_eq!( + local, remote, + "le contenu envoyé à S3 doit être une copie exacte du journal local" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_failing_s3_does_not_stop_the_journal_from_being_usable() { + let app = Router::new().route("/{*path}", any(handle_failing)); + let endpoint = serve(app).await; + + let dir = std::env::temp_dir().join(format!( + "ra-console-s3-ko-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let audit_file = dir.join("audit.log"); + + let log = Arc::new(oe_audit::Log::open(&audit_file).unwrap()); + let recorder = AuditRecorder::new( + log, + audit_file.to_string_lossy().into_owned(), + Some((s3_client(endpoint), "ra-console/audit.log".to_string())), + ); + + // `append` ne rend rien : un service S3 en panne ne doit ni bloquer ni + // faire paniquer l'appelant (décision déjà prise pour ce journal). + ra_console::audit::Recorder::append( + &recorder, + "ra.login_succeeded", + serde_json::json!({ "operateur": "bob" }), + ); + settle().await; + + let local = std::fs::read_to_string(&audit_file).unwrap(); + assert!( + local.contains("ra.login_succeeded"), + "l'échec de S3 ne doit pas empêcher l'écriture locale : {local}" + ); +} diff --git a/crates/oe-config/src/lib.rs b/crates/oe-config/src/lib.rs index f0de564..38f451d 100644 --- a/crates/oe-config/src/lib.rs +++ b/crates/oe-config/src/lib.rs @@ -33,6 +33,8 @@ pub enum ConfigError { KeyBitsTooSmall(i64), #[error("OID invalide: {0:?}")] InvalidOid(String), + #[error("OPENEIDAS_POLICY_OID={0:?} appartient à l'arc de test : refusé quand OPENEIDAS_PRODUCTION est activé")] + TestPolicyOidInProduction(String), #[error("algorithme de signature non supporté: {0:?} (sha256, sha384 ou sha512)")] UnsupportedDigest(String), #[error("OPENEIDAS_PIN est obligatoire (code PIN du token PKCS#11)")] @@ -69,6 +71,8 @@ pub struct Config { pub audit_replica_timeout: Duration, pub policy_oid: ObjectIdentifier, + /// Mode production : refuse de démarrer avec un OID de politique de test. + pub production: bool, pub accuracy: Duration, pub signing_digest: SigningDigest, @@ -110,6 +114,14 @@ impl Config { return Err(ConfigError::KeyBitsTooSmall(key_bits)); } + let policy_oid = parse_oid(&env_str("OPENEIDAS_POLICY_OID", "1.3.6.1.4.1.99999.1.1.1"))?; + let production = env_bool("OPENEIDAS_PRODUCTION", false)?; + if production && is_test_oid(&policy_oid) { + return Err(ConfigError::TestPolicyOidInProduction(oid_to_string( + &policy_oid, + ))); + } + Ok(Config { listen: env_str("OPENEIDAS_LISTEN", ":8318"), shutdown_timeout: Duration::from_secs(15), @@ -148,7 +160,8 @@ impl Config { Duration::from_secs(30), )?, - policy_oid: parse_oid(&env_str("OPENEIDAS_POLICY_OID", "1.3.6.1.4.1.99999.1.1.1"))?, + policy_oid, + production, accuracy: env_duration("OPENEIDAS_ACCURACY", Duration::from_secs(1))?, signing_digest: parse_digest(&env_str("OPENEIDAS_SIGNING_DIGEST", "sha256"))?, @@ -189,6 +202,27 @@ fn split_list(s: &str) -> Vec { .collect() } +/// Préfixe des numéros d'entreprise privés : `1.3.6.1.4.1.`. +const PEN_PREFIX: [u64; 6] = [1, 3, 6, 1, 4, 1]; +/// Numéro d'entreprise historiquement utilisé pour le banc d'essai, non attribué à OTSPI. +const LEGACY_TEST_PEN: u64 = 99999; +/// Sous-arc réservé au test et au staging sous le numéro d'entreprise d'OTSPI +/// (`.9`, voir docs/cadrage/oid-arc.md du dépôt de gouvernance). +const TEST_SUB_ARC: u64 = 9; + +/// Indique si l'OID appartient à un arc de test : l'ancien numéro d'essai `99999`, ou le +/// sous-arc `9` de tout numéro d'entreprise. +pub fn is_test_oid(oid: &[u64]) -> bool { + if oid.len() < 8 || oid[..6] != PEN_PREFIX { + return false; + } + oid[6] == LEGACY_TEST_PEN || oid[7] == TEST_SUB_ARC +} + +fn oid_to_string(oid: &[u64]) -> String { + oid.iter().map(u64::to_string).collect::>().join(".") +} + fn parse_oid(s: &str) -> Result { let parts: Vec<&str> = s.trim().split('.').collect(); if parts.len() < 2 { @@ -349,6 +383,38 @@ mod tests { clear_env(); } + #[test] + fn test_oid_detection() { + assert!(is_test_oid(&[1, 3, 6, 1, 4, 1, 99999, 1, 1, 1])); + assert!(is_test_oid(&[1, 3, 6, 1, 4, 1, 12345, 9, 1, 1, 1])); + assert!(!is_test_oid(&[1, 3, 6, 1, 4, 1, 12345, 1, 1, 1])); + assert!(!is_test_oid(&[0, 4, 0, 2023, 1, 1])); + assert!(!is_test_oid(&[1, 3, 6, 1, 4, 1])); + } + + #[test] + #[serial] + fn load_refuses_test_oid_in_production() { + clear_env(); + env::set_var("OPENEIDAS_PIN", "1234"); + env::set_var("OPENEIDAS_PRODUCTION", "true"); + let err = Config::load().unwrap_err(); + assert!(matches!(err, ConfigError::TestPolicyOidInProduction(_))); + clear_env(); + } + + #[test] + #[serial] + fn load_accepts_production_oid_in_production() { + clear_env(); + env::set_var("OPENEIDAS_PIN", "1234"); + env::set_var("OPENEIDAS_PRODUCTION", "true"); + env::set_var("OPENEIDAS_POLICY_OID", "1.3.6.1.4.1.12345.1.1.1"); + let cfg = Config::load().unwrap(); + assert!(cfg.production); + clear_env(); + } + #[test] #[serial] fn load_reads_cors_allowed_origin() { diff --git a/docs/API.md b/docs/API.md index cf39f61..0f67456 100644 --- a/docs/API.md +++ b/docs/API.md @@ -193,6 +193,7 @@ Toutes les options sont pilotées par variables d'environnement. | `OPENEIDAS_CERT_FILE` | `/var/lib/open-eidas/tsu.pem` | Certificat de la TSU | | `OPENEIDAS_CHAIN_FILE` | `/var/lib/open-eidas/chain.pem` | Chaîne d'émission | | `OPENEIDAS_POLICY_OID` | `1.3.6.1.4.1.99999.1.1.1` | OID de la politique d'horodatage | +| `OPENEIDAS_PRODUCTION` | `false` | Mode production : le service refuse de démarrer si l'OID de politique appartient à un arc de test (ancien numéro `99999`, ou sous-arc `9` d'un numéro d'entreprise) | | `OPENEIDAS_ACCURACY` | `1s` | Précision annoncée dans le `TSTInfo` | | `OPENEIDAS_SIGNING_DIGEST` | `sha256` | Empreinte utilisée pour signer le jeton | | `OPENEIDAS_MAX_REQUEST_BYTES` | `65536` | Taille maximale d'une requête | diff --git a/scripts/set-oid-arc.sh b/scripts/set-oid-arc.sh new file mode 100755 index 0000000..8d9bc90 --- /dev/null +++ b/scripts/set-oid-arc.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: EUPL-1.2 +# Remplace l'OID de test historique (1.3.6.1.4.1.99999.1.1.1) par l'OID de l'arc de TEST d'OTSPI, +# 1.3.6.1.4.1..9.1.1.1, une fois le numéro d'entreprise IANA attribué. +# Voir docs/cadrage/oid-arc.md du dépôt de gouvernance (otspi/organisation). +# +# Usage : +# scripts/set-oid-arc.sh +# +# L'OID de production (.1.1.1) n'est PAS posé ici : il n'est renseigné qu'à la mise en service +# du service qualifié, après approbation de la politique par le CPC, avec OPENEIDAS_PRODUCTION=true. + +set -euo pipefail + +if [ "$#" -ne 1 ] || ! [[ "$1" =~ ^[0-9]+$ ]]; then + echo "usage : $0 " >&2 + exit 2 +fi +pen="$1" +old="1.3.6.1.4.1.99999.1.1.1" +new="1.3.6.1.4.1.${pen}.9.1.1.1" + +cd "$(git rev-parse --show-toplevel)" +files="$(grep -rlF "$old" . --exclude-dir=.git --exclude-dir=target --exclude=set-oid-arc.sh || true)" +if [ -z "$files" ]; then + echo "Aucune occurrence de $old" >&2 + exit 1 +fi + +# shellcheck disable=SC2086 +sed -i "s/${old//./\\.}/${new}/g" $files +echo "OID de test remplacé par $new dans :" +echo "$files" + +# Le test de configuration compare l'OID par défaut sous forme de suite d'entiers. +sed -i "s/cfg.policy_oid, vec!\[1, 3, 6, 1, 4, 1, 99999, 1, 1, 1\]/cfg.policy_oid, vec![1, 3, 6, 1, 4, 1, ${pen}, 9, 1, 1, 1]/" crates/oe-config/src/lib.rs + +if grep -rF "99999.1.1.1" . --exclude-dir=.git --exclude-dir=target --exclude=set-oid-arc.sh >/dev/null; then + echo "Il reste des occurrences de l'ancien OID : à traiter à la main." >&2 + exit 1 +fi +echo "Vérifier ensuite : cargo fmt --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace"