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
7 changes: 5 additions & 2 deletions bin/ra-console/examples/e2e_console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,15 @@ async fn main() {
// L'opérateur et sa clé, comme en production mais sans passer par
// l'enregistrement relayé (qui a ses propres tests).
let now = time::OffsetDateTime::now_utc();
// Trois opérateurs : alice décide des demandes (étape 6b), bob et carol
// révoquent à deux (étape 6c, double contrôle).
// alice décide des demandes (étape 6b), bob et carol révoquent à deux
// (étape 6c, double contrôle).
let people = [
("alice", Role::RaOperateur),
("bob", Role::CaOperateur),
("carol", Role::CaOperateur),
// Registre (6e) : root administre, dave voit son rôle changer et sa clé révoquée.
("root", Role::Admin),
("dave", Role::RaOperateur),
];
let reg_verifier = verifier();
// Le SoftToken s'enregistre dans ce fichier à sa fermeture : c'est ainsi que
Expand Down
23 changes: 22 additions & 1 deletion bin/ra-console/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::audit::{self, Recorder};
use crate::ca_link::{CaLink, Relayed};
use crate::login::{LoginError, LoginService};
use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL};
use crate::{certificates, quorum, requests};
use crate::{certificates, operators, quorum, requests};

/// Assez pour un objet d'attestation, pas pour bourrer la mémoire.
const MAX_BODY_BYTES: usize = 64 * 1024;
Expand Down Expand Up @@ -57,6 +57,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/v1/logout", post(handle_logout))
.route("/api/v1/requests", get(handle_requests))
.route("/api/v1/certificates", get(handle_certificates))
.route("/api/v1/operators", get(handle_operators))
.route("/api/v1/webauthn/challenge", post(handle_action_challenge))
.route("/api/v1/requests/{id}/approve", post(handle_approve))
.route("/api/v1/requests/{id}/reject", post(handle_reject))
Expand Down Expand Up @@ -843,6 +844,26 @@ pub(crate) fn quorum_status(body: &serde_json::Value) -> serde_json::Value {
})
}

/// `GET /api/v1/operators` : le registre en lecture seule (docs/WEBUI.md §10),
/// pour toute session authentifiée ; les écritures restent des actions signées
/// que `ca-server` juge.
async fn handle_operators(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
if let Err(resp) = authenticate(&state, &headers).await {
return resp;
}
match operators::list(&state.pool, time::OffsetDateTime::now_utc()).await {
Ok(registry) => Json(registry).into_response(),
Err(e) => {
tracing::error!(erreur = %e, "operators : base indisponible");
error(
StatusCode::SERVICE_UNAVAILABLE,
"unavailable",
"service indisponible",
)
}
}
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificatesQuery {
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 @@ -19,6 +19,7 @@ pub mod config;
pub mod db_guard;
pub mod http;
pub mod login;
pub mod operators;
pub mod purge;
pub mod quorum;
pub mod registry_routes;
Expand Down
119 changes: 119 additions & 0 deletions bin/ra-console/src/operators.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! `GET /api/v1/operators` (docs/WEBUI.md §10, §15 étape 6e) : le registre des
//! opérateurs, leurs clés et les clés en attente de confirmation, en lecture
//! seule sur les tables de `ca-server`. Toute écriture passe par une action
//! signée (`registry_routes`).
//!
//! L'empreinte d'une clé en attente est recalculée ici avec la fonction même
//! de `ca-server` (`oe_actions::key_fingerprint`) : l'administrateur la compare
//! hors bande à celle que l'invité a reçue à l'enregistrement (§10), puis la
//! signe ; `ca-server` la recompare à la clé stockée. Une console qui
//! afficherait une autre empreinte ferait échouer la confirmation, pas passer
//! une autre clé.

use oe_webauthn::Uuid;
use serde::Serialize;
use sqlx::{PgPool, Row};
use time::OffsetDateTime;

#[derive(Debug, Serialize)]
pub struct Credential {
pub credential_id: String,
pub label: String,
#[serde(with = "time::serde::rfc3339")]
pub initiated_at: OffsetDateTime,
pub confirmed_by: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_used_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
pub revoked_at: Option<OffsetDateTime>,
}

#[derive(Debug, Serialize)]
pub struct Operator {
pub name: String,
pub role: String,
pub disabled: bool,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
pub credentials: Vec<Credential>,
}

#[derive(Debug, Serialize)]
pub struct PendingKey {
pub credential_id: String,
pub operator: String,
/// `None` si la clé stockée ne se relit pas : rien à confirmer alors.
pub key_fingerprint: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub registered_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
}

#[derive(Debug, Serialize)]
pub struct Registry {
pub operators: Vec<Operator>,
pub pending: Vec<PendingKey>,
}

pub async fn list(pool: &PgPool, now: OffsetDateTime) -> Result<Registry, sqlx::Error> {
let ops =
sqlx::query("SELECT id, name, role, created_at, disabled_at FROM operators ORDER BY name")
.fetch_all(pool)
.await?;
let creds = sqlx::query(
"SELECT credential_id, operator_id, label, initiated_at, confirmed_by, last_used_at, revoked_at
FROM webauthn_credentials ORDER BY initiated_at",
)
.fetch_all(pool)
.await?;
let operators = ops
.iter()
.map(|o| {
let id: Uuid = o.get("id");
Operator {
name: o.get("name"),
role: o.get("role"),
disabled: o.get::<Option<OffsetDateTime>, _>("disabled_at").is_some(),
created_at: o.get("created_at"),
credentials: creds
.iter()
.filter(|c| c.get::<Uuid, _>("operator_id") == id)
.map(|c| Credential {
credential_id: c.get("credential_id"),
label: c.get("label"),
initiated_at: c.get("initiated_at"),
confirmed_by: c.get("confirmed_by"),
last_used_at: c.get("last_used_at"),
revoked_at: c.get("revoked_at"),
})
.collect(),
}
})
.collect();

let pending = sqlx::query(
"SELECT p.credential_id, o.name, p.passkey, p.registered_at, p.expires_at
FROM pending_credentials p JOIN operators o ON o.id = p.operator_id
WHERE p.expires_at > $1
ORDER BY p.registered_at",
)
.bind(now)
.fetch_all(pool)
.await?
.iter()
.map(|p| {
let passkey: serde_json::Value = p.get("passkey");
PendingKey {
credential_id: p.get("credential_id"),
operator: p.get("name"),
key_fingerprint: serde_json::from_value::<oe_webauthn::AttestedPasskey>(passkey)
.ok()
.and_then(|k| oe_actions::key_fingerprint(&k).ok()),
registered_at: p.get("registered_at"),
expires_at: p.get("expires_at"),
}
})
.collect();
Ok(Registry { operators, pending })
}
28 changes: 27 additions & 1 deletion bin/ra-console/tests/action_challenge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ impl Env {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let name = format!("chal_{nanos}");
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let name = format!("chal_{nanos}_{seq}");
let admin = PgPoolOptions::new().connect(&base).await.unwrap();
sqlx::query(&format!("CREATE DATABASE {name}"))
.execute(&admin)
Expand Down Expand Up @@ -1076,6 +1078,19 @@ async fn an_admin_invites_and_confirms_an_operator_through_the_console() {
assert_eq!(pending["status"], "pending_confirmation", "{pending}");
let credential_id = pending["credential_id"].as_str().unwrap().to_string();

// La console montre la clé en attente, avec l'empreinte même que ca-server a
// remise à l'invitée : c'est elle que l'administrateur compare hors bande.
let (status, registry) = env.get("/api/v1/operators", &admin).await;
assert_eq!(status, StatusCode::OK, "{registry}");
let shown = registry["pending"]
.as_array()
.unwrap()
.iter()
.find(|p| p["credential_id"] == credential_id.as_str())
.unwrap_or_else(|| panic!("clé en attente absente : {registry}"));
assert_eq!(shown["operator"], "eve");
assert_eq!(shown["key_fingerprint"], pending["key_fingerprint"]);

let confirm = serde_json::json!({
"action": "confirm_key",
"credential_id": credential_id,
Expand All @@ -1093,6 +1108,17 @@ async fn an_admin_invites_and_confirms_an_operator_through_the_console() {
// La clé est active : l'invitée peut se connecter.
let eve = env.log_in("eve").await;
assert!(eve.starts_with("session="));
let (_, registry) = env.get("/api/v1/operators", &admin).await;
assert_eq!(registry["pending"], serde_json::json!([]), "{registry}");
let eve_entry = registry["operators"]
.as_array()
.unwrap()
.iter()
.find(|o| o["name"] == "eve")
.unwrap();
assert_eq!(eve_entry["credentials"].as_array().unwrap().len(), 1);
let (status, _) = env.get("/api/v1/operators", "session=n-importe-quoi").await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}

/// Révocation d'une clé : la cible de la route est contrôlée par ca-server.
Expand Down
13 changes: 13 additions & 0 deletions bin/ra-console/web/dist/console.css
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,16 @@ select {
max-height: 200px;
font-size: 12px;
}

/* --- 6e : opérateurs --- */
.actions.start {
justify-content: flex-start;
margin-bottom: 12px;
}
.key {
border-top: 1px solid var(--border-subtle);
padding: 8px 0;
}
.key p {
margin: 0 0 4px;
}
2 changes: 1 addition & 1 deletion bin/ra-console/web/dist/console.js

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions bin/ra-console/web/e2e/operators.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Registre des opérateurs dans le navigateur (docs/WEBUI.md §10, §15 étape
// 6e) : invitation (jeton affiché une seule fois), changement de rôle,
// révocation de clé — chaque écriture signée et exécutée par ca-server.

import { expect, test, type Page } from "@playwright/test";
import { collectErrors, logIn, withOperatorKey } from "./helpers";

interface Registry {
operators: { name: string; role: string; credentials: { revoked_at: string | null }[] }[];
}

async function registry(page: Page): Promise<Registry> {
return (await (await page.request.get("/api/v1/operators")).json()) as Registry;
}

test("un administrateur invite, change un rôle et révoque une clé", async ({ page }) => {
const errors = collectErrors(page);
await withOperatorKey(page, "root");
await page.goto("/");
await logIn(page, "root");
await page.getByTestId("nav-operators").click();

// Invitation : le jeton n'est montré qu'une fois.
await page.getByTestId("invite").click();
await page.getByTestId("invite-name").fill("frank");
await page.getByTestId("invite-role").selectOption("ra_operateur");
await page.getByTestId("comment-next").click();
await expect(page.getByTestId("frozen-body")).toContainText(`"action": "invite_operator"`);
await page.getByTestId("sign").click();
const token = page.getByTestId("invite-token");
await expect(token).toHaveText(/^\S{30,}$/);
await page.getByTestId("token-close").click();
await expect(page.getByTestId("token-dialog")).toHaveCount(0);
await expect(page.getByTestId("operator-frank")).toBeVisible();

// Rôle de dave : auditeur (pas de double contrôle hors rôle admin).
await page.getByTestId("operator-dave").click();
await page.getByTestId("new-role").selectOption("auditeur");
await page.getByTestId("change-role").click();
await expect(page.getByTestId("frozen-body")).toContainText(`"operator": "dave"`);
await page.getByTestId("sign").click();
await expect(page.getByTestId("operators-status")).toContainText("auditeur");
expect((await registry(page)).operators.find((o) => o.name === "dave")?.role).toBe("auditeur");

// Révocation de la clé de dave, motif obligatoire.
await page.getByTestId("operator-dave").click();
await page.getByTestId("revoke-key-dave").click();
await page.getByTestId("comment").fill("départ de l'association");
await page.getByTestId("comment-next").click();
await expect(page.getByTestId("frozen-body")).toContainText(`"action": "revoke_key"`);
await page.getByTestId("sign").click();
await expect(page.getByTestId("operators-status")).toContainText("révoquée");
const dave = (await registry(page)).operators.find((o) => o.name === "dave");
expect(dave?.credentials.every((c) => c.revoked_at !== null)).toBe(true);
expect(errors).toEqual([]);
});

test("un non-administrateur consulte le registre sans pouvoir l'écrire", async ({ page }) => {
await withOperatorKey(page);
await page.goto("/");
await logIn(page);
await page.getByTestId("nav-operators").click();
await expect(page.getByTestId("operator-root")).toBeVisible();
await expect(page.getByTestId("invite")).toBeDisabled();
await page.getByTestId("operator-root").click();
await expect(page.getByTestId("change-role")).toBeDisabled();
await expect(page.getByTestId("revoke-key-root")).toBeDisabled();
});
Loading
Loading