Skip to content
Merged
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
5 changes: 5 additions & 0 deletions bin/ra-console/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ pub const EVENT_LOGIN_SUCCEEDED: &str = "ra.login_succeeded";
pub const EVENT_LOGIN_REFUSED: &str = "ra.login_refused";
pub const EVENT_SESSION_OPENED: &str = "ra.session_opened";
pub const EVENT_SESSION_CLOSED: &str = "ra.session_closed";
/// Une action signée préparée par `ca-server` à la demande d'un opérateur
/// (docs/WEBUI.md §4, étapes 1 à 3) : l'identifiant de l'action et
/// l'empreinte du corps figé, pour rapprocher ce journal de celui de
/// `ca-server`, qui fait foi.
pub const EVENT_ACTION_CHALLENGE: &str = "ra.action_challenge";

/// Même forme que `oe_ca_core::Recorder` / `oe_raflow::Recorder`, dupliquée
/// plutôt que partagée (ce sont des traits d'un seul étage, la duplication
Expand Down
72 changes: 72 additions & 0 deletions bin/ra-console/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use axum::{Json, Router};
use serde::Deserialize;
use sqlx::PgPool;

use crate::audit::{self, Recorder};
use crate::ca_link::{CaLink, Relayed};
use crate::login::{LoginError, LoginService};
use crate::requests;
Expand All @@ -27,6 +28,7 @@ pub struct AppState {
pub link: CaLink,
pub login: LoginService,
pub sessions: Sessions,
pub journal: Arc<dyn Recorder>,
}

pub fn router(state: Arc<AppState>) -> Router {
Expand All @@ -45,6 +47,7 @@ pub fn router(state: Arc<AppState>) -> 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/webauthn/challenge", post(handle_action_challenge))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state)
}
Expand Down Expand Up @@ -438,6 +441,75 @@ async fn handle_requests(
}
}

/// Les actions que la console relaie à ce stade (docs/WEBUI.md §15, étape 3) :
/// décider d'une demande d'enrôlement. La révocation (étape 4) et la gestion
/// du registre suivront ; d'ici là, la console refuse de les préparer, même si
/// `ca-server` saurait les exécuter.
fn relayed_at_this_stage(action: &oe_actions::Action) -> bool {
matches!(
action,
oe_actions::Action::ApproveRequest { .. } | oe_actions::Action::RejectRequest { .. }
)
}

/// `POST /api/v1/webauthn/challenge` (docs/WEBUI.md §4 étapes 1 à 3, §5) :
/// l'opérateur connecté demande à `ca-server` de figer une action et d'émettre
/// le challenge qu'il signera. Le corps rendu est celui que `ca-server`
/// exécutera, à afficher tel quel.
///
/// Ce que la console décide : que la session est valide, et **pour qui** le
/// challenge est émis — l'opérateur de la session, jamais une valeur du
/// navigateur. Ce qu'elle ne décide pas : le rôle suffisant, l'état de la
/// demande, le corps final. `ca-server` en juge.
async fn handle_action_challenge(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if !is_json(&headers) {
return unsupported_media_type();
}
let who = match authenticate(&state, &headers).await {
Ok(a) => a,
Err(resp) => return resp,
};
// Relue dans l'énumération fermée d'`oe_actions`, puis resérialisée : un
// champ en trop (un `operator_hint` glissé par le navigateur, par exemple)
// ne franchit jamais la console.
let action: oe_actions::Action = match serde_json::from_slice(&body) {
Ok(a) => a,
Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"),
};
if !relayed_at_this_stage(&action) {
return error(
StatusCode::FORBIDDEN,
"action_not_available",
"cette action n'est pas encore proposée par la console",
);
}
let result = state
.link
.post(
"/internal/v1/challenge",
&serde_json::json!({ "body": action, "operator_hint": who.operator_id }),
)
.await;
if let Ok(r) = &result {
state.journal.append(
audit::EVENT_ACTION_CHALLENGE,
serde_json::json!({
"operator": who.operator,
"action": r.body.get("body").and_then(|b| b.get("action")),
"action_id": r.body.get("action_id"),
"body_hash": r.body.get("body_hash"),
"status": r.status,
"error": r.body.get("error"),
}),
);
}
relayed(result)
}

/// `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.
Expand Down
3 changes: 2 additions & 1 deletion bin/ra-console/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ async fn run_serve() {
cfg.webauthn.login_decoy_secret.into_bytes(),
journal.clone(),
);
let sessions = Sessions::new(oe_actions::Registry::new(pool.clone()), journal);
let sessions = Sessions::new(oe_actions::Registry::new(pool.clone()), journal.clone());

// Purge périodique des sessions et challenges expirés (§15 étape 1c-2b) :
// aucune opération manuelle, arrêtée par le même signal que le serveur.
Expand All @@ -123,6 +123,7 @@ async fn run_serve() {
link,
login,
sessions,
journal,
}));
let listener = tokio::net::TcpListener::bind(bind_addr(&cfg.listen))
.await
Expand Down
4 changes: 4 additions & 0 deletions bin/ra-console/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ pub enum SessionError {

/// Une session authentifiée, relue en base à l'instant de l'appel.
pub struct Authenticated {
/// Identifiant de l'opérateur dans le registre : ce qu'une route relaie à
/// `ca-server` (`operator_hint`), jamais une valeur venue du navigateur.
pub operator_id: Uuid,
pub operator: String,
pub role: Role,
}
Expand Down Expand Up @@ -118,6 +121,7 @@ impl Sessions {
.execute(self.registry.pool())
.await;
Ok(Authenticated {
operator_id: operator.id,
operator: operator.name,
role: operator.role,
})
Expand Down
Loading
Loading