Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions bin/ra-console/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub fn router(state: Arc<AppState>) -> 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)
}
Expand Down Expand Up @@ -92,7 +93,7 @@ async fn handle_health(State(state): State<Arc<AppState>>) -> Response {
}

/// `{"error": "<code>", "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 })),
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 { .. }
)
}

Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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(),
Expand All @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions bin/ra-console/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
138 changes: 138 additions & 0 deletions bin/ra-console/src/registry_routes.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<AppState>> {
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<Arc<AppState>>,
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<Arc<AppState>>,
Path(credential_id): Path<String>,
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<Arc<AppState>>,
Path(credential_id): Path<String>,
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<Arc<AppState>>,
Path(name): Path<String>,
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
}
Loading
Loading