From a5c011e6a001c2a8b2abc35c06246c56664fa63e Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Sun, 27 Sep 2026 12:23:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(ra-console):=20gestion=20du=20registre=20d?= =?UTF-8?q?es=20op=C3=A9rateurs=20depuis=20la=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WEBUI.md §5, §10 : ra-console relaie, sur le schéma des étapes 3 et 4 (challenge puis exécution, cible contrôlée par ca-server), les actions de registre qu'oe_actions exécute déjà : invite_operator, confirm_key, revoke_key, set_role. - Routes (module registry_routes) : POST /api/v1/operators, POST /api/v1/credentials/{credential_id}/confirm et /revoke, POST /api/v1/operators/{name}/role. Le jeton d'invitation n'existe que dans result.invite_token de la réponse, jamais journalisé. - oe_actions::Expect gagne credential_id et operator ; le contrôle de cible devient générique (une cible d'un autre type est refusée), avant toute consommation de la cérémonie. Une invitation n'a pas de cible. - Élever au rôle admin ou changer celui d'un administrateur : deux administrateurs, co-signature par /api/v1/quorum/{id}/sign (la route de signature joint désormais la cible des actions de registre). - Tests de bout en bout : invitation puis enregistrement puis confirmation, jeton absent du journal de la console ; révocation de clé et changement de rôle où seule la cible distingue ; élévation admin à deux. Cinq mutations tuées. Co-authored-by: Claude --- bin/ra-console/src/http.rs | 32 ++- bin/ra-console/src/lib.rs | 1 + bin/ra-console/src/registry_routes.rs | 138 +++++++++++ bin/ra-console/tests/action_challenge.rs | 293 ++++++++++++++++++++++- crates/oe-actions/src/lib.rs | 120 ++++++++-- docs/RA-CONSOLE.md | 41 +++- 6 files changed, 588 insertions(+), 37 deletions(-) create mode 100644 bin/ra-console/src/registry_routes.rs diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 6c12542..abb4010 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -53,6 +53,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/certificates/{serial}/revoke", post(handle_revoke)) .route("/api/v1/quorum", get(handle_quorum)) .route("/api/v1/quorum/{action_id}/sign", post(handle_quorum_sign)) + .merge(crate::registry_routes::routes()) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -92,7 +93,7 @@ async fn handle_health(State(state): State>) -> Response { } /// `{"error": "", "message": "..."}` (docs/WEBUI.md §5), sans trace interne. -fn error(status: StatusCode, code: &str, message: &str) -> Response { +pub(crate) fn error(status: StatusCode, code: &str, message: &str) -> Response { ( status, Json(serde_json::json!({ "error": code, "message": message })), @@ -250,7 +251,7 @@ struct LoginFinish { /// Un nom d'opérateur : ce qu'un humain saisit, pas un identifiant technique. /// Une longueur bornée suffit à écarter un corps abusif avant toute requête ; /// le reste (existe ou non) ne se voit jamais dans la réponse (§16). -fn looks_like_a_name(s: &str) -> bool { +pub(crate) fn looks_like_a_name(s: &str) -> bool { !s.is_empty() && s.chars().count() <= 256 } @@ -446,16 +447,22 @@ async fn handle_requests( } } -/// Les actions que la console relaie à ce stade (docs/WEBUI.md §15, étapes 3 -/// et 4) : décider d'une demande d'enrôlement, révoquer un certificat. La -/// gestion du registre suivra ; d'ici là, la console refuse de la préparer, -/// même si `ca-server` saurait l'exécuter. +/// Les actions que la console relaie (docs/WEBUI.md §5, §15 étapes 3 et 4) : +/// décider d'une demande d'enrôlement, révoquer un certificat, et gérer le +/// registre des opérateurs (inviter, confirmer ou révoquer une clé, changer un +/// rôle). L'énumération d'`oe_actions` est fermée : il n'en existe pas d'autre +/// aujourd'hui ; une action ajoutée plus tard n'est pas relayée tant qu'elle +/// n'est pas nommée ici. fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { matches!( action, oe_actions::Action::ApproveRequest { .. } | oe_actions::Action::RejectRequest { .. } | oe_actions::Action::RevokeCertificate { .. } + | oe_actions::Action::InviteOperator { .. } + | oe_actions::Action::ConfirmKey { .. } + | oe_actions::Action::RevokeKey { .. } + | oe_actions::Action::SetRole { .. } ) } @@ -619,7 +626,7 @@ async fn handle_reject( /// /// L'`Err` est la réponse à rendre telle quelle (voir [`authenticate`]). #[allow(clippy::result_large_err)] -async fn relay_assertion( +pub(crate) async fn relay_assertion( state: &AppState, headers: &HeaderMap, body: &[u8], @@ -790,7 +797,14 @@ async fn handle_quorum_sign( oe_actions::Action::RevokeCertificate { serial, .. } => { expect["serial"] = serde_json::json!(serial); } - _ => {} + oe_actions::Action::ConfirmKey { credential_id, .. } + | oe_actions::Action::RevokeKey { credential_id, .. } => { + expect["credential_id"] = serde_json::json!(credential_id); + } + oe_actions::Action::SetRole { operator, .. } => { + expect["operator"] = serde_json::json!(operator); + } + oe_actions::Action::InviteOperator { .. } => {} } match relay_assertion(&state, &headers, &body, expect).await { Ok(r) => Json(quorum_status(&r.body)).into_response(), @@ -807,7 +821,7 @@ fn action_kind(action: &oe_actions::Action) -> String { } /// La forme du §5 pour une action à plusieurs signatures. -fn quorum_status(body: &serde_json::Value) -> serde_json::Value { +pub(crate) fn quorum_status(body: &serde_json::Value) -> serde_json::Value { let executed = body.get("status").and_then(|s| s.as_str()) == Some("executed"); serde_json::json!({ "action_id": body.get("action_id"), diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index f855557..1e82d38 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -20,6 +20,7 @@ pub mod http; pub mod login; pub mod purge; pub mod quorum; +pub mod registry_routes; pub mod requests; pub mod session; pub mod webauthn_models; diff --git a/bin/ra-console/src/registry_routes.rs b/bin/ra-console/src/registry_routes.rs new file mode 100644 index 0000000..51dcaab --- /dev/null +++ b/bin/ra-console/src/registry_routes.rs @@ -0,0 +1,138 @@ +//! Gestion du registre des opérateurs depuis la console (docs/WEBUI.md §5, +//! §10) : inviter un opérateur, confirmer ou révoquer une clé, changer un rôle. +//! Même schéma que les décisions et la révocation (§4) : le challenge est +//! préparé par `POST /api/v1/webauthn/challenge` avec l'action voulue, puis +//! l'assertion est relayée ici, **sans corps** ; `ca-server` exécute celui +//! qu'il a figé, après avoir comparé la cible de la route (`expect`) au corps +//! figé. Seul un `admin` signe ces actions, et deux pour créer un +//! administrateur ou changer le rôle de l'un d'eux : c'est la politique de +//! `ca-server`, jamais une décision de la console. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; + +use crate::http::{error, looks_like_a_name, quorum_status, relay_assertion, AppState}; + +pub(crate) fn routes() -> Router> { + Router::new() + .route("/api/v1/operators", post(handle_invite)) + .route( + "/api/v1/credentials/{credential_id}/confirm", + post(handle_confirm_key), + ) + .route( + "/api/v1/credentials/{credential_id}/revoke", + post(handle_revoke_key), + ) + .route("/api/v1/operators/{name}/role", post(handle_set_role)) +} + +/// Un identifiant de clé WebAuthn tel que le registre le range : base64url, +/// borné. Il n'est qu'une attente : `ca-server` le compare au corps figé. +fn looks_like_a_credential_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 1024 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +async fn relay( + state: &AppState, + headers: &HeaderMap, + body: &[u8], + expect: serde_json::Value, +) -> Response { + match relay_assertion(state, headers, body, expect).await { + Ok(r) => Json(quorum_status(&r.body)).into_response(), + Err(resp) => resp, + } +} + +/// `POST /api/v1/operators` : exécute l'invitation figée +/// (`{"action": "invite_operator", "name", "role"}`). Le jeton d'invitation +/// n'existe que dans `result.invite_token` de la réponse d'exécution : ni +/// `ca-server` ni la console ne le journalisent ni ne le conservent. Une +/// invitation n'a pas de cible dans la route : seul le type est contrôlé. +async fn handle_invite( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Response { + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "invite_operator" }), + ) + .await +} + +/// `POST /api/v1/credentials/{credential_id}/confirm` : active une clé en +/// attente. L'empreinte, transmise hors bande par l'invité (§10), fait partie +/// du corps signé ; `ca-server` la recompare à la clé en attente. +async fn handle_confirm_key( + State(state): State>, + Path(credential_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_credential_id(&credential_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "confirm_key", "credential_id": credential_id }), + ) + .await +} + +/// `POST /api/v1/credentials/{credential_id}/revoke` (perte de clé, départ, +/// §14). `ca-server` refuse de révoquer la dernière clé d'administrateur active. +async fn handle_revoke_key( + State(state): State>, + Path(credential_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_credential_id(&credential_id) { + return error(StatusCode::BAD_REQUEST, "bad_request", "clé invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "revoke_key", "credential_id": credential_id }), + ) + .await +} + +/// `POST /api/v1/operators/{name}/role` : l'opérateur est désigné par son +/// **nom**, comme dans le corps signé (`set_role`), pas par un identifiant +/// technique. Élever un opérateur au rôle `admin`, ou changer celui d'un +/// administrateur, exige deux administrateurs : la première signature rend +/// `AWAITING_QUORUM`, la seconde passe par `/api/v1/quorum/{id}/sign`. +async fn handle_set_role( + State(state): State>, + Path(name): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !looks_like_a_name(&name) { + return error(StatusCode::BAD_REQUEST, "bad_request", "opérateur invalide"); + } + relay( + &state, + &headers, + &body, + serde_json::json!({ "action": "set_role", "operator": name }), + ) + .await +} diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 97b7720..4f59af4 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -44,8 +44,20 @@ fn origin() -> Url { Url::parse(&format!("https://{HOST}")).unwrap() } +/// Le journal de la console, relu par les tests : un jeton d'invitation ne +/// doit jamais y figurer. +#[derive(Default)] +struct ConsoleJournal(std::sync::Mutex>); + +impl ra_console::audit::Recorder for ConsoleJournal { + fn append(&self, event: &str, data: serde_json::Value) { + self.0.lock().unwrap().push(format!("{event} {data}")); + } +} + struct Env { console: axum::Router, + journal: Arc, registry: Registry, store: Arc, issuer: Arc, @@ -155,6 +167,7 @@ impl Env { let link = CaLink::new(&pki.files(&dir, &client, port)).unwrap(); let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); + let journal = Arc::new(ConsoleJournal::default()); let console = router(Arc::new(AppState { pool: pool.clone(), link, @@ -165,11 +178,12 @@ impl Env { Arc::new(ra_console::audit::NullRecorder), ), sessions: common::sessions(pool), - journal: Arc::new(ra_console::audit::NullRecorder), + journal: journal.clone(), })); Some(Env { console, + journal, registry, store, issuer, @@ -376,15 +390,15 @@ async fn the_console_prepares_nothing_without_a_session_or_outside_step_3() { assert_eq!(status, StatusCode::UNAUTHORIZED, "{err}"); } - // Une action que ca-server saurait exécuter, mais que la console ne propose - // pas encore (gestion du registre : après l'étape 4). + // La gestion du registre est relayée, mais réservée aux administrateurs : + // c'est ca-server qui refuse à un ca_operateur, sans rien figer. for action in [ - serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "admin" }), - serde_json::json!({ "action": "set_role", "operator": "alice", "role": "admin" }), + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "auditeur" }), + serde_json::json!({ "action": "set_role", "operator": "alice", "role": "auditeur" }), ] { let (status, err) = env.challenge(Some(&cookie), action).await; assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); - assert_eq!(err["error"], "action_not_available"); + assert_eq!(err["error"], "denied"); } // Une action inconnue, ou un corps qui n'est pas une action. @@ -883,3 +897,270 @@ async fn a_co_signature_only_counts_for_its_action() { assert_eq!(status, StatusCode::NOT_FOUND, "{id} {err}"); } } + +// --- Gestion du registre depuis la console (invitation, clés, rôles) --- + +impl Env { + /// Prépare `action`, la signe, et la relaie sur `path` : rend la réponse. + async fn sign_and_send( + &mut self, + cookie: &str, + action: serde_json::Value, + path: &str, + ) -> (StatusCode, serde_json::Value) { + let (status, issued) = self.challenge(Some(cookie), action).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = self.sign(&issued); + self.decide(Some(cookie), path, &issued, &assertion).await + } + + async fn role_of(&self, name: &str) -> String { + sqlx::query_scalar("SELECT role FROM operators WHERE name = $1") + .bind(name) + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + async fn key_revoked(&self, credential_id: &str) -> bool { + sqlx::query_scalar( + "SELECT revoked_at IS NOT NULL FROM webauthn_credentials WHERE credential_id = $1", + ) + .bind(credential_id) + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + /// Une seconde clé pour un opérateur existant, posée dans le registre. + async fn second_key(&mut self, operator: Uuid, name: &str) -> String { + let before = self.credential_ids(operator).await; + let now = time::OffsetDateTime::now_utc(); + let (options, state) = self + .verifier + .start_registration(operator, name, None) + .unwrap(); + let reg = self.authn.do_registration(origin(), options).unwrap(); + let key = self.verifier.finish_registration(®, &state).unwrap(); + self.registry + .add_credential( + NewCredential { + operator_id: operator, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "secours", + initiated_by: "test", + confirmed_by: Some("test"), + }, + now, + ) + .await + .unwrap(); + self.credential_ids(operator) + .await + .into_iter() + .find(|k| !before.contains(k)) + .unwrap() + } +} + +/// Un administrateur invite un opérateur par la console : le jeton n'est rendu +/// qu'une fois, dans la réponse, et ne figure dans aucun journal de la console. +/// L'invité enregistre sa clé par le relais existant ; l'administrateur la +/// confirme en signant son empreinte, transmise hors bande (§10). +#[tokio::test] +async fn an_admin_invites_and_confirms_an_operator_through_the_console() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let admin = env.log_in("root").await; + + let invite = + serde_json::json!({ "action": "invite_operator", "name": "eve", "role": "ra_operateur" }); + let (status, done) = env.sign_and_send(&admin, invite, "/api/v1/operators").await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + let token = done["result"]["invite_token"].as_str().unwrap().to_string(); + assert!(token.len() > 30); + assert_eq!(env.role_of("eve").await, "ra_operateur"); + let journal = env.journal.0.lock().unwrap().join("\n"); + assert!(journal.contains("ra.action_relayed"), "{journal}"); + assert!( + !journal.contains(&token), + "le jeton est journalisé : {journal}" + ); + + // L'invitée enregistre sa clé : elle attend la confirmation d'un tiers. + let (_, _, begun) = env + .post( + "/api/v1/webauthn/register/begin", + serde_json::json!({ "token": token }), + None, + "application/json", + ) + .await; + let options: oe_webauthn::CreationChallengeResponse = + serde_json::from_value(serde_json::json!({ "publicKey": begun["webauthn"] })).unwrap(); + let credential = env.authn.do_registration(origin(), options).unwrap(); + let (status, _, pending) = env + .post( + "/api/v1/webauthn/register/finish", + serde_json::json!({ "ceremony_id": begun["ceremony_id"], "credential": credential }), + None, + "application/json", + ) + .await; + assert_eq!(status, StatusCode::OK, "{pending}"); + assert_eq!(pending["status"], "pending_confirmation", "{pending}"); + let credential_id = pending["credential_id"].as_str().unwrap().to_string(); + + let confirm = serde_json::json!({ + "action": "confirm_key", + "credential_id": credential_id, + "key_fingerprint": pending["key_fingerprint"], + }); + let (status, done) = env + .sign_and_send( + &admin, + confirm, + &format!("/api/v1/credentials/{credential_id}/confirm"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + // La clé est active : l'invitée peut se connecter. + let eve = env.log_in("eve").await; + assert!(eve.starts_with("session=")); +} + +/// Révocation d'une clé : la cible de la route est contrôlée par ca-server. +/// Les deux clés appartiennent au même opérateur et l'action est la même : +/// seul l'identifiant de la clé distingue, c'est bien lui qui est comparé. +#[tokio::test] +async fn a_key_revocation_only_revokes_the_signed_key() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let carol = env.operator_with_key("carol", Role::RaOperateur).await; + let k1 = env.credential_ids(carol).await.remove(0); + let k2 = env.second_key(carol, "carol").await; + assert_ne!(k1, k2); + let admin = env.log_in("root").await; + + let (_, issued) = env + .challenge( + Some(&admin), + serde_json::json!({ "action": "revoke_key", "credential_id": k1, "reason": "perdue" }), + ) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&admin), + &format!("/api/v1/credentials/{k2}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + assert!(!env.key_revoked(&k1).await && !env.key_revoked(&k2).await); + + let (status, done) = env + .decide( + Some(&admin), + &format!("/api/v1/credentials/{k1}/revoke"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED"); + assert!(env.key_revoked(&k1).await); + assert!(!env.key_revoked(&k2).await); + + // Un identifiant de clé hors de la forme base64url n'est pas relayé. + let (status, _) = env + .decide( + Some(&admin), + "/api/v1/credentials/cl%C3%A9%20invalide/revoke", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +/// Changement de rôle : la cible (l'opérateur, par son nom) est contrôlée ; +/// élever au rôle admin exige deux administrateurs (co-signature par la salle +/// d'attente). +#[tokio::test] +async fn role_changes_target_the_signed_operator_and_admin_needs_two() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + env.operator_with_key("root2", Role::Admin).await; + env.operator_with_key("carol", Role::RaOperateur).await; + env.operator_with_key("dave", Role::RaOperateur).await; + let admin = env.log_in("root").await; + + // Même action, même rôle : seul l'opérateur visé distingue. + let (_, issued) = env + .challenge( + Some(&admin), + serde_json::json!({ "action": "set_role", "operator": "carol", "role": "auditeur" }), + ) + .await; + let assertion = env.sign(&issued); + let (status, err) = env + .decide( + Some(&admin), + "/api/v1/operators/dave/role", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{err}"); + assert_eq!(err["error"], "action_mismatch"); + let (status, done) = env + .decide( + Some(&admin), + "/api/v1/operators/carol/role", + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(env.role_of("carol").await, "auditeur"); + assert_eq!(env.role_of("dave").await, "ra_operateur"); + + // Élever dave au rôle admin : une signature ne suffit pas. + let (status, done) = env + .sign_and_send( + &admin, + serde_json::json!({ "action": "set_role", "operator": "dave", "role": "admin" }), + "/api/v1/operators/dave/role", + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "AWAITING_QUORUM", "{done}"); + assert_eq!(env.role_of("dave").await, "ra_operateur"); + let action_id = done["action_id"].as_str().unwrap().to_string(); + + let second = env.log_in("root2").await; + let (status, issued) = env + .challenge(Some(&second), serde_json::json!({ "action_id": action_id })) + .await; + assert_eq!(status, StatusCode::OK, "{issued}"); + let assertion = env.sign(&issued); + let (status, done) = env + .decide( + Some(&second), + &format!("/api/v1/quorum/{action_id}/sign"), + &issued, + &assertion, + ) + .await; + assert_eq!(status, StatusCode::OK, "{done}"); + assert_eq!(done["status"], "EXECUTED", "{done}"); + assert_eq!(env.role_of("dave").await, "admin"); +} diff --git a/crates/oe-actions/src/lib.rs b/crates/oe-actions/src/lib.rs index baf0ace..d09ab59 100644 --- a/crates/oe-actions/src/lib.rs +++ b/crates/oe-actions/src/lib.rs @@ -209,6 +209,13 @@ pub struct Expect { /// présenté doit avoir été émis pour elle. #[serde(default)] pub action_id: Option, + /// Clé visée par une confirmation ou une révocation de clé. + #[serde(default)] + pub credential_id: Option, + /// Opérateur visé par un changement de rôle (par son nom, celui du corps + /// figé). + #[serde(default)] + pub operator: Option, } impl Expect { @@ -226,32 +233,50 @@ impl Expect { action.kind() ))); } - // La cible que porte le corps figé, et celle que l'appelant attend pour - // ce type d'action ; l'autre champ d'attente doit rester vide. - let (frozen, expected, other) = match action { + // La cible que porte le corps figé, par le nom du champ d'attente qui + // la désigne. Une invitation n'a pas de cible (l'opérateur n'existe pas + // encore) : seul le type compte. + let frozen: Option<(&str, &String)> = match action { Action::ApproveRequest { transaction_id, .. } | Action::RejectRequest { transaction_id, .. } => { - (Some(transaction_id), &self.transaction_id, &self.serial) + Some(("transaction_id", transaction_id)) } - Action::RevokeCertificate { serial, .. } => { - (Some(serial), &self.serial, &self.transaction_id) + Action::RevokeCertificate { serial, .. } => Some(("serial", serial)), + Action::ConfirmKey { credential_id, .. } | Action::RevokeKey { credential_id, .. } => { + Some(("credential_id", credential_id)) } - _ => (None, &None, &None), + Action::SetRole { operator, .. } => Some(("operator", operator)), + Action::InviteOperator { .. } => None, }; - if other.is_some() { - return Err(Error::BadRequest( - "cible sans rapport avec ce type d'action".to_string(), - )); + let targets = [ + ("transaction_id", &self.transaction_id), + ("serial", &self.serial), + ("credential_id", &self.credential_id), + ("operator", &self.operator), + ]; + // Tout autre champ d'attente doit rester vide. + for (name, value) in targets { + if value.is_some() && frozen.map(|(f, _)| f) != Some(name) { + return Err(Error::BadRequest( + "cible sans rapport avec ce type d'action".to_string(), + )); + } } - match (frozen, expected) { - (Some(frozen), Some(expected)) if frozen == expected => Ok(()), - (Some(_), None) => Err(Error::BadRequest( + let Some((field, frozen)) = frozen else { + return Ok(()); + }; + let expected = targets + .iter() + .find(|(name, _)| *name == field) + .and_then(|(_, v)| v.as_ref()); + match expected { + Some(expected) if expected == frozen => Ok(()), + None => Err(Error::BadRequest( "la cible visée doit être précisée".to_string(), )), - (Some(frozen), Some(expected)) => Err(Error::Mismatch(format!( + Some(expected) => Err(Error::Mismatch(format!( "cible attendue {expected}, figée {frozen}" ))), - (None, _) => Ok(()), } } } @@ -1013,6 +1038,8 @@ mod expect_tests { transaction_id: tx.map(str::to_string), serial: None, action_id: None, + credential_id: None, + operator: None, } } @@ -1034,12 +1061,67 @@ mod expect_tests { expect("approve_request", None).check(&approve("tx-a"), Uuid::nil()), Err(Error::BadRequest(_)) )); - // Une action sans demande visée : seul le type compte. + // Une invitation n'a pas de cible : seul le type compte. + let invite = Action::InviteOperator { + name: "eve".to_string(), + role: Role::Auditeur, + ttl_minutes: 60, + }; + assert!(expect("invite_operator", None) + .check(&invite, Uuid::nil()) + .is_ok()); + + // Changement de rôle : la cible est l'opérateur, par son nom. let role = Action::SetRole { operator: "alice".to_string(), role: Role::Auditeur, }; - assert!(expect("set_role", None).check(&role, Uuid::nil()).is_ok()); + let for_operator = |o: &str| Expect { + operator: Some(o.to_string()), + ..expect("set_role", None) + }; + assert!(for_operator("alice").check(&role, Uuid::nil()).is_ok()); + assert!(matches!( + for_operator("bob").check(&role, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + assert!(matches!( + expect("set_role", None).check(&role, Uuid::nil()), + Err(Error::BadRequest(_)) + )); + + // Clés : la cible est l'identifiant de la clé. + let revoke_key = Action::RevokeKey { + credential_id: "k1".to_string(), + reason: "perdue".to_string(), + }; + let for_key = |k: &str, action: &str| Expect { + credential_id: Some(k.to_string()), + ..expect(action, None) + }; + assert!(for_key("k1", "revoke_key") + .check(&revoke_key, Uuid::nil()) + .is_ok()); + assert!(matches!( + for_key("k2", "revoke_key").check(&revoke_key, Uuid::nil()), + Err(Error::Mismatch(_)) + )); + let confirm = Action::ConfirmKey { + credential_id: "k1".to_string(), + key_fingerprint: "AA".to_string(), + }; + assert!(for_key("k1", "confirm_key") + .check(&confirm, Uuid::nil()) + .is_ok()); + // Une cible d'un autre type (opérateur sur une clé) est refusée. + let mixed = Expect { + operator: Some("alice".to_string()), + ..for_key("k1", "revoke_key") + }; + assert!(matches!( + mixed.check(&revoke_key, Uuid::nil()), + Err(Error::BadRequest(_)) + )); // Révocation : la cible est le numéro de série, jamais une demande. let revoke = Action::RevokeCertificate { @@ -1052,6 +1134,8 @@ mod expect_tests { transaction_id: None, serial: Some(s.to_string()), action_id: None, + credential_id: None, + operator: None, }; assert!(by_serial("0a1b").check(&revoke, Uuid::nil()).is_ok()); assert!(matches!( diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index a66e9b7..9dd991c 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -66,9 +66,11 @@ exécutera, son empreinte (`body_hash`) et les options WebAuthn à passer à la (`operator_hint`) vient de la session, jamais du navigateur. L'action est relue dans l'énumération fermée d'`oe_actions` puis resérialisée : un champ en trop ne franchit pas la console. -- Sont préparés à ce stade l'approbation et le rejet d'une demande (§15, étape 3) et - la révocation d'un certificat (`revoke_certificate`, étape 4) ; toute autre action - est refusée (`403 action_not_available`) sans solliciter `ca-server`. +- Sont préparées toutes les actions d'`oe_actions` : l'approbation et le rejet d'une + demande (§15, étape 3), la révocation d'un certificat (`revoke_certificate`, étape + 4), et la gestion du registre (`invite_operator`, `confirm_key`, `revoke_key`, + `set_role`, voir plus bas). Une action ajoutée plus tard à l'énumération ne sera pas + préparée tant que la console ne la nomme pas (`403 action_not_available`). - Le rôle et l'état de la demande sont jugés par `ca-server` (un administrateur ne peut pas approuver) ; la console relaie son refus. - Chaque préparation est inscrite au journal de la console (`ra.action_challenge` : @@ -137,6 +139,36 @@ lecture sur `actions`, ajouté au script des droits. Rejouer `psql -f crates/oe-castore/sql/ra_console_grants.sql` (idempotent) ; sans cela, `GET /api/v1/quorum` et la co-signature répondent `503`. +## Gestion du registre des opérateurs + +Même schéma : le challenge est préparé avec l'action voulue, puis l'assertion est +relayée à la route correspondante (`{"challenge_id", "assertion"}`), qui rend la forme +d'une action à plusieurs signatures (`status`, `signatures`, `required`, `signed_by`, +`result`). Seul un `admin` signe ces actions ; `ca-server` en décide. + +| Route | Action préparée | Cible contrôlée par `ca-server` | +|---|---|---| +| `POST /api/v1/operators` | `{"action": "invite_operator", "name", "role"}` | le type seulement (l'opérateur n'existe pas encore) | +| `POST /api/v1/credentials/{credential_id}/confirm` | `{"action": "confirm_key", "credential_id", "key_fingerprint"}` | l'identifiant de la clé | +| `POST /api/v1/credentials/{credential_id}/revoke` | `{"action": "revoke_key", "credential_id", "reason"}` | l'identifiant de la clé | +| `POST /api/v1/operators/{name}/role` | `{"action": "set_role", "operator", "role"}` | l'opérateur, par son nom | + +- **Invitation** : le jeton n'existe que dans `result.invite_token` de la réponse + d'exécution, rendu **une seule fois** ; ni `ca-server` ni la console ne le + journalisent ni ne le conservent. L'invité enregistre ensuite sa clé par le relais + d'enregistrement (plus haut) : elle reste en attente, avec une empreinte que l'invité + transmet hors bande. +- **Confirmation** : l'administrateur signe l'empreinte ; `ca-server` la recompare à la + clé en attente et refuse qu'un opérateur confirme sa propre clé. +- **Révocation de clé** : motif obligatoire ; `ca-server` refuse de révoquer la dernière + clé d'administrateur active (la voie de secours est `recover-admin`). +- **Rôle `admin`** : créer un administrateur, élever un opérateur au rôle `admin` ou + changer le rôle d'un administrateur exige **deux administrateurs** — la première + signature rend `AWAITING_QUORUM`, la seconde passe par la salle d'attente + (`/api/v1/quorum/{action_id}/sign`). Un opérateur ne change pas son propre rôle. +- Identifiant de clé : base64url, 1 024 caractères au plus ; nom d'opérateur : 1 à 256 + caractères. Toute autre forme est refusée avant relais. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -171,7 +203,8 @@ lecture sur `actions`, ajouté au script des droits. Rejouer ## Ce qui n'existe pas encore -La gestion du registre depuis la console (invitations, clés, rôles), le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La +La liste des clés en attente de confirmation, le libre-service (ajout et retrait de ses +propres clés, §10), le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se