From 0b26fcdf4da0061ffc42738eabd4cd2d0981a784 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Mon, 28 Sep 2026 10:20:24 +0200 Subject: [PATCH 1/4] feat(config): refus de l'OID de test en production et script de bascule vers l'arc PEN (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute OPENEIDAS_PRODUCTION : quand elle est activée, 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), garde-fou prévu par le plan d'OID d'OTSPI. Ajoute scripts/set-oid-arc.sh pour remplacer l'OID de test historique par l'arc de test 1.3.6.1.4.1..9.1.1.1 dans le code, les tests, le déploiement et la documentation, dès que l'IANA aura attribué le numéro d'entreprise. Prépare la résolution de l'issue #36. --- crates/oe-config/src/lib.rs | 68 ++++++++++++++++++++++++++++++++++++- docs/API.md | 1 + scripts/set-oid-arc.sh | 42 +++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100755 scripts/set-oid-arc.sh 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" From b72fde179a887d27362a35299b3bf0f667d1a161 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Mon, 28 Sep 2026 10:21:18 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(ca-server):=20r=C3=A9plication=20du=20?= =?UTF-8?q?journal=20d'audit=20sur=20un=20stockage=20S3-compatible=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 2b (docs/WEBUI.md §7, §15) : le journal d'audit de ca-server peut être copié à chaque écriture sur un stockage objet compatible S3, auto-hébergé (MinIO, Garage, Ceph… jamais AWS ni un service géré, décision de l'association) — voir crates/oe-s3, ajouté et vérifié précédemment. - OPENEIDAS_S3_ENDPOINT/BUCKET/REGION/ACCESS_KEY/SECRET_KEY/KEY, toutes optionnelles ensemble : sans OPENEIDAS_S3_ENDPOINT, le journal reste purement local, rétrocompatible. - AuditRecorder écrit d'abord en local (source de vérité, verrou et chaînage), puis, si S3 est configuré, relit le fichier entier et l'envoie en une fois (S3 ne connaît pas d'ajout partiel). - Bloquant comme le reste du journal (PR précédentes sur oe-ca-core, oe-raflow, oe-tsa-core) : un échec de l'envoi S3 fait échouer l'opération qui en dépend, rien n'est journalisé tant que S3 ne l'a pas reçu. - tests/s3_journal.rs : contre un vrai PostgreSQL et un serveur HTTP qui se comporte comme un service S3-compatible, deux preuves — la copie envoyée à S3 est identique au journal local, et un envoi S3 en échec ne commit rien en base (pas seulement une erreur rendue). Testé par mutation : en retirant le blocage, le second test échoue bien. Co-authored-by: Claude --- Cargo.lock | 1 + bin/ca-server/Cargo.toml | 3 + bin/ca-server/src/config.rs | 47 ++++++++ bin/ca-server/src/main.rs | 137 ++++++++++++++--------- bin/ca-server/tests/s3_journal.rs | 175 ++++++++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 54 deletions(-) create mode 100644 bin/ca-server/tests/s3_journal.rs diff --git a/Cargo.lock b/Cargo.lock index 486a1a1..396ed0c 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", 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é" + ); +} From de7ff68b3c8df0266c4460b6c85d0953ae0a560b Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Mon, 28 Sep 2026 10:22:30 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(ra-console):=20r=C3=A9plication=20best?= =?UTF-8?q?-effort=20du=20journal=20d'audit=20sur=20S3=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 2b-C du plan WEBUI (docs/WEBUI.md §7, §15) : le journal d'audit propre à ra-console (2b-A) peut lui aussi être copié sur le stockage objet compatible S3 auto-hébergé (mêmes variables OPENEIDAS_S3_* que ca-server, OPENEIDAS_S3_KEY distinguant les deux journaux dans le même compartiment). À la différence de ca-server (journal bloquant, preuve légale de la PKI), celui de ra-console reste best-effort — décision déjà prise et documentée dans audit.rs : un journal qui n'écrit pas ne doit pas faire échouer une connexion par ailleurs valide. L'envoi S3 suit la même règle : il est tenté dans une tâche détachée après l'écriture locale, sans jamais bloquer ni faire échouer l'appelant. - AuditRecorder passe d'un tuple struct à des champs nommés (log, path, s3) pour porter le client S3 optionnel ; API construite via AuditRecorder::new(). - tests/s3_journal.rs : contre un serveur HTTP qui se comporte comme un service S3-compatible (MinIO indisponible dans cet environnement) — la copie envoyée est identique au journal local, et un service S3 en panne n'empêche ni l'écriture locale ni l'appelant de continuer. Testé par mutation (retrait de l'envoi S3 → le premier test échoue bien). Co-authored-by: Claude --- Cargo.lock | 1 + bin/ra-console/Cargo.toml | 3 + bin/ra-console/src/audit.rs | 44 +++++++- bin/ra-console/src/config.rs | 36 +++++++ bin/ra-console/src/main.rs | 29 +++++- bin/ra-console/tests/login.rs | 9 +- bin/ra-console/tests/s3_journal.rs | 156 +++++++++++++++++++++++++++++ 7 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 bin/ra-console/tests/s3_journal.rs diff --git a/Cargo.lock b/Cargo.lock index 396ed0c..3a4f607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2227,6 +2227,7 @@ dependencies = [ "oe-enroll", "oe-hsm", "oe-raflow", + "oe-s3", "oe-webauthn", "rand 0.8.8", "reqwest", 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/config.rs b/bin/ra-console/src/config.rs index ef0f09f..e07baea 100644 --- a/bin/ra-console/src/config.rs +++ b/bin/ra-console/src/config.rs @@ -18,6 +18,41 @@ 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, +} + +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"), + })) } /// Vérification des connexions (docs/WEBUI.md §15, étape 1c, §16) : `ra-console` @@ -117,6 +152,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/main.rs b/bin/ra-console/src/main.rs index 3de65fb..ac8cf67 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()), diff --git a/bin/ra-console/tests/login.rs b/bin/ra-console/tests/login.rs index 1cc7beb..2f8e8be 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(), 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}" + ); +} From 0c0db6dc5e0ee362c2f1110d2f93dde78499771f Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sat, 26 Sep 2026 13:02:28 +0200 Subject: [PATCH 4/4] =?UTF-8?q?feat(ra-console):=20GET=20/api/v1/audit/sea?= =?UTF-8?q?rch,=20lecture=20et=20v=C3=A9rification=20des=20deux=20journaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 2b-D du plan WEBUI (docs/WEBUI.md §7, §15) : la route relit et vérifie intégralement les deux journaux chaînés (celui de ca-server, qui fait foi pour la PKI, et celui de ra-console) depuis le stockage S3 commun, avant de répondre — jamais un index qui pourrait avoir divergé des fichiers source. Contrat déjà fixé par docs/WEBUI.md §7 : filtres serial/from/to, réponse {chain_verified, entries_checked, results}, chaque résultat marqué de son journal d'origine. entries_checked porte sur l'intégralité de ce qui a été relu, pas seulement sur les entrées retournées. Décision explicite pour cette tranche : un journal rompu ou illisible (l'un des deux, ou les deux) 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. Sans stockage S3 configuré, la route refuse (503) : ra-console n'a aucun accès local au journal de ca-server, seul S3 les met en commun (nouvelle variable OPENEIDAS_S3_CA_KEY, même bucket que OPENEIDAS_S3_* de 2b-C, clé distincte par défaut "ca-server/audit.log"). Une panne S3 (indisponibilité) et une chaîne rompue (constat d'intégrité) sont deux échecs distincts : la première répond 503, la seconde répond 200 avec chain_verified: false — l'appelant ne doit jamais confondre « je n'ai pas pu vérifier » avec « j'ai vérifié, et c'est rompu ». - audit_search.rs (nouveau module) : search() fait tout le travail, testable sans HTTP ni PostgreSQL. - tests/audit_search.rs : contre un serveur HTTP qui se comporte comme un service S3-compatible (MinIO indisponible dans cet environnement) — fusion et tri des deux journaux, filtre par série, chaîne rompue bloque tout (testé par mutation : sans le blocage, ce test échoue), panne S3 distinguée d'une chaîne rompue. Co-authored-by: Claude --- bin/ra-console/src/audit_search.rs | 176 ++++++++++++++++++++ bin/ra-console/src/config.rs | 6 + bin/ra-console/src/http.rs | 84 ++++++++++ bin/ra-console/src/lib.rs | 1 + bin/ra-console/src/main.rs | 1 + bin/ra-console/tests/audit_search.rs | 219 +++++++++++++++++++++++++ bin/ra-console/tests/healthz.rs | 2 + bin/ra-console/tests/login.rs | 1 + bin/ra-console/tests/register_relay.rs | 2 + bin/ra-console/tests/requests.rs | 1 + 10 files changed, 493 insertions(+) create mode 100644 bin/ra-console/src/audit_search.rs create mode 100644 bin/ra-console/tests/audit_search.rs 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 e07baea..292a967 100644 --- a/bin/ra-console/src/config.rs +++ b/bin/ra-console/src/config.rs @@ -38,6 +38,11 @@ pub struct S3Config { 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> { @@ -52,6 +57,7 @@ fn s3_config() -> Result, String> { 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"), })) } 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 ac8cf67..960f2f4 100644 --- a/bin/ra-console/src/main.rs +++ b/bin/ra-console/src/main.rs @@ -123,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 2f8e8be..e1205bd 100644 --- a/bin/ra-console/tests/login.rs +++ b/bin/ra-console/tests/login.rs @@ -114,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,