Skip to content
Closed
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
2 changes: 1 addition & 1 deletion bin/ca-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ tracing = "0.1"
tracing-subscriber = "0.3"
der = { version = "0.8", features = ["pem", "std"] }
x509-cert = "0.3"
time = { version = "0.3", features = ["std", "formatting"] }
time = { version = "0.3", features = ["std", "formatting", "parsing"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hex = "0.4"
Expand Down
126 changes: 126 additions & 0 deletions bin/ca-server/src/cli_review.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Revue a posteriori des décisions prises par la voie de secours (constat
//! R-1 de l'audit du 2026-09-25, docs/WEBUI.md §20, décision O4) : le CLI
//! `ra approve|reject` et `revoke` reste ouvert, mais chacune de ses décisions
//! (`authenticated_via: "cli"`) doit être relue par une personne, et cette
//! relecture doit elle-même laisser une trace.
//!
//! Tout part du journal chaîné, qui fait foi : la revue lit ce qu'il atteste,
//! jamais la base. Un acquittement (`ra.cli_decisions_reviewed`) couvre les
//! enregistrements jusqu'à un numéro donné ; la revue suivante repart de là.

use oe_audit::Record;

/// Événement d'acquittement d'une revue.
pub const REVIEW_EVENT: &str = "ra.cli_decisions_reviewed";

/// Une décision prise par la voie de secours, telle que le journal l'atteste.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct CliDecision {
pub seq: u64,
pub time: String,
pub event: String,
pub operator: String,
pub comment: String,
/// Transaction (décision RA) ou numéro de série (révocation).
pub subject: String,
pub host: String,
pub uid: Option<u64>,
}

/// Ce qu'une revue a devant elle.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct Review {
/// Décisions CLI non encore couvertes par un acquittement (ou postérieures
/// à `--since`).
pub decisions: Vec<CliDecision>,
/// Numéro du dernier enregistrement couvert par le dernier acquittement
/// (0 : aucun).
pub acknowledged_up_to: u64,
/// Numéro du dernier enregistrement lu : ce qu'un acquittement couvrira.
pub head: u64,
}

fn str_field(data: &oe_audit::Data, key: &str) -> String {
data.get(key)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}

/// Relève les décisions CLI à revoir. Sans `since`, la fenêtre part du dernier
/// acquittement ; avec `since` (date de l'enregistrement, RFC 3339), elle part
/// de cette date, acquittements ou non — pour relire une période passée.
pub fn review(records: &[Record], since: Option<time::OffsetDateTime>) -> Result<Review, String> {
let acknowledged_up_to = records
.iter()
.rev()
.find(|r| r.event == REVIEW_EVENT)
.and_then(|r| r.data.as_ref()?.get("jusqu_a")?.as_u64())
.unwrap_or(0);
let head = records.last().map(|r| r.seq).unwrap_or(0);

let mut decisions = Vec::new();
for r in records {
// L'acquittement passe lui aussi par le CLI, mais ce n'est pas une
// décision : le relister rendrait toute revue interminable.
if r.event == REVIEW_EVENT {
continue;
}
let Some(data) = &r.data else { continue };
if data.get("authenticated_via").and_then(|v| v.as_str()) != Some("cli") {
continue;
}
let in_window = match since {
Some(since) => {
let at = time::OffsetDateTime::parse(
&r.time,
&time::format_description::well_known::Rfc3339,
)
.map_err(|e| format!("enregistrement n° {} : date illisible : {e}", r.seq))?;
at >= since
}
None => r.seq > acknowledged_up_to,
};
if !in_window {
continue;
}
let identity = data.get("identite_systeme");
let subject = [str_field(data, "transaction"), str_field(data, "serie")]
.into_iter()
.find(|s| !s.is_empty())
.unwrap_or_default();
decisions.push(CliDecision {
seq: r.seq,
time: r.time.clone(),
event: r.event.clone(),
operator: str_field(data, "operateur"),
comment: str_field(data, "commentaire"),
subject,
host: identity
.and_then(|i| i.get("hote"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
uid: identity.and_then(|i| i.get("uid")).and_then(|v| v.as_u64()),
});
}
Ok(Review {
decisions,
acknowledged_up_to,
head,
})
}

/// Données de l'événement d'acquittement : qui a relu, jusqu'où, combien de
/// décisions, et pourquoi (le relecteur n'est que déclaré, comme toute
/// commande de secours : l'appelant y ajoute `authenticated_via`).
pub fn acknowledgement(review: &Review, reviewer: &str, comment: &str) -> serde_json::Value {
serde_json::json!({
"relecteur": reviewer,
"commentaire": comment,
"jusqu_a": review.head,
"depuis": review.acknowledged_up_to,
"decisions": review.decisions.len(),
"sequences": review.decisions.iter().map(|d| d.seq).collect::<Vec<_>>(),
})
}
1 change: 1 addition & 0 deletions bin/ca-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! que ses tests d'intégration (`tests/`) puissent construire un `Server`
//! réel — la CLI (`main.rs`) reste le seul point d'entrée exécutable.

pub mod cli_review;
pub mod config;
pub mod http;
pub mod internal;
Expand Down
150 changes: 148 additions & 2 deletions bin/ca-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
//! Sous-commandes : `ceremony`, `serve`, `ra list|approve|reject`, `revoke`,
//! `authority revoke|publish-arl`,
//! `operators bootstrap-admin|recover-admin|audit|reconcile`, `internal-cert server`, `conformance`,
//! `healthcheck`, `verify-audit`.
//! `healthcheck`, `verify-audit`, `audit cli-decisions`.

use std::sync::Arc;

use ca_server::{config, http, internal, internal_tls, registry_check, revoker, webauthn_models};
use ca_server::{
cli_review, config, http, internal, internal_tls, registry_check, revoker, webauthn_models,
};
use clap::{Parser, Subcommand};
use config::Config;
use oe_hsm::SigningToken;
Expand Down Expand Up @@ -79,6 +81,11 @@ enum Command {
Healthcheck,
/// Vérifie l'intégrité de la chaîne du journal d'audit.
VerifyAudit { path: Option<String> },
/// Revues fondées sur le journal chaîné.
Audit {
#[command(subcommand)]
action: AuditAction,
},
/// Affiche la version (identique à `--version`, sous forme de
/// sous-commande — reproduit `cmd/ca-server` (Go), qui n'a que celle-ci).
Version,
Expand Down Expand Up @@ -107,6 +114,36 @@ enum AuthorityAction {
PublishArl,
}

#[derive(Subcommand)]
enum AuditAction {
/// Revue a posteriori des décisions prises par la voie de secours
/// (`authenticated_via: cli`, docs/WEBUI.md §20) : liste celles qui n'ont
/// pas encore été revues. Code de sortie 0 : rien à revoir ; 1 : des
/// décisions à revoir ; 2 : journal illisible ou rompu.
CliDecisions {
/// Relit à partir de cette date (RFC 3339), revues antérieures ou non.
#[arg(long)]
since: Option<String>,
/// Journal à lire (défaut : `OPENEIDAS_AUDIT_FILE`), par exemple la
/// copie répliquée hors de l'hôte. Incompatible avec `--acknowledge`.
#[arg(long)]
journal: Option<String>,
/// Sortie JSON sur la sortie standard.
#[arg(long)]
json: bool,
/// Consigne la revue au journal (`ra.cli_decisions_reviewed`) : les
/// décisions listées sont couvertes, la revue suivante repart d'ici.
#[arg(long, requires = "reviewer")]
acknowledge: bool,
/// Identité de la personne qui a relu (avec `--acknowledge`).
#[arg(long, requires = "acknowledge")]
reviewer: Option<String>,
/// Commentaire de la revue (obligatoire avec `--acknowledge`).
#[arg(trailing_var_arg = true)]
comment: Vec<String>,
},
}

#[derive(Subcommand)]
enum InternalCertAction {
/// Demande (ou récupère) le certificat `internal_server` de ce service.
Expand Down Expand Up @@ -1238,6 +1275,105 @@ async fn run_healthcheck() {
println!("{}", body.trim());
}

fn audit_file_path() -> String {
std::env::var("OPENEIDAS_AUDIT_FILE")
.ok()
.filter(|p| !p.is_empty())
.unwrap_or_else(|| "/var/lib/open-eidas/state/ca-audit.log".to_string())
}

/// Voir `AuditAction::CliDecisions`. N'ouvre ni la base ni un token : le
/// journal chaîné fait foi, et une copie répliquée suffit pour la lecture.
fn run_audit_cli_decisions(
since: Option<String>,
journal: Option<String>,
json: bool,
acknowledge: bool,
reviewer: Option<String>,
comment: Vec<String>,
) {
if acknowledge && journal.is_some() {
die(
"revue des décisions CLI",
"--acknowledge écrit dans le journal en service : incompatible avec --journal",
);
}
let comment = join_comment(&comment);
if acknowledge && comment.trim().is_empty() {
die(
"revue des décisions CLI",
"--acknowledge exige un commentaire (ce qui a été vérifié)",
);
}
let since = since.map(|s| {
time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
.unwrap_or_else(|e| die("--since (RFC 3339 attendu)", e))
});
let path = journal.unwrap_or_else(audit_file_path);
let records = match oe_audit::read(&path) {
Ok(r) => r,
Err(e) => {
eprintln!("journal illisible ou rompu ({path}) : {e}");
eprintln!("Aucune revue sur un journal dont l'intégrité n'est pas garantie.");
std::process::exit(2);
}
};
let review = cli_review::review(&records, since).unwrap_or_else(|e| {
eprintln!("journal inexploitable ({path}) : {e}");
std::process::exit(2);
});

if json {
println!(
"{}",
serde_json::to_string_pretty(&review).unwrap_or_default()
);
} else {
eprintln!(
"journal : {path} (enregistrements 1 à {}, dernière revue jusqu'au n° {})",
review.head, review.acknowledged_up_to
);
eprintln!(
"{} décision(s) prise(s) par la voie de secours à revoir",
review.decisions.len()
);
for d in &review.decisions {
let uid = d
.uid
.map(|u| u.to_string())
.unwrap_or_else(|| "?".to_string());
println!(
"n° {}\t{}\t{}\t{}\topérateur déclaré : {}\thôte : {} (uid {})\t{}",
d.seq, d.time, d.event, d.subject, d.operator, d.host, uid, d.comment
);
}
}

if acknowledge {
let reviewer = reviewer.unwrap_or_default();
if reviewer.trim().is_empty() {
die(
"revue des décisions CLI",
"--reviewer ne peut pas être vide",
);
}
let mut data = cli_review::acknowledgement(&review, &reviewer, &comment);
oe_ca_core::Via::Cli(oe_ca_core::SystemIdentity::current()).annotate(&mut data);
let log = oe_audit::Log::open(&path).unwrap_or_else(|e| die("journal d'audit", e));
log.append(cli_review::REVIEW_EVENT, json_to_audit_data(data))
.unwrap_or_else(|e| die("consignation de la revue", e));
eprintln!(
"revue consignée par {reviewer} jusqu'au n° {} ({} décision(s))",
review.head,
review.decisions.len()
);
return;
}
if !review.decisions.is_empty() {
std::process::exit(1);
}
}

async fn run_verify_audit(path: Option<String>) {
let path = path
.or_else(|| std::env::var("OPENEIDAS_AUDIT_FILE").ok())
Expand Down Expand Up @@ -1280,6 +1416,16 @@ async fn main() {
comment,
} => run_revoke(serial_hex, reason, operator, comment).await,
Command::Authority { action } => run_authority(action).await,
Command::Audit { action } => match action {
AuditAction::CliDecisions {
since,
journal,
json,
acknowledge,
reviewer,
comment,
} => run_audit_cli_decisions(since, journal, json, acknowledge, reviewer, comment),
},
Command::Operators { action } => match action {
OperatorsAction::BootstrapAdmin { name, ttl_minutes } => {
run_operators_bootstrap_admin(name, ttl_minutes).await
Expand Down
Loading
Loading