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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions bin/ra-console/examples/e2e_console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ async fn main() {
),
sessions: common::sessions(pool),
journal: Arc::new(ra_console::audit::NullRecorder),
s3: None,
}),
ra_console::web::Console {
environment: ra_console::web::Environment::Staging,
Expand Down
176 changes: 176 additions & 0 deletions bin/ra-console/src/audit_search.rs
Original file line number Diff line number Diff line change
@@ -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<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub chain_verified: bool,
pub entries_checked: u64,
pub results: Vec<Entry>,
}

#[derive(Debug, Default, Clone)]
pub struct Query {
/// Numéro de série hexadécimal (`data.serie`, insensible à la casse).
pub serial: Option<String>,
pub from: Option<time::OffsetDateTime>,
pub to: Option<time::OffsetDateTime>,
}

#[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<Vec<oe_audit::Record>, 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<Option<Vec<oe_audit::Record>>, 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<Report, Error> {
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() },
})
}
6 changes: 6 additions & 0 deletions bin/ra-console/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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<Option<S3Config>, String> {
Expand All @@ -54,6 +59,7 @@ fn s3_config() -> Result<Option<S3Config>, 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"),
}))
}

Expand Down
84 changes: 84 additions & 0 deletions bin/ra-console/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ use serde::Deserialize;
use sqlx::PgPool;

use crate::audit::{self, Recorder};
use crate::audit_search;
use crate::ca_link::{CaLink, Relayed};
use crate::config::S3Config;
use crate::login::{LoginError, LoginService};
use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL};
use crate::{certificates, operators, quorum, requests};
Expand All @@ -29,6 +31,9 @@ pub struct AppState {
pub login: LoginService,
pub sessions: Sessions,
pub journal: Arc<dyn Recorder>,
/// `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<S3Config>,
}

/// L'application complète servie par `ra-console` : l'API ([`router`]), le
Expand Down Expand Up @@ -65,6 +70,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/v1/quorum", get(handle_quorum))
.route("/api/v1/quorum/{action_id}/sign", post(handle_quorum_sign))
.merge(crate::registry_routes::routes())
.route("/api/v1/audit/search", get(handle_audit_search))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state)
}
Expand Down Expand Up @@ -899,6 +905,84 @@ async fn handle_certificates(
}
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AuditSearchQuery {
serial: Option<String>,
from: Option<String>,
to: Option<String>,
}

/// `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<String>, field: &str) -> Result<Option<time::OffsetDateTime>, 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<Arc<AppState>>,
headers: HeaderMap,
Query(q): Query<AuditSearchQuery>,
) -> 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.
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 @@ -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 certificates;
pub mod config;
Expand Down
1 change: 1 addition & 0 deletions bin/ra-console/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ async fn run_serve() {
login,
sessions,
journal,
s3: cfg.s3.clone(),
}),
console,
);
Expand Down
1 change: 1 addition & 0 deletions bin/ra-console/tests/action_challenge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ impl Env {
),
sessions: common::sessions(pool),
journal: journal.clone(),
s3: None,
}));

Some(Env {
Expand Down
Loading
Loading