reprise rustdoc
EBO <eric.bouhana@softalys.com> committé le 2026-05-18 21:34
5deaa8f340926373f0b18b329aefdf54dc0b1c50
1 parent(s)
49 fichiers modifiés
+6155
-56
M
crates/rustwarden-core/src/auth/jwt.rs
+2
-2
@@ -356,7 +356,7 @@ impl JwtService {
| 356 | 356 | |
| 357 | 357 | /// Extract token from Authorization header |
| 358 | 358 | /// |
| 359 | - /// Parses the "Bearer <token>" format from HTTP Authorization header. | |
| 359 | + /// Parses the `"Bearer <token>"` format from HTTP Authorization header. | |
| 360 | 360 | /// |
| 361 | 361 | /// # Arguments |
| 362 | 362 | /// |
@@ -394,7 +394,7 @@ impl JwtService {
| 394 | 394 | |
| 395 | 395 | /// Get expiration DateTime from claims |
| 396 | 396 | /// |
| 397 | - /// Converts the exp claim (Unix timestamp) to a DateTime<Utc>. | |
| 397 | + /// Converts the exp claim (Unix timestamp) to a `DateTime<Utc>`. | |
| 398 | 398 | /// |
| 399 | 399 | /// # Arguments |
| 400 | 400 | /// |
M
crates/rustwarden-core/src/auth/jwt_extractor.rs
+1
-1
@@ -110,7 +110,7 @@ pub fn extract_jwt_from_request_parts(
| 110 | 110 | |
| 111 | 111 | /// Extract Bearer token from Authorization header value |
| 112 | 112 | /// |
| 113 | -/// Parses strings like "Bearer <token>" and returns the token part. | |
| 113 | +/// Parses strings like `"Bearer <token>"` and returns the token part. | |
| 114 | 114 | /// |
| 115 | 115 | /// # Arguments |
| 116 | 116 | /// |
M
crates/rustwarden-core/src/bin/change_password.rs
+1
-1
@@ -1,7 +1,7 @@
| 1 | 1 | //! Script CLI pour changer le mot de passe d'un utilisateur |
| 2 | 2 | //! |
| 3 | 3 | //! Usage: |
| 4 | -//! cargo run --bin change_password -- <username> <nouveau_password> | |
| 4 | +//! cargo run --bin change_password -- `<username>` `<nouveau_password>` | |
| 5 | 5 | //! |
| 6 | 6 | //! Exemples: |
| 7 | 7 | //! cargo run --bin change_password -- john_doe "NewPassword123!" |
M
crates/rustwarden-core/src/config/email.rs
+1
-1
@@ -54,7 +54,7 @@ pub struct EmailConfig {
| 54 | 54 | pub queue_retry_attempts: u32, |
| 55 | 55 | /// Delay between retries in seconds |
| 56 | 56 | pub queue_retry_delay_seconds: u64, |
| 57 | - /// Base URL for email links (e.g., https://rustwarden.nuage.ebii:3000) | |
| 57 | + /// Base URL for email links (e.g., `https://rustwarden.nuage.ebii:3000`) | |
| 58 | 58 | pub email_base_url: String, |
| 59 | 59 | /// Request DSN (Delivery Status Notifications) when sending |
| 60 | 60 | pub request_dsn: bool, |
M
crates/rustwarden-core/src/dashboard.rs
+2
-2
@@ -1,10 +1,10 @@
| 1 | 1 | //! Dashboard extension surface used by companion crates. |
| 2 | 2 | //! |
| 3 | 3 | //! The admin dashboard rendered by `rustwarden-ui` accepts a list of |
| 4 | -//! [`DashboardCardDTO`] entries injected via Axum `Extension`. Companion | |
| 4 | +//! `DashboardCardDTO` entries injected via Axum `Extension`. Companion | |
| 5 | 5 | //! crates (e.g. `rustwarden-fediverse-ui`) build a card and the |
| 6 | 6 | //! consumer application registers it through |
| 7 | -//! [`crate::app::RustwardenBuilder::add_dashboard_card`]. Living in | |
| 7 | +//! `RustwardenBuilder::add_dashboard_card`. Living in | |
| 8 | 8 | //! `rustwarden-core` keeps the type available to the builder without |
| 9 | 9 | //! creating a `rustwarden-core → rustwarden-ui` cycle. |
| 10 | 10 |
M
crates/rustwarden-core/src/identity.rs
+3
-3
@@ -1,15 +1,15 @@
| 1 | 1 | //! Unified actor abstraction (RFC 0008). |
| 2 | 2 | //! |
| 3 | -//! [`Identity`] is the type that services should accept everywhere a | |
| 3 | +//! `Identity` is the type that services should accept everywhere a | |
| 4 | 4 | //! handler used to take `Uuid /* users.id */`. Discriminating local |
| 5 | 5 | //! and remote callers at the type level prevents the confused-deputy |
| 6 | 6 | //! hazard: an authoriser cannot accidentally treat a federated subject |
| 7 | 7 | //! as if it had local roles. |
| 8 | 8 | //! |
| 9 | -//! Always compiled in core: [`Identity`] and [`IdentityResolver`] are | |
| 9 | +//! Always compiled in core: `Identity` and `IdentityResolver` are | |
| 10 | 10 | //! generic abstractions that do not depend on `rustwarden-fediverse`. |
| 11 | 11 | //! Consumers can use them whether or not federation is enabled — |
| 12 | -//! federation simply implements [`IdentityResolver`] to populate the | |
| 12 | +//! federation simply implements `IdentityResolver` to populate the | |
| 13 | 13 | //! `Remote` variant. |
| 14 | 14 | |
| 15 | 15 | use async_trait::async_trait; |
M
crates/rustwarden-core/src/lib.rs
+4
-1
@@ -29,10 +29,13 @@ pub mod i18n;
| 29 | 29 | /// Axum middleware: authentication, CSRF, rate-limiting, error handling. |
| 30 | 30 | pub mod middleware; |
| 31 | 31 | /// SeaORM database migrations bundled with the core crate. |
| 32 | +/// | |
| 33 | +/// Use `core_migrations()` to retrieve the migration list, | |
| 34 | +/// then pass it to `RustwardenApp::auto_migrate` or run it manually. | |
| 32 | 35 | pub mod migrations; |
| 33 | 36 | /// SeaORM entity models. |
| 34 | 37 | pub mod models; |
| 35 | -/// Server-sent events and in-memory notification bus. | |
| 38 | +/// Server-sent events and in-memory notification bus (`InMemoryBus`). | |
| 36 | 39 | pub mod notifications; |
| 37 | 40 | /// Business logic services (auth, users, roles, resources, teams, etc.). |
| 38 | 41 | pub mod services; |
M
crates/rustwarden-core/src/middleware/csrf.rs
+2
-2
@@ -4,9 +4,9 @@
| 4 | 4 | //! session-bound tokens for state-changing operations. |
| 5 | 5 | //! |
| 6 | 6 | //! Two entry points: |
| 7 | -//! - [`csrf_protection`] — default behavior. Accepts the token only in the | |
| 7 | +//! - `csrf_protection` — default behavior. Accepts the token only in the | |
| 8 | 8 | //! `X-CSRF-Token` header. Suited to JS clients / HTMX. |
| 9 | -//! - [`csrf_protection_with_config`] — explicit [`CsrfProtectionConfig`]. | |
| 9 | +//! - `csrf_protection_with_config` — explicit `CsrfProtectionConfig`. | |
| 10 | 10 | //! When `accept_form_body = true`, also looks for `csrf_token=...` in a |
| 11 | 11 | //! `application/x-www-form-urlencoded` body and buffers it up to |
| 12 | 12 | //! `max_form_body_bytes`. Suited to SSR apps without JS (e.g. Gitrust). |
M
crates/rustwarden-core/src/middleware/mod.rs
+4
-0
@@ -14,6 +14,10 @@ pub mod auth_required;
| 14 | 14 | /// Service layer for authentication checks used by middleware. |
| 15 | 15 | pub mod auth_service; |
| 16 | 16 | /// CSRF token generation and validation middleware. |
| 17 | +/// | |
| 18 | +/// Key entry points: [`crate::middleware::csrf::csrf_protection`], | |
| 19 | +/// [`crate::middleware::csrf::csrf_protection_with_config`], | |
| 20 | +/// and [`crate::middleware::csrf::CsrfProtectionConfig`]. | |
| 17 | 21 | pub mod csrf; |
| 18 | 22 | /// Trait consulted by `AuthService` to refuse revoked remote JWTs |
| 19 | 23 | /// (implemented and injected by `rustwarden-fediverse`). |
M
crates/rustwarden-core/src/middleware/rate_limit.rs
+2
-1
@@ -18,7 +18,8 @@
| 18 | 18 | //! | Auth per IP | env `RATE_LIMIT_AUTH_PER_MINUTE`, default 10 req/min | |
| 19 | 19 | //! |
| 20 | 20 | //! Public share and auth limits are backed by the in-process |
| 21 | -//! [`RateLimitService`] so they share bounding, cleanup and the canonical | |
| 21 | +//! [`RateLimitService`](crate::services::rate_limit_service::RateLimitService) | |
| 22 | +//! so they share bounding, cleanup and the canonical | |
| 22 | 23 | //! `TooManyRequests` error (HTTP 429 + `Retry-After`). |
| 23 | 24 | |
| 24 | 25 | use axum::{ |
M
crates/rustwarden-core/src/migrations/mod.rs
+2
-2
@@ -2,7 +2,7 @@
| 2 | 2 | //! |
| 3 | 3 | //! Single source of truth for all SeaORM migrations bundled with the |
| 4 | 4 | //! framework. Both the embedded auto-migrator (used by |
| 5 | -//! [`crate::app::RustwardenApp::auto_migrate`]) and the standalone | |
| 5 | +//! `RustwardenApp::auto_migrate`) and the standalone | |
| 6 | 6 | //! `migrate` binary read this list, so adding a migration in one place |
| 7 | 7 | //! is sufficient — there is no parallel list to keep in sync. |
| 8 | 8 | //! |
@@ -11,7 +11,7 @@
| 11 | 11 | //! 1. Create `mYYYYMMDD_NNNNNN_<name>.rs` in this directory. |
| 12 | 12 | //! 2. Add a `pub mod` declaration below. |
| 13 | 13 | //! 3. Append `Box::new(mYYYYMMDD_NNNNNN_<name>::Migration)` to |
| 14 | -//! [`core_migrations`]. | |
| 14 | +//! `core_migrations()`. | |
| 15 | 15 | //! |
| 16 | 16 | //! # Example: extending with application-specific migrations |
| 17 | 17 | //! |
M
crates/rustwarden-core/src/notifications/mod.rs
+1
-1
@@ -8,7 +8,7 @@
| 8 | 8 | //! |
| 9 | 9 | //! # Multi-instance deployments |
| 10 | 10 | //! |
| 11 | -//! The built-in [`InMemoryBus`] is single-process only. For multi-instance | |
| 11 | +//! The built-in `InMemoryBus` is single-process only. For multi-instance | |
| 12 | 12 | //! (horizontal scaling) the trait is stable enough to add a `redis-bus` |
| 13 | 13 | //! feature later with the same signatures — out of scope for v1. |
| 14 | 14 | //! |
M
crates/rustwarden-core/src/services/crypto_service.rs
+1
-1
@@ -53,7 +53,7 @@ impl CryptoService {
| 53 | 53 | Ok(format!("{}{}", ENCRYPTED_PREFIX, B64.encode(&combined))) |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | - /// Decrypt a value previously encrypted by [`encrypt`]. Handles both | |
| 56 | + /// Decrypt a value previously encrypted by [`Self::encrypt`]. Handles both | |
| 57 | 57 | /// `enc:` prefixed (encrypted) and plain text (legacy/migration) values. |
| 58 | 58 | pub fn decrypt(value: &str) -> Result<String, AppError> { |
| 59 | 59 | let encoded = match value.strip_prefix(ENCRYPTED_PREFIX) { |
M
crates/rustwarden-core/src/services/email_service.rs
+2
-2
@@ -23,8 +23,8 @@ use tokio::sync::Mutex;
| 23 | 23 | /// It uses connection pooling for better performance. |
| 24 | 24 | pub struct EmailService { |
| 25 | 25 | config: EmailConfig, |
| 26 | - /// SMTP transport with connection pool (max 10 concurrent connections) | |
| 27 | - /// Wrapped in Arc<Mutex> for thread-safe sharing | |
| 26 | + /// SMTP transport with connection pool (max 10 concurrent connections). | |
| 27 | + /// Wrapped in `Arc<Mutex<SmtpTransport>>` for thread-safe sharing. | |
| 28 | 28 | transport: Arc<Mutex<SmtpTransport>>, |
| 29 | 29 | } |
| 30 | 30 |
M
crates/rustwarden-core/src/services/personal_token_service.rs
+4
-4
@@ -1,15 +1,15 @@
| 1 | 1 | //! Personal access token service. |
| 2 | 2 | //! |
| 3 | -//! Generic over a user-defined [`TokenScope`] so each consumer app keeps a | |
| 3 | +//! Generic over a user-defined `TokenScope` so each consumer app keeps a | |
| 4 | 4 | //! type-safe vocabulary (e.g. gitrust → `RepoScope`, PasterMan → `WallScope`) |
| 5 | 5 | //! while sharing the hash/verify/revoke/cleanup plumbing. |
| 6 | 6 | //! |
| 7 | 7 | //! # Security |
| 8 | 8 | //! |
| 9 | -//! - Token plaintext is returned once from [`PersonalTokenService::create`] | |
| 9 | +//! - Token plaintext is returned once from `PersonalTokenService::create` | |
| 10 | 10 | //! and never persisted. Only the SHA-256 digest is stored. |
| 11 | -//! - [`PersonalTokenService::verify`] uses a constant-time comparison on the | |
| 12 | -//! hash (via [`subtle`]) to reject timing oracles. | |
| 11 | +//! - `PersonalTokenService::verify` uses a constant-time comparison on the | |
| 12 | +//! hash (via `subtle`) to reject timing oracles. | |
| 13 | 13 | //! - Expired and revoked tokens refuse authentication without leaking the |
| 14 | 14 | //! reason (uniform `AppError::InvalidToken`). |
| 15 | 15 | //! |
M
crates/rustwarden-core/src/services/team_service.rs
+2
-2
@@ -174,7 +174,7 @@ impl TeamService {
| 174 | 174 | Ok(record) |
| 175 | 175 | } |
| 176 | 176 | |
| 177 | - /// RFC 0009: add a member by [`Identity`]. Local identities take the | |
| 177 | + /// RFC 0009: add a member by [`crate::identity::Identity`]. Local identities take the | |
| 178 | 178 | /// existing `(team_id, user_id)` happy path; remote identities are |
| 179 | 179 | /// inserted with `user_id = nil` (PK constraint) and the |
| 180 | 180 | /// `remote_identity_id` sibling column populated. The unique |
@@ -206,7 +206,7 @@ impl TeamService {
| 206 | 206 | } |
| 207 | 207 | } |
| 208 | 208 | |
| 209 | - /// RFC 0009: every team the [`Identity`] belongs to. Local | |
| 209 | + /// RFC 0009: every team the [`crate::identity::Identity`] belongs to. Local | |
| 210 | 210 | /// identities re-use [`Self::teams_of_user`]; remote identities |
| 211 | 211 | /// are looked up via `team_members.remote_identity_id`. |
| 212 | 212 | pub async fn teams_of_identity( |
M
crates/rustwarden-fediverse-ui/src/dashboard.rs
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | //! Dashboard card factory for the admin dashboard injection point. |
| 2 | 2 | //! |
| 3 | -//! Returns a [`DashboardCardDTO`] that the application merges into the | |
| 3 | +//! Returns a [`rustwarden_core::dashboard::DashboardCardDTO`] that the application merges into the | |
| 4 | 4 | //! admin dashboard via `RustwardenBuilder::add_dashboard_card`. Keeps |
| 5 | 5 | //! the federation card self-described so `rustwarden-ui` no longer |
| 6 | 6 | //! needs to know federation exists. |
M
crates/rustwarden-fediverse-ui/src/dto/admin_tabs.rs
+2
-2
@@ -1,7 +1,7 @@
| 1 | 1 | //! Tab navigation DTO shared by every `/admin/fediverse/*` screen. |
| 2 | 2 | //! |
| 3 | -//! Exposes [`AdminTabDTO`] (one entry rendered into the tab bar) and the | |
| 4 | -//! [`admin_tabs`] async factory that builds the six tabs with labels | |
| 3 | +//! Exposes `AdminTabDTO` (one entry rendered into the tab bar) and the | |
| 4 | +//! `admin_tabs` async factory that builds the six tabs with labels | |
| 5 | 5 | //! resolved against the i18n service. Each handler then passes the |
| 6 | 6 | //! `tabs` vector to its template plus an `active_tab` slug so the |
| 7 | 7 | //! current entry gets the `tab-active` class. |
M
crates/rustwarden-fediverse-ui/src/dto/mod.rs
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | //! DTOs shared across the federation UI templates. |
| 2 | 2 | |
| 3 | 3 | /// Tab navigation entries for the `/admin/fediverse/*` screens. |
| 4 | -pub mod admin_tabs; | |
| 4 | +mod admin_tabs; | |
| 5 | 5 | |
| 6 | 6 | pub use admin_tabs::{admin_tabs, AdminTabDTO}; |
M
crates/rustwarden-fediverse-ui/src/handlers/user_auth.rs
+2
-2
@@ -396,7 +396,7 @@ const SSE_MAX_POLLS: u32 = 150;
| 396 | 396 | /// |
| 397 | 397 | /// Emits one `event: status` per poll with payload |
| 398 | 398 | /// `{"state":"pending|approved|rejected|expired|consumed"}`. Stops after |
| 399 | -/// the first terminal status or [`SSE_MAX_POLLS`] iterations, whichever | |
| 399 | +/// the first terminal status or `SSE_MAX_POLLS` iterations, whichever | |
| 400 | 400 | /// comes first. The endpoint is **public** — the challenge UUID is |
| 401 | 401 | /// itself the unguessable bearer (256 bits of entropy, RFC 0006). |
| 402 | 402 | pub async fn sse_status( |
@@ -504,7 +504,7 @@ rustwarden_core::impl_template_response!(FediverseConsentTemplate);
| 504 | 504 | /// supports the *Reject* path. *Approve* requires the inbound auth |
| 505 | 505 | /// challenge to carry a `requested_remote_id`, which the inbox handler |
| 506 | 506 | /// will set in a follow-up to RFC 0006 (the existing inbox `auth_inbox` |
| 507 | -/// at [`crate::fediverse::auth_routes`] documents this TODO at the | |
| 507 | +/// in `rustwarden_fediverse::auth_routes` documents this TODO at the | |
| 508 | 508 | /// service-layer level). When `requested_remote_id` is absent the POST |
| 509 | 509 | /// handler refuses Approve with a clear i18n error. |
| 510 | 510 | pub async fn consent_form( |
M
crates/rustwarden-fediverse/migration/src/m20260516_000002_remote_resource_shares.rs
+1
-1
@@ -17,7 +17,7 @@
| 17 | 17 | //! | `created_at` | timestamptz NOT NULL DEFAULT NOW() | bookkeeping | |
| 18 | 18 | //! |
| 19 | 19 | //! Unique index on `(resource_id, remote_identity_id)` guarantees upsert |
| 20 | -//! semantics in [`RemoteResourceShareService::share`]. Secondary index on | |
| 20 | +//! semantics in `RemoteResourceShareService::share`. Secondary index on | |
| 21 | 21 | //! `remote_identity_id` accelerates the "list resources shared with this |
| 22 | 22 | //! remote identity" lookup. |
| 23 | 23 |
M
crates/rustwarden-fediverse/migration/src/m20260516_000003_remote_pat.rs
+1
-1
@@ -22,7 +22,7 @@
| 22 | 22 | //! Indexes: |
| 23 | 23 | //! - `idx_fediverse_pat_remote_identity` on `remote_identity_id` |
| 24 | 24 | //! - `idx_fediverse_pat_active` partial index on `token_hash` where |
| 25 | -//! `revoked_at IS NULL` — used by [`RemotePersonalAccessTokenService::verify`] | |
| 25 | +//! `revoked_at IS NULL` — used by `RemotePersonalAccessTokenService::verify` | |
| 26 | 26 | //! for sub-millisecond lookups. |
| 27 | 27 | |
| 28 | 28 | use sea_orm_migration::prelude::*; |
M
crates/rustwarden-fediverse/src/http_signatures.rs
+3
-3
@@ -1,15 +1,15 @@
| 1 | 1 | //! Mastodon-compatible HTTP signatures (draft-cavage-12) (RFC 0015). |
| 2 | 2 | //! |
| 3 | 3 | //! This module ships the production-ready cryptographic primitives |
| 4 | -//! that plug into the [`SignatureVerifier`] / [`OutboundDelivery`] | |
| 4 | +//! that plug into the `SignatureVerifier` / `OutboundDelivery` | |
| 5 | 5 | //! traits. The current `SignatureVerifier` trait signature only carries |
| 6 | 6 | //! `signature_header` + `body` + `public_key_pem`; real HTTP-Sig |
| 7 | 7 | //! verification also needs the request method, path, and `host` / |
| 8 | 8 | //! `date` headers. Until a follow-up RFC widens the trait, callers |
| 9 | -//! invoke [`parse_and_verify_cavage`] directly with the full request | |
| 9 | +//! invoke `parse_and_verify_cavage` directly with the full request | |
| 10 | 10 | //! context. |
| 11 | 11 | //! |
| 12 | -//! The signing side is integrated end-to-end via [`HttpSignedDelivery`]. | |
| 12 | +//! The signing side is integrated end-to-end via `HttpSignedDelivery`. | |
| 13 | 13 | //! |
| 14 | 14 | |
| 15 | 15 | use std::collections::BTreeMap; |
M
crates/rustwarden-fediverse/src/inbox.rs
+1
-1
@@ -3,7 +3,7 @@
| 3 | 3 | //! (RFC 0012). |
| 4 | 4 | //! |
| 5 | 5 | //! The route reads the `Signature` HTTP header, delegates verification |
| 6 | -//! to the configured [`SignatureVerifier`], then routes by activity | |
| 6 | +//! to the configured `SignatureVerifier`, then routes by activity | |
| 7 | 7 | //! `type`. Only `Update`, `Delete`, and `Revoke` are supported in this |
| 8 | 8 | //! slice; the rest get `501 Not Implemented`. |
| 9 | 9 | //! |
M
crates/rustwarden-fediverse/src/models/fediverse_outbound_queue.rs
+1
-1
@@ -1,7 +1,7 @@
| 1 | 1 | //! SeaORM entity for the `fediverse_outbound_queue` table (RFC 0011). |
| 2 | 2 | //! |
| 3 | 3 | //! Persistent journal of ActivityPub activities that must be delivered |
| 4 | -//! to remote inboxes by [`rustwarden_core::services::fediverse_outbound_service::OutboundWorker`]. | |
| 4 | +//! to remote inboxes by [`crate::services::fediverse_outbound_service::OutboundWorker`]. | |
| 5 | 5 | |
| 6 | 6 | use chrono::{DateTime, Utc}; |
| 7 | 7 | use sea_orm::entity::prelude::*; |
M
crates/rustwarden-fediverse/src/models/fediverse_personal_access_token.rs
+2
-1
@@ -9,7 +9,8 @@
| 9 | 9 | //! |
| 10 | 10 | //! Only the SHA-256 hex digest of the plaintext token is persisted in |
| 11 | 11 | //! `token_hash` — the plaintext is returned once from |
| 12 | -//! [`RemotePersonalAccessTokenService::create`] and never stored. | |
| 12 | +//! [`crate::services::remote_personal_access_token_service::RemotePersonalAccessTokenService::create`] | |
| 13 | +//! and never stored. | |
| 13 | 14 | //! |
| 14 | 15 | //! # Token format |
| 15 | 16 | //! |
M
crates/rustwarden-fediverse/src/models/fediverse_resource_share.rs
+2
-1
@@ -10,7 +10,8 @@
| 10 | 10 | //! Schema: see migration |
| 11 | 11 | //! `m20260516_000002_remote_resource_shares`. A unique partial index on |
| 12 | 12 | //! `(resource_id, remote_identity_id)` guarantees that the service |
| 13 | -//! [`RemoteResourceShareService::share`] performs an upsert per pair. | |
| 13 | +//! [`crate::services::remote_resource_share_service::RemoteResourceShareService::share`] | |
| 14 | +//! performs an upsert per pair. | |
| 14 | 15 | |
| 15 | 16 | use chrono::{DateTime, Utc}; |
| 16 | 17 | use sea_orm::entity::prelude::*; |
M
crates/rustwarden-fediverse/src/models/remote_identity.rs
+1
-1
@@ -2,7 +2,7 @@
| 2 | 2 | //! |
| 3 | 3 | //! Local cache of remote actors resolved via WebFinger + actor fetch. |
| 4 | 4 | //! Created and maintained by |
| 5 | -//! [`RemoteIdentityService`](rustwarden_core::services::remote_identity_service::RemoteIdentityService). | |
| 5 | +//! [`crate::services::remote_identity_service::RemoteIdentityService`]. | |
| 6 | 6 | //! |
| 7 | 7 | |
| 8 | 8 | use chrono::{DateTime, Utc}; |
M
crates/rustwarden-fediverse/src/services/fediverse_auth_service.rs
+1
-1
@@ -486,7 +486,7 @@ pub fn build_auth_request_payload(
| 486 | 486 | /// The IRI-only form of `object` would require the IdP to know B's |
| 487 | 487 | /// AuthRequest URL — which we don't store. We use the inline-object |
| 488 | 488 | /// form `{ type: "AuthRequest", nonce }` instead; the receiving side |
| 489 | -/// extracts the nonce via [`extract_nonce_from_decision_object`]. | |
| 489 | +/// extracts the nonce via `extract_nonce_from_decision_object`. | |
| 490 | 490 | /// |
| 491 | 491 | /// `idp_base_url` is the canonical base URL of A (the IdP, e.g. |
| 492 | 492 | /// `http://alpha.local/`). `approver_actor_url` is the AP id of the |
M
crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs
+2
-2
@@ -4,8 +4,8 @@
| 4 | 4 | //! On `Accept`, the service resolves the remote identity and inserts |
| 5 | 5 | //! a `team_members` row with `remote_identity_id` populated. The AP |
| 6 | 6 | //! activities themselves are produced by the `build_*_payload` helpers |
| 7 | -//! in [`crate::services::fediverse_invite_payloads`] and dispatched by | |
| 8 | -//! the consumer through [`super::fediverse_outbound_service::FediverseOutboundService`]. | |
| 7 | +//! in `fediverse_invite_payloads` and dispatched by | |
| 8 | +//! the consumer through [`crate::services::fediverse_outbound_service::FediverseOutboundService`]. | |
| 9 | 9 | //! |
| 10 | 10 | |
| 11 | 11 | use chrono::{Duration as ChronoDuration, Utc}; |
M
crates/rustwarden-fediverse/src/services/fediverse_outbound_service.rs
+1
-1
@@ -2,7 +2,7 @@
| 2 | 2 | //! |
| 3 | 3 | //! Persistent journal of activities that must be delivered to remote |
| 4 | 4 | //! inboxes (auth `Accept{AuthRequest}`, invitations, lifecycle signals). |
| 5 | -//! The transport layer is abstracted behind [`OutboundDelivery`] so | |
| 5 | +//! The transport layer is abstracted behind `OutboundDelivery` so | |
| 6 | 6 | //! that RFC 0010 can plug in HTTP-Sig signing without disturbing the |
| 7 | 7 | //! queue/worker plumbing. |
| 8 | 8 | //! |
M
crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs
+1
-1
@@ -3,7 +3,7 @@
| 3 | 3 | //! Applies incoming AP `Update{Actor}` / `Delete{Actor}` / |
| 4 | 4 | //! `Revoke{Membership}` signals to the local state. The HTTP-Sig |
| 5 | 5 | //! verification of the request that carried the signal is delegated to |
| 6 | -//! [`SignatureVerifier`] — the implementation that ships with this RFC | |
| 6 | +//! `SignatureVerifier` — the implementation that ships with this RFC | |
| 7 | 7 | //! is `AlwaysAcceptVerifier` (test-only); a follow-up RFC will plug |
| 8 | 8 | //! the real RFC 9421 verifier. |
| 9 | 9 | //! |
M
crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs
+4
-4
@@ -4,7 +4,7 @@
| 4 | 4 | //! Companion of |
| 5 | 5 | //! [`rustwarden_core::services::personal_token_service::PersonalTokenService`] |
| 6 | 6 | //! but scoped to `remote_identities` instead of `users`. Generic over a |
| 7 | -//! [`TokenScope`] type so consumer apps reuse their existing scope | |
| 7 | +//! `TokenScope` type so consumer apps reuse their existing scope | |
| 8 | 8 | //! vocabularies (e.g. gitrust's `RepoScope`) for both local and remote |
| 9 | 9 | //! PATs. |
| 10 | 10 | //! |
@@ -20,9 +20,9 @@
| 20 | 20 | //! # Security |
| 21 | 21 | //! |
| 22 | 22 | //! - SHA-256 of the plaintext is stored; the plaintext is returned once |
| 23 | -//! from [`RemotePersonalAccessTokenService::create`]. | |
| 24 | -//! - Constant-time hash comparison via [`subtle`] in | |
| 25 | -//! [`RemotePersonalAccessTokenService::verify`]. | |
| 23 | +//! from `RemotePersonalAccessTokenService::create`. | |
| 24 | +//! - Constant-time hash comparison via `subtle` in | |
| 25 | +//! `RemotePersonalAccessTokenService::verify`. | |
| 26 | 26 | //! - A token whose owning `remote_identity` is `disabled_at IS NOT NULL` |
| 27 | 27 | //! is rejected uniformly (returns `None`), defending against the |
| 28 | 28 | //! "actor offline since" race after a `Delete{Actor}` signal. |
M
crates/rustwarden-fediverse/src/services/revocation.rs
+1
-2
@@ -17,8 +17,7 @@ use crate::models::remote_identity;
| 17 | 17 | /// Default revocation checker backed by the `remote_identities` table. |
| 18 | 18 | /// |
| 19 | 19 | /// Injected as `Arc<dyn RemoteIdentityRevocationChecker>` Tower |
| 20 | -/// `Extension` from | |
| 21 | -/// [`crate::routes::router`](crate::routes::router) when the federation | |
| 20 | +/// `Extension` from [`crate::routes::router`] when the federation | |
| 22 | 21 | /// stack is mounted on a Rustwarden builder. |
| 23 | 22 | #[derive(Clone)] |
| 24 | 23 | pub struct SeaOrmRemoteIdentityRevocationChecker { |
M
crates/rustwarden-ui/src/dto/admin_dto.rs
+3
-3
@@ -127,11 +127,11 @@ pub struct CreateUserRequest {
| 127 | 127 | pub roles: Vec<String>, |
| 128 | 128 | } |
| 129 | 129 | |
| 130 | -/// Custom deserializer that accepts either a String or Vec<String> | |
| 130 | +/// Custom deserializer that accepts either a `String` or `Vec<String>` | |
| 131 | 131 | /// |
| 132 | 132 | /// This is needed because HTML forms send: |
| 133 | -/// - `roles=user` when one checkbox is checked (String) | |
| 134 | -/// - `roles=user&roles=admin` when multiple checkboxes are checked (Vec<String>) | |
| 133 | +/// - `roles=user` when one checkbox is checked (`String`) | |
| 134 | +/// - `roles=user&roles=admin` when multiple checkboxes are checked (`Vec<String>`) | |
| 135 | 135 | /// |
| 136 | 136 | /// serde_urlencoded handles duplicate fields by creating a map where values are either |
| 137 | 137 | /// a single string or a vector. We need to handle both cases. |
A
docs/V2_ROADMAP.md
+532
-0
@@ -0,0 +1,532 @@
| 1 | +# Rustwarden V2 — Roadmap | |
| 2 | + | |
| 3 | +## Préambule | |
| 4 | + | |
| 5 | +Rustwarden V1 a posé des fondations solides : architecture service/repository propre, séparation nette fédération/UI, CSRF constant-time, blacklist JWT opérationnelle, thiserror cohérent. Après l'audit de sécurité du 17 mai 2026 (228 fichiers .rs, ~59 500 lignes), plusieurs zones critiques appellent un traitement structuré. | |
| 6 | + | |
| 7 | +V2 n'est pas une réécriture. C'est un cycle de durcissement raisonné autour de quatre principes : | |
| 8 | + | |
| 9 | +- **Security-first** : les failles identifiées (CSP, JWT, rate limit distribué) sont traitées avant toute nouvelle fonctionnalité. | |
| 10 | +- **Observabilité native** : un framework backend sans traces structurées ni métriques est aveugle en production. V2 corrige cela. | |
| 11 | +- **Modularité préservée** : les god-files nuisent à la lisibilité et aux revues. Le découpage est chirurgical, sans réarchitecturer ce qui fonctionne. | |
| 12 | +- **Migration zero-downtime depuis V1** : aucun breaking change sans shim de compatibilité et cycle de dépréciation explicite. | |
| 13 | + | |
| 14 | +--- | |
| 15 | + | |
| 16 | +## Vision V2 | |
| 17 | + | |
| 18 | +Rustwarden V2 vise à être un framework d'authentification et de gestion utilisateurs **production-ready** : sécurisé par défaut, observable sans configuration supplémentaire, et suffisamment modulaire pour que n'importe quel lead dev puisse onboarder en une journée. | |
| 19 | + | |
| 20 | +### Cinq piliers | |
| 21 | + | |
| 22 | +| Pilier | Objectif V2 | | |
| 23 | +|--------|-------------| | |
| 24 | +| **Sécurité durcie** | CSP sans `unsafe-*`, JWT multi-secret avec rotation, rate limit distribué Redis | | |
| 25 | +| **Observabilité native** | Traces OTel sur tous les services, `/metrics` Prometheus, `/healthz` + `/readyz` | | |
| 26 | +| **Modularité plus fine** | Aucun fichier > 1 000 lignes, découpage des trois god-files, discussion `rustwarden-domain` | | |
| 27 | +| **Qualité code** | Zéro `unwrap`/`expect` hors tests, tests e2e auth complets, feature `redis-session` corrigée | | |
| 28 | +| **DX consommateur** | Rustdoc à zéro warning, tutoriels graduels, exemples runnables dans `app/examples/` | | |
| 29 | + | |
| 30 | +--- | |
| 31 | + | |
| 32 | +## Top 10 priorisé | |
| 33 | + | |
| 34 | +| # | Item | Sévérité | Effort | Référence | Justification | | |
| 35 | +|---|------|----------|--------|-----------|---------------| | |
| 36 | +| 1 | CSP : supprimer `unsafe-inline` + `unsafe-eval` | HIGH | M | [`app.rs:741`](../crates/rustwarden-core/src/app.rs#L741) | Annule toute protection XSS malgré l'en-tête | | |
| 37 | +| 2 | JWT : rotation de secret + support `kid` | HIGH | L | [`jwt.rs:221`](../crates/rustwarden-core/src/auth/jwt.rs#L221) | Secret unique sans rotation = exposition permanente en cas de fuite | | |
| 38 | +| 3 | Rate limit distribué Redis | MEDIUM | M | [`rate_limit.rs`](../crates/rustwarden-core/src/middleware/rate_limit.rs) | Bypass trivial avec ≥ 2 réplicas | | |
| 39 | +| 4 | JWT clock skew | MEDIUM | S | [`jwt.rs:328`](../crates/rustwarden-core/src/auth/jwt.rs#L328) | `Validation::default()` = skew 0, rejet légitime possible | | |
| 40 | +| 5 | OTel traces + Prometheus `/metrics` | — | L | (absent) | 0 span structuré sur 394 appels `tracing::` | | |
| 41 | +| 6 | `/healthz` + `/readyz` avec vérification DB | — | S | (absent) | `/health` retourne `{"status":"ok"}` sans vérifier la DB | | |
| 42 | +| 7 | Découpage des trois god-files | MEDIUM | M | `admin_handler.rs:3058`, `admin.rs:2113`, `handlers.rs:1552` | Bloque les revues et augmente le risque de régression | | |
| 43 | +| 8 | Fix `totp_service.rs:360` (`unwrap` dangereux) | MEDIUM | S | [`totp_service.rs:360`](../crates/rustwarden-core/src/services/totp_service.rs#L360) | Panic potentiel sur `ActiveValue::NotSet` en production | | |
| 44 | +| 9 | Tests e2e auth (login → refresh → blacklist → logout) | — | M | (absent) | 538 tests mais zéro flow HTTP complet | | |
| 45 | +| 10 | Feature `redis-session` fonctionnelle ou supprimée | LOW | S | (mémoire projet) | Ne compile pas — état incohérent avec le Cargo.toml | | |
| 46 | + | |
| 47 | +--- | |
| 48 | + | |
| 49 | +## § A. Sécurité | |
| 50 | + | |
| 51 | +### A1. CSP : élimination de `unsafe-inline` et `unsafe-eval` | |
| 52 | + | |
| 53 | +La CSP actuelle ([`app.rs:741`](../crates/rustwarden-core/src/app.rs#L741)) inclut `'unsafe-inline'` et `'unsafe-eval'` sur `script-src`. Ces directives annulent la protection XSS que la CSP est censée fournir. | |
| 54 | + | |
| 55 | +**Plan de migration :** | |
| 56 | + | |
| 57 | +1. Migrer Alpine.js en [mode CSP](https://alpinejs.dev/advanced/csp) (`@alpinejs/csp`) — ce build interdit `eval()` et les expressions inline. | |
| 58 | +2. Remplacer les handlers `x-on:click="..."` inline par des composants Alpine nommés enregistrés via `Alpine.data()`. | |
| 59 | +3. Pour les rares scripts inline légitimes (nonces Askama), injecter un nonce par requête : | |
| 60 | + | |
| 61 | +```rust | |
| 62 | +// Dans le middleware de sécurité, avant de servir la réponse HTML | |
| 63 | +let nonce = base64::encode(rand::random::<[u8; 16]>()); | |
| 64 | +// Passer le nonce au template Askama via l'état de la requête | |
| 65 | +// CSP résultante : | |
| 66 | +"script-src 'self' 'nonce-{nonce}'; style-src 'self' 'nonce-{nonce}'" | |
| 67 | +``` | |
| 68 | + | |
| 69 | +4. Supprimer `'unsafe-eval'` : vérifier qu'aucun code JS n'appelle `eval()`, `new Function()`, ou `setTimeout(string)`. | |
| 70 | + | |
| 71 | +**Critère d'acceptation :** rapport CSP Evaluator (Google) sans erreur critique sur l'environnement de staging. | |
| 72 | + | |
| 73 | +### A2. JWT : rotation de secret et support `kid` | |
| 74 | + | |
| 75 | +L'implémentation actuelle ([`jwt.js:221`](../crates/rustwarden-core/src/auth/jwt.js#L221)) utilise HS256 avec un secret unique sans identifiant de clé (`kid`). En cas de compromission, tous les tokens en circulation sont compromis sans possibilité de rotation progressive. | |
| 76 | + | |
| 77 | +**Approche recommandée (deux niveaux) :** | |
| 78 | + | |
| 79 | +*Niveau 1 — Rotation HS256 zero-downtime (effort M) :* | |
| 80 | + | |
| 81 | +```rust | |
| 82 | +// Variables d'environnement | |
| 83 | +// JWT_SECRET=<nouveau_secret> | |
| 84 | +// JWT_SECRET_PREVIOUS=<ancien_secret> (optionnel, pour la période de transition) | |
| 85 | + | |
| 86 | +pub fn validate_token(config: &AuthConfig, token: &str) -> Result<Claims, AppError> { | |
| 87 | + // Tenter avec le secret courant | |
| 88 | + if let Ok(claims) = try_decode(token, &config.jwt_secret) { | |
| 89 | + return Ok(claims); | |
| 90 | + } | |
| 91 | + // Fallback sur l'ancien secret si présent (fenêtre de rotation) | |
| 92 | + if let Some(prev) = &config.jwt_secret_previous { | |
| 93 | + return try_decode(token, prev).map_err(|_| AppError::Unauthorized("invalid token".into())); | |
| 94 | + } | |
| 95 | + Err(AppError::Unauthorized("invalid token".into())) | |
| 96 | +} | |
| 97 | +``` | |
| 98 | + | |
| 99 | +*Niveau 2 — RS256/EdDSA avec JWKS (effort L) :* émettre les tokens avec une clé privée RSA/Ed25519, exposer `/auth/.well-known/jwks.json`, permettre à des services tiers de valider sans partager de secret. | |
| 100 | + | |
| 101 | +### A3. JWT clock skew | |
| 102 | + | |
| 103 | +`Validation::default()` ([`jwt.rs:328`](../crates/rustwarden-core/src/auth/jwt.rs#L328)) applique un skew de 0 seconde. Les environnements distribués avec une dérive NTP modeste rejettent des tokens valides. | |
| 104 | + | |
| 105 | +```rust | |
| 106 | +let mut validation = Validation::new(Algorithm::HS256); | |
| 107 | +validation.set_clock_skew(Duration::from_secs(30)); | |
| 108 | +``` | |
| 109 | + | |
| 110 | +Changement de deux lignes, impact nul sur la sécurité, élimine les faux négatifs. | |
| 111 | + | |
| 112 | +### A4. Rate limit distribué | |
| 113 | + | |
| 114 | +Le `RateLimitService` est stocké dans un `OnceLock` en mémoire de processus ([`rate_limit.rs`](../crates/rustwarden-core/src/middleware/rate_limit.rs)). Avec deux réplicas derrière un load balancer, chaque réplica compte ses propres 10 req/min : le vrai quota effectif est `10 × nb_réplicas`. | |
| 115 | + | |
| 116 | +**Recommandation :** extraire le stockage vers Redis via `fred` ou `deadpool-redis`. L'interface `RateLimitBackend` peut être rendue générique : | |
| 117 | + | |
| 118 | +```rust | |
| 119 | +#[async_trait] | |
| 120 | +pub trait RateLimitBackend: Send + Sync { | |
| 121 | + async fn increment(&self, key: &str, window: Duration) -> Result<u64, AppError>; | |
| 122 | +} | |
| 123 | + | |
| 124 | +// Implémentations : InMemoryBackend (défaut, tests) + RedisBackend (production) | |
| 125 | +``` | |
| 126 | + | |
| 127 | +Configurer via `RATE_LIMIT_BACKEND=redis` + `REDIS_URL`. | |
| 128 | + | |
| 129 | +### A5. `accept_invalid_certs` SMTP/IMAP | |
| 130 | + | |
| 131 | +Les connexions SMTP/IMAP acceptent des certificats invalides si l'option est activée. Les defaults sont corrects (`false`), mais le paramètre est exposé sans garde-fou en production. Recommandation : si `APP_DEBUG=false`, ignorer la variable et forcer la validation — logguer une erreur au démarrage si la variable est à `true` en mode production. | |
| 132 | + | |
| 133 | +### A6. Rotation des clés acteur ActivityPub | |
| 134 | + | |
| 135 | +`ActorKeyService` génère une paire RSA sans `expires_at` ni mécanisme de re-génération. Recommandation : ajouter un champ `key_expires_at` en base, planifier une rotation périodique (ex. 90 jours), et diffuser une `Update` Activity aux instances fédérées après régénération. | |
| 136 | + | |
| 137 | +--- | |
| 138 | + | |
| 139 | +## § B. Observabilité | |
| 140 | + | |
| 141 | +### État actuel | |
| 142 | + | |
| 143 | +- 394 appels `tracing::` (debug/info/warn/error) — logs textuels uniquement. | |
| 144 | +- **0** appel `#[instrument]` — aucun span structuré, corrélation inter-requêtes impossible. | |
| 145 | +- **0** référence OpenTelemetry ou Prometheus. | |
| 146 | +- `/health` retourne `{"status":"ok"}` sans vérifier la connexion DB ni le session store. | |
| 147 | + | |
| 148 | +### Architecture cible | |
| 149 | + | |
| 150 | +```mermaid | |
| 151 | +flowchart LR | |
| 152 | + App["rustwarden-app\n(tracing + OTel SDK)"] | |
| 153 | + Collector["OpenTelemetry\nCollector"] | |
| 154 | + Tempo["Grafana Tempo\n(traces)"] | |
| 155 | + Loki["Grafana Loki\n(logs JSON)"] | |
| 156 | + Mimir["Grafana Mimir\n(métriques)"] | |
| 157 | + Grafana["Grafana\nDashboards"] | |
| 158 | + Prom["/metrics\n(Prometheus scrape)"] | |
| 159 | + | |
| 160 | + App -->|OTLP gRPC| Collector | |
| 161 | + App -->|stdout JSON| Loki | |
| 162 | + App -->|HTTP scrape| Prom | |
| 163 | + Collector --> Tempo | |
| 164 | + Collector --> Mimir | |
| 165 | + Prom --> Mimir | |
| 166 | + Tempo --> Grafana | |
| 167 | + Loki --> Grafana | |
| 168 | + Mimir --> Grafana | |
| 169 | +``` | |
| 170 | + | |
| 171 | +### Plan d'implémentation | |
| 172 | + | |
| 173 | +**Dépendances à ajouter dans `rustwarden-core/Cargo.toml` :** | |
| 174 | + | |
| 175 | +```toml | |
| 176 | +tracing-opentelemetry = "0.28" | |
| 177 | +opentelemetry = { version = "0.27", features = ["trace"] } | |
| 178 | +opentelemetry-otlp = { version = "0.27", features = ["grpc-tonic"] } | |
| 179 | +opentelemetry_sdk = { version = "0.27", features = ["rt-tokio"] } | |
| 180 | +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } | |
| 181 | +metrics = "0.24" | |
| 182 | +metrics-exporter-prometheus = "0.16" | |
| 183 | +``` | |
| 184 | + | |
| 185 | +**Instrumentation des services (priorité haute) :** | |
| 186 | + | |
| 187 | +```rust | |
| 188 | +use tracing::instrument; | |
| 189 | + | |
| 190 | +impl UserService { | |
| 191 | + #[instrument(skip(db, password), fields(username = %username))] | |
| 192 | + pub async fn authenticate(db: &DatabaseConnection, username: &str, password: &str) | |
| 193 | + -> Result<User, AppError> { ... } | |
| 194 | + | |
| 195 | + #[instrument(skip(db), fields(user_id = %user_id))] | |
| 196 | + pub async fn find_by_id(db: &DatabaseConnection, user_id: Uuid) | |
| 197 | + -> Result<Option<User>, AppError> { ... } | |
| 198 | +} | |
| 199 | +``` | |
| 200 | + | |
| 201 | +**Services à instrumenter (liste exhaustive) :** | |
| 202 | +- `UserService` : authenticate, find_by_id, create, update, delete | |
| 203 | +- `JwtService` : generate_token, validate_token, blacklist_token | |
| 204 | +- `OAuthService` : authorize, token_exchange, refresh | |
| 205 | +- `TotpService` : generate_challenge, verify_challenge | |
| 206 | +- `TeamService` : create, add_member, remove_member | |
| 207 | +- `EmailService` : send (avec tag provider SMTP) | |
| 208 | +- `FederationService` : send_activity, process_inbox | |
| 209 | + | |
| 210 | +**Métriques Prometheus à exposer (≥ 20) :** | |
| 211 | + | |
| 212 | +| Métrique | Type | Labels | | |
| 213 | +|----------|------|--------| | |
| 214 | +| `rw_auth_login_total` | Counter | `result` (ok/fail) | | |
| 215 | +| `rw_auth_login_duration_seconds` | Histogram | — | | |
| 216 | +| `rw_jwt_tokens_issued_total` | Counter | — | | |
| 217 | +| `rw_jwt_validation_errors_total` | Counter | `reason` | | |
| 218 | +| `rw_jwt_blacklist_size` | Gauge | — | | |
| 219 | +| `rw_oauth_flows_total` | Counter | `provider`, `result` | | |
| 220 | +| `rw_totp_challenges_total` | Counter | `result` | | |
| 221 | +| `rw_rate_limit_hits_total` | Counter | `route` | | |
| 222 | +| `rw_email_sent_total` | Counter | `result` | | |
| 223 | +| `rw_email_bounces_total` | Counter | — | | |
| 224 | +| `rw_team_members_total` | Gauge | — | | |
| 225 | +| `rw_active_sessions_total` | Gauge | `backend` | | |
| 226 | +| `rw_db_query_duration_seconds` | Histogram | `operation` | | |
| 227 | +| `rw_http_requests_total` | Counter | `method`, `path`, `status` | | |
| 228 | +| `rw_http_request_duration_seconds` | Histogram | `method`, `path` | | |
| 229 | +| `rw_federation_activities_sent_total` | Counter | `type`, `result` | | |
| 230 | +| `rw_federation_inbox_received_total` | Counter | `type` | | |
| 231 | +| `rw_2fa_enforced_users_ratio` | Gauge | — | | |
| 232 | +| `rw_password_resets_total` | Counter | — | | |
| 233 | +| `rw_actor_key_age_seconds` | Gauge | `actor` | | |
| 234 | + | |
| 235 | +**Health endpoints :** | |
| 236 | + | |
| 237 | +```rust | |
| 238 | +// GET /healthz — liveness (process alive) | |
| 239 | +async fn healthz() -> impl IntoResponse { | |
| 240 | + StatusCode::OK | |
| 241 | +} | |
| 242 | + | |
| 243 | +// GET /readyz — readiness (DB + session store) | |
| 244 | +async fn readyz(State(state): State<AppState>) -> impl IntoResponse { | |
| 245 | + let db_ok = state.db.ping().await.is_ok(); | |
| 246 | + let session_ok = check_session_backend(&state).await; | |
| 247 | + if db_ok && session_ok { | |
| 248 | + (StatusCode::OK, Json(json!({"status": "ready"}))) | |
| 249 | + } else { | |
| 250 | + (StatusCode::SERVICE_UNAVAILABLE, Json(json!({ | |
| 251 | + "status": "not ready", | |
| 252 | + "db": db_ok, | |
| 253 | + "session": session_ok | |
| 254 | + }))) | |
| 255 | + } | |
| 256 | +} | |
| 257 | +``` | |
| 258 | + | |
| 259 | +**Format de logs JSON :** | |
| 260 | + | |
| 261 | +```rust | |
| 262 | +tracing_subscriber::registry() | |
| 263 | + .with(tracing_subscriber::fmt::layer().json()) | |
| 264 | + .with(tracing_opentelemetry::layer().with_tracer(tracer)) | |
| 265 | + .with(EnvFilter::from_default_env()) | |
| 266 | + .init(); | |
| 267 | +``` | |
| 268 | + | |
| 269 | +--- | |
| 270 | + | |
| 271 | +## § C. Séparation et Modularité | |
| 272 | + | |
| 273 | +### Les trois god-files | |
| 274 | + | |
| 275 | +| Fichier | Lignes | Problème | | |
| 276 | +|---------|--------|---------| | |
| 277 | +| `crates/rustwarden-ui/src/handlers/admin_handler.rs` | 3 058 | Couvre users, roles, permissions, teams, settings, OAuth clients, TOTP admin | | |
| 278 | +| `crates/rustwarden-fediverse-ui/src/handlers/admin.rs` | 2 113 | Couvre actors, followers, federation settings, invites, modération | | |
| 279 | +| `crates/rustwarden-core/src/auth/handlers.rs` | 1 552 | Couvre login, register, refresh, logout, password reset, OAuth callback | | |
| 280 | + | |
| 281 | +**Plan de découpage — `admin_handler.rs` (exemple) :** | |
| 282 | + | |
| 283 | +``` | |
| 284 | +crates/rustwarden-ui/src/handlers/admin/ | |
| 285 | +├── mod.rs # re-exports + router assembly | |
| 286 | +├── users.rs # CRUD utilisateurs, suspension, impersonation | |
| 287 | +├── roles.rs # création/édition rôles, assignation | |
| 288 | +├── permissions.rs # gestion des permissions granulaires | |
| 289 | +├── teams.rs # équipes, membres, transferts | |
| 290 | +├── settings.rs # paramètres application, SMTP, IMAP | |
| 291 | +├── oauth_clients.rs # enregistrement et gestion clients OAuth | |
| 292 | +└── totp.rs # reset TOTP admin, audit 2FA | |
| 293 | +``` | |
| 294 | + | |
| 295 | +Le même schéma s'applique aux deux autres fichiers. La règle projet — aucun fichier > 1 000 lignes — doit être enforced via un check CI : | |
| 296 | + | |
| 297 | +```bash | |
| 298 | +# Dans .github/workflows/ci.yml ou équivalent | |
| 299 | +find crates -name "*.rs" | xargs wc -l | awk '$1 > 1000 && $2 != "total" {print $2": "$1" lignes"; exit 1}' | |
| 300 | +``` | |
| 301 | + | |
| 302 | +**Sur `rustwarden-domain` :** | |
| 303 | + | |
| 304 | +Extraire un crate `rustwarden-domain` pur (entités + traits de repository sans dépendance Axum) est une option valide à long terme, mais non prioritaire en V2. Les services sont déjà découplés d'Axum. L'extraction ne serait justifiée que si un consommateur souhaite utiliser les services hors contexte HTTP — à évaluer en V3 selon la demande. | |
| 305 | + | |
| 306 | +--- | |
| 307 | + | |
| 308 | +## § D. Qualité Code | |
| 309 | + | |
| 310 | +### D1. Fix `totp_service.rs:360` | |
| 311 | + | |
| 312 | +```rust | |
| 313 | +// AVANT — panic potentiel sur ActiveValue::NotSet | |
| 314 | +model.attempts = Set(model.attempts.unwrap() + 1); | |
| 315 | + | |
| 316 | +// APRÈS — extraction safe | |
| 317 | +let current = match &model.attempts { | |
| 318 | + ActiveValue::Set(v) | ActiveValue::Unchanged(v) => *v, | |
| 319 | + ActiveValue::NotSet => 0, | |
| 320 | +}; | |
| 321 | +model.attempts = Set(current + 1); | |
| 322 | +``` | |
| 323 | + | |
| 324 | +Ce pattern doit être appliqué à tout `ActiveValue::unwrap()` dans les services. Un grep ciblé permet d'identifier les autres occurrences : | |
| 325 | + | |
| 326 | +```bash | |
| 327 | +grep -rn "\.unwrap()" crates/rustwarden-core/src/services/ | |
| 328 | +``` | |
| 329 | + | |
| 330 | +### D2. Tests e2e auth | |
| 331 | + | |
| 332 | +538 tests (239 sync + 299 async) couvrent les unités mais aucun flow HTTP complet. Les scénarios à couvrir en V2 : | |
| 333 | + | |
| 334 | +| Scénario | Assertions clés | | |
| 335 | +|----------|----------------| | |
| 336 | +| Login valide → JWT | Status 200, token présent, claims corrects | | |
| 337 | +| Login invalide → 401 | Pas de token, message générique | | |
| 338 | +| JWT expiré → refresh → nouveau JWT | Ancien token rejeté, nouveau valide | | |
| 339 | +| Refresh révoqué → 401 | Blacklist opérationnelle | | |
| 340 | +| Logout → token blacklisté | Token précédent invalide après logout | | |
| 341 | +| Rate limit login → 429 | Après N tentatives, `Retry-After` présent | | |
| 342 | +| 2FA challenge → verify → JWT | Flow complet TOTP | | |
| 343 | +| OAuth callback → session → JWT | Flow complet OAuth (mock provider) | | |
| 344 | + | |
| 345 | +Framework recommandé : `axum-test` ou `tower::ServiceExt::oneshot` avec une DB de test SQLite en mémoire (ou PostgreSQL via `testcontainers`). | |
| 346 | + | |
| 347 | +### D3. Feature `redis-session` | |
| 348 | + | |
| 349 | +La feature `redis-session` de `rustwarden-core` ne compile pas (confirmé mémoire projet). Deux options : | |
| 350 | + | |
| 351 | +- **Corriger** : aligner `tower-sessions-redis-store` avec la version de `tower-sessions` 0.14 utilisée. Ajouter un job CI `cargo build --features redis-session` pour prévenir la régression. | |
| 352 | +- **Supprimer** : retirer la feature flag et documenter Redis uniquement comme backend de rate limit (via `RATE_LIMIT_BACKEND=redis`). | |
| 353 | + | |
| 354 | +La décision dépend de l'appétit pour les sessions persistantes Redis vs les sessions en mémoire. Si aucun consommateur connu ne l'utilise, supprimer est plus propre. | |
| 355 | + | |
| 356 | +### D4. Hygène CI | |
| 357 | + | |
| 358 | +Ajouter au pipeline CI : | |
| 359 | + | |
| 360 | +```bash | |
| 361 | +cargo audit # vulnérabilités connues dans les dépendances | |
| 362 | +cargo outdated --exit-code 1 # dépendances avec mises à jour majeures disponibles | |
| 363 | +cargo clippy -- -D warnings # zéro warning | |
| 364 | +``` | |
| 365 | + | |
| 366 | +--- | |
| 367 | + | |
| 368 | +## § E. DX et Documentation | |
| 369 | + | |
| 370 | +### E1. Rustdoc | |
| 371 | + | |
| 372 | +La passe de correction des 57 warnings rustdoc est en cours (branche `fedivers`). Critère V2 : `cargo doc --no-deps 2>&1 | grep "^warning" | wc -l` = 0 en CI. | |
| 373 | + | |
| 374 | +Règle à enforcer : `#![deny(missing_docs)]` au niveau workspace (actuellement `warn`). Passer à `deny` une fois les warnings existants résolus. | |
| 375 | + | |
| 376 | +### E2. Tutoriels graduels | |
| 377 | + | |
| 378 | +Structure suggérée dans `docs/developer-guide/` : | |
| 379 | + | |
| 380 | +``` | |
| 381 | +docs/developer-guide/ | |
| 382 | +├── 01-quickstart.md # app minimale en 15 minutes | |
| 383 | +├── 02-authentication.md # JWT, sessions, refresh tokens | |
| 384 | +├── 03-rbac.md # rôles, permissions, teams | |
| 385 | +├── 04-oauth.md # intégration Google/GitHub/Discord | |
| 386 | +├── 05-2fa.md # TOTP setup et enforcement | |
| 387 | +├── 06-federation.md # ActivityPub headless | |
| 388 | +├── 07-observability.md # OTel, Prometheus, Grafana | |
| 389 | +└── 08-production-checklist.md # sécurité, HSTS, CSP, secrets | |
| 390 | +``` | |
| 391 | + | |
| 392 | +### E3. Exemples runnables | |
| 393 | + | |
| 394 | +Créer `app/examples/` avec des binaires autonomes compilables : | |
| 395 | + | |
| 396 | +``` | |
| 397 | +app/examples/ | |
| 398 | +├── minimal_auth.rs # login + JWT en ~50 lignes | |
| 399 | +├── rbac_teams.rs # création rôle + assignation + vérification | |
| 400 | +├── oauth_flow.rs # mock OAuth provider + callback | |
| 401 | +└── federation_actor.rs # création acteur AP + envoi d'une activité | |
| 402 | +``` | |
| 403 | + | |
| 404 | +Chaque exemple doit compiler avec `cargo run --example <nom>` et être inclus dans la CI. | |
| 405 | + | |
| 406 | +--- | |
| 407 | + | |
| 408 | +## Non-goals V2 | |
| 409 | + | |
| 410 | +V2 ne fera **pas** : | |
| 411 | + | |
| 412 | +- Migration vers un framework web autre qu'Axum. Axum 0.8 + Tower est le choix retenu pour le cycle V2. | |
| 413 | +- Support d'une base de données autre que PostgreSQL. SQLite pourrait être envisagé en V3 pour les déploiements embarqués. | |
| 414 | +- Breaking changes sur l'API publique Rust sans cycle de dépréciation (`#[deprecated]` + une version mineure d'avertissement avant suppression). | |
| 415 | +- Réécriture du système de permissions. Le modèle RBAC actuel est fonctionnel — des extensions (ABAC, policies) sont une discussion V3. | |
| 416 | +- Interface graphique d'administration réécrite. L'UI Askama/HTMX existante est améliorée incrémentalement, pas remplacée. | |
| 417 | +- Support multi-base de données simultané (multi-tenancy cross-DB) : hors scope. | |
| 418 | + | |
| 419 | +--- | |
| 420 | + | |
| 421 | +## Migration path V1 → V2 | |
| 422 | + | |
| 423 | +### Breaking changes potentiels | |
| 424 | + | |
| 425 | +| Changement | Impact | Mitigation | | |
| 426 | +|------------|--------|-----------| | |
| 427 | +| `JWT_SECRET_PREVIOUS` (nouveau champ `AuthConfig`) | Optionnel — aucun impact si absent | Valeur par défaut `None`, rotation désactivée | | |
| 428 | +| Découpage des god-files | Chemins de modules Rust modifiés | Re-exports dans `mod.rs` pour préserver l'API publique | | |
| 429 | +| Feature `redis-session` supprimée (si décidé) | Consommateurs utilisant `--features redis-session` | Notification dans CHANGELOG + période de dépréciation 1 version mineure | | |
| 430 | +| `/health` → `/healthz` + `/readyz` | Sondes Kubernetes à mettre à jour | Maintenir `/health` avec réponse identique pendant 1 version | | |
| 431 | +| Format logs JSON (si activé) | Parsers de logs côté ops | Opt-in via `LOG_FORMAT=json`, texte par défaut pendant la transition | | |
| 432 | + | |
| 433 | +### Calendrier suggéré | |
| 434 | + | |
| 435 | +1. **V2.0.0-alpha** (mois 1-2) : sécurité critique (CSP, clock skew, `unwrap` TOTP), `/healthz`/`/readyz`, découpage god-files. | |
| 436 | +2. **V2.0.0-beta** (mois 3-4) : OTel + Prometheus, JWT rotation HS256, rate limit Redis, tests e2e. | |
| 437 | +3. **V2.0.0-rc** (mois 5) : rustdoc zéro warning, exemples runnables, `redis-session` corrigée ou supprimée, audit `cargo audit`. | |
| 438 | +4. **V2.0.0-stable** (mois 6) : gel fonctionnel, validation en staging, publication CHANGELOG complet. | |
| 439 | + | |
| 440 | +Les consommateurs de V1 peuvent migrer progressivement : les alpha/beta sont utilisables avec les mêmes variables d'environnement qu'en V1 (aucune variable obligatoire nouvelle en phase alpha). | |
| 441 | + | |
| 442 | +--- | |
| 443 | + | |
| 444 | +## Diagramme de planification | |
| 445 | + | |
| 446 | +```mermaid | |
| 447 | +gantt | |
| 448 | + title Rustwarden V2 — Planning indicatif (6 mois) | |
| 449 | + dateFormat YYYY-MM-DD | |
| 450 | + axisFormat %b %Y | |
| 451 | + | |
| 452 | + section Sécurité | |
| 453 | + CSP sans unsafe-* :a1, 2026-05-18, 14d | |
| 454 | + JWT clock skew :a2, 2026-05-18, 2d | |
| 455 | + Fix unwrap TOTP :a3, 2026-05-18, 1d | |
| 456 | + JWT rotation HS256 :a4, after a1, 21d | |
| 457 | + Rate limit Redis :a5, after a4, 14d | |
| 458 | + Rotation clés acteur AP :a6, after a5, 10d | |
| 459 | + | |
| 460 | + section Observabilité | |
| 461 | + healthz + readyz :b1, 2026-05-20, 3d | |
| 462 | + Spans instrument services :b2, after b1, 21d | |
| 463 | + OTel OTLP + Prometheus :b3, after b2, 14d | |
| 464 | + Logs JSON :b4, after b3, 5d | |
| 465 | + | |
| 466 | + section Modularité | |
| 467 | + Découpage admin_handler.rs :c1, 2026-06-01, 14d | |
| 468 | + Découpage fediverse admin.rs :c2, after c1, 10d | |
| 469 | + Découpage auth handlers.rs :c3, after c2, 7d | |
| 470 | + Check CI 1000 lignes max :c4, after c3, 2d | |
| 471 | + | |
| 472 | + section Qualité | |
| 473 | + Tests e2e auth :d1, 2026-06-15, 21d | |
| 474 | + redis-session corrigée/supp :d2, 2026-06-01, 7d | |
| 475 | + CI cargo-audit + clippy -D :d3, after d2, 3d | |
| 476 | + | |
| 477 | + section DX | |
| 478 | + Rustdoc zéro warning :e1, 2026-05-18, 10d | |
| 479 | + Tutoriels developer-guide :e2, 2026-07-01, 21d | |
| 480 | + Exemples runnables :e3, after e2, 14d | |
| 481 | +``` | |
| 482 | + | |
| 483 | +--- | |
| 484 | + | |
| 485 | +## Critères d'acceptation V2 | |
| 486 | + | |
| 487 | +- [ ] CSP sans `unsafe-inline` ni `unsafe-eval` — validé par CSP Evaluator sur staging | |
| 488 | +- [ ] JWT rotation testée en intégration (rotation de secret, tokens V1 acceptés pendant la fenêtre, rejetés après) | |
| 489 | +- [ ] OTel traces visibles dans Tempo (au moins 5 services instrumentés avec `#[instrument]`) | |
| 490 | +- [ ] `/metrics` expose ≥ 20 indicateurs Prometheus | |
| 491 | +- [ ] `/readyz` vérifie DB + session store et retourne 503 si l'un est indisponible | |
| 492 | +- [ ] Aucun fichier `.rs` > 1 000 lignes (check CI automatisé) | |
| 493 | +- [ ] 0 `unwrap()`/`expect()` hors tests dans `crates/*/src/services/` (grep CI) | |
| 494 | +- [ ] Tests e2e auth verts : login → JWT → refresh → blacklist → logout (8 scénarios) | |
| 495 | +- [ ] 0 warning rustdoc (`cargo doc --no-deps` propre) | |
| 496 | +- [ ] Feature `redis-session` fonctionnelle (CI `--features redis-session`) ou supprimée du Cargo.toml | |
| 497 | + | |
| 498 | +--- | |
| 499 | + | |
| 500 | +## Annexes | |
| 501 | + | |
| 502 | +### A1. Tableau d'effort et dépendances | |
| 503 | + | |
| 504 | +| Item | Effort | Dépendances | Notes | | |
| 505 | +|------|--------|-------------|-------| | |
| 506 | +| JWT clock skew | S (< 1j) | — | 2 lignes, risque nul | | |
| 507 | +| Fix `unwrap` TOTP | S (< 1j) | — | Pattern répétable sur autres services | | |
| 508 | +| `/healthz` + `/readyz` | S (< 1j) | — | Nouveaux endpoints, pas de breaking change | | |
| 509 | +| `redis-session` corrigée/supprimée | S (< 1j) | Décision produit | Bloquer sur décision avant d'implémenter | | |
| 510 | +| `accept_invalid_certs` garde-fou prod | S (< 1j) | — | Lecture `APP_DEBUG` au démarrage | | |
| 511 | +| CI cargo-audit + clippy -D | S (< 1j) | Pipeline CI existant | | | |
| 512 | +| CSP sans `unsafe-*` | M (1-5j) | Alpine.js CSP build, audit templates | Dépend du volume de scripts inline | | |
| 513 | +| Découpage god-files (×3) | M (1-5j) | Tests existants comme filet | 3 fichiers × ~2j chacun | | |
| 514 | +| Rate limit Redis | M (1-5j) | `JWT_SECRET_PREVIOUS` config shape | Interface backend générique d'abord | | |
| 515 | +| Tests e2e auth | M (1-5j) | Infrastructure test (testcontainers ou SQLite) | 8 scénarios, ~0.5j chacun | | |
| 516 | +| Rotation clés acteur AP | M (1-5j) | Migration DB (`key_expires_at`) | | | |
| 517 | +| OTel + Prometheus | L (> 5j) | Dépendances OTel SDK, config collector | Instrumentation de tous les services | | |
| 518 | +| JWT rotation RS256/EdDSA + JWKS | L (> 5j) | Décision sur niveau 1 vs niveau 2 d'abord | Niveau 1 (HS256 multi-secret) déblocable en M | | |
| 519 | +| Tutoriels + exemples runnables | L (> 5j) | Rustdoc propre en prérequis | | | |
| 520 | + | |
| 521 | +### A2. Références documentation existante | |
| 522 | + | |
| 523 | +- [Rapport de conformité ANSSI](ANSSI_COMPLIANCE_REPORT.md) | |
| 524 | +- [Rapport de conformité ANSSI — Fédération](ANSSI_FEDIVERSE_COMPLIANCE.md) | |
| 525 | +- [Audit de sécurité — mai 2026](SECURITY_AUDIT_2026_05.md) | |
| 526 | +- [Constitution du projet](constitution.md) | |
| 527 | +- [Guide développeur](developer-guide/) | |
| 528 | +- [Guide fédération consommateur](fediverse-consumer-guide.md) | |
| 529 | +- [Modèle de sécurité fédération](fediverse-security-model.md) | |
| 530 | +- [Guide de migration](MIGRATION_GUIDE.md) | |
| 531 | +- [Référence des services](SERVICES_REFERENCE.md) | |
| 532 | +- [Processus d'évolution Rustwarden](RUSTWARDEN_EVOLUTION_PROCESS.md) |
A
docs/developer-guide/01-architecture-overview.md
+234
-0
@@ -0,0 +1,234 @@
| 1 | +# Architecture de Rustwarden | |
| 2 | + | |
| 3 | +## Vision | |
| 4 | + | |
| 5 | +Rustwarden est conçu selon trois principes non négociables. | |
| 6 | + | |
| 7 | +**Modulaire.** Chaque fonctionnalité vit dans une crate dédiée. On peut intégrer uniquement `rustwarden-core` (API JSON headless), y ajouter `rustwarden-ui` (interface HTML), puis `rustwarden-fediverse` (ActivityPub) de façon totalement indépendante. Les crates compagnons ne créent jamais de dépendance inversée vers `rustwarden-core` — elles s'y branchent via des traits. | |
| 8 | + | |
| 9 | +**Headless-first.** L'API REST JSON est la surface primaire. L'interface HTML (`rustwarden-ui`) est un consommateur comme un autre. Un projet SPA ou mobile n'a besoin que de `rustwarden-core`. | |
| 10 | + | |
| 11 | +**Extension via traits.** Le consumer n'est pas obligé de forker le framework pour réagir aux événements métier. Il implémente `RustwardenHooks` et passe l'objet au builder. La fédération s'accroche de la même façon via `LocalUserResolver`, `SignatureVerifier` et `FediverseEventObserver`. | |
| 12 | + | |
| 13 | +--- | |
| 14 | + | |
| 15 | +## Carte des crates | |
| 16 | + | |
| 17 | +```mermaid | |
| 18 | +flowchart LR | |
| 19 | + APP([rustwarden-app\nbinaire de référence]) | |
| 20 | + | |
| 21 | + subgraph CORE ["rustwarden-core"] | |
| 22 | + CORE_NODE[auth · RBAC · services\nmiddleware · i18n · hooks] | |
| 23 | + end | |
| 24 | + | |
| 25 | + subgraph UI ["rustwarden-ui"] | |
| 26 | + UI_NODE[templates Askama\nHTMX · DaisyUI] | |
| 27 | + end | |
| 28 | + | |
| 29 | + subgraph FEDI ["rustwarden-fediverse"] | |
| 30 | + FEDI_NODE[ActivityPub headless\nacteur · inbox · signatures] | |
| 31 | + end | |
| 32 | + | |
| 33 | + subgraph FEDI_UI ["rustwarden-fediverse-ui"] | |
| 34 | + FEDI_UI_NODE[handlers HTML fédération\nconsentement · invitations] | |
| 35 | + end | |
| 36 | + | |
| 37 | + subgraph GUARD ["rustwarden-fediverse-guard"] | |
| 38 | + GUARD_NODE[détection d'abus\nrate-limit fédéré] | |
| 39 | + end | |
| 40 | + | |
| 41 | + APP --> CORE_NODE | |
| 42 | + APP --> UI_NODE | |
| 43 | + UI_NODE --> CORE_NODE | |
| 44 | + FEDI_UI_NODE --> FEDI_NODE | |
| 45 | + FEDI_NODE --> CORE_NODE | |
| 46 | + GUARD_NODE --> FEDI_NODE | |
| 47 | +``` | |
| 48 | + | |
| 49 | +`rustwarden-core` n'a aucune connaissance des crates au-dessus de lui. La flèche va toujours vers le bas. | |
| 50 | + | |
| 51 | +--- | |
| 52 | + | |
| 53 | +## Vue en couches | |
| 54 | + | |
| 55 | +```mermaid | |
| 56 | +flowchart TB | |
| 57 | + subgraph PRES ["Couche présentation"] | |
| 58 | + T[Templates Askama + HTMX] | |
| 59 | + H[Handlers HTML — rustwarden-ui / fediverse-ui] | |
| 60 | + end | |
| 61 | + | |
| 62 | + subgraph API ["Couche API"] | |
| 63 | + R[Routeur Axum] | |
| 64 | + E[Extracteurs : AuthUser · Json · Form · Locale] | |
| 65 | + end | |
| 66 | + | |
| 67 | + subgraph MW ["Couche middleware"] | |
| 68 | + MW1[auth_required · optional_auth] | |
| 69 | + MW2[CSRF · rate_limit] | |
| 70 | + MW3[admin_required · revocation] | |
| 71 | + end | |
| 72 | + | |
| 73 | + subgraph SVC ["Couche services"] | |
| 74 | + S1[AuthService · JwtService · JwtBlacklistService] | |
| 75 | + S2[UserService · RoleService · PermissionService] | |
| 76 | + S3[RefreshTokenService · TotpService · OAuthService] | |
| 77 | + S4[TeamService · ResourceService · EmailService] | |
| 78 | + end | |
| 79 | + | |
| 80 | + subgraph PERSIST ["Couche persistance"] | |
| 81 | + M[Entités SeaORM] | |
| 82 | + MIG[Migrations sea-orm-migration] | |
| 83 | + end | |
| 84 | + | |
| 85 | + subgraph INFRA ["Couche infrastructure"] | |
| 86 | + DB[(PostgreSQL)] | |
| 87 | + REDIS[(Redis — optionnel)] | |
| 88 | + SMTP[SMTP · IMAP] | |
| 89 | + end | |
| 90 | + | |
| 91 | + PRES --> API | |
| 92 | + API --> MW | |
| 93 | + MW --> SVC | |
| 94 | + SVC --> PERSIST | |
| 95 | + PERSIST --> INFRA | |
| 96 | +``` | |
| 97 | + | |
| 98 | +Chaque couche ne connaît que la couche immédiatement inférieure. Les handlers appellent les services, jamais les entités SeaORM directement. | |
| 99 | + | |
| 100 | +--- | |
| 101 | + | |
| 102 | +## Trois axes d'extension | |
| 103 | + | |
| 104 | +### 1. Hooks métier — `RustwardenHooks` | |
| 105 | + | |
| 106 | +Le trait [`RustwardenHooks`](../../crates/rustwarden-core/src/hooks.rs) expose des méthodes appelées par le framework après chaque événement significatif. Toutes ont une implémentation par défaut no-op. | |
| 107 | + | |
| 108 | +```rust | |
| 109 | +use rustwarden_core::hooks::RustwardenHooks; | |
| 110 | +use async_trait::async_trait; | |
| 111 | +use sea_orm::DatabaseConnection; | |
| 112 | +use uuid::Uuid; | |
| 113 | + | |
| 114 | +struct MyHooks; | |
| 115 | + | |
| 116 | +#[async_trait] | |
| 117 | +impl RustwardenHooks for MyHooks { | |
| 118 | + async fn on_user_login( | |
| 119 | + &self, | |
| 120 | + db: &DatabaseConnection, | |
| 121 | + user_id: Uuid, | |
| 122 | + username: &str, | |
| 123 | + ) -> anyhow::Result<()> { | |
| 124 | + // journaliser dans une table métier, envoyer une notification… | |
| 125 | + Ok(()) | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +// Branchement au builder : | |
| 130 | +RustwardenApp::builder() | |
| 131 | + .from_env() | |
| 132 | + .hooks(MyHooks) | |
| 133 | + .build() | |
| 134 | + .await? | |
| 135 | + .run() | |
| 136 | + .await | |
| 137 | +``` | |
| 138 | + | |
| 139 | +Événements disponibles : `on_user_registered`, `on_user_login`, `on_user_logout`, `on_password_changed`, `on_email_verified`, `on_role_changed`, `on_resource_shared`, `on_resource_unshared`, `on_user_deleted`, `on_totp_enabled`, `on_totp_disabled`, `on_audit_event`. | |
| 140 | + | |
| 141 | +### 2. Traits fédération | |
| 142 | + | |
| 143 | +`rustwarden-fediverse` se branche sur `rustwarden-core` via trois traits définis dans core (pas dans fediverse) : | |
| 144 | + | |
| 145 | +- **`LocalUserResolver`** — résout un `acct:` ActivityPub en `Uuid` local. | |
| 146 | +- **`SignatureVerifier`** — vérifie les signatures HTTP des requêtes AP entrantes. | |
| 147 | +- **`FediverseEventObserver`** — reçoit les activités (Follow, Accept, Like…) délivrées à l'inbox. | |
| 148 | + | |
| 149 | +Ce modèle garantit que `rustwarden-core` compile sans `rustwarden-fediverse` dans le graph de dépendances. | |
| 150 | + | |
| 151 | +### 3. Feature flags Cargo | |
| 152 | + | |
| 153 | +| Feature | Effet | État | | |
| 154 | +|---|---|---| | |
| 155 | +| `default` | Auth JWT, RBAC, TOTP, OAuth (si activé), sessions mémoire/SeaORM | Stable | | |
| 156 | +| `oauth` | Active `openidconnect` + `reqwest` — providers Google, GitHub, Microsoft, Discord | Stable | | |
| 157 | +| `redis-session` | Remplace le backend de sessions par `tower-sessions-redis-store` | **Ne compile pas actuellement** — éviter en CI | | |
| 158 | + | |
| 159 | +--- | |
| 160 | + | |
| 161 | +## Flux d'une requête type | |
| 162 | + | |
| 163 | +```mermaid | |
| 164 | +sequenceDiagram | |
| 165 | + participant C as Client | |
| 166 | + participant R as Routeur Axum | |
| 167 | + participant MW as Middleware auth_required | |
| 168 | + participant BL as JwtBlacklistService | |
| 169 | + participant JWT as JwtService | |
| 170 | + participant H as Handler | |
| 171 | + participant SVC as Service métier | |
| 172 | + participant DB as PostgreSQL | |
| 173 | + | |
| 174 | + C->>R: GET /api/v1/resource (Authorization: Bearer <token>) | |
| 175 | + R->>MW: vérification auth requise | |
| 176 | + MW->>BL: is_blacklisted(token_hash) | |
| 177 | + BL-->>MW: false | |
| 178 | + MW->>JWT: validate_token(token) | |
| 179 | + JWT-->>MW: Claims { sub, roles, … } | |
| 180 | + MW->>H: request + Claims en extension | |
| 181 | + H->>SVC: appel service métier | |
| 182 | + SVC->>DB: requête SeaORM | |
| 183 | + DB-->>SVC: résultat | |
| 184 | + SVC-->>H: donnée métier | |
| 185 | + H-->>C: 200 JSON / HTML | |
| 186 | +``` | |
| 187 | + | |
| 188 | +Pour les routes HTML protégées (`/home`, `/profile`, `/admin`…), le middleware `auth_required` redirige vers `/login` au lieu de retourner 401 si le token est absent. | |
| 189 | + | |
| 190 | +--- | |
| 191 | + | |
| 192 | +## Builder API — aperçu des méthodes | |
| 193 | + | |
| 194 | +Le builder [`RustwardenBuilder`](../../crates/rustwarden-core/src/app.rs#L122) s'utilise de façon fluide. Voici les méthodes essentielles : | |
| 195 | + | |
| 196 | +```rust | |
| 197 | +RustwardenApp::builder() | |
| 198 | + // Configuration | |
| 199 | + .from_env() // charge .env, initialise tracing | |
| 200 | + .database_url("postgres://…") // ou .database(conn) si déjà connecté | |
| 201 | + .address("0.0.0.0:3000") | |
| 202 | + | |
| 203 | + // Fonctionnalités optionnelles | |
| 204 | + .with_ui(rustwarden_ui::default_routes) // active l'UI HTML | |
| 205 | + .hooks(MyHooks) // hooks métier custom | |
| 206 | + .headless() // API JSON uniquement (sans UI) | |
| 207 | + .auto_migrate(true) // migrations au démarrage | |
| 208 | + .without_email() // désactive SMTP | |
| 209 | + .without_background_tasks() // désactive tâches de fond | |
| 210 | + | |
| 211 | + // Routes supplémentaires | |
| 212 | + .merge_routes(my_app_router()) | |
| 213 | + | |
| 214 | + // Fédération (injecté par rustwarden_fediverse::mount) | |
| 215 | + .register_protected_prefix("/fediverse/consent") | |
| 216 | + .register_revocation_checker(Arc::new(my_checker)) | |
| 217 | + .add_dashboard_card(fediverse_dashboard_card()) | |
| 218 | + | |
| 219 | + // Localisation | |
| 220 | + .locale_dir("locales/") | |
| 221 | + .supported_locales(vec!["fr".into(), "en".into()]) | |
| 222 | + .default_locale("fr") | |
| 223 | + | |
| 224 | + .build().await? | |
| 225 | + .run().await | |
| 226 | +``` | |
| 227 | + | |
| 228 | +Après `.build()`, l'objet `RustwardenApp` expose : | |
| 229 | + | |
| 230 | +- `.database()` — pool SeaORM partagé (pour les migrations métier) | |
| 231 | +- `.auth_config()` — configuration JWT/cookies | |
| 232 | +- `.i18n_service()` — service de traduction | |
| 233 | +- `.address()` — adresse d'écoute | |
| 234 | +- `.into_router()` — routeur assemblé (pour les tests d'intégration) |
A
docs/developer-guide/02-request-lifecycle.md
+303
-0
@@ -0,0 +1,303 @@
| 1 | +# Cycle de vie d'une requête | |
| 2 | + | |
| 3 | +Ce document suit le chemin d'une requête HTTP depuis l'entrée dans Axum jusqu'à la réponse, en se concentrant sur les flux d'authentification. L'objectif est de comprendre *pourquoi* le code est organisé ainsi, pas seulement *comment* l'appeler. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## Le voyage d'une requête authentifiée | |
| 8 | + | |
| 9 | +```mermaid | |
| 10 | +sequenceDiagram | |
| 11 | + participant C as Client | |
| 12 | + participant R as Routeur Axum | |
| 13 | + participant EXT as Extension AuthConfig | |
| 14 | + participant BL as JwtBlacklistService | |
| 15 | + participant JWT as JwtService | |
| 16 | + participant AU as Extracteur AuthUser | |
| 17 | + participant H as Handler | |
| 18 | + participant SVC as Service métier | |
| 19 | + participant DB as PostgreSQL | |
| 20 | + | |
| 21 | + C->>R: requête HTTP (Authorization: Bearer <jwt> ou cookie jwt_token) | |
| 22 | + R->>EXT: récupère Arc<AuthConfig> depuis les extensions Axum | |
| 23 | + R->>BL: is_blacklisted(SHA256(token)) | |
| 24 | + BL->>DB: SELECT FROM jwt_blacklist WHERE hash = ? | |
| 25 | + DB-->>BL: résultat | |
| 26 | + BL-->>R: false (non révoqué) | |
| 27 | + R->>JWT: validate_token(auth_config, token) | |
| 28 | + JWT-->>R: Claims { sub, jti, username, roles, permissions, kind } | |
| 29 | + R->>AU: from_request_parts → AuthUser { user_id, username, roles, permissions } | |
| 30 | + AU->>H: handler reçoit AuthUser en paramètre | |
| 31 | + H->>SVC: appel service | |
| 32 | + SVC->>DB: requête SeaORM | |
| 33 | + DB-->>SVC: données | |
| 34 | + SVC-->>H: résultat métier | |
| 35 | + H-->>C: réponse JSON (200 OK) | |
| 36 | +``` | |
| 37 | + | |
| 38 | +La validation du token ne touche la base de données qu'une seule fois (vérification blacklist). La vérification de signature JWT est purement en mémoire — c'est intentionnel pour la scalabilité. | |
| 39 | + | |
| 40 | +--- | |
| 41 | + | |
| 42 | +## Login — email + mot de passe | |
| 43 | + | |
| 44 | +**Endpoint :** `POST /api/v1/auth/login` | |
| 45 | +**Handler :** [`login`](../../crates/rustwarden-core/src/auth/handlers.rs#L285) | |
| 46 | + | |
| 47 | +```mermaid | |
| 48 | +sequenceDiagram | |
| 49 | + participant C as Client | |
| 50 | + participant H as Handler login | |
| 51 | + participant RL as RateLimitService | |
| 52 | + participant AS as AuthService | |
| 53 | + participant US as UserService | |
| 54 | + participant TOTP as TotpService | |
| 55 | + participant JWT as JwtService | |
| 56 | + participant RT as RefreshTokenService | |
| 57 | + participant HK as RustwardenHooks | |
| 58 | + participant DB as PostgreSQL | |
| 59 | + | |
| 60 | + C->>H: POST /api/v1/auth/login {username, password, remember_me?} | |
| 61 | + H->>RL: check_auth_rate_limit (5 req/min par IP) | |
| 62 | + alt limite atteinte | |
| 63 | + RL-->>C: 429 Too Many Requests | |
| 64 | + end | |
| 65 | + H->>AS: authenticate_user(db, username, password) | |
| 66 | + AS->>DB: SELECT user WHERE username = ? | |
| 67 | + DB-->>AS: user model | |
| 68 | + AS->>AS: bcrypt::verify(password, hash) | |
| 69 | + alt credentials invalides | |
| 70 | + AS-->>C: 401 Unauthorized (message générique) | |
| 71 | + end | |
| 72 | + AS-->>H: User | |
| 73 | + H->>H: vérifier email_verified (si requis par AppSettings) | |
| 74 | + H->>TOTP: is_totp_enabled(db, user_id) | |
| 75 | + alt 2FA activé | |
| 76 | + TOTP->>DB: create_challenge(user_id) | |
| 77 | + DB-->>TOTP: challenge_token | |
| 78 | + TOTP-->>C: 200 { requires_2fa: true, challenge_token } | |
| 79 | + Note over C: le client fait ensuite POST /api/v1/auth/2fa/verify | |
| 80 | + end | |
| 81 | + H->>US: get_user_roles(db, user_id) | |
| 82 | + DB-->>US: roles[] | |
| 83 | + H->>JWT: generate_token(auth_config, user_id, username, roles, permissions) | |
| 84 | + JWT-->>H: access_token (JWT signé HS256) | |
| 85 | + H->>RT: create_refresh_token(db, user_id, expiration_days) | |
| 86 | + RT->>DB: INSERT refresh_token (hash SHA-256) | |
| 87 | + DB-->>RT: CreatedRefreshToken | |
| 88 | + RT-->>H: plain_token (envoyé au client) | |
| 89 | + H->>H: construire cookie jwt_token (HttpOnly, Secure, SameSite=Strict) | |
| 90 | + H->>HK: on_user_login(db, user_id, username) | |
| 91 | + H-->>C: 200 LoginResponse + Set-Cookie: jwt_token | |
| 92 | +``` | |
| 93 | + | |
| 94 | +Points notables du flux de login : | |
| 95 | + | |
| 96 | +- `AuthService::authenticate_user` utilise `bcrypt::verify` en temps constant pour éviter les attaques temporelles. | |
| 97 | +- Les messages d'erreur sont intentionnellement génériques (pas de distinction "utilisateur inconnu" / "mot de passe incorrect"). | |
| 98 | +- Le cookie `jwt_token` est `HttpOnly` — JavaScript ne peut pas le lire. Le body JSON contient aussi le token pour les clients API (mobile, SPA avec storage explicite). | |
| 99 | +- `remember_me: true` allonge la durée du refresh token (`remember_me_expiration_days`) sans modifier l'access token. | |
| 100 | + | |
| 101 | +### Flux 2FA | |
| 102 | + | |
| 103 | +Si `TotpService::is_totp_enabled` retourne `true`, le handler n'émet pas de JWT. Il crée un challenge temporaire et retourne `{ requires_2fa: true, challenge_token }`. Le client doit ensuite appeler `POST /api/v1/auth/2fa/verify` avec le code TOTP à 6 chiffres ou un code de récupération. | |
| 104 | + | |
| 105 | +--- | |
| 106 | + | |
| 107 | +## Refresh token | |
| 108 | + | |
| 109 | +**Endpoint :** `POST /api/v1/auth/refresh` | |
| 110 | +**Handler :** [`refresh_token`](../../crates/rustwarden-core/src/auth/handlers.rs#L967) | |
| 111 | + | |
| 112 | +```mermaid | |
| 113 | +sequenceDiagram | |
| 114 | + participant C as Client | |
| 115 | + participant H as Handler refresh_token | |
| 116 | + participant RL as RateLimitService | |
| 117 | + participant RT as RefreshTokenService | |
| 118 | + participant US as UserService | |
| 119 | + participant JWT as JwtService | |
| 120 | + participant DB as PostgreSQL | |
| 121 | + | |
| 122 | + C->>H: POST /api/v1/auth/refresh { refresh_token: "abc123..." } | |
| 123 | + H->>RL: check_auth_rate_limit | |
| 124 | + H->>RT: validate_refresh_token(db, plain_token) | |
| 125 | + RT->>RT: SHA256(plain_token) → hash | |
| 126 | + RT->>DB: SELECT WHERE hash = ? AND NOT revoked AND NOT expired | |
| 127 | + DB-->>RT: refresh_token model | |
| 128 | + RT->>RT: has_been_used() ? | |
| 129 | + alt token déjà utilisé (attaque de réutilisation) | |
| 130 | + RT->>DB: revoke_all_tokens_for_user (sécurité maximale) | |
| 131 | + RT-->>C: 401 TokenReused — toutes les sessions révoquées | |
| 132 | + end | |
| 133 | + RT-->>H: old_token model | |
| 134 | + H->>US: get_user_by_id(db, old_token.user_id) | |
| 135 | + DB-->>US: user | |
| 136 | + H->>US: get_user_roles + get_permissions | |
| 137 | + H->>JWT: generate_token(auth_config, …) | |
| 138 | + JWT-->>H: nouveau access_token | |
| 139 | + H->>RT: create_refresh_token(db, user_id, expiration_days) | |
| 140 | + DB-->>RT: new CreatedRefreshToken | |
| 141 | + H->>RT: mark_as_replaced(db, old_id, new_id) | |
| 142 | + DB-->>RT: OK | |
| 143 | + H-->>C: 200 RefreshResponse { access_token, refresh_token, expires_in } + Set-Cookie | |
| 144 | +``` | |
| 145 | + | |
| 146 | +La **rotation automatique** signifie que chaque refresh token n'est utilisable qu'une seule fois. Après usage, il est marqué `replaced` et un nouveau token est émis. Si le serveur détecte une tentative de réutilisation d'un token déjà remplacé, il révoque **toutes** les sessions de l'utilisateur — signal fort d'un vol de token. | |
| 147 | + | |
| 148 | +--- | |
| 149 | + | |
| 150 | +## Logout | |
| 151 | + | |
| 152 | +**Endpoint :** `POST /api/v1/auth/logout` | |
| 153 | +**Handler :** [`logout`](../../crates/rustwarden-core/src/auth/handlers.rs#L789) | |
| 154 | + | |
| 155 | +```mermaid | |
| 156 | +sequenceDiagram | |
| 157 | + participant C as Client | |
| 158 | + participant H as Handler logout | |
| 159 | + participant JWT as JwtService | |
| 160 | + participant BL as JwtBlacklistService | |
| 161 | + participant RT as RefreshTokenService | |
| 162 | + participant HK as RustwardenHooks | |
| 163 | + participant DB as PostgreSQL | |
| 164 | + | |
| 165 | + C->>H: POST /api/v1/auth/logout (Authorization: Bearer ou cookie) | |
| 166 | + H->>H: extraire JWT (priorité : header > cookie) | |
| 167 | + H->>JWT: validate_token(auth_config, jwt) | |
| 168 | + JWT-->>H: Claims { sub: user_id, ... } | |
| 169 | + H->>BL: add_to_blacklist(db, jwt, user_id) | |
| 170 | + BL->>BL: SHA256(jwt) → hash | |
| 171 | + BL->>DB: INSERT jwt_blacklist (hash, user_id, expires_at) | |
| 172 | + H->>RT: revoke_all_tokens_for_user(db, user_id) | |
| 173 | + DB-->>RT: nombre de tokens révoqués | |
| 174 | + H->>HK: on_user_logout(db, user_id) | |
| 175 | + H->>H: construire cookie jwt_token vide (MaxAge=0) | |
| 176 | + alt requête HTML (Accept: text/html) | |
| 177 | + H-->>C: 302 Redirect /login + Set-Cookie: jwt_token= (expiré) | |
| 178 | + else requête API | |
| 179 | + H-->>C: 200 { message, tokens_revoked } + Set-Cookie: jwt_token= (expiré) | |
| 180 | + end | |
| 181 | +``` | |
| 182 | + | |
| 183 | +Le logout effectue deux opérations complémentaires : blacklister l'access token courant (révocation immédiate, même si le JWT n'est pas encore expiré) ET révoquer tous les refresh tokens de l'utilisateur. La réponse est adaptée selon le header `Accept` — redirect pour les formulaires HTML, JSON pour les clients API. | |
| 184 | + | |
| 185 | +--- | |
| 186 | + | |
| 187 | +## Extraction d'utilisateur dans un handler | |
| 188 | + | |
| 189 | +L'extracteur `AuthUser` est le mécanisme standard pour accéder à l'identité de l'appelant dans un handler Axum. | |
| 190 | + | |
| 191 | +```rust | |
| 192 | +use axum::{routing::get, Router, Json}; | |
| 193 | +use rustwarden_core::auth::extractors::AuthUser; | |
| 194 | + | |
| 195 | +async fn get_profile(user: AuthUser) -> Json<serde_json::Value> { | |
| 196 | + Json(serde_json::json!({ | |
| 197 | + "user_id": user.user_id, | |
| 198 | + "username": user.username, | |
| 199 | + "roles": user.roles, | |
| 200 | + "permissions": user.permissions, | |
| 201 | + })) | |
| 202 | +} | |
| 203 | + | |
| 204 | +let app: Router = Router::new().route("/profile", get(get_profile)); | |
| 205 | +``` | |
| 206 | + | |
| 207 | +**Ce que fait `AuthUser` en coulisse** (voir [`extractors.rs`](../../crates/rustwarden-core/src/auth/extractors.rs)) : | |
| 208 | + | |
| 209 | +1. Cherche `Authorization: Bearer <token>` dans les headers — priorité absolue. | |
| 210 | +2. Si absent, cherche le cookie `jwt_token` (HttpOnly, posé au login). | |
| 211 | +3. Lit `Arc<AuthConfig>` depuis les extensions Axum (chargé une fois au démarrage). | |
| 212 | +4. Appelle `JwtService::validate_token` — vérification de signature et d'expiration. | |
| 213 | +5. Consulte `JwtBlacklistService::is_blacklisted` — vérification de révocation. | |
| 214 | +6. Construit `AuthUser { user_id, username, roles, permissions }` depuis les `Claims`. | |
| 215 | + | |
| 216 | +Si l'une de ces étapes échoue, Axum retourne `401 Unauthorized` automatiquement — le handler n'est jamais appelé. | |
| 217 | + | |
| 218 | +**Vérification de rôle ou de permission dans un handler :** | |
| 219 | + | |
| 220 | +```rust | |
| 221 | +async fn admin_only(user: AuthUser) -> Result<Json<serde_json::Value>, StatusCode> { | |
| 222 | + if !user.has_role("admin") { | |
| 223 | + return Err(StatusCode::FORBIDDEN); | |
| 224 | + } | |
| 225 | + // Le rôle "admin" implique toutes les permissions (has_permission retourne true) | |
| 226 | + Ok(Json(serde_json::json!({ "ok": true }))) | |
| 227 | +} | |
| 228 | + | |
| 229 | +async fn check_permission(user: AuthUser) -> Result<Json<serde_json::Value>, StatusCode> { | |
| 230 | + if !user.has_permission("users.list") { | |
| 231 | + return Err(StatusCode::FORBIDDEN); | |
| 232 | + } | |
| 233 | + Ok(Json(serde_json::json!({ "ok": true }))) | |
| 234 | +} | |
| 235 | +``` | |
| 236 | + | |
| 237 | +--- | |
| 238 | + | |
| 239 | +## Diagramme d'état du JWT | |
| 240 | + | |
| 241 | +```mermaid | |
| 242 | +stateDiagram-v2 | |
| 243 | + [*] --> Generated : JwtService::generate_token() | |
| 244 | + | |
| 245 | + Generated --> Active : émis au client\n(login ou refresh) | |
| 246 | + | |
| 247 | + Active --> Expired : exp < now\n(automatique) | |
| 248 | + Active --> Blacklisted : logout explicite\nJwtBlacklistService::add_to_blacklist() | |
| 249 | + Active --> Revoked : révocation globale\n(réutilisation refresh token détectée) | |
| 250 | + | |
| 251 | + Blacklisted --> [*] : nettoyage périodique\n(jwt_blacklist_cleanup task) | |
| 252 | + Expired --> [*] | |
| 253 | + Revoked --> [*] | |
| 254 | + | |
| 255 | + note right of Active | |
| 256 | + Durée configurable via | |
| 257 | + JWT_EXPIRATION_MINUTES | |
| 258 | + (défaut: 15 min) | |
| 259 | + end note | |
| 260 | + | |
| 261 | + note right of Blacklisted | |
| 262 | + Hash SHA-256 stocké en DB | |
| 263 | + jusqu'à l'expiration naturelle | |
| 264 | + end note | |
| 265 | +``` | |
| 266 | + | |
| 267 | +Un token `Blacklisted` reste dans la table `jwt_blacklist` jusqu'à sa date d'expiration naturelle. La tâche de fond `jwt_blacklist_cleanup` purge les entrées expirées pour limiter la croissance de la table. | |
| 268 | + | |
| 269 | +--- | |
| 270 | + | |
| 271 | +## Bearer vs Cookie — tableau comparatif | |
| 272 | + | |
| 273 | +| Critère | `Authorization: Bearer` | Cookie `jwt_token` | | |
| 274 | +|---|---|---| | |
| 275 | +| **Client cible** | API mobile, SPA avec storage, services inter-serveurs | Navigateur web (formulaires HTML, HTMX) | | |
| 276 | +| **Stockage côté client** | localStorage / sessionStorage / keychain | Cookie HttpOnly (inaccessible à JS) | | |
| 277 | +| **Vulnérabilité XSS** | Exposé si stocké dans localStorage | Protégé par HttpOnly | | |
| 278 | +| **Vulnérabilité CSRF** | Immunisé (header custom) | Nécessite le middleware CSRF | | |
| 279 | +| **Priorité d'extraction** | **Prioritaire** — vérifié en premier | Fallback si header absent | | |
| 280 | +| **Posé automatiquement** | Non — le client doit stocker et envoyer | Oui — `Set-Cookie` au login | | |
| 281 | +| **Effacé au logout** | Non — le client doit supprimer | Oui — `Set-Cookie: jwt_token=; MaxAge=0` | | |
| 282 | + | |
| 283 | +La priorité Bearer > Cookie est délibérée : un client API peut toujours surcharger le cookie en envoyant un header explicite, par exemple pour tester avec un token de test sans toucher aux cookies du navigateur. | |
| 284 | + | |
| 285 | +**Recommandation pratique** : | |
| 286 | + | |
| 287 | +- SPA découplée ou application mobile : utiliser `Authorization: Bearer` exclusivement, stocker le token en mémoire (pas localStorage) ou dans un stockage sécurisé natif. | |
| 288 | +- Interface HTML rendue côté serveur (HTMX, formulaires) : laisser le framework gérer le cookie — rien à faire côté client. | |
| 289 | +- Services inter-serveurs : `Authorization: Bearer` avec un Personal Access Token (`/api/v1/auth/tokens`). | |
| 290 | + | |
| 291 | +--- | |
| 292 | + | |
| 293 | +## Flux du `jwt_extractor` | |
| 294 | + | |
| 295 | +Le module [`jwt_extractor`](../../crates/rustwarden-core/src/auth/) centralise la logique d'extraction du token brut depuis la requête. Il est utilisé par l'extracteur `AuthUser`, le middleware `auth_required`, et le handler `logout` — tous trois appliquent la même règle : header avant cookie. | |
| 296 | + | |
| 297 | +``` | |
| 298 | +jwt_extractor::extract_jwt_from_request(request) | |
| 299 | + ├── Authorization: Bearer <token> → extract_bearer_token(header) | |
| 300 | + └── Cookie: jwt_token=<token> → cookie_jar.get("jwt_token") | |
| 301 | +``` | |
| 302 | + | |
| 303 | +Si ni l'un ni l'autre n'est présent, la fonction retourne une erreur, et le middleware décide de la réponse (401 pour l'extracteur API, redirect `/login` pour le middleware HTML). |
A
docs/developer-guide/03-rbac-model.md
+419
-0
@@ -0,0 +1,419 @@
| 1 | +# 03 — Modèle RBAC de Rustwarden | |
| 2 | + | |
| 3 | +Rustwarden implémente un contrôle d'accès basé sur les rôles (RBAC) à deux niveaux : un niveau global (rôles systèmes attribués à l'utilisateur) et un niveau team-scoped (rôle de l'utilisateur au sein d'une équipe). Ce document décrit les entités, leurs relations, le flux de vérification, et les limites actuelles du modèle. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 1. Vocabulaire | |
| 8 | + | |
| 9 | +| Terme | Définition | | |
| 10 | +|-------|-----------| | |
| 11 | +| **User** | Compte local enregistré dans la table `users`. Identifié par un UUID. | | |
| 12 | +| **Role** | Groupe nommé de permissions (`admin`, `user`, `app_manager`). Un utilisateur peut avoir plusieurs rôles. | | |
| 13 | +| **Permission** | Capacité atomique nommée par un code pointillé (`users.delete`, `audit.view`). Toujours attachée à une catégorie. | | |
| 14 | +| **TeamRole** | Rôle d'un utilisateur *à l'intérieur* d'une équipe (`owner`, `admin`, `member`). Indépendant des rôles système. | | |
| 15 | +| **Team** | Groupe d'utilisateurs identifié par un slug unique. Possède un owner. | | |
| 16 | +| **Resource** | Entité métier enregistrée dans le registre générique (`resource_type` + `resource_id`). Peut être partagée avec des utilisateurs ou des équipes via des `resource_shares`. | | |
| 17 | +| **Principal** | Abstraction qui unifie un utilisateur individuel (`Principal::User(uuid)`) ou une équipe (`Principal::Team(uuid)`) comme cible de partage. Défini dans [`resource_service.rs`](../../crates/rustwarden-core/src/services/resource_service.rs#L17). | | |
| 18 | +| **Identity** | Énumération `Identity::Local(uuid)` / `Identity::Remote(uuid)` qui étend le concept de Principal aux acteurs fédérés. Définie dans [`identity.rs`](../../crates/rustwarden-core/src/identity.rs#L29). | | |
| 19 | + | |
| 20 | +--- | |
| 21 | + | |
| 22 | +## 2. Diagramme entité-relation | |
| 23 | + | |
| 24 | +Le schéma RBAC est établi par les migrations [`m20260305_000001`](../../crates/rustwarden-core/src/migrations/m20260305_000001_initial_schema.rs), [`m20260309_000003`](../../crates/rustwarden-core/src/migrations/m20260309_000003_create_permissions_tables.rs), [`m20260309_000004`](../../crates/rustwarden-core/src/migrations/m20260309_000004_create_resources_tables.rs) et [`m20260423_000008`](../../crates/rustwarden-core/src/migrations/m20260423_000008_create_teams.rs). | |
| 25 | + | |
| 26 | +```mermaid | |
| 27 | +erDiagram | |
| 28 | + users { | |
| 29 | + uuid id PK | |
| 30 | + string username | |
| 31 | + string email | |
| 32 | + string password_hash | |
| 33 | + bool email_verified | |
| 34 | + bool must_change_password | |
| 35 | + timestamptz created_at | |
| 36 | + timestamptz updated_at | |
| 37 | + timestamptz last_login_at | |
| 38 | + string last_login_ip | |
| 39 | + } | |
| 40 | + | |
| 41 | + roles { | |
| 42 | + uuid id PK | |
| 43 | + string name | |
| 44 | + string description | |
| 45 | + timestamptz created_at | |
| 46 | + timestamptz updated_at | |
| 47 | + } | |
| 48 | + | |
| 49 | + permissions { | |
| 50 | + uuid id PK | |
| 51 | + string name | |
| 52 | + string description | |
| 53 | + string category | |
| 54 | + timestamptz created_at | |
| 55 | + } | |
| 56 | + | |
| 57 | + user_roles { | |
| 58 | + uuid user_id FK | |
| 59 | + uuid role_id FK | |
| 60 | + } | |
| 61 | + | |
| 62 | + role_permissions { | |
| 63 | + uuid role_id FK | |
| 64 | + uuid permission_id FK | |
| 65 | + } | |
| 66 | + | |
| 67 | + teams { | |
| 68 | + uuid id PK | |
| 69 | + string slug | |
| 70 | + string name | |
| 71 | + uuid owner_id FK | |
| 72 | + timestamptz created_at | |
| 73 | + } | |
| 74 | + | |
| 75 | + team_members { | |
| 76 | + uuid id PK | |
| 77 | + uuid team_id FK | |
| 78 | + uuid user_id FK | |
| 79 | + string role | |
| 80 | + timestamptz joined_at | |
| 81 | + } | |
| 82 | + | |
| 83 | + resources { | |
| 84 | + uuid id PK | |
| 85 | + string resource_type | |
| 86 | + uuid resource_id | |
| 87 | + uuid owner_id FK | |
| 88 | + bool is_public | |
| 89 | + timestamptz created_at | |
| 90 | + } | |
| 91 | + | |
| 92 | + resource_shares { | |
| 93 | + uuid id PK | |
| 94 | + uuid resource_id FK | |
| 95 | + uuid shared_with_user_id FK | |
| 96 | + uuid shared_with_team_id FK | |
| 97 | + string permission_level | |
| 98 | + uuid shared_by_user_id FK | |
| 99 | + timestamptz created_at | |
| 100 | + } | |
| 101 | + | |
| 102 | + users ||--o{ user_roles : "possède" | |
| 103 | + roles ||--o{ user_roles : "attribué via" | |
| 104 | + roles ||--o{ role_permissions : "dispose de" | |
| 105 | + permissions ||--o{ role_permissions : "incluse dans" | |
| 106 | + users ||--o{ team_members : "appartient à" | |
| 107 | + teams ||--o{ team_members : "comprend" | |
| 108 | + users ||--o{ teams : "owner" | |
| 109 | + users ||--o{ resources : "propriétaire" | |
| 110 | + resources ||--o{ resource_shares : "partagée via" | |
| 111 | + users ||--o{ resource_shares : "partagée avec (user)" | |
| 112 | + teams ||--o{ resource_shares : "partagée avec (team)" | |
| 113 | +``` | |
| 114 | + | |
| 115 | +> **Note sur `team_members`** : depuis la migration `m20260423_000008`, la colonne `user_id` est nullable pour permettre l'accueil de membres fédérés (`remote_identity_id`). La contrainte `chk_resource_shares_principal` garantit qu'exactement un des deux colonnes (`shared_with_user_id`, `shared_with_team_id`) est non-null dans `resource_shares`. | |
| 116 | + | |
| 117 | +--- | |
| 118 | + | |
| 119 | +## 3. Hiérarchie d'autorisation | |
| 120 | + | |
| 121 | +```mermaid | |
| 122 | +flowchart TD | |
| 123 | + REQ[Requête HTTP] --> EXT[Extraction du Principal\nAuthUser via JWT ou session] | |
| 124 | + EXT --> ADMIN{is_admin == true ?} | |
| 125 | + ADMIN -->|oui| GRANT[Accès accordé] | |
| 126 | + ADMIN -->|non| PERM{Permission directe ?\nuser_roles JOIN role_permissions} | |
| 127 | + PERM -->|oui| GRANT | |
| 128 | + PERM -->|non| TEAM{Permission team-scoped ?\nteam_members.role} | |
| 129 | + TEAM -->|oui| GRANT | |
| 130 | + TEAM -->|non| DENY[403 Forbidden] | |
| 131 | +``` | |
| 132 | + | |
| 133 | +**Trois chemins d'autorisation :** | |
| 134 | + | |
| 135 | +1. **Admin global** — le champ `is_admin` sur l'utilisateur n'existe pas directement comme colonne, mais le rôle système `admin` reçoit automatiquement toutes les permissions lors du seed. Un utilisateur avec le rôle `admin` passe toujours la vérification de permission. | |
| 136 | + | |
| 137 | +2. **Permission directe** — `user_roles` → `role_permissions` → `permissions`. C'est le chemin principal utilisé par [`PermissionService::get_permissions_for_user`](../../crates/rustwarden-core/src/services/permission_service.rs#L62). | |
| 138 | + | |
| 139 | +3. **Team-scoped** — le `TeamRole` d'un membre détermine ce qu'il peut faire dans le contexte de l'équipe (inviter, retirer des membres, supprimer l'équipe). Ce niveau est géré par [`TeamService`](../../crates/rustwarden-core/src/services/team_service.rs). | |
| 140 | + | |
| 141 | +--- | |
| 142 | + | |
| 143 | +## 4. Le concept Principal | |
| 144 | + | |
| 145 | +L'abstraction `Principal` apparaît à deux endroits du codebase avec des sémantiques légèrement différentes. | |
| 146 | + | |
| 147 | +### Principal dans ResourceService | |
| 148 | + | |
| 149 | +Défini dans [`resource_service.rs:17`](../../crates/rustwarden-core/src/services/resource_service.rs#L17) : | |
| 150 | + | |
| 151 | +```rust | |
| 152 | +pub enum Principal { | |
| 153 | + User(Uuid), | |
| 154 | + Team(Uuid), | |
| 155 | +} | |
| 156 | +``` | |
| 157 | + | |
| 158 | +Il sert de cible de partage lors des appels à `ResourceService::share_with(db, resource_id, principal, permission_level, shared_by)`. Un handler peut partager une ressource avec un utilisateur individuel ou avec une équipe entière. | |
| 159 | + | |
| 160 | +### Identity — extension fédérée | |
| 161 | + | |
| 162 | +Défini dans [`identity.rs:29`](../../crates/rustwarden-core/src/identity.rs#L29) : | |
| 163 | + | |
| 164 | +```rust | |
| 165 | +pub enum Identity { | |
| 166 | + Local(Uuid), // users.id — compte local | |
| 167 | + Remote(Uuid), // remote_identities.id — acteur fédéré (ActivityPub) | |
| 168 | +} | |
| 169 | +``` | |
| 170 | + | |
| 171 | +`Identity` étend `Principal` pour les contextes où un acteur fédéré peut agir sur le système. Les services qui acceptent des acteurs doivent utiliser `Identity` plutôt qu'un `Uuid` brut pour éviter la confusion entre identifiants locaux et fédérés (guard contre le *confused-deputy* pattern). | |
| 172 | + | |
| 173 | +**Conversion depuis les claims JWT :** | |
| 174 | + | |
| 175 | +```rust | |
| 176 | +// identity.rs:77 | |
| 177 | +pub fn from_claims(claims: &Claims) -> Self { | |
| 178 | + match claims.remote_identity_id() { | |
| 179 | + Some(remote_id) => Self::Remote(remote_id), | |
| 180 | + None => Self::Local(claims.sub), | |
| 181 | + } | |
| 182 | +} | |
| 183 | +``` | |
| 184 | + | |
| 185 | +**Résolution via `IdentityResolver`** — le trait [`IdentityResolver`](../../crates/rustwarden-core/src/identity.rs#L91) expose `resolve_local(user_id)` et `resolve_remote(remote_identity_id)` pour vérifier l'existence d'un acteur dans la base avant d'agir. | |
| 186 | + | |
| 187 | +--- | |
| 188 | + | |
| 189 | +## 5. Flow d'une vérification de permission | |
| 190 | + | |
| 191 | +```mermaid | |
| 192 | +sequenceDiagram | |
| 193 | + participant H as Handler Axum | |
| 194 | + participant E as AuthUser Extractor | |
| 195 | + participant PS as PermissionService | |
| 196 | + participant DB as PostgreSQL | |
| 197 | + | |
| 198 | + H->>E: Extension<AuthUser> | |
| 199 | + E-->>H: AuthUser { id, roles, ... } | |
| 200 | + H->>PS: get_permissions_for_user(db, user_id) | |
| 201 | + PS->>DB: SELECT user_id, role_id FROM user_roles WHERE user_id = ? | |
| 202 | + DB-->>PS: [(user_id, role_id), ...] | |
| 203 | + PS->>DB: SELECT role_id, permission_id FROM role_permissions WHERE role_id IN (...) | |
| 204 | + DB-->>PS: [(role_id, permission_id), ...] | |
| 205 | + PS->>DB: SELECT name FROM permissions WHERE id IN (...) | |
| 206 | + DB-->>PS: ["users.list", "users.edit", ...] | |
| 207 | + PS-->>H: Vec<String> | |
| 208 | + H->>H: permissions.contains("users.delete") ? | |
| 209 | + alt contient la permission | |
| 210 | + H-->>H: traiter la requête | |
| 211 | + else ne contient pas | |
| 212 | + H-->>H: AppError::Forbidden → 403 | |
| 213 | + end | |
| 214 | +``` | |
| 215 | + | |
| 216 | +**Code type dans un handler :** | |
| 217 | + | |
| 218 | +```rust | |
| 219 | +use crate::auth::extractors::AuthUser; | |
| 220 | +use crate::services::permission_service::PermissionService; | |
| 221 | +use crate::error::AppError; | |
| 222 | +use axum::{Extension, extract::State}; | |
| 223 | +use sea_orm::DatabaseConnection; | |
| 224 | + | |
| 225 | +async fn delete_user_handler( | |
| 226 | + State(db): State<DatabaseConnection>, | |
| 227 | + Extension(auth_user): Extension<AuthUser>, | |
| 228 | +) -> Result<(), AppError> { | |
| 229 | + let perms = PermissionService::get_permissions_for_user(&db, auth_user.id).await?; | |
| 230 | + if !perms.contains(&"users.delete".to_string()) { | |
| 231 | + return Err(AppError::Forbidden); | |
| 232 | + } | |
| 233 | + // ... logique de suppression | |
| 234 | + Ok(()) | |
| 235 | +} | |
| 236 | +``` | |
| 237 | + | |
| 238 | +--- | |
| 239 | + | |
| 240 | +## 6. TeamRole — rôles et matrice de permissions | |
| 241 | + | |
| 242 | +Le type [`TeamRole`](../../crates/rustwarden-core/src/services/team_service.rs#L28) est défini dans `team_service.rs` avec trois variantes : | |
| 243 | + | |
| 244 | +```rust | |
| 245 | +pub enum TeamRole { | |
| 246 | + Owner, // "owner" | |
| 247 | + Admin, // "admin" | |
| 248 | + Member, // "member" | |
| 249 | +} | |
| 250 | +``` | |
| 251 | + | |
| 252 | +### Matrice de permissions par rôle | |
| 253 | + | |
| 254 | +| Action | Owner | Admin | Member | | |
| 255 | +|--------|-------|-------|--------| | |
| 256 | +| Voir les membres de l'équipe | oui | oui | oui | | |
| 257 | +| Ajouter un membre | oui | oui | non | | |
| 258 | +| Retirer un membre | oui | oui | non | | |
| 259 | +| Modifier le rôle d'un membre | oui | non | non | | |
| 260 | +| Supprimer l'équipe | oui | non | non | | |
| 261 | +| Partager une ressource avec l'équipe | oui | oui | non | | |
| 262 | +| Voir les ressources partagées avec l'équipe | oui | oui | oui | | |
| 263 | + | |
| 264 | +> **Remarque** : Il n'y a pas de rôle `Viewer` dans le modèle actuel de `TeamRole`. Les trois variantes sont `Owner`, `Admin`, et `Member`. Un membre en lecture seule n'existe pas comme variante distincte — tous les membres peuvent voir les ressources partagées. | |
| 265 | + | |
| 266 | +### Création et insertion du owner comme premier membre | |
| 267 | + | |
| 268 | +Lors de la création d'une équipe via [`TeamService::create`](../../crates/rustwarden-core/src/services/team_service.rs#L63), le owner est automatiquement inséré dans `team_members` avec le rôle `owner` : | |
| 269 | + | |
| 270 | +```rust | |
| 271 | +team_member::ActiveModel { | |
| 272 | + id: Set(Uuid::new_v4()), | |
| 273 | + team_id: Set(team_id), | |
| 274 | + user_id: Set(Some(owner_id)), | |
| 275 | + role: Set(TeamRole::Owner.as_str().to_string()), | |
| 276 | + joined_at: Set(Utc::now()), | |
| 277 | + remote_identity_id: Set(None), | |
| 278 | +} | |
| 279 | +.insert(db) | |
| 280 | +.await?; | |
| 281 | +``` | |
| 282 | + | |
| 283 | +--- | |
| 284 | + | |
| 285 | +## 7. Exemples concrets | |
| 286 | + | |
| 287 | +### Cas 1 — Admin global : accès à tout | |
| 288 | + | |
| 289 | +Un utilisateur avec le rôle `admin` reçoit toutes les permissions lors du seed initial (migration `m20260309_000003`) : | |
| 290 | + | |
| 291 | +```sql | |
| 292 | +INSERT INTO role_permissions (role_id, permission_id) | |
| 293 | +SELECT r.id, p.id | |
| 294 | +FROM roles r | |
| 295 | +CROSS JOIN permissions p | |
| 296 | +WHERE r.name = 'admin' | |
| 297 | +ON CONFLICT DO NOTHING; | |
| 298 | +``` | |
| 299 | + | |
| 300 | +Dans le code, la vérification revient à : | |
| 301 | + | |
| 302 | +```rust | |
| 303 | +let perms = PermissionService::get_permissions_for_user(&db, user_id).await?; | |
| 304 | +// perms contient ["users.list", "users.create", "users.edit", "users.delete", | |
| 305 | +// "users.reset_password", "roles.list", "roles.create", "roles.edit", | |
| 306 | +// "roles.delete", "settings.view", "settings.edit", "audit.view"] | |
| 307 | +``` | |
| 308 | + | |
| 309 | +### Cas 2 — Utilisateur avec rôle "editor" | |
| 310 | + | |
| 311 | +Supposons un rôle `editor` créé via `RoleService::create_role` avec les permissions `users.list` et `users.edit` uniquement : | |
| 312 | + | |
| 313 | +```rust | |
| 314 | +// Création du rôle | |
| 315 | +let editor = RoleService::create_role(&db, "editor".into(), Some("Éditeur".into())).await?; | |
| 316 | + | |
| 317 | +// Attribution des permissions | |
| 318 | +let list_perm = PermissionService::get_by_name(&db, "users.list").await?.unwrap(); | |
| 319 | +let edit_perm = PermissionService::get_by_name(&db, "users.edit").await?.unwrap(); | |
| 320 | +PermissionService::set_role_permissions(&db, editor.id, vec![list_perm.id, edit_perm.id]).await?; | |
| 321 | + | |
| 322 | +// Attribution du rôle à un utilisateur (via user_role::ActiveModel) | |
| 323 | +``` | |
| 324 | + | |
| 325 | +Cet utilisateur peut `GET /api/v1/users` et `PUT /api/v1/users/{id}`, mais pas `DELETE /api/v1/users/{id}` (permission `users.delete` absente). | |
| 326 | + | |
| 327 | +### Cas 3 — Owner d'une team : inviter et supprimer | |
| 328 | + | |
| 329 | +```rust | |
| 330 | +// Créer une team | |
| 331 | +let team = TeamService::create( | |
| 332 | + &db, | |
| 333 | + owner_id, | |
| 334 | + TeamSlug::new("my-team"), | |
| 335 | + "Mon équipe", | |
| 336 | + Some(&hooks), | |
| 337 | +).await?; | |
| 338 | + | |
| 339 | +// Ajouter un membre (uniquement owner ou admin peuvent le faire) | |
| 340 | +TeamService::add_member(&db, team.id, new_user_id, TeamRole::Member, Some(&hooks)).await?; | |
| 341 | + | |
| 342 | +// Retirer un membre | |
| 343 | +TeamService::remove_member(&db, team.id, new_user_id, Some(&hooks)).await?; | |
| 344 | + | |
| 345 | +// Supprimer la team (uniquement le owner) | |
| 346 | +TeamService::delete(&db, team.id, Some(&hooks)).await?; | |
| 347 | +``` | |
| 348 | + | |
| 349 | +### Cas 4 — Personal access token avec scope limité | |
| 350 | + | |
| 351 | +Les personal access tokens (migration `m20260422_000007`) permettent d'émettre un JWT avec un sous-ensemble de permissions. Le token porte ses propres `permissions` dans les claims JWT ; le middleware d'authentification les injecte dans `AuthUser` sans consulter `user_roles`. | |
| 352 | + | |
| 353 | +```rust | |
| 354 | +// Création d'un PAT via PersonalTokenService | |
| 355 | +let token = PersonalTokenService::create( | |
| 356 | + &db, | |
| 357 | + user_id, | |
| 358 | + "CI token", | |
| 359 | + Some(expires_at), | |
| 360 | + vec!["users.list".into()], // scope restreint | |
| 361 | +).await?; | |
| 362 | +``` | |
| 363 | + | |
| 364 | +Un handler qui vérifie `users.delete` refusera ce token même si l'utilisateur propriétaire possède ce droit globalement. | |
| 365 | + | |
| 366 | +--- | |
| 367 | + | |
| 368 | +## 8. Permissions par défaut | |
| 369 | + | |
| 370 | +Le seed initial (via `PermissionService::initialize_default_permissions` ou la migration `m20260309_000003`) crée les permissions suivantes : | |
| 371 | + | |
| 372 | +| Code | Catégorie | Description | | |
| 373 | +|------|-----------|-------------| | |
| 374 | +| `users.list` | users | Voir la liste des utilisateurs | | |
| 375 | +| `users.create` | users | Créer de nouveaux utilisateurs | | |
| 376 | +| `users.edit` | users | Modifier des utilisateurs existants | | |
| 377 | +| `users.delete` | users | Supprimer des utilisateurs | | |
| 378 | +| `users.reset_password` | users | Réinitialiser les mots de passe | | |
| 379 | +| `roles.list` | roles | Voir la liste des rôles | | |
| 380 | +| `roles.create` | roles | Créer des rôles | | |
| 381 | +| `roles.edit` | roles | Modifier des rôles | | |
| 382 | +| `roles.delete` | roles | Supprimer des rôles | | |
| 383 | +| `settings.view` | settings | Voir les paramètres de l'application | | |
| 384 | +| `settings.edit` | settings | Modifier les paramètres | | |
| 385 | +| `audit.view` | audit | Consulter les logs d'audit | | |
| 386 | + | |
| 387 | +**Rôles par défaut créés par `RoleService::initialize_default_roles` :** | |
| 388 | + | |
| 389 | +| Rôle | Description | | |
| 390 | +|------|-------------| | |
| 391 | +| `admin` | Administrateur avec accès complet — reçoit toutes les permissions | | |
| 392 | +| `user` | Utilisateur standard avec accès de base — aucune permission RBAC par défaut | | |
| 393 | +| `app_manager` | Peut activer/désactiver des applications | | |
| 394 | + | |
| 395 | +--- | |
| 396 | + | |
| 397 | +## 9. Limites actuelles et pistes V2 | |
| 398 | + | |
| 399 | +### Limites du modèle V1 | |
| 400 | + | |
| 401 | +**Pas de hiérarchie de rôles.** Il n'existe pas de mécanisme d'héritage entre rôles (`admin` hérite de `editor` qui hérite de `viewer`). Chaque rôle est indépendant et ses permissions sont entièrement explicites. | |
| 402 | + | |
| 403 | +**Pas d'ABAC (Attribute-Based Access Control).** Les vérifications de permission ne tiennent pas compte des attributs de la ressource cible (propriétaire, état, tags). Un utilisateur avec `users.edit` peut modifier n'importe quel utilisateur, pas seulement les siens. | |
| 404 | + | |
| 405 | +**Pas de permissions wildcard.** Le code `users.*` n'est pas interprété comme "toutes les permissions de la catégorie users". Chaque permission doit être listée explicitement dans `role_permissions`. | |
| 406 | + | |
| 407 | +**Pas de deny explicite.** Le modèle est purement additif : une permission est soit présente, soit absente. Il n'est pas possible d'interdire explicitement une action à un utilisateur qui aurait le droit via un autre rôle. | |
| 408 | + | |
| 409 | +**Permissions non portées dans le JWT par défaut.** Le JWT porte les `roles` de l'utilisateur mais pas ses `permissions`. La vérification nécessite un aller-retour en base via `PermissionService::get_permissions_for_user`. Une évolution possible est d'inclure les permissions dans les claims à l'émission du token. | |
| 410 | + | |
| 411 | +### Pistes d'évolution V2 | |
| 412 | + | |
| 413 | +| Fonctionnalité | Description | | |
| 414 | +|----------------|-------------| | |
| 415 | +| Hiérarchie de rôles | Arbre de rôles avec héritage — `admin` inclut automatiquement `editor` | | |
| 416 | +| Permissions dans le JWT | Inclure les `permissions[]` dans les claims pour éviter les requêtes DB sur chaque vérification | | |
| 417 | +| ABAC light | Ajouter un contexte de ressource à la vérification : `can_user_edit(user_id, resource_id)` | | |
| 418 | +| Wildcard permissions | Support de `users.*` comme raccourci pour toutes les permissions d'une catégorie | | |
| 419 | +| Deny rules | Règles d'interdiction explicite prioritaires sur les permissions positives | |
A
docs/developer-guide/04-hooks-and-events.md
+587
-0
@@ -0,0 +1,587 @@
| 1 | +# 04 — Hooks et événements | |
| 2 | + | |
| 3 | +Rustwarden expose un mécanisme de hooks qui permet d'étendre le comportement du framework sans modifier son code source. Ce document décrit le trait `RustwardenHooks`, tous ses callbacks, leur cycle de vie, et comment les implémenter dans une application consommatrice. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 1. Pourquoi des hooks | |
| 8 | + | |
| 9 | +Le framework gère l'authentification, les rôles, les équipes et les ressources. Une application métier a souvent besoin de réagir à ces événements sans forker le framework : | |
| 10 | + | |
| 11 | +- **Email transactionnel** : envoyer un email de bienvenue à l'inscription, un email de confirmation de changement de mot de passe. | |
| 12 | +- **Audit log** : écrire dans une table `audit_logs` métier chaque connexion ou suppression d'utilisateur. | |
| 13 | +- **Synchronisation CRM** : pousser un nouveau profil utilisateur vers HubSpot ou Salesforce à l'inscription. | |
| 14 | +- **Webhook sortant** : notifier Slack ou Discord quand une équipe est créée. | |
| 15 | +- **Initialisation de données** : créer un espace de travail par défaut pour chaque nouvel utilisateur. | |
| 16 | +- **Nettoyage** : libérer des ressources filesystem ou des indices de recherche avant la suppression d'un utilisateur. | |
| 17 | + | |
| 18 | +Le trait `RustwardenHooks` fournit des points d'injection à tous ces moments sans que l'application ait à toucher aux handlers ou services du core. | |
| 19 | + | |
| 20 | +--- | |
| 21 | + | |
| 22 | +## 2. Le trait `RustwardenHooks` | |
| 23 | + | |
| 24 | +Défini dans [`crates/rustwarden-core/src/hooks.rs`](../../crates/rustwarden-core/src/hooks.rs). | |
| 25 | + | |
| 26 | +```rust | |
| 27 | +#[async_trait] | |
| 28 | +pub trait RustwardenHooks: Send + Sync { | |
| 29 | + async fn on_user_registered( | |
| 30 | + &self, | |
| 31 | + _db: &DatabaseConnection, | |
| 32 | + _user_id: Uuid, | |
| 33 | + _username: &str, | |
| 34 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 35 | + | |
| 36 | + async fn on_user_login( | |
| 37 | + &self, | |
| 38 | + _db: &DatabaseConnection, | |
| 39 | + _user_id: Uuid, | |
| 40 | + _username: &str, | |
| 41 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 42 | + | |
| 43 | + async fn on_user_logout( | |
| 44 | + &self, | |
| 45 | + _db: &DatabaseConnection, | |
| 46 | + _user_id: Uuid, | |
| 47 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 48 | + | |
| 49 | + async fn on_password_changed( | |
| 50 | + &self, | |
| 51 | + _db: &DatabaseConnection, | |
| 52 | + _user_id: Uuid, | |
| 53 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 54 | + | |
| 55 | + async fn on_email_verified( | |
| 56 | + &self, | |
| 57 | + _db: &DatabaseConnection, | |
| 58 | + _user_id: Uuid, | |
| 59 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 60 | + | |
| 61 | + async fn on_role_changed( | |
| 62 | + &self, | |
| 63 | + _db: &DatabaseConnection, | |
| 64 | + _user_id: Uuid, | |
| 65 | + _roles: &[String], | |
| 66 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 67 | + | |
| 68 | + async fn on_resource_shared( | |
| 69 | + &self, | |
| 70 | + _db: &DatabaseConnection, | |
| 71 | + _resource_type: &str, | |
| 72 | + _resource_id: Uuid, | |
| 73 | + _shared_with_user_id: Uuid, | |
| 74 | + _permission_level: &str, | |
| 75 | + _shared_by_user_id: Uuid, | |
| 76 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 77 | + | |
| 78 | + async fn on_resource_unshared( | |
| 79 | + &self, | |
| 80 | + _db: &DatabaseConnection, | |
| 81 | + _resource_type: &str, | |
| 82 | + _resource_id: Uuid, | |
| 83 | + _user_id: Uuid, | |
| 84 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 85 | + | |
| 86 | + async fn on_user_deleted( | |
| 87 | + &self, | |
| 88 | + _db: &DatabaseConnection, | |
| 89 | + _user_id: Uuid, | |
| 90 | + _username: &str, | |
| 91 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 92 | + | |
| 93 | + async fn on_totp_enabled( | |
| 94 | + &self, | |
| 95 | + _db: &DatabaseConnection, | |
| 96 | + _user_id: Uuid, | |
| 97 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 98 | + | |
| 99 | + async fn on_totp_disabled( | |
| 100 | + &self, | |
| 101 | + _db: &DatabaseConnection, | |
| 102 | + _user_id: Uuid, | |
| 103 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 104 | + | |
| 105 | + async fn on_oauth_login( | |
| 106 | + &self, | |
| 107 | + _db: &DatabaseConnection, | |
| 108 | + _user_id: Uuid, | |
| 109 | + _provider: &str, | |
| 110 | + _is_new_user: bool, | |
| 111 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 112 | + | |
| 113 | + async fn on_resource_registered( | |
| 114 | + &self, | |
| 115 | + _db: &DatabaseConnection, | |
| 116 | + _resource_type: &str, | |
| 117 | + _resource_id: Uuid, | |
| 118 | + _owner_id: Uuid, | |
| 119 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 120 | + | |
| 121 | + async fn on_resource_unregistered( | |
| 122 | + &self, | |
| 123 | + _db: &DatabaseConnection, | |
| 124 | + _resource_type: &str, | |
| 125 | + _resource_id: Uuid, | |
| 126 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 127 | + | |
| 128 | + async fn on_team_created( | |
| 129 | + &self, | |
| 130 | + _db: &DatabaseConnection, | |
| 131 | + _team_id: Uuid, | |
| 132 | + _owner_id: Uuid, | |
| 133 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 134 | + | |
| 135 | + async fn on_team_deleted( | |
| 136 | + &self, | |
| 137 | + _db: &DatabaseConnection, | |
| 138 | + _team_id: Uuid, | |
| 139 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 140 | + | |
| 141 | + async fn on_team_member_added( | |
| 142 | + &self, | |
| 143 | + _db: &DatabaseConnection, | |
| 144 | + _team_id: Uuid, | |
| 145 | + _user_id: Uuid, | |
| 146 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 147 | + | |
| 148 | + async fn on_team_member_removed( | |
| 149 | + &self, | |
| 150 | + _db: &DatabaseConnection, | |
| 151 | + _team_id: Uuid, | |
| 152 | + _user_id: Uuid, | |
| 153 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 154 | + | |
| 155 | + async fn on_audit_event( | |
| 156 | + &self, | |
| 157 | + _db: &DatabaseConnection, | |
| 158 | + _event: AuditEvent<'_>, | |
| 159 | + ) -> anyhow::Result<()> { Ok(()) } | |
| 160 | +} | |
| 161 | +``` | |
| 162 | + | |
| 163 | +Toutes les méthodes ont une implémentation par défaut `Ok(())`. Une implémentation concrète ne surcharge que les callbacks qui la concernent. | |
| 164 | + | |
| 165 | +Le trait exige `Send + Sync` car l'instance est partagée derrière un `Arc<dyn RustwardenHooks>` accessible depuis plusieurs threads Tokio simultanément. | |
| 166 | + | |
| 167 | +--- | |
| 168 | + | |
| 169 | +## 3. NoopHooks — implémentation par défaut | |
| 170 | + | |
| 171 | +[`NoopHooks`](../../crates/rustwarden-core/src/hooks.rs#L260) est l'implémentation zéro-overhead utilisée quand aucun hook n'est configuré : | |
| 172 | + | |
| 173 | +```rust | |
| 174 | +pub struct NoopHooks; | |
| 175 | + | |
| 176 | +#[async_trait] | |
| 177 | +impl RustwardenHooks for NoopHooks {} | |
| 178 | +``` | |
| 179 | + | |
| 180 | +Le builder utilise `NoopHooks` par défaut : | |
| 181 | + | |
| 182 | +```rust | |
| 183 | +// app.rs — Default impl de RustwardenBuilder | |
| 184 | +hooks: Arc::new(NoopHooks), | |
| 185 | +``` | |
| 186 | + | |
| 187 | +--- | |
| 188 | + | |
| 189 | +## 4. Tableau des hooks | |
| 190 | + | |
| 191 | +| Méthode | Déclencheur | Paramètres clés | Contexte d'appel | | |
| 192 | +|---------|-------------|-----------------|-----------------| | |
| 193 | +| `on_user_registered` | Après insertion en base d'un nouvel utilisateur | `user_id`, `username` | Awaité dans le handler d'inscription | | |
| 194 | +| `on_user_login` | Après vérification réussie du mot de passe | `user_id`, `username` | Awaité dans le handler de login | | |
| 195 | +| `on_user_logout` | Après invalidation de la session | `user_id` | Awaité dans le handler de logout | | |
| 196 | +| `on_password_changed` | Après mise à jour du hash en base | `user_id` | Awaité dans le service de changement de mot de passe | | |
| 197 | +| `on_email_verified` | Après validation du token d'email | `user_id` | Awaité dans le handler de vérification | | |
| 198 | +| `on_role_changed` | Après mise à jour des rôles d'un utilisateur | `user_id`, `roles[]` | Awaité dans le handler admin | | |
| 199 | +| `on_resource_shared` | Après insertion d'un `resource_share` | `resource_type`, `resource_id`, `shared_with_user_id`, `permission_level`, `shared_by_user_id` | Awaité dans `ResourceService::share_with` | | |
| 200 | +| `on_resource_unshared` | Après suppression d'un `resource_share` | `resource_type`, `resource_id`, `user_id` | Awaité dans `ResourceService::unshare` | | |
| 201 | +| `on_user_deleted` | Avant la suppression en cascade de l'utilisateur | `user_id`, `username` | Awaité avant `DELETE FROM users` | | |
| 202 | +| `on_totp_enabled` | Après activation du TOTP | `user_id` | Awaité dans `TotpService` | | |
| 203 | +| `on_totp_disabled` | Après désactivation du TOTP | `user_id` | Awaité dans `TotpService` | | |
| 204 | +| `on_oauth_login` | Après authentification OAuth réussie | `user_id`, `provider`, `is_new_user` | Awaité dans le handler OAuth | | |
| 205 | +| `on_resource_registered` | Après insertion dans la table `resources` | `resource_type`, `resource_id`, `owner_id` | Awaité dans `ResourceService::register` | | |
| 206 | +| `on_resource_unregistered` | Après suppression du registre | `resource_type`, `resource_id` | Awaité dans `ResourceService::unregister` | | |
| 207 | +| `on_team_created` | Après insertion de la team et du premier membre | `team_id`, `owner_id` | Awaité dans `TeamService::create` | | |
| 208 | +| `on_team_deleted` | Après suppression de la team | `team_id` | Awaité dans `TeamService::delete` | | |
| 209 | +| `on_team_member_added` | Après insertion dans `team_members` | `team_id`, `user_id` | Awaité dans `TeamService::add_member` | | |
| 210 | +| `on_team_member_removed` | Après suppression de `team_members` | `team_id`, `user_id` | Awaité dans `TeamService::remove_member` | | |
| 211 | +| `on_audit_event` | Émis par `AuthLoggingService` ou par l'application | `AuditEvent { kind, actor_id, target, metadata }` | Awaité — **peut bloquer** si l'impl est lente | | |
| 212 | + | |
| 213 | +### Le payload `AuditEvent` | |
| 214 | + | |
| 215 | +```rust | |
| 216 | +// hooks.rs:37 | |
| 217 | +pub struct AuditEvent<'a> { | |
| 218 | + pub kind: &'a str, // ex: "user.login_success" | |
| 219 | + pub actor_id: Option<Uuid>, // None pour les événements système | |
| 220 | + pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id) | |
| 221 | + pub metadata: &'a serde_json::Value, // données libres | |
| 222 | +} | |
| 223 | +``` | |
| 224 | + | |
| 225 | +--- | |
| 226 | + | |
| 227 | +## 5. Cycle de vie d'un hook | |
| 228 | + | |
| 229 | +Les hooks sont **awaités directement** dans le contexte de la requête ou du service qui les déclenche. Ils ne sont pas fire-and-forget par défaut. Si un hook est lent ou échoue, il affecte le temps de réponse de la requête. | |
| 230 | + | |
| 231 | +Le pattern observable dans [`TeamService::create`](../../crates/rustwarden-core/src/services/team_service.rs#L104) est représentatif : | |
| 232 | + | |
| 233 | +```rust | |
| 234 | +if let Some(hooks) = hooks { | |
| 235 | + if let Err(e) = hooks.on_team_created(db, team_id, owner_id).await { | |
| 236 | + tracing::warn!(team_id = %team_id, "on_team_created hook failed: {}", e); | |
| 237 | + } | |
| 238 | +} | |
| 239 | +``` | |
| 240 | + | |
| 241 | +**L'erreur retournée par le hook est absorbée** : elle est loguée comme warning mais n'interrompt pas le flux principal. Le hook ne peut pas annuler une opération déjà commitée en base. | |
| 242 | + | |
| 243 | +```mermaid | |
| 244 | +sequenceDiagram | |
| 245 | + participant H as Handler HTTP | |
| 246 | + participant S as TeamService | |
| 247 | + participant DB as PostgreSQL | |
| 248 | + participant HK as RustwardenHooks | |
| 249 | + | |
| 250 | + H->>S: TeamService::create(db, owner_id, slug, name, hooks) | |
| 251 | + S->>DB: INSERT INTO teams ... | |
| 252 | + DB-->>S: team::Model | |
| 253 | + S->>DB: INSERT INTO team_members (owner) | |
| 254 | + DB-->>S: team_member::Model | |
| 255 | + S->>HK: hooks.on_team_created(db, team_id, owner_id).await | |
| 256 | + alt hook OK | |
| 257 | + HK-->>S: Ok(()) | |
| 258 | + else hook échoue | |
| 259 | + HK-->>S: Err(e) | |
| 260 | + S->>S: tracing::warn!("on_team_created hook failed") | |
| 261 | + end | |
| 262 | + S-->>H: Ok(team::Model) | |
| 263 | +``` | |
| 264 | + | |
| 265 | +### Conséquence : les hooks ne sont pas transactionnels | |
| 266 | + | |
| 267 | +La mutation en base est commitée avant l'appel au hook. Si le hook envoie un email et que la connexion SMTP échoue, l'utilisateur est quand même créé. C'est un choix délibéré : la robustesse du core ne dépend pas de la fiabilité d'un système externe. | |
| 268 | + | |
| 269 | +Pour les opérations critiques qui doivent être atomiques avec la mutation, utiliser une transaction applicative dans le hook lui-même ou une file de tâches avec retry. | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +## 6. Implémenter un hook custom | |
| 274 | + | |
| 275 | +L'exemple complet est disponible dans [`app/examples/custom_hooks.rs`](../../app/examples/custom_hooks.rs). | |
| 276 | + | |
| 277 | +### Structure minimale | |
| 278 | + | |
| 279 | +```rust | |
| 280 | +use async_trait::async_trait; | |
| 281 | +use rustwarden_core::prelude::*; | |
| 282 | +use sea_orm::DatabaseConnection; | |
| 283 | +use uuid::Uuid; | |
| 284 | + | |
| 285 | +struct MyAppHooks; | |
| 286 | + | |
| 287 | +#[async_trait] | |
| 288 | +impl RustwardenHooks for MyAppHooks { | |
| 289 | + async fn on_user_registered( | |
| 290 | + &self, | |
| 291 | + _db: &DatabaseConnection, | |
| 292 | + user_id: Uuid, | |
| 293 | + username: &str, | |
| 294 | + ) -> anyhow::Result<()> { | |
| 295 | + tracing::info!("Nouvel utilisateur : {} ({})", username, user_id); | |
| 296 | + // Créer un espace de travail par défaut, envoyer un email welcome, etc. | |
| 297 | + Ok(()) | |
| 298 | + } | |
| 299 | + | |
| 300 | + async fn on_user_login( | |
| 301 | + &self, | |
| 302 | + _db: &DatabaseConnection, | |
| 303 | + _user_id: Uuid, | |
| 304 | + username: &str, | |
| 305 | + ) -> anyhow::Result<()> { | |
| 306 | + tracing::info!("Connexion : {}", username); | |
| 307 | + Ok(()) | |
| 308 | + } | |
| 309 | +} | |
| 310 | +``` | |
| 311 | + | |
| 312 | +### Enregistrement dans le builder | |
| 313 | + | |
| 314 | +```rust | |
| 315 | +#[tokio::main] | |
| 316 | +async fn main() -> anyhow::Result<()> { | |
| 317 | + RustwardenApp::builder() | |
| 318 | + .from_env() | |
| 319 | + .with_ui(rustwarden_ui::default_routes) | |
| 320 | + .hooks(MyAppHooks) // <-- ici | |
| 321 | + .auto_migrate(true) | |
| 322 | + .build() | |
| 323 | + .await? | |
| 324 | + .run() | |
| 325 | + .await | |
| 326 | +} | |
| 327 | +``` | |
| 328 | + | |
| 329 | +La méthode [`hooks`](../../crates/rustwarden-core/src/app.rs#L232) accepte n'importe quelle valeur qui implémente `RustwardenHooks + 'static` et l'emballe dans un `Arc<dyn RustwardenHooks>`. | |
| 330 | + | |
| 331 | +### Hook avec état partagé | |
| 332 | + | |
| 333 | +Si l'implémentation a besoin d'un client HTTP ou d'une connexion à un système externe, les stocker dans la struct et dériver `Clone` si nécessaire : | |
| 334 | + | |
| 335 | +```rust | |
| 336 | +use std::sync::Arc; | |
| 337 | + | |
| 338 | +struct MyAppHooks { | |
| 339 | + http_client: reqwest::Client, | |
| 340 | + webhook_url: Arc<str>, | |
| 341 | +} | |
| 342 | + | |
| 343 | +impl MyAppHooks { | |
| 344 | + fn new(webhook_url: &str) -> Self { | |
| 345 | + Self { | |
| 346 | + http_client: reqwest::Client::new(), | |
| 347 | + webhook_url: webhook_url.into(), | |
| 348 | + } | |
| 349 | + } | |
| 350 | +} | |
| 351 | + | |
| 352 | +#[async_trait] | |
| 353 | +impl RustwardenHooks for MyAppHooks { | |
| 354 | + async fn on_team_created( | |
| 355 | + &self, | |
| 356 | + _db: &DatabaseConnection, | |
| 357 | + team_id: Uuid, | |
| 358 | + _owner_id: Uuid, | |
| 359 | + ) -> anyhow::Result<()> { | |
| 360 | + let payload = serde_json::json!({ | |
| 361 | + "text": format!("Nouvelle équipe créée : {}", team_id) | |
| 362 | + }); | |
| 363 | + self.http_client | |
| 364 | + .post(self.webhook_url.as_ref()) | |
| 365 | + .json(&payload) | |
| 366 | + .send() | |
| 367 | + .await?; | |
| 368 | + Ok(()) | |
| 369 | + } | |
| 370 | +} | |
| 371 | + | |
| 372 | +// Dans main() : | |
| 373 | +RustwardenApp::builder() | |
| 374 | + .hooks(MyAppHooks::new("https://hooks.slack.com/services/T00/B00/xxx")) | |
| 375 | + // ... | |
| 376 | +``` | |
| 377 | + | |
| 378 | +--- | |
| 379 | + | |
| 380 | +## 7. Hooks vs Observer (fédération) | |
| 381 | + | |
| 382 | +Le système de hooks du core et le système d'observateurs de `rustwarden-fediverse` coexistent avec des périmètres distincts. | |
| 383 | + | |
| 384 | +```mermaid | |
| 385 | +flowchart LR | |
| 386 | + subgraph core["rustwarden-core"] | |
| 387 | + HS[RustwardenHooks\ntrait] | |
| 388 | + EV[Événements lifecycle\nuser, team, resource, audit] | |
| 389 | + HS --- EV | |
| 390 | + end | |
| 391 | + | |
| 392 | + subgraph fediverse["rustwarden-fediverse"] | |
| 393 | + OB[FediverseEventObserver\ntrait] | |
| 394 | + FEV[Événements AP\nFollow, Accept, Announce...] | |
| 395 | + OB --- FEV | |
| 396 | + end | |
| 397 | + | |
| 398 | + APP[Application consommatrice] --> HS | |
| 399 | + APP --> OB | |
| 400 | +``` | |
| 401 | + | |
| 402 | +| Caractéristique | `RustwardenHooks` | `FediverseEventObserver` | | |
| 403 | +|----------------|-------------------|--------------------------| | |
| 404 | +| Périmètre | Événements lifecycle utilisateur/team/resource | Activités ActivityPub entrantes et sortantes | | |
| 405 | +| Définition | `rustwarden-core` | `rustwarden-fediverse` | | |
| 406 | +| Invocation | Awaité dans les services core | Invoqué par le pipeline ActivityPub | | |
| 407 | +| Gestion d'erreur | Erreur absorbée (warn log) | Varie selon l'activité | | |
| 408 | +| Disponible sans fédération | Oui, toujours | Non, uniquement avec la crate fediverse | | |
| 409 | + | |
| 410 | +--- | |
| 411 | + | |
| 412 | +## 8. Anti-patterns | |
| 413 | + | |
| 414 | +### Blocking I/O synchrone dans un hook async | |
| 415 | + | |
| 416 | +```rust | |
| 417 | +// MAUVAIS — bloque le thread Tokio | |
| 418 | +async fn on_user_registered(&self, ...) -> anyhow::Result<()> { | |
| 419 | + std::thread::sleep(std::time::Duration::from_secs(2)); // bloque | |
| 420 | + Ok(()) | |
| 421 | +} | |
| 422 | + | |
| 423 | +// CORRECT — utiliser les primitives async | |
| 424 | +async fn on_user_registered(&self, ...) -> anyhow::Result<()> { | |
| 425 | + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; | |
| 426 | + Ok(()) | |
| 427 | +} | |
| 428 | +``` | |
| 429 | + | |
| 430 | +### Propager des erreurs métier pour annuler l'action principale | |
| 431 | + | |
| 432 | +```rust | |
| 433 | +// MAUVAIS — l'erreur est absorbée par le framework, l'annulation n'est pas garantie | |
| 434 | +async fn on_user_registered(&self, db: &DatabaseConnection, user_id: Uuid, _username: &str) -> anyhow::Result<()> { | |
| 435 | + if self.crm_client.create_contact(user_id).await.is_err() { | |
| 436 | + return Err(anyhow::anyhow!("CRM indisponible")); // n'annule PAS l'inscription | |
| 437 | + } | |
| 438 | + Ok(()) | |
| 439 | +} | |
| 440 | + | |
| 441 | +// CORRECT — gérer l'échec localement, ne pas tenter d'annuler | |
| 442 | +async fn on_user_registered(&self, db: &DatabaseConnection, user_id: Uuid, _username: &str) -> anyhow::Result<()> { | |
| 443 | + if let Err(e) = self.crm_client.create_contact(user_id).await { | |
| 444 | + tracing::error!(user_id = %user_id, "Échec sync CRM : {}", e); | |
| 445 | + // Envoyer dans une file de retry si nécessaire | |
| 446 | + } | |
| 447 | + Ok(()) | |
| 448 | +} | |
| 449 | +``` | |
| 450 | + | |
| 451 | +### Dépendre de l'ordre d'exécution entre hooks | |
| 452 | + | |
| 453 | +Le framework appelle les hooks séquentiellement dans l'ordre de leur définition. Cet ordre est un détail d'implémentation non documenté. Ne pas supposer qu'`on_team_created` est toujours appelé avant `on_team_member_added` lors d'une création d'équipe. | |
| 454 | + | |
| 455 | +### Appels DB lourds sans timeout | |
| 456 | + | |
| 457 | +Les hooks reçoivent la `DatabaseConnection` du core. Un hook qui exécute une requête longue allonge le temps de réponse de la requête originale. Utiliser des timeouts explicites ou déléguer à une tâche de fond. | |
| 458 | + | |
| 459 | +--- | |
| 460 | + | |
| 461 | +## 9. Use cases courants | |
| 462 | + | |
| 463 | +### Email transactionnel — welcome email | |
| 464 | + | |
| 465 | +```rust | |
| 466 | +async fn on_user_registered( | |
| 467 | + &self, | |
| 468 | + db: &DatabaseConnection, | |
| 469 | + user_id: Uuid, | |
| 470 | + username: &str, | |
| 471 | +) -> anyhow::Result<()> { | |
| 472 | + // Récupérer l'email depuis la base | |
| 473 | + use rustwarden_core::services::user_service::UserService; | |
| 474 | + if let Some(user) = UserService::get_by_id(db, user_id).await? { | |
| 475 | + self.mailer | |
| 476 | + .send_welcome(&user.email, &user.username) | |
| 477 | + .await | |
| 478 | + .ok(); // Ne pas propager — l'inscription reste valide même si l'email échoue | |
| 479 | + } | |
| 480 | + Ok(()) | |
| 481 | +} | |
| 482 | +``` | |
| 483 | + | |
| 484 | +### Audit log — écrire dans une table métier | |
| 485 | + | |
| 486 | +```rust | |
| 487 | +async fn on_audit_event( | |
| 488 | + &self, | |
| 489 | + db: &DatabaseConnection, | |
| 490 | + event: AuditEvent<'_>, | |
| 491 | +) -> anyhow::Result<()> { | |
| 492 | + // Insérer dans la table audit_logs de l'application consommatrice | |
| 493 | + sqlx::query!( | |
| 494 | + "INSERT INTO audit_logs (kind, actor_id, created_at) VALUES ($1, $2, NOW())", | |
| 495 | + event.kind, | |
| 496 | + event.actor_id, | |
| 497 | + ) | |
| 498 | + .execute(&self.pool) | |
| 499 | + .await?; | |
| 500 | + Ok(()) | |
| 501 | +} | |
| 502 | +``` | |
| 503 | + | |
| 504 | +> Note : l'application consommatrice peut utiliser son propre pool (sqlx, diesel…) en parallèle du pool SeaORM du framework. | |
| 505 | + | |
| 506 | +### Webhook sortant vers Slack | |
| 507 | + | |
| 508 | +```rust | |
| 509 | +async fn on_team_created( | |
| 510 | + &self, | |
| 511 | + _db: &DatabaseConnection, | |
| 512 | + team_id: Uuid, | |
| 513 | + owner_id: Uuid, | |
| 514 | +) -> anyhow::Result<()> { | |
| 515 | + let body = serde_json::json!({ | |
| 516 | + "text": format!(":busts_in_silhouette: Nouvelle équipe `{}` créée par `{}`", team_id, owner_id) | |
| 517 | + }); | |
| 518 | + // Erreur non propagée — le webhook Slack ne doit pas bloquer la création | |
| 519 | + if let Err(e) = self.http.post(&self.slack_url).json(&body).send().await { | |
| 520 | + tracing::warn!("Slack webhook failed: {}", e); | |
| 521 | + } | |
| 522 | + Ok(()) | |
| 523 | +} | |
| 524 | +``` | |
| 525 | + | |
| 526 | +### Sync vers CRM externe avec retry asynchrone | |
| 527 | + | |
| 528 | +```rust | |
| 529 | +async fn on_user_registered( | |
| 530 | + &self, | |
| 531 | + _db: &DatabaseConnection, | |
| 532 | + user_id: Uuid, | |
| 533 | + _username: &str, | |
| 534 | +) -> anyhow::Result<()> { | |
| 535 | + // Enqueue pour retry — ne pas bloquer la requête | |
| 536 | + let tx = self.task_queue.clone(); | |
| 537 | + tokio::spawn(async move { | |
| 538 | + if let Err(e) = tx.send(CrmTask::CreateContact(user_id)).await { | |
| 539 | + tracing::error!("Impossible d'enqueuer la sync CRM : {}", e); | |
| 540 | + } | |
| 541 | + }); | |
| 542 | + Ok(()) | |
| 543 | +} | |
| 544 | +``` | |
| 545 | + | |
| 546 | +--- | |
| 547 | + | |
| 548 | +## 10. Diagramme d'état d'un utilisateur et hooks associés | |
| 549 | + | |
| 550 | +```mermaid | |
| 551 | +stateDiagram-v2 | |
| 552 | + [*] --> Enregistré : POST /register | |
| 553 | + note right of Enregistré : on_user_registered | |
| 554 | + | |
| 555 | + Enregistré --> EmailVérifié : GET /verify-email?token=... | |
| 556 | + note right of EmailVérifié : on_email_verified | |
| 557 | + | |
| 558 | + EmailVérifié --> Connecté : POST /login (mdp) | |
| 559 | + Enregistré --> Connecté : POST /login (mdp, email non vérifié) | |
| 560 | + note right of Connecté : on_user_login | |
| 561 | + | |
| 562 | + Connecté --> Connecté : POST /login (OAuth) | |
| 563 | + note right of Connecté : on_oauth_login | |
| 564 | + | |
| 565 | + Connecté --> Déconnecté : POST /logout | |
| 566 | + note right of Déconnecté : on_user_logout | |
| 567 | + | |
| 568 | + Déconnecté --> Connecté : POST /login | |
| 569 | + | |
| 570 | + Connecté --> MdpModifié : PUT /profile/password | |
| 571 | + MdpModifié --> Connecté : (session maintenue) | |
| 572 | + note right of MdpModifié : on_password_changed | |
| 573 | + | |
| 574 | + Connecté --> TOTPActivé : POST /totp/enable | |
| 575 | + note right of TOTPActivé : on_totp_enabled | |
| 576 | + | |
| 577 | + TOTPActivé --> Connecté : POST /totp/disable | |
| 578 | + note right of Connecté : on_totp_disabled | |
| 579 | + | |
| 580 | + Connecté --> RôlesModifiés : PATCH /admin/users/:id/roles | |
| 581 | + RôlesModifiés --> Connecté : (session maintenue) | |
| 582 | + note right of RôlesModifiés : on_role_changed | |
| 583 | + | |
| 584 | + Connecté --> Supprimé : DELETE /admin/users/:id | |
| 585 | + note right of Supprimé : on_user_deleted (avant cascade) | |
| 586 | + Supprimé --> [*] | |
| 587 | +``` |
A
docs/developer-guide/05-extending-rustwarden.md
+769
-0
@@ -0,0 +1,769 @@
| 1 | +# 05 — Étendre Rustwarden | |
| 2 | + | |
| 3 | +Ce guide recense les points d'extension du framework sous forme de recettes autonomes. Chaque recette suit le format : objectif → étapes → exemple de code → pièges à éviter. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## Quel point d'extension utiliser ? | |
| 8 | + | |
| 9 | +```mermaid | |
| 10 | +flowchart TD | |
| 11 | + Q[Quel est mon besoin ?] --> A{Réagir à un événement\ndu framework ?} | |
| 12 | + A -->|oui| HOOK[Hook\nRustwardenHooks\n→ recette 1.1] | |
| 13 | + A -->|non| B{Intercepter ou\ntransformer des requêtes HTTP ?} | |
| 14 | + B -->|oui| MW[Middleware\n→ recette 3] | |
| 15 | + B -->|non| C{Nouvelle route API\nou page ?} | |
| 16 | + C -->|oui| ROUTE[Handler + route\n→ recette 2] | |
| 17 | + C -->|non| D{Nouvelle entité\nen base ?} | |
| 18 | + D -->|oui| MIG[Migration + entité SeaORM\n→ recettes 4 et 5] | |
| 19 | + D -->|non| E{Nouvelle logique métier\nréutilisable ?} | |
| 20 | + E -->|oui| SVC[Service\n→ recette 1] | |
| 21 | + E -->|non| F{Modifier l'interface HTML ?} | |
| 22 | + F -->|oui| TPL[Override templates\n→ recette 7] | |
| 23 | + F -->|non| G{Nouvelle langue ?} | |
| 24 | + G -->|oui| I18N[Fichier YAML locales\n→ recette 8] | |
| 25 | + G -->|non| MISC[Voir recettes OAuth, background tasks] | |
| 26 | +``` | |
| 27 | + | |
| 28 | +--- | |
| 29 | + | |
| 30 | +## Recette 1 — Ajouter un service métier | |
| 31 | + | |
| 32 | +**Objectif** : encapsuler de la logique métier réutilisable (par exemple : gestion de projets, facturation) en suivant le pattern des services du core. | |
| 33 | + | |
| 34 | +### Étapes | |
| 35 | + | |
| 36 | +1. Créer le fichier `crates/rustwarden-core/src/services/my_service.rs`. | |
| 37 | +2. Définir une struct unitaire et des méthodes `async` qui prennent `&DatabaseConnection`. | |
| 38 | +3. Re-exporter le module dans `crates/rustwarden-core/src/services/mod.rs`. | |
| 39 | +4. Utiliser le service depuis un handler via `State<DatabaseConnection>`. | |
| 40 | + | |
| 41 | +### Exemple | |
| 42 | + | |
| 43 | +```rust | |
| 44 | +// crates/rustwarden-core/src/services/project_service.rs | |
| 45 | + | |
| 46 | +use crate::error::AppError; | |
| 47 | +use sea_orm::DatabaseConnection; | |
| 48 | +use uuid::Uuid; | |
| 49 | + | |
| 50 | +/// Service de gestion des projets. | |
| 51 | +pub struct ProjectService; | |
| 52 | + | |
| 53 | +impl ProjectService { | |
| 54 | + /// Crée un nouveau projet et retourne son identifiant. | |
| 55 | + pub async fn create( | |
| 56 | + db: &DatabaseConnection, | |
| 57 | + owner_id: Uuid, | |
| 58 | + name: &str, | |
| 59 | + ) -> Result<Uuid, AppError> { | |
| 60 | + // Insertion SeaORM (voir recette 5 pour le modèle) | |
| 61 | + let project_id = Uuid::new_v4(); | |
| 62 | + // project::ActiveModel { ... }.insert(db).await?; | |
| 63 | + Ok(project_id) | |
| 64 | + } | |
| 65 | + | |
| 66 | + /// Récupère tous les projets d'un utilisateur. | |
| 67 | + pub async fn list_for_user( | |
| 68 | + db: &DatabaseConnection, | |
| 69 | + user_id: Uuid, | |
| 70 | + ) -> Result<Vec<Uuid>, AppError> { | |
| 71 | + // project::Entity::find() | |
| 72 | + // .filter(project::Column::OwnerId.eq(user_id)) | |
| 73 | + // .all(db).await? | |
| 74 | + Ok(vec![]) | |
| 75 | + } | |
| 76 | +} | |
| 77 | +``` | |
| 78 | + | |
| 79 | +Enregistrement dans `services/mod.rs` : | |
| 80 | + | |
| 81 | +```rust | |
| 82 | +/// Service de gestion des projets. | |
| 83 | +pub mod project_service; | |
| 84 | +``` | |
| 85 | + | |
| 86 | +### Pièges à éviter | |
| 87 | + | |
| 88 | +- Ne pas stocker d'état mutable dans la struct du service. Les services sont sans état ; la `DatabaseConnection` est passée par référence à chaque appel. | |
| 89 | +- Ne pas appeler `unwrap()` ou `expect()` — utiliser `?` et `AppError`. | |
| 90 | +- Ne pas écrire de SQL brut — tout passe par SeaORM (règle `constitution.md`). | |
| 91 | + | |
| 92 | +--- | |
| 93 | + | |
| 94 | +## Recette 2 — Ajouter une route API | |
| 95 | + | |
| 96 | +**Objectif** : exposer un nouvel endpoint HTTP (REST ou HTML) dans l'application. | |
| 97 | + | |
| 98 | +### Étapes | |
| 99 | + | |
| 100 | +1. Créer le handler dans un module approprié (nouveau fichier ou dans `auth/handlers.rs` si lié à l'auth). | |
| 101 | +2. Utiliser les extracteurs Axum standards : `State`, `Extension`, `Json`, `Path`. | |
| 102 | +3. Retourner `Result<Json<MyResponse>, AppError>` (API) ou `Result<Html<String>, AppError>` (HTML). | |
| 103 | +4. Enregistrer la route dans l'application via `.merge_routes(router)` sur le builder. | |
| 104 | + | |
| 105 | +### Exemple — endpoint JSON | |
| 106 | + | |
| 107 | +```rust | |
| 108 | +// src/handlers/project_handler.rs | |
| 109 | + | |
| 110 | +use axum::{ | |
| 111 | + extract::{Path, State}, | |
| 112 | + Extension, Json, | |
| 113 | +}; | |
| 114 | +use sea_orm::DatabaseConnection; | |
| 115 | +use serde::Serialize; | |
| 116 | +use uuid::Uuid; | |
| 117 | + | |
| 118 | +use crate::auth::extractors::AuthUser; | |
| 119 | +use crate::error::AppError; | |
| 120 | +use crate::services::project_service::ProjectService; | |
| 121 | + | |
| 122 | +#[derive(Serialize)] | |
| 123 | +pub struct ProjectListResponse { | |
| 124 | + pub projects: Vec<Uuid>, | |
| 125 | +} | |
| 126 | + | |
| 127 | +pub async fn list_projects( | |
| 128 | + State(db): State<DatabaseConnection>, | |
| 129 | + Extension(auth_user): Extension<AuthUser>, | |
| 130 | +) -> Result<Json<ProjectListResponse>, AppError> { | |
| 131 | + let projects = ProjectService::list_for_user(&db, auth_user.id).await?; | |
| 132 | + Ok(Json(ProjectListResponse { projects })) | |
| 133 | +} | |
| 134 | +``` | |
| 135 | + | |
| 136 | +Enregistrement du router dans `main.rs` ou dans la logique de montage : | |
| 137 | + | |
| 138 | +```rust | |
| 139 | +use axum::routing::get; | |
| 140 | +use axum::Router; | |
| 141 | + | |
| 142 | +let project_routes = Router::new() | |
| 143 | + .route("/api/v1/projects", get(list_projects)); | |
| 144 | + | |
| 145 | +RustwardenApp::builder() | |
| 146 | + .from_env() | |
| 147 | + .merge_routes(project_routes) // <-- injecte les routes dans le routeur principal | |
| 148 | + .hooks(MyHooks) | |
| 149 | + .build() | |
| 150 | + .await? | |
| 151 | + .run() | |
| 152 | + .await | |
| 153 | +``` | |
| 154 | + | |
| 155 | +### Extracteurs disponibles dans les handlers | |
| 156 | + | |
| 157 | +| Extracteur | Source | Usage | | |
| 158 | +|-----------|--------|-------| | |
| 159 | +| `State<DatabaseConnection>` | Pool SeaORM partagé | Accès à la base de données | | |
| 160 | +| `Extension<AuthUser>` | Middleware d'authentification | Utilisateur authentifié courant | | |
| 161 | +| `Json<T>` | Corps de la requête | Désérialisation d'un payload JSON | | |
| 162 | +| `Path<T>` | Segment d'URL | Paramètre de route (`:id`) | | |
| 163 | +| `axum_extra::extract::CookieJar` | Cookies de la requête | Lecture des cookies HTTP | | |
| 164 | + | |
| 165 | +### Pièges à éviter | |
| 166 | + | |
| 167 | +- Ne pas déclarer un endpoint sans implémenter son handler — pas de `todo!()` ou `StatusCode::NOT_IMPLEMENTED` en production (règle `constitution.md`). | |
| 168 | +- Les routes ajoutées via `merge_routes` sont **sans** le middleware d'authentification par défaut. Pour protéger une route HTML, appeler `register_protected_prefix("/mon-prefix")` sur le builder. | |
| 169 | + | |
| 170 | +--- | |
| 171 | + | |
| 172 | +## Recette 3 — Ajouter un middleware | |
| 173 | + | |
| 174 | +**Objectif** : intercepter toutes les requêtes (ou un sous-ensemble) pour ajouter un comportement transversal : logging, vérification d'en-tête, injection de contexte. | |
| 175 | + | |
| 176 | +### Étapes | |
| 177 | + | |
| 178 | +1. Écrire une fonction async `my_middleware(req: Request, next: Next) -> Response`. | |
| 179 | +2. L'appliquer sur un `Router` via `.layer(middleware::from_fn(my_middleware))`. | |
| 180 | +3. Pour accéder à l'état applicatif, utiliser `middleware::from_fn_with_state(state, my_middleware)`. | |
| 181 | + | |
| 182 | +### Exemple — middleware de logging de requêtes | |
| 183 | + | |
| 184 | +```rust | |
| 185 | +use axum::{ | |
| 186 | + extract::Request, | |
| 187 | + middleware::Next, | |
| 188 | + response::Response, | |
| 189 | +}; | |
| 190 | + | |
| 191 | +pub async fn request_logger(req: Request, next: Next) -> Response { | |
| 192 | + let method = req.method().clone(); | |
| 193 | + let uri = req.uri().clone(); | |
| 194 | + tracing::info!("{} {}", method, uri); | |
| 195 | + let response = next.run(req).await; | |
| 196 | + tracing::info!("{} {} → {}", method, uri, response.status()); | |
| 197 | + response | |
| 198 | +} | |
| 199 | +``` | |
| 200 | + | |
| 201 | +Application sur un groupe de routes : | |
| 202 | + | |
| 203 | +```rust | |
| 204 | +use axum::middleware; | |
| 205 | + | |
| 206 | +let protected_routes = Router::new() | |
| 207 | + .route("/api/v1/projects", get(list_projects)) | |
| 208 | + .layer(middleware::from_fn(request_logger)); | |
| 209 | + | |
| 210 | +RustwardenApp::builder() | |
| 211 | + .merge_routes(protected_routes) | |
| 212 | + // ... | |
| 213 | +``` | |
| 214 | + | |
| 215 | +### Exemple — middleware avec accès à l'état | |
| 216 | + | |
| 217 | +```rust | |
| 218 | +use axum::{extract::{Request, State}, middleware::Next, response::Response}; | |
| 219 | +use sea_orm::DatabaseConnection; | |
| 220 | +use std::sync::Arc; | |
| 221 | + | |
| 222 | +pub async fn rate_check_middleware( | |
| 223 | + State(db): State<DatabaseConnection>, | |
| 224 | + req: Request, | |
| 225 | + next: Next, | |
| 226 | +) -> Response { | |
| 227 | + // Vérification custom... | |
| 228 | + next.run(req).await | |
| 229 | +} | |
| 230 | + | |
| 231 | +// Enregistrement : | |
| 232 | +let router = Router::new() | |
| 233 | + .route("/api/v1/heavy", get(heavy_handler)) | |
| 234 | + .layer(middleware::from_fn_with_state(db.clone(), rate_check_middleware)); | |
| 235 | +``` | |
| 236 | + | |
| 237 | +### Pièges à éviter | |
| 238 | + | |
| 239 | +- Les middlewares appliqués via `.layer()` s'exécutent **de l'extérieur vers l'intérieur** dans Axum. L'ordre d'empilement est inverse à l'ordre d'ajout. | |
| 240 | +- Ne pas cloner la `DatabaseConnection` à chaque requête — la passer via `State` ou `Extension` depuis le builder. | |
| 241 | + | |
| 242 | +--- | |
| 243 | + | |
| 244 | +## Recette 4 — Ajouter une migration SeaORM | |
| 245 | + | |
| 246 | +**Objectif** : créer une nouvelle table ou modifier le schéma existant de façon versionnée et réversible. | |
| 247 | + | |
| 248 | +### Étapes | |
| 249 | + | |
| 250 | +1. Créer le fichier de migration dans `crates/rustwarden-core/src/migrations/`. | |
| 251 | +2. Nommer le fichier selon la convention : `m{AAAAMMJJ}_{NNNNNN}_{description}.rs`. | |
| 252 | +3. Implémenter `MigrationName` et `MigrationTrait` (méthodes `up` et `down`). | |
| 253 | +4. Enregistrer la migration dans `crates/rustwarden-core/src/migrations/mod.rs`. | |
| 254 | +5. Lancer `cargo run --bin migrate` pour appliquer. | |
| 255 | + | |
| 256 | +### Exemple — créer une table `projects` | |
| 257 | + | |
| 258 | +```rust | |
| 259 | +// crates/rustwarden-core/src/migrations/m20260601_000010_create_projects.rs | |
| 260 | + | |
| 261 | +use sea_orm_migration::prelude::*; | |
| 262 | + | |
| 263 | +#[derive(DeriveMigrationName)] | |
| 264 | +pub struct Migration; | |
| 265 | + | |
| 266 | +#[async_trait::async_trait] | |
| 267 | +impl MigrationTrait for Migration { | |
| 268 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 269 | + manager | |
| 270 | + .create_table( | |
| 271 | + Table::create() | |
| 272 | + .table(Projects::Table) | |
| 273 | + .if_not_exists() | |
| 274 | + .col(ColumnDef::new(Projects::Id).uuid().not_null().primary_key()) | |
| 275 | + .col(ColumnDef::new(Projects::Name).string().string_len(128).not_null()) | |
| 276 | + .col(ColumnDef::new(Projects::OwnerId).uuid().not_null()) | |
| 277 | + .col( | |
| 278 | + ColumnDef::new(Projects::CreatedAt) | |
| 279 | + .timestamp_with_time_zone() | |
| 280 | + .not_null() | |
| 281 | + .default(Expr::current_timestamp()), | |
| 282 | + ) | |
| 283 | + .foreign_key( | |
| 284 | + ForeignKey::create() | |
| 285 | + .name("fk_projects_owner_id") | |
| 286 | + .from(Projects::Table, Projects::OwnerId) | |
| 287 | + .to(Users::Table, Users::Id) | |
| 288 | + .on_delete(ForeignKeyAction::Cascade), | |
| 289 | + ) | |
| 290 | + .to_owned(), | |
| 291 | + ) | |
| 292 | + .await | |
| 293 | + } | |
| 294 | + | |
| 295 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 296 | + manager | |
| 297 | + .drop_table(Table::drop().table(Projects::Table).to_owned()) | |
| 298 | + .await | |
| 299 | + } | |
| 300 | +} | |
| 301 | + | |
| 302 | +#[derive(DeriveIden)] | |
| 303 | +enum Projects { | |
| 304 | + Table, | |
| 305 | + Id, | |
| 306 | + Name, | |
| 307 | + OwnerId, | |
| 308 | + CreatedAt, | |
| 309 | +} | |
| 310 | + | |
| 311 | +#[derive(DeriveIden)] | |
| 312 | +enum Users { | |
| 313 | + Table, | |
| 314 | + Id, | |
| 315 | +} | |
| 316 | +``` | |
| 317 | + | |
| 318 | +Enregistrement dans `migrations/mod.rs` : | |
| 319 | + | |
| 320 | +```rust | |
| 321 | +// mod.rs — liste des migrations dans l'ordre chronologique | |
| 322 | +pub use sea_orm_migration::MigratorTrait; | |
| 323 | + | |
| 324 | +pub struct Migrator; | |
| 325 | + | |
| 326 | +#[async_trait::async_trait] | |
| 327 | +impl MigratorTrait for Migrator { | |
| 328 | + fn migrations() -> Vec<Box<dyn MigrationTrait>> { | |
| 329 | + vec![ | |
| 330 | + // ... migrations existantes ... | |
| 331 | + Box::new(m20260601_000010_create_projects::Migration), | |
| 332 | + ] | |
| 333 | + } | |
| 334 | +} | |
| 335 | +``` | |
| 336 | + | |
| 337 | +Exécution : | |
| 338 | + | |
| 339 | +```bash | |
| 340 | +cargo run --bin migrate | |
| 341 | +``` | |
| 342 | + | |
| 343 | +### Générer l'entité SeaORM depuis la table créée | |
| 344 | + | |
| 345 | +Après avoir appliqué la migration, générer l'entité : | |
| 346 | + | |
| 347 | +```bash | |
| 348 | +sea-orm-cli generate entity \ | |
| 349 | + --database-url "$DATABASE_URL" \ | |
| 350 | + --output-dir crates/rustwarden-core/src/models \ | |
| 351 | + --tables projects | |
| 352 | +``` | |
| 353 | + | |
| 354 | +### Pièges à éviter | |
| 355 | + | |
| 356 | +- Toujours implémenter `down` pour permettre les rollbacks, même si elle se contente de `DROP TABLE`. | |
| 357 | +- Utiliser `if_not_exists()` dans `up` pour que la migration soit idempotente en cas de re-run sur un schéma partiellement migré. | |
| 358 | +- Ne jamais modifier une migration déjà appliquée en production — créer une nouvelle migration à la place. | |
| 359 | + | |
| 360 | +--- | |
| 361 | + | |
| 362 | +## Recette 5 — Ajouter une entité SeaORM | |
| 363 | + | |
| 364 | +**Objectif** : créer ou modifier manuellement un modèle SeaORM correspondant à une table existante. | |
| 365 | + | |
| 366 | +### Étapes | |
| 367 | + | |
| 368 | +1. Créer `crates/rustwarden-core/src/models/project.rs`. | |
| 369 | +2. Dériver `DeriveEntityModel` et déclarer la struct `Model` avec les champs. | |
| 370 | +3. Déclarer les relations via `DeriveRelation`. | |
| 371 | +4. Re-exporter dans `crates/rustwarden-core/src/models/mod.rs`. | |
| 372 | + | |
| 373 | +### Exemple | |
| 374 | + | |
| 375 | +```rust | |
| 376 | +// crates/rustwarden-core/src/models/project.rs | |
| 377 | + | |
| 378 | +use chrono::{DateTime, Utc}; | |
| 379 | +use sea_orm::entity::prelude::*; | |
| 380 | +use serde::{Deserialize, Serialize}; | |
| 381 | +use uuid::Uuid; | |
| 382 | + | |
| 383 | +/// Entité projet — table `projects`. | |
| 384 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 385 | +#[sea_orm(table_name = "projects")] | |
| 386 | +pub struct Model { | |
| 387 | + #[sea_orm(primary_key)] | |
| 388 | + pub id: Uuid, | |
| 389 | + pub name: String, | |
| 390 | + pub owner_id: Uuid, | |
| 391 | + pub created_at: DateTime<Utc>, | |
| 392 | +} | |
| 393 | + | |
| 394 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 395 | +pub enum Relation { | |
| 396 | + #[sea_orm( | |
| 397 | + belongs_to = "crate::models::user::Entity", | |
| 398 | + from = "Column::OwnerId", | |
| 399 | + to = "crate::models::user::Column::Id", | |
| 400 | + on_delete = "Cascade" | |
| 401 | + )] | |
| 402 | + Owner, | |
| 403 | +} | |
| 404 | + | |
| 405 | +impl Related<crate::models::user::Entity> for Entity { | |
| 406 | + fn to() -> RelationDef { | |
| 407 | + Relation::Owner.def() | |
| 408 | + } | |
| 409 | +} | |
| 410 | + | |
| 411 | +impl ActiveModelBehavior for ActiveModel {} | |
| 412 | +``` | |
| 413 | + | |
| 414 | +Re-export dans `models/mod.rs` : | |
| 415 | + | |
| 416 | +```rust | |
| 417 | +/// Entité projet. | |
| 418 | +pub mod project; | |
| 419 | +``` | |
| 420 | + | |
| 421 | +### Pièges à éviter | |
| 422 | + | |
| 423 | +- Le type Rust d'une colonne `uuid` SeaORM doit être `Uuid` (crate `uuid`), pas `String`. | |
| 424 | +- Les colonnes `timestamp_with_time_zone` mappent vers `DateTime<Utc>` avec la crate `chrono`. | |
| 425 | +- `Option<T>` pour les colonnes nullable, `T` pour les colonnes `NOT NULL`. | |
| 426 | + | |
| 427 | +--- | |
| 428 | + | |
| 429 | +## Recette 6 — Ajouter une permission | |
| 430 | + | |
| 431 | +**Objectif** : déclarer une nouvelle capacité métier vérifiable via `PermissionService`. | |
| 432 | + | |
| 433 | +### Étapes | |
| 434 | + | |
| 435 | +1. Créer une migration qui insère la permission dans la table `permissions`. | |
| 436 | +2. Optionnel : l'assigner immédiatement au rôle `admin`. | |
| 437 | +3. Vérifier la permission dans le handler avec `PermissionService::get_permissions_for_user`. | |
| 438 | + | |
| 439 | +### Exemple — migration d'insertion | |
| 440 | + | |
| 441 | +```rust | |
| 442 | +// Dans la méthode up() d'une migration | |
| 443 | +manager | |
| 444 | + .get_connection() | |
| 445 | + .execute_unprepared( | |
| 446 | + r#" | |
| 447 | + INSERT INTO permissions (id, name, description, category, created_at) | |
| 448 | + VALUES (gen_random_uuid(), 'projects.delete', 'Supprimer des projets', 'projects', NOW()) | |
| 449 | + ON CONFLICT (name) DO NOTHING; | |
| 450 | + | |
| 451 | + INSERT INTO role_permissions (role_id, permission_id) | |
| 452 | + SELECT r.id, p.id | |
| 453 | + FROM roles r, permissions p | |
| 454 | + WHERE r.name = 'admin' AND p.name = 'projects.delete' | |
| 455 | + ON CONFLICT DO NOTHING; | |
| 456 | + "#, | |
| 457 | + ) | |
| 458 | + .await?; | |
| 459 | +``` | |
| 460 | + | |
| 461 | +### Vérification dans un handler | |
| 462 | + | |
| 463 | +```rust | |
| 464 | +use crate::services::permission_service::PermissionService; | |
| 465 | +use crate::auth::extractors::AuthUser; | |
| 466 | +use crate::error::AppError; | |
| 467 | + | |
| 468 | +pub async fn delete_project( | |
| 469 | + State(db): State<DatabaseConnection>, | |
| 470 | + Extension(auth_user): Extension<AuthUser>, | |
| 471 | + Path(project_id): Path<Uuid>, | |
| 472 | +) -> Result<(), AppError> { | |
| 473 | + let perms = PermissionService::get_permissions_for_user(&db, auth_user.id).await?; | |
| 474 | + if !perms.contains(&"projects.delete".to_string()) { | |
| 475 | + return Err(AppError::Forbidden); | |
| 476 | + } | |
| 477 | + // ... suppression | |
| 478 | + Ok(()) | |
| 479 | +} | |
| 480 | +``` | |
| 481 | + | |
| 482 | +### Pièges à éviter | |
| 483 | + | |
| 484 | +- Utiliser `ON CONFLICT (name) DO NOTHING` pour que la migration soit idempotente. | |
| 485 | +- Ne pas coder en dur le nom de la permission dans plusieurs handlers — définir une constante : | |
| 486 | + | |
| 487 | +```rust | |
| 488 | +const PERM_PROJECTS_DELETE: &str = "projects.delete"; | |
| 489 | +``` | |
| 490 | + | |
| 491 | +--- | |
| 492 | + | |
| 493 | +## Recette 7 — Personnaliser les templates Askama | |
| 494 | + | |
| 495 | +**Objectif** : modifier ou remplacer les pages HTML rendues par `rustwarden-ui` (login, profil, panneau admin) sans toucher au code du crate UI. | |
| 496 | + | |
| 497 | +### Structure des templates existants | |
| 498 | + | |
| 499 | +Les templates se trouvent dans `crates/rustwarden-ui/templates/`. Ils utilisent Askama 0.15 avec l'héritage de templates (`{% extends "base.html" %}`). | |
| 500 | + | |
| 501 | +### Méthode : répertoire de templates alternatif | |
| 502 | + | |
| 503 | +Le builder expose [`locale_dir`](../../crates/rustwarden-core/src/app.rs#L300) pour les traductions. Pour les templates Askama, la surcharge se fait en configurant Askama pour chercher dans un répertoire alternatif **au moment de la compilation** via `askama.toml` : | |
| 504 | + | |
| 505 | +```toml | |
| 506 | +# askama.toml à la racine du projet consommateur | |
| 507 | +[general] | |
| 508 | +dirs = ["templates", "crates/rustwarden-ui/templates"] | |
| 509 | +``` | |
| 510 | + | |
| 511 | +Les templates dans le premier répertoire `templates/` ont la priorité. Copier le template à surcharger et le modifier : | |
| 512 | + | |
| 513 | +``` | |
| 514 | +my-app/ | |
| 515 | + templates/ | |
| 516 | + auth/ | |
| 517 | + login.html ← surcharge le template du crate UI | |
| 518 | +``` | |
| 519 | + | |
| 520 | +### Exemple — surcharger la page de login | |
| 521 | + | |
| 522 | +```html | |
| 523 | +{# templates/auth/login.html #} | |
| 524 | +{% extends "base.html" %} | |
| 525 | + | |
| 526 | +{% block title %}Connexion — Mon Application{% endblock %} | |
| 527 | + | |
| 528 | +{% block content %} | |
| 529 | +<div class="my-custom-login-wrapper"> | |
| 530 | + <img src="/static/logo.png" alt="Logo" class="logo" /> | |
| 531 | + {# Inclure le formulaire original ou le réécrire #} | |
| 532 | + <form method="POST" action="/login"> | |
| 533 | + <!-- ... --> | |
| 534 | + </form> | |
| 535 | +</div> | |
| 536 | +{% endblock %} | |
| 537 | +``` | |
| 538 | + | |
| 539 | +### Pièges à éviter | |
| 540 | + | |
| 541 | +- Askama compile les templates au moment du build Rust, pas à l'exécution. Un template mal formé est une erreur de compilation, pas une erreur runtime. | |
| 542 | +- Les variables disponibles dans un template sont celles de la struct Askama correspondante. Vérifier la struct `LoginTemplate` dans `rustwarden-ui` avant de modifier le template. | |
| 543 | +- Ne pas supprimer les blocs CSRF requis par le framework — les formulaires POST doivent inclure le token CSRF. | |
| 544 | + | |
| 545 | +--- | |
| 546 | + | |
| 547 | +## Recette 8 — Ajouter une langue (i18n) | |
| 548 | + | |
| 549 | +**Objectif** : rendre l'interface disponible dans une nouvelle langue sans modifier le code Rust. | |
| 550 | + | |
| 551 | +### Comment fonctionne l'i18n | |
| 552 | + | |
| 553 | +Le framework charge les fichiers YAML de traductions au démarrage depuis les répertoires configurés. Le chargement est dynamique (runtime), pas compilé. L'extracteur `Locale` détecte la langue depuis le cookie `locale` ou l'en-tête `Accept-Language`. | |
| 554 | + | |
| 555 | +Les langues actuellement supportées par défaut : `fr`, `en`, `de`, `es` (défini dans [`app.rs:166`](../../crates/rustwarden-core/src/app.rs#L166)). | |
| 556 | + | |
| 557 | +### Étapes | |
| 558 | + | |
| 559 | +1. Localiser un fichier de traductions existant dans le projet (chercher `locales/` ou le répertoire configuré via `locale_dir`). | |
| 560 | +2. Créer `locales/it.yaml` (ou le répertoire correspondant) en copiant `fr.yaml`. | |
| 561 | +3. Traduire les valeurs. | |
| 562 | +4. Ajouter `"it"` à la liste des locales supportées dans le builder. | |
| 563 | + | |
| 564 | +### Exemple | |
| 565 | + | |
| 566 | +```bash | |
| 567 | +# Copier le fichier de référence | |
| 568 | +cp locales/fr.yaml locales/it.yaml | |
| 569 | +``` | |
| 570 | + | |
| 571 | +```yaml | |
| 572 | +# locales/it.yaml | |
| 573 | +auth: | |
| 574 | + login: "Accedi" | |
| 575 | + logout: "Disconnetti" | |
| 576 | + username: "Nome utente" | |
| 577 | + password: "Password" | |
| 578 | + login_button: "Accedi" | |
| 579 | + invalid_credentials: "Credenziali non valide" | |
| 580 | + # ... traduire toutes les clés | |
| 581 | +``` | |
| 582 | + | |
| 583 | +Enregistrement dans le builder : | |
| 584 | + | |
| 585 | +```rust | |
| 586 | +RustwardenApp::builder() | |
| 587 | + .locale_dir("locales/") | |
| 588 | + .supported_locales(vec!["fr".into(), "en".into(), "de".into(), "es".into(), "it".into()]) | |
| 589 | + .default_locale("fr") | |
| 590 | + // ... | |
| 591 | +``` | |
| 592 | + | |
| 593 | +### Pièges à éviter | |
| 594 | + | |
| 595 | +- Une clé manquante dans le fichier YAML fait tomber en fallback sur la locale par défaut, pas sur une erreur. Vérifier l'exhaustivité des clés en comparant avec `fr.yaml`. | |
| 596 | +- Les noms de fichiers doivent correspondre exactement aux codes de locale (`it.yaml` pour `"it"`, pas `italian.yaml`). | |
| 597 | +- Le rechargement des traductions n'est pas dynamique — un redémarrage du serveur est nécessaire après ajout d'un fichier. | |
| 598 | + | |
| 599 | +--- | |
| 600 | + | |
| 601 | +## Recette 9 — Implémenter un provider OAuth custom | |
| 602 | + | |
| 603 | +**Objectif** : ajouter un fournisseur OAuth2/OIDC non supporté nativement (Google, GitHub, Microsoft, Discord le sont). | |
| 604 | + | |
| 605 | +### Structure existante | |
| 606 | + | |
| 607 | +Les providers OAuth sont dans `crates/rustwarden-core/src/services/oauth/`. La feature Cargo `oauth` doit être activée. | |
| 608 | + | |
| 609 | +### Étapes | |
| 610 | + | |
| 611 | +1. Lire `crates/rustwarden-core/src/services/oauth/` pour comprendre le pattern des providers existants. | |
| 612 | +2. Créer `oauth/my_provider.rs` en suivant le même pattern (configuration, échange de code, récupération du profil utilisateur). | |
| 613 | +3. Implémenter la conversion du profil provider vers les champs `OAuthAccount` (email, external_id, provider_name). | |
| 614 | +4. Enregistrer le provider dans `oauth/mod.rs`. | |
| 615 | + | |
| 616 | +### Squelette d'un provider custom | |
| 617 | + | |
| 618 | +```rust | |
| 619 | +// crates/rustwarden-core/src/services/oauth/my_provider.rs | |
| 620 | + | |
| 621 | +use crate::error::AppError; | |
| 622 | + | |
| 623 | +pub struct MyProviderConfig { | |
| 624 | + pub client_id: String, | |
| 625 | + pub client_secret: String, | |
| 626 | + pub redirect_uri: String, | |
| 627 | +} | |
| 628 | + | |
| 629 | +pub struct MyProviderProfile { | |
| 630 | + pub id: String, | |
| 631 | + pub email: String, | |
| 632 | + pub name: String, | |
| 633 | +} | |
| 634 | + | |
| 635 | +impl MyProviderConfig { | |
| 636 | + /// Construit l'URL d'autorisation pour rediriger l'utilisateur. | |
| 637 | + pub fn authorization_url(&self) -> String { | |
| 638 | + format!( | |
| 639 | + "https://my-provider.example.com/oauth/authorize\ | |
| 640 | + ?client_id={}&redirect_uri={}&response_type=code&scope=email", | |
| 641 | + self.client_id, self.redirect_uri | |
| 642 | + ) | |
| 643 | + } | |
| 644 | + | |
| 645 | + /// Échange le code d'autorisation contre un profil utilisateur. | |
| 646 | + pub async fn exchange_code(&self, code: &str) -> Result<MyProviderProfile, AppError> { | |
| 647 | + // 1. POST vers le token endpoint du provider | |
| 648 | + // 2. GET vers le userinfo endpoint avec le bearer token | |
| 649 | + // 3. Parser et retourner MyProviderProfile | |
| 650 | + todo!() | |
| 651 | + } | |
| 652 | +} | |
| 653 | +``` | |
| 654 | + | |
| 655 | +### Pièges à éviter | |
| 656 | + | |
| 657 | +- Ne pas stocker le `client_secret` en dur — le lire depuis une variable d'environnement via `config/oauth.rs`. | |
| 658 | +- Valider le `state` parameter pour éviter les attaques CSRF sur le callback OAuth. | |
| 659 | +- Les secrets OAuth stockés en base (`oauth_accounts`) sont chiffrés avec `CryptoService` (AES-256-GCM). Ne pas stocker de tokens en clair. | |
| 660 | + | |
| 661 | +--- | |
| 662 | + | |
| 663 | +## Recette 10 — Ajouter une background task | |
| 664 | + | |
| 665 | +**Objectif** : exécuter une tâche périodique en arrière-plan (nettoyage, synchronisation, reporting). | |
| 666 | + | |
| 667 | +### Pattern existant | |
| 668 | + | |
| 669 | +Le pattern est établi par [`EmailQueueProcessor`](../../crates/rustwarden-core/src/tasks/email_queue_processor.rs) : | |
| 670 | + | |
| 671 | +```rust | |
| 672 | +pub struct EmailQueueProcessor { | |
| 673 | + db: Arc<DatabaseConnection>, | |
| 674 | + email_service: Arc<EmailService>, | |
| 675 | + config: EmailConfig, | |
| 676 | +} | |
| 677 | + | |
| 678 | +impl EmailQueueProcessor { | |
| 679 | + pub fn start(self) { | |
| 680 | + tokio::spawn(async move { | |
| 681 | + let mut interval = tokio::time::interval(Duration::from_secs(30)); | |
| 682 | + interval.tick().await; // Skip first tick | |
| 683 | + | |
| 684 | + loop { | |
| 685 | + interval.tick().await; | |
| 686 | + if let Err(e) = self.process_queue().await { | |
| 687 | + tracing::error!("Email queue error: {}", e); | |
| 688 | + } | |
| 689 | + } | |
| 690 | + }); | |
| 691 | + } | |
| 692 | +} | |
| 693 | +``` | |
| 694 | + | |
| 695 | +### Exemple — tâche de nettoyage de projets expirés | |
| 696 | + | |
| 697 | +```rust | |
| 698 | +// crates/rustwarden-core/src/tasks/project_cleanup.rs | |
| 699 | + | |
| 700 | +use crate::services::project_service::ProjectService; | |
| 701 | +use sea_orm::DatabaseConnection; | |
| 702 | +use std::sync::Arc; | |
| 703 | +use tokio::time::Duration; | |
| 704 | + | |
| 705 | +/// Supprime les projets archivés depuis plus de 90 jours. | |
| 706 | +pub struct ProjectCleanupTask { | |
| 707 | + db: Arc<DatabaseConnection>, | |
| 708 | +} | |
| 709 | + | |
| 710 | +impl ProjectCleanupTask { | |
| 711 | + pub fn new(db: Arc<DatabaseConnection>) -> Self { | |
| 712 | + Self { db } | |
| 713 | + } | |
| 714 | + | |
| 715 | + /// Démarre la tâche en arrière-plan. | |
| 716 | + /// S'exécute toutes les 24 heures. | |
| 717 | + pub fn start(self) { | |
| 718 | + tokio::spawn(async move { | |
| 719 | + let mut interval = tokio::time::interval(Duration::from_secs(86_400)); | |
| 720 | + interval.tick().await; // Skip first tick | |
| 721 | + | |
| 722 | + loop { | |
| 723 | + interval.tick().await; | |
| 724 | + match ProjectService::cleanup_archived(&self.db).await { | |
| 725 | + Ok(count) => { | |
| 726 | + if count > 0 { | |
| 727 | + tracing::info!("ProjectCleanup: {} projets supprimés", count); | |
| 728 | + } | |
| 729 | + } | |
| 730 | + Err(e) => tracing::error!("ProjectCleanup error: {}", e), | |
| 731 | + } | |
| 732 | + } | |
| 733 | + }); | |
| 734 | + } | |
| 735 | +} | |
| 736 | +``` | |
| 737 | + | |
| 738 | +Démarrage depuis `main.rs` après la construction de l'application : | |
| 739 | + | |
| 740 | +```rust | |
| 741 | +let app = RustwardenApp::builder() | |
| 742 | + .from_env() | |
| 743 | + .build() | |
| 744 | + .await?; | |
| 745 | + | |
| 746 | +// Démarrer la tâche de fond | |
| 747 | +let db = Arc::new(app.database().clone()); | |
| 748 | +ProjectCleanupTask::new(db).start(); | |
| 749 | + | |
| 750 | +app.run().await | |
| 751 | +``` | |
| 752 | + | |
| 753 | +### Pièges à éviter | |
| 754 | + | |
| 755 | +- Appeler `interval.tick().await` une première fois avant la boucle pour éviter une exécution immédiate au démarrage (le premier tick est instantané). | |
| 756 | +- Ne pas `panic!` dans une background task — utiliser `tracing::error!` et continuer la boucle. | |
| 757 | +- Passer la `DatabaseConnection` enveloppée dans `Arc` (pas de `clone()` du pool à chaque tick). | |
| 758 | +- Si la tâche doit pouvoir être arrêtée proprement, utiliser un `tokio::sync::CancellationToken` partagé avec le signal de shutdown du serveur. | |
| 759 | + | |
| 760 | +### Désactiver les background tasks en test | |
| 761 | + | |
| 762 | +```rust | |
| 763 | +RustwardenApp::builder() | |
| 764 | + .without_background_tasks() // désactive email queue, JWT cleanup, etc. | |
| 765 | + .build() | |
| 766 | + .await? | |
| 767 | +``` | |
| 768 | + | |
| 769 | +Les tâches custom démarrées manuellement (comme `ProjectCleanupTask`) ne sont pas concernées par ce flag — les conditionner explicitement en fonction de l'environnement. |
A
docs/developer-guide/06-data-model.md
+534
-0
@@ -0,0 +1,534 @@
| 1 | +# 06 — Modèle de données | |
| 2 | + | |
| 3 | +Ce document explique comment les données sont organisées dans Rustwarden : quelles tables existent, pourquoi elles ont cette forme, comment elles évoluent dans le temps, et comment SeaORM les manipule. L'objectif est de donner à un développeur qui lit le code pour la première fois une carte mentale claire avant de plonger dans les migrations ou les services. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## Vue d'ensemble des domaines | |
| 8 | + | |
| 9 | +Le schéma est découpé en domaines fonctionnels cohérents. Chaque domaine regroupe les tables qui partagent une responsabilité métier. | |
| 10 | + | |
| 11 | +| Domaine | Tables principales | Migration source | | |
| 12 | +|---|---|---| | |
| 13 | +| **Auth / Utilisateurs** | `users`, `user_preferences` | `m20260305_000001` | | |
| 14 | +| **RBAC** | `roles`, `permissions`, `role_permissions`, `user_roles` | `m20260305_000001`, `m20260309_000003` | | |
| 15 | +| **Sessions & tokens** | `refresh_token`, `jwt_blacklist` | `m20260305_000001` | | |
| 16 | +| **Tokens personnels** | `personal_access_tokens` | `m20260422_000007` | | |
| 17 | +| **Mots de passe** | `password_reset_tokens`, `email_validation_tokens`, `password_change_requests` | `m20260305_000001` | | |
| 18 | +| **OAuth** | `oauth_accounts` | `m20260310_000005` | | |
| 19 | +| **TOTP / 2FA** | `user_totp`, `totp_challenges` | `m20260409_000006` | | |
| 20 | +| **Équipes** | `teams`, `team_members` | `m20260423_000008` | | |
| 21 | +| **Ressources** | `resources`, `resource_shares` | `m20260309_000004` | | |
| 22 | +| **Email** | `email_queue`, `email_delivery_status` | `m20260305_000001` | | |
| 23 | +| **Paramètres** | `app_settings` | `m20260306_000002` | | |
| 24 | +| **Audit** | `audit_logs` | `m20260305_000001` | | |
| 25 | +| **Fediverse** | `remote_identities` + colonnes AP sur `users` + colonnes FK sur `team_members`, `resource_shares`, `audit_logs` | `m20260510_000009` | | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## Diagramme ER global | |
| 30 | + | |
| 31 | +Le diagramme suivant représente toutes les tables du schéma core et leurs relations de clé étrangère. Les tables fediverse pures (`fediverse_trusted_domains`, `fediverse_outbound_queue`, `fediverse_auth_challenges`, `fediverse_pending_invites`) vivent dans le migrateur de `rustwarden-fediverse` et sont représentées de façon simplifiée — voir [08-fediverse-integration.md](08-fediverse-integration.md). | |
| 32 | + | |
| 33 | +```mermaid | |
| 34 | +erDiagram | |
| 35 | + users { | |
| 36 | + uuid id PK | |
| 37 | + string username UK | |
| 38 | + string email UK | |
| 39 | + string password_hash | |
| 40 | + bool must_change_password | |
| 41 | + bool email_verified | |
| 42 | + timestamptz email_verified_at | |
| 43 | + timestamptz last_login_at | |
| 44 | + string last_login_ip | |
| 45 | + timestamptz password_changed_at | |
| 46 | + text ap_public_key_pem | |
| 47 | + text ap_private_key_pem | |
| 48 | + bool ap_discoverable | |
| 49 | + timestamptz created_at | |
| 50 | + timestamptz updated_at | |
| 51 | + } | |
| 52 | + | |
| 53 | + user_preferences { | |
| 54 | + uuid id PK | |
| 55 | + uuid user_id FK | |
| 56 | + string theme | |
| 57 | + string language | |
| 58 | + timestamptz created_at | |
| 59 | + timestamptz updated_at | |
| 60 | + } | |
| 61 | + | |
| 62 | + roles { | |
| 63 | + uuid id PK | |
| 64 | + string name UK | |
| 65 | + text description | |
| 66 | + timestamptz created_at | |
| 67 | + timestamptz updated_at | |
| 68 | + } | |
| 69 | + | |
| 70 | + user_roles { | |
| 71 | + uuid user_id FK | |
| 72 | + uuid role_id FK | |
| 73 | + timestamptz created_at | |
| 74 | + } | |
| 75 | + | |
| 76 | + permissions { | |
| 77 | + uuid id PK | |
| 78 | + string name UK | |
| 79 | + text description | |
| 80 | + string category | |
| 81 | + timestamptz created_at | |
| 82 | + } | |
| 83 | + | |
| 84 | + role_permissions { | |
| 85 | + uuid role_id FK | |
| 86 | + uuid permission_id FK | |
| 87 | + } | |
| 88 | + | |
| 89 | + refresh_token { | |
| 90 | + uuid id PK | |
| 91 | + uuid user_id FK | |
| 92 | + string token UK | |
| 93 | + timestamptz expires_at | |
| 94 | + bool revoked | |
| 95 | + timestamptz revoked_at | |
| 96 | + uuid replaced_by FK | |
| 97 | + timestamptz created_at | |
| 98 | + } | |
| 99 | + | |
| 100 | + jwt_blacklist { | |
| 101 | + uuid id PK | |
| 102 | + string token_hash UK | |
| 103 | + uuid user_id FK | |
| 104 | + timestamptz expires_at | |
| 105 | + timestamptz revoked_at | |
| 106 | + timestamptz created_at | |
| 107 | + } | |
| 108 | + | |
| 109 | + personal_access_tokens { | |
| 110 | + uuid id PK | |
| 111 | + uuid user_id FK | |
| 112 | + string name | |
| 113 | + string token_hash UK | |
| 114 | + text[] scopes | |
| 115 | + timestamptz expires_at | |
| 116 | + timestamptz last_used_at | |
| 117 | + timestamptz revoked_at | |
| 118 | + timestamptz created_at | |
| 119 | + } | |
| 120 | + | |
| 121 | + password_reset_tokens { | |
| 122 | + uuid id PK | |
| 123 | + uuid user_id FK | |
| 124 | + string token_hash UK | |
| 125 | + timestamptz expires_at | |
| 126 | + timestamptz used_at | |
| 127 | + timestamptz created_at | |
| 128 | + } | |
| 129 | + | |
| 130 | + email_validation_tokens { | |
| 131 | + uuid id PK | |
| 132 | + uuid user_id FK | |
| 133 | + string token_hash UK | |
| 134 | + timestamptz expires_at | |
| 135 | + timestamptz used_at | |
| 136 | + timestamptz created_at | |
| 137 | + } | |
| 138 | + | |
| 139 | + password_change_requests { | |
| 140 | + uuid id PK | |
| 141 | + uuid user_id FK | |
| 142 | + text new_password_hash | |
| 143 | + string confirmation_token UK | |
| 144 | + timestamptz expires_at | |
| 145 | + timestamptz confirmed_at | |
| 146 | + timestamptz email_sent_at | |
| 147 | + int retry_count | |
| 148 | + timestamptz created_at | |
| 149 | + } | |
| 150 | + | |
| 151 | + oauth_accounts { | |
| 152 | + uuid id PK | |
| 153 | + uuid user_id FK | |
| 154 | + string provider | |
| 155 | + string provider_user_id | |
| 156 | + string email | |
| 157 | + string display_name | |
| 158 | + string avatar_url | |
| 159 | + timestamptz created_at | |
| 160 | + timestamptz updated_at | |
| 161 | + } | |
| 162 | + | |
| 163 | + user_totp { | |
| 164 | + uuid id PK | |
| 165 | + uuid user_id FK UK | |
| 166 | + text secret_encrypted | |
| 167 | + bool enabled | |
| 168 | + text backup_codes_encrypted | |
| 169 | + int backup_codes_remaining | |
| 170 | + timestamptz enabled_at | |
| 171 | + timestamptz created_at | |
| 172 | + timestamptz updated_at | |
| 173 | + } | |
| 174 | + | |
| 175 | + totp_challenges { | |
| 176 | + uuid id PK | |
| 177 | + uuid user_id FK | |
| 178 | + string challenge_token UK | |
| 179 | + int attempts | |
| 180 | + int max_attempts | |
| 181 | + timestamptz expires_at | |
| 182 | + timestamptz created_at | |
| 183 | + } | |
| 184 | + | |
| 185 | + teams { | |
| 186 | + uuid id PK | |
| 187 | + string slug UK | |
| 188 | + string name | |
| 189 | + uuid owner_id FK | |
| 190 | + timestamptz created_at | |
| 191 | + } | |
| 192 | + | |
| 193 | + team_members { | |
| 194 | + uuid id PK | |
| 195 | + uuid team_id FK | |
| 196 | + uuid user_id | |
| 197 | + uuid remote_identity_id | |
| 198 | + string role | |
| 199 | + timestamptz joined_at | |
| 200 | + } | |
| 201 | + | |
| 202 | + resources { | |
| 203 | + uuid id PK | |
| 204 | + string resource_type | |
| 205 | + uuid resource_id | |
| 206 | + uuid owner_id FK | |
| 207 | + bool is_public | |
| 208 | + timestamptz created_at | |
| 209 | + } | |
| 210 | + | |
| 211 | + resource_shares { | |
| 212 | + uuid id PK | |
| 213 | + uuid resource_id FK | |
| 214 | + uuid shared_with_user_id | |
| 215 | + uuid shared_with_team_id | |
| 216 | + uuid shared_with_remote_identity_id | |
| 217 | + string permission_level | |
| 218 | + uuid shared_by_user_id FK | |
| 219 | + timestamptz created_at | |
| 220 | + } | |
| 221 | + | |
| 222 | + email_queue { | |
| 223 | + uuid id PK | |
| 224 | + string to_email | |
| 225 | + string to_name | |
| 226 | + string from_email | |
| 227 | + string from_name | |
| 228 | + string subject | |
| 229 | + text body_html | |
| 230 | + text body_text | |
| 231 | + string email_type | |
| 232 | + string status | |
| 233 | + int attempts | |
| 234 | + timestamptz last_attempt_at | |
| 235 | + timestamptz sent_at | |
| 236 | + text error_message | |
| 237 | + string smtp_code | |
| 238 | + text smtp_response | |
| 239 | + timestamptz created_at | |
| 240 | + timestamptz updated_at | |
| 241 | + } | |
| 242 | + | |
| 243 | + email_delivery_status { | |
| 244 | + uuid id PK | |
| 245 | + string email_type | |
| 246 | + string recipient | |
| 247 | + string template | |
| 248 | + string state | |
| 249 | + string smtp_code | |
| 250 | + text smtp_response | |
| 251 | + int attempt_count | |
| 252 | + timestamptz last_attempt_at | |
| 253 | + timestamptz last_state_at | |
| 254 | + uuid correlation_id | |
| 255 | + timestamptz created_at | |
| 256 | + timestamptz updated_at | |
| 257 | + } | |
| 258 | + | |
| 259 | + app_settings { | |
| 260 | + uuid id PK | |
| 261 | + string key UK | |
| 262 | + text value | |
| 263 | + text description | |
| 264 | + uuid updated_by FK | |
| 265 | + timestamptz updated_at | |
| 266 | + } | |
| 267 | + | |
| 268 | + audit_logs { | |
| 269 | + uuid id PK | |
| 270 | + uuid user_id FK | |
| 271 | + string action | |
| 272 | + uuid target_user_id FK | |
| 273 | + uuid actor_remote_identity_id FK | |
| 274 | + uuid target_remote_identity_id FK | |
| 275 | + string level | |
| 276 | + text message | |
| 277 | + timestamptz created_at | |
| 278 | + } | |
| 279 | + | |
| 280 | + remote_identities { | |
| 281 | + uuid id PK | |
| 282 | + text ap_id UK | |
| 283 | + text username | |
| 284 | + text domain | |
| 285 | + text display_name | |
| 286 | + text summary_html | |
| 287 | + text inbox_url | |
| 288 | + text shared_inbox_url | |
| 289 | + text public_key_pem | |
| 290 | + text icon_url | |
| 291 | + text actor_type | |
| 292 | + jsonb raw_json | |
| 293 | + timestamptz last_fetched_at | |
| 294 | + timestamptz disabled_at | |
| 295 | + timestamptz created_at | |
| 296 | + timestamptz updated_at | |
| 297 | + } | |
| 298 | + | |
| 299 | + users ||--o{ user_preferences : "1:1" | |
| 300 | + users ||--o{ user_roles : "a" | |
| 301 | + roles ||--o{ user_roles : "a" | |
| 302 | + roles ||--o{ role_permissions : "a" | |
| 303 | + permissions ||--o{ role_permissions : "a" | |
| 304 | + users ||--o{ refresh_token : "a" | |
| 305 | + refresh_token ||--o| refresh_token : "replaced_by" | |
| 306 | + users ||--o{ jwt_blacklist : "a" | |
| 307 | + users ||--o{ personal_access_tokens : "a" | |
| 308 | + users ||--o{ password_reset_tokens : "a" | |
| 309 | + users ||--o{ email_validation_tokens : "a" | |
| 310 | + users ||--o{ password_change_requests : "a" | |
| 311 | + users ||--o{ oauth_accounts : "a" | |
| 312 | + users ||--o| user_totp : "1:1" | |
| 313 | + users ||--o{ totp_challenges : "a" | |
| 314 | + users ||--o{ teams : "owner" | |
| 315 | + teams ||--o{ team_members : "a" | |
| 316 | + users ||--o{ team_members : "member local" | |
| 317 | + remote_identities ||--o{ team_members : "member remote" | |
| 318 | + users ||--o{ resources : "owner" | |
| 319 | + resources ||--o{ resource_shares : "a" | |
| 320 | + users ||--o{ resource_shares : "shared_with" | |
| 321 | + teams ||--o{ resource_shares : "shared_with_team" | |
| 322 | + remote_identities ||--o{ resource_shares : "shared_with_remote" | |
| 323 | + users ||--o{ audit_logs : "actor" | |
| 324 | + users ||--o{ audit_logs : "target" | |
| 325 | + remote_identities ||--o{ audit_logs : "actor_remote" | |
| 326 | + remote_identities ||--o{ audit_logs : "target_remote" | |
| 327 | + users ||--o{ app_settings : "updated_by" | |
| 328 | +``` | |
| 329 | + | |
| 330 | +--- | |
| 331 | + | |
| 332 | +## Conventions de nommage | |
| 333 | + | |
| 334 | +Rustwarden suit des conventions strictes et homogènes sur l'ensemble du schéma. | |
| 335 | + | |
| 336 | +**Identifiants** | |
| 337 | + | |
| 338 | +Toutes les clés primaires sont de type `UUID`. Les valeurs sont générées avec `gen_random_uuid()` (via l'extension PostgreSQL `pgcrypto`). Il n'y a aucun entier auto-incrémenté en tant que PK. | |
| 339 | + | |
| 340 | +**Clés étrangères** | |
| 341 | + | |
| 342 | +Les colonnes FK portent le suffixe `_id` et référencent explicitement la table cible avec `ON DELETE CASCADE` pour les données dépendantes (ex. un utilisateur supprimé emporte ses tokens) ou `ON DELETE SET NULL` pour les données d'audit qui doivent être conservées (ex. `audit_logs.user_id`). | |
| 343 | + | |
| 344 | +**Timestamps** | |
| 345 | + | |
| 346 | +| Colonne | Sémantique | | |
| 347 | +|---|---| | |
| 348 | +| `created_at` | Horodatage d'insertion, jamais modifié | | |
| 349 | +| `updated_at` | Mis à jour à chaque modification via le service | | |
| 350 | +| `deleted_at` | Présent uniquement là où un soft-delete est nécessaire (non utilisé dans le schéma core actuel) | | |
| 351 | +| `disabled_at` | Utilisé dans `remote_identities` pour indiquer qu'un acteur AP distant est désactivé sans suppression | | |
| 352 | +| `expires_at` | Présent sur toutes les tables à durée de vie limitée : `refresh_token`, `jwt_blacklist`, `password_reset_tokens`, `email_validation_tokens`, `password_change_requests`, `totp_challenges`, `personal_access_tokens` | | |
| 353 | + | |
| 354 | +**snake_case partout** | |
| 355 | + | |
| 356 | +Noms de tables et de colonnes en `snake_case`. Les entités SeaORM utilisent `PascalCase` en Rust et mappent automatiquement vers `snake_case` SQL. | |
| 357 | + | |
| 358 | +--- | |
| 359 | + | |
| 360 | +## Cycle de vie des tables temporelles | |
| 361 | + | |
| 362 | +Plusieurs tables contiennent des données éphémères. Comprendre leur cycle de vie permet d'éviter les fuites mémoire en base. | |
| 363 | + | |
| 364 | +### `jwt_blacklist` | |
| 365 | + | |
| 366 | +La table stocke le hash SHA-256 des JWT révoqués avant leur expiration naturelle (logout explicite, révocation admin, détection de réutilisation de refresh token). Chaque entrée porte un `expires_at` calé sur la durée de vie du JWT original. | |
| 367 | + | |
| 368 | +```mermaid | |
| 369 | +flowchart LR | |
| 370 | + A[JWT émis] -->|révocation explicite| B[Insertion dans jwt_blacklist] | |
| 371 | + B -->|expires_at atteint| C[Suppression par background task] | |
| 372 | + A -->|expiration naturelle| D[JWT rejeté par exp claim\nsans toucher la blacklist] | |
| 373 | +``` | |
| 374 | + | |
| 375 | +La background task `JwtBlacklistCleanupProcessor` supprime périodiquement les entrées dont `expires_at < NOW()`. Les entrées expirées sont inoffensives mais consomment de l'espace inutilement. | |
| 376 | + | |
| 377 | +### `email_queue` | |
| 378 | + | |
| 379 | +La file d'emails suit un cycle à trois états contraints par une `CHECK` constraint : | |
| 380 | + | |
| 381 | +```mermaid | |
| 382 | +stateDiagram-v2 | |
| 383 | + [*] --> pending : INSERT | |
| 384 | + pending --> sent : envoi SMTP réussi | |
| 385 | + pending --> failed : max tentatives atteint | |
| 386 | + sent --> [*] | |
| 387 | + failed --> [*] | |
| 388 | +``` | |
| 389 | + | |
| 390 | +Les colonnes clés : `status` (`pending` / `sent` / `failed`), `attempts` (compteur de tentatives), `last_attempt_at`, `smtp_code`, `smtp_response` (pour le diagnostic). La background task de mail lit les entrées `pending` et tente l'envoi SMTP. | |
| 391 | + | |
| 392 | +### `password_reset_tokens` | |
| 393 | + | |
| 394 | +Token à usage unique avec TTL court (typiquement 30 minutes). La colonne `used_at` est positionnée lors de la consommation. Une fois `used_at IS NOT NULL`, le token est rejeté même s'il n'a pas encore expiré. Un index partiel sur `(user_id) WHERE confirmed_at IS NULL` garantit qu'un seul reset est actif par utilisateur à la fois. | |
| 395 | + | |
| 396 | +### `totp_challenges` | |
| 397 | + | |
| 398 | +Créées lors du second facteur en cours de login, TTL de 5 minutes, maximum 5 tentatives (`max_attempts`). La cleanup task `TotpService::cleanup_expired_challenges` supprime les challenges expirés. | |
| 399 | + | |
| 400 | +### `personal_access_tokens` | |
| 401 | + | |
| 402 | +Les PAT n'ont pas de TTL obligatoire (`expires_at` nullable). La suppression physique n'intervient que si le token est à la fois expiré **et** révoqué (`cleanup_expired` — conserver les tokens expirés mais non révoqués pour l'audit). | |
| 403 | + | |
| 404 | +--- | |
| 405 | + | |
| 406 | +## Migrations | |
| 407 | + | |
| 408 | +Les migrations SeaORM sont définies dans [`crates/rustwarden-core/src/migrations/`](../../crates/rustwarden-core/src/migrations/). Chaque fichier est nommé `m<date>_<seq>_<description>.rs` et implémente le trait `MigrationTrait` avec deux méthodes : `up` et `down`. | |
| 409 | + | |
| 410 | +```mermaid | |
| 411 | +flowchart LR | |
| 412 | + A[Éditer\nm<date>_<N>_desc.rs] --> B[cargo run --bin migrate] | |
| 413 | + B --> C{Base à jour ?} | |
| 414 | + C -->|Non| D[Applique up de\nchaque migration manquante] | |
| 415 | + C -->|Oui| E[No-op] | |
| 416 | + D --> F[Schema PostgreSQL\nmis à jour] | |
| 417 | + F --> G[sea-orm-cli generate entity\noptionnel] | |
| 418 | + G --> H[Entités dans\ncrates/rustwarden-core/src/models/] | |
| 419 | +``` | |
| 420 | + | |
| 421 | +**Ordre d'exécution** : les migrations s'appliquent dans l'ordre lexicographique de leurs noms. La table `seaql_migrations` trace ce qui a déjà été appliqué. | |
| 422 | + | |
| 423 | +**Rollback** : `cargo run --bin migrate -- down` applique la méthode `down` de la dernière migration. | |
| 424 | + | |
| 425 | +**Règle constitution** : les migrations ne doivent contenir aucun SQL brut (clause `execute_unprepared`) sauf quand le DSL SeaORM ne permet pas d'exprimer l'opération — par exemple les index partiels PostgreSQL (`WHERE ...`), les contraintes `CHECK` complexes, ou la modification de colonnes existantes. Dans ces cas précis, du SQL brut est acceptable et documenté dans le code source. | |
| 426 | + | |
| 427 | +--- | |
| 428 | + | |
| 429 | +## Patterns SeaORM utilisés | |
| 430 | + | |
| 431 | +### Entités et ActiveModel | |
| 432 | + | |
| 433 | +Chaque table correspond à une entité dans `crates/rustwarden-core/src/models/`. Le pattern de base pour une insertion : | |
| 434 | + | |
| 435 | +```rust | |
| 436 | +let model = user_totp::ActiveModel { | |
| 437 | + id: Set(Uuid::new_v4()), | |
| 438 | + user_id: Set(user_id), | |
| 439 | + secret_encrypted: Set(secret_encrypted), | |
| 440 | + enabled: Set(false), | |
| 441 | + // ... | |
| 442 | +}; | |
| 443 | +model.insert(db).await?; | |
| 444 | +``` | |
| 445 | + | |
| 446 | +Pour une mise à jour partielle, on convertit le modèle existant en `ActiveModel` et on ne positionne que les champs modifiés : | |
| 447 | + | |
| 448 | +```rust | |
| 449 | +let mut active: user_totp::ActiveModel = record.into(); | |
| 450 | +active.enabled = Set(true); | |
| 451 | +active.enabled_at = Set(Some(now)); | |
| 452 | +active.update(db).await?; | |
| 453 | +``` | |
| 454 | + | |
| 455 | +### Relations via l'enum `Relation` | |
| 456 | + | |
| 457 | +Les relations FK sont déclarées dans l'entité via l'enum `Relation` et le trait `RelationTrait`. Cela permet les requêtes `find_with_related` sans JOIN manuel. | |
| 458 | + | |
| 459 | +### Pagination via `paginate()` | |
| 460 | + | |
| 461 | +```rust | |
| 462 | +let paginator = User::find().paginate(db, 20); | |
| 463 | +let total_pages = paginator.num_pages().await?; | |
| 464 | +let page = paginator.fetch_page(0).await?; | |
| 465 | +``` | |
| 466 | + | |
| 467 | +### Transactions | |
| 468 | + | |
| 469 | +Les opérations multi-tables qui doivent être atomiques utilisent les transactions SeaORM : | |
| 470 | + | |
| 471 | +```rust | |
| 472 | +db.transaction::<_, (), AppError>(|txn| { | |
| 473 | + Box::pin(async move { | |
| 474 | + // toutes les opérations utilisent `txn` au lieu de `db` | |
| 475 | + Ok(()) | |
| 476 | + }) | |
| 477 | +}).await?; | |
| 478 | +``` | |
| 479 | + | |
| 480 | +### Query DSL vs `find_by_id` vs raw SQL | |
| 481 | + | |
| 482 | +| Méthode | Usage | | |
| 483 | +|---|---| | |
| 484 | +| `Entity::find_by_id(id)` | Lookup par PK — le cas le plus fréquent | | |
| 485 | +| `Entity::find().filter(Column::Foo.eq(val))` | Requêtes avec conditions | | |
| 486 | +| `Entity::find().filter(...).order_by_desc(...)` | Tri + filtre combinés | | |
| 487 | +| SQL brut | **Interdit** par la constitution. Uniquement dans les migrations pour les opérations non exprimables en DSL | | |
| 488 | + | |
| 489 | +--- | |
| 490 | + | |
| 491 | +## Index et performances | |
| 492 | + | |
| 493 | +Les index critiques pour les chemins chauds sont listés ci-dessous : | |
| 494 | + | |
| 495 | +| Table | Colonne(s) | Type | Raison | | |
| 496 | +|---|---|---|---| | |
| 497 | +| `users` | `email` | UNIQUE | Login par email — requête systématique | | |
| 498 | +| `users` | `username` | UNIQUE | Login par username | | |
| 499 | +| `users` | `email_verified` | BTREE | Filtrage des comptes non vérifiés | | |
| 500 | +| `jwt_blacklist` | `token_hash` | UNIQUE | Vérification de révocation à chaque requête | | |
| 501 | +| `jwt_blacklist` | `expires_at` | BTREE | Cleanup de la background task | | |
| 502 | +| `refresh_token` | `token` | UNIQUE | Échange de refresh token | | |
| 503 | +| `refresh_token` | `expires_at` | BTREE | Cleanup | | |
| 504 | +| `personal_access_tokens` | `token_hash` | UNIQUE | Vérification PAT à chaque requête API | | |
| 505 | +| `personal_access_tokens` | `(user_id, name)` | UNIQUE | Unicité du nom par utilisateur | | |
| 506 | +| `personal_access_tokens` | `expires_at` | BTREE | Cleanup | | |
| 507 | +| `password_reset_tokens` | `token_hash` | UNIQUE | Lookup lors de la réinitialisation | | |
| 508 | +| `password_reset_tokens` | `expires_at` | BTREE | Cleanup | | |
| 509 | +| `oauth_accounts` | `(provider, provider_user_id)` | UNIQUE | Lookup lors du callback OAuth | | |
| 510 | +| `oauth_accounts` | `user_id` | BTREE | Liste des comptes liés pour un utilisateur | | |
| 511 | +| `app_settings` | `key` | UNIQUE | Lookup par clé de paramètre | | |
| 512 | +| `remote_identities` | `ap_id` | UNIQUE | Résolution de l'acteur AP par URL | | |
| 513 | +| `remote_identities` | `(username, domain)` | UNIQUE | Résolution par handle `@user@domain` | | |
| 514 | +| `team_members` | `remote_identity_id` | PARTIEL (NOT NULL) | Index fediverse sur la colonne nullable | | |
| 515 | +| `resource_shares` | `shared_with_remote_identity_id` | PARTIEL (NOT NULL) | Index fediverse sur la colonne nullable | | |
| 516 | + | |
| 517 | +--- | |
| 518 | + | |
| 519 | +## Backup et restore | |
| 520 | + | |
| 521 | +La base PostgreSQL de développement est définie dans [`database/`](../../database/) via un `docker-compose.yml`. Pour les environnements de production, la stratégie de backup recommandée est `pg_dump` avec restauration via `pg_restore`. | |
| 522 | + | |
| 523 | +```bash | |
| 524 | +# Backup logique | |
| 525 | +pg_dump -Fc -U rustwarden rustwarden > backup_$(date +%Y%m%d).dump | |
| 526 | + | |
| 527 | +# Restore | |
| 528 | +pg_restore -U rustwarden -d rustwarden backup_20260517.dump | |
| 529 | + | |
| 530 | +# Après restore, rejouer les migrations si la base vient d'un schéma antérieur | |
| 531 | +cargo run --bin migrate | |
| 532 | +``` | |
| 533 | + | |
| 534 | +Les credentials PostgreSQL de développement sont dans `database/.env`. Ne jamais versionner ces credentials en production. |
A
docs/developer-guide/07-security-model.md
+354
-0
@@ -0,0 +1,354 @@
| 1 | +# 07 — Modèle de sécurité | |
| 2 | + | |
| 3 | +Ce document décrit les contrôles de sécurité mis en œuvre dans Rustwarden : comment les tokens sont forgés et révoqués, comment le CSRF est bloqué, comment les mots de passe et les secrets OAuth sont protégés, et où se trouvent les limites connues. L'objectif est de donner à un auditeur ou à un intégrateur une vue complète et honnête — y compris des points d'attention explicites. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## Vue d'ensemble des contrôles | |
| 8 | + | |
| 9 | +| Vecteur d'attaque | Contrôle mitigant | Fichier | | |
| 10 | +|---|---|---| | |
| 11 | +| Usurpation d'identité (token forgé) | Signature HMAC-SHA256, validation issuer + audience | [`auth/jwt.rs`](../../crates/rustwarden-core/src/auth/jwt.rs) | | |
| 12 | +| Replay de JWT révoqué | Blacklist `jwt_blacklist` consultée à chaque requête | [`services/jwt_blacklist_service.rs`](../../crates/rustwarden-core/src/services/jwt_blacklist_service.rs) | | |
| 13 | +| Vol de password hash | bcrypt coût 12 (min), résistant aux GPU | [`services/password_service.rs`](../../crates/rustwarden-core/src/services/password_service.rs) | | |
| 14 | +| Exposition de secret OAuth/TOTP en DB | Chiffrement AES-256-GCM avant insertion | [`services/crypto_service.rs`](../../crates/rustwarden-core/src/services/crypto_service.rs) | | |
| 15 | +| CSRF (Cross-Site Request Forgery) | Token de session 256 bits, comparaison constant-time | [`middleware/csrf.rs`](../../crates/rustwarden-core/src/middleware/csrf.rs) | | |
| 16 | +| Brute-force login / reset password | Rate limiter in-process, fenêtre glissante | [`services/rate_limit_service.rs`](../../crates/rustwarden-core/src/services/rate_limit_service.rs) | | |
| 17 | +| Timing oracle sur comparaisons | `subtle::ConstantTimeEq` partout | [`middleware/csrf.rs`](../../crates/rustwarden-core/src/middleware/csrf.rs), [`services/personal_token_service.rs`](../../crates/rustwarden-core/src/services/personal_token_service.rs) | | |
| 18 | +| XSS via injection de contenu | CSP appliquée sur toutes les réponses | [`app.rs`](../../crates/rustwarden-core/src/app.rs#L737) | | |
| 19 | +| Clickjacking | `frame-ancestors 'none'` dans CSP + X-Frame-Options | [`app.rs`](../../crates/rustwarden-core/src/app.rs#L737) | | |
| 20 | +| Open redirect | Validation du paramètre `redirect` (doit commencer par `/`) | middleware `auth_required` | | |
| 21 | +| Fuite de session via TOTP brute-force | Max 5 tentatives par challenge, 5 min TTL | [`services/totp_service.rs`](../../crates/rustwarden-core/src/services/totp_service.rs) | | |
| 22 | +| Élévation de privilèges via lien OAuth | Vérification email vérifié par le provider | [`services/oauth/service.rs`](../../crates/rustwarden-core/src/services/oauth/service.rs) | | |
| 23 | +| Déni de service mémoire sur rate limiter | Cap à 100 000 entrées distinctes | [`services/rate_limit_service.rs`](../../crates/rustwarden-core/src/services/rate_limit_service.rs) | | |
| 24 | + | |
| 25 | +--- | |
| 26 | + | |
| 27 | +## Cryptographie utilisée | |
| 28 | + | |
| 29 | +### JWT — HS256 | |
| 30 | + | |
| 31 | +Les tokens JWT sont signés avec l'algorithme **HS256** (HMAC-SHA256) via la crate `jsonwebtoken 10`. La clé est lue depuis la variable d'environnement `JWT_SECRET`. `AuthConfig` rejette au démarrage les valeurs trop courtes ou les placeholders connus (`changeme`, `secret`, etc.). | |
| 32 | + | |
| 33 | +Chaque token porte un `jti` (JWT ID) unique (UUID v4) qui permet la révocation individuelle sans invalider les autres tokens de l'utilisateur. | |
| 34 | + | |
| 35 | +La validation exige : | |
| 36 | +- la signature HMAC correcte, | |
| 37 | +- `exp` non dépassé, | |
| 38 | +- `iss` et `aud` correspondant à `AuthConfig::jwt_issuer`. | |
| 39 | + | |
| 40 | +### bcrypt pour les mots de passe | |
| 41 | + | |
| 42 | +Les mots de passe sont hachés avec bcrypt. Le coût par défaut est **12**, configurable via la variable `BCRYPT_COST` (clampé entre 12 et 20 pour éviter des valeurs non sécurisées ou bloquantes). Le hachage est réalisé sur un thread bloquant dédié (`tokio::task::spawn_blocking`) pour ne pas bloquer le runtime async. | |
| 43 | + | |
| 44 | +``` | |
| 45 | +coût effectif = BCRYPT_COST.clamp(12, 20) (défaut : 12) | |
| 46 | +durée typique à coût 12 : ~100–300 ms | |
| 47 | +``` | |
| 48 | + | |
| 49 | +### AES-256-GCM pour les secrets en base | |
| 50 | + | |
| 51 | +Les secrets sensibles stockés en base (secrets TOTP, codes de backup TOTP) sont chiffrés avec **AES-256-GCM** via la crate `aes-gcm`. La clé est lue depuis `ENCRYPTION_KEY` (ou `OAUTH_ENCRYPTION_KEY` — alias legacy), encodée en hexadécimal (64 caractères = 32 octets). | |
| 52 | + | |
| 53 | +Format de stockage : `enc:<base64(nonce || ciphertext)>`. Le préfixe `enc:` permet de distinguer les valeurs chiffrées des valeurs legacy en clair (compatibilité migration). | |
| 54 | + | |
| 55 | +Le nonce (12 octets) est généré aléatoirement par `OsRng` pour chaque chiffrement. Un nonce différent par opération garantit la sécurité sémantique. | |
| 56 | + | |
| 57 | +Référence : [`services/crypto_service.rs`](../../crates/rustwarden-core/src/services/crypto_service.rs) | |
| 58 | + | |
| 59 | +### SHA-256 pour les personal access tokens | |
| 60 | + | |
| 61 | +Le plaintext d'un PAT (32 octets aléatoires encodés hex, soit 64 caractères) est haché avec SHA-256 avant stockage. Seul le digest hex est persisté. Le plaintext est retourné une seule fois lors de la création et n'est jamais journalisé. | |
| 62 | + | |
| 63 | +### Comparaisons constant-time | |
| 64 | + | |
| 65 | +Partout où une comparaison de token peut exposer un timing oracle, Rustwarden utilise `subtle::ConstantTimeEq` : | |
| 66 | + | |
| 67 | +- Validation du token CSRF (header vs session) | |
| 68 | +- Re-vérification du hash PAT après lookup SQL | |
| 69 | +- Validation du token de réinitialisation de mot de passe | |
| 70 | + | |
| 71 | +--- | |
| 72 | + | |
| 73 | +## JWT — anatomie | |
| 74 | + | |
| 75 | +```mermaid | |
| 76 | +flowchart LR | |
| 77 | + subgraph Header | |
| 78 | + ALG["alg: HS256"] | |
| 79 | + TYP["typ: JWT"] | |
| 80 | + end | |
| 81 | + subgraph Payload | |
| 82 | + SUB["sub: UUID utilisateur"] | |
| 83 | + JTI["jti: UUID unique du token"] | |
| 84 | + ISS["iss: jwt_issuer"] | |
| 85 | + AUD["aud: jwt_issuer"] | |
| 86 | + EXP["exp: timestamp expiration"] | |
| 87 | + IAT["iat: timestamp émission"] | |
| 88 | + USERNAME["username: string"] | |
| 89 | + ROLES["roles: string[]"] | |
| 90 | + PERMS["permissions: string[]"] | |
| 91 | + KIND["kind: local | remote"] | |
| 92 | + RID["remote_id: UUID? (RFC 0007)"] | |
| 93 | + end | |
| 94 | + subgraph Signature | |
| 95 | + HMAC["HMAC-SHA256(header.payload, JWT_SECRET)"] | |
| 96 | + end | |
| 97 | + | |
| 98 | + Header --> Payload --> Signature | |
| 99 | +``` | |
| 100 | + | |
| 101 | +Les claims `kind` et `remote_id` ont été introduits par RFC 0007 pour permettre aux middlewares de distinguer un compte local d'une identité fédérée (`remote_identities.id`) sans requête en base. Les tokens antérieurs à RFC 0007 désérialisent `kind` comme `local` par défaut. | |
| 102 | + | |
| 103 | +**Important** : les claims ne sont pas chiffrés, uniquement signés. Ne jamais y placer de données sensibles (mot de passe, secret). | |
| 104 | + | |
| 105 | +--- | |
| 106 | + | |
| 107 | +## JWT — états et transitions | |
| 108 | + | |
| 109 | +```mermaid | |
| 110 | +stateDiagram-v2 | |
| 111 | + [*] --> Generated : JwtService::generate_token() | |
| 112 | + | |
| 113 | + Generated --> Active : Émis au client | |
| 114 | + | |
| 115 | + Active --> Expired : exp atteint | |
| 116 | + Active --> Blacklisted : logout / révocation admin\nJwtBlacklistService::blacklist() | |
| 117 | + Active --> Revoked : refresh token réutilisé\n(détection de vol) | |
| 118 | + | |
| 119 | + Expired --> [*] : rejeté par validate_token() (ExpiredSignature) | |
| 120 | + Blacklisted --> [*] : rejeté par JwtBlacklistService::is_blacklisted() | |
| 121 | + Revoked --> [*] : rejeté (blacklisté + tous les tokens de la chaîne révoqués) | |
| 122 | +``` | |
| 123 | + | |
| 124 | +La blacklist est nettoyée en arrière-plan : les entrées dont `expires_at < NOW()` sont supprimées car elles ne peuvent plus être présentées avec une signature valide. | |
| 125 | + | |
| 126 | +--- | |
| 127 | + | |
| 128 | +## CSRF | |
| 129 | + | |
| 130 | +Le middleware CSRF protège toutes les routes non-GET/HEAD/OPTIONS contre les requêtes forgées depuis un autre domaine. | |
| 131 | + | |
| 132 | +```mermaid | |
| 133 | +sequenceDiagram | |
| 134 | + participant B as Navigateur | |
| 135 | + participant S as Rustwarden | |
| 136 | + | |
| 137 | + B->>S: GET /form | |
| 138 | + S->>S: get_or_create_csrf_token(session) | |
| 139 | + Note right of S: Génère 32 octets aléatoires\nencodés hex (64 chars) | |
| 140 | + S->>B: 200 OK<br/>cookie: session=...<br/>HTML: <input name="csrf_token" value="..."><br/>ou header X-CSRF-Token injecté par JS | |
| 141 | + | |
| 142 | + B->>S: POST /submit<br/>X-CSRF-Token: <token> (ou form field) | |
| 143 | + S->>S: Extraire token depuis header ou form body | |
| 144 | + S->>S: csrf_tokens_match(incoming, session_token)<br/>subtle::ConstantTimeEq | |
| 145 | + alt Token valide | |
| 146 | + S->>B: 200 OK | |
| 147 | + else Token absent ou incorrect | |
| 148 | + S->>B: 403 Forbidden | |
| 149 | + end | |
| 150 | +``` | |
| 151 | + | |
| 152 | +**Bypass pour les API SPA** : si la requête porte un header `Authorization: Bearer ...`, le middleware CSRF est court-circuité. Un client API qui transporte son JWT manuellement n'est pas vulnérable au CSRF car le navigateur ne peut pas ajouter ce header automatiquement depuis un autre domaine. | |
| 153 | + | |
| 154 | +**Deux modes** : | |
| 155 | +- `csrf_protection` (défaut) : token uniquement dans le header `X-CSRF-Token`. | |
| 156 | +- `csrf_protection_with_config` avec `accept_form_body = true` : accepte aussi le champ `csrf_token` dans un corps `application/x-www-form-urlencoded`, utile pour les formulaires SSR sans JavaScript. Les corps multipart ne sont jamais bufférisés (risque d'uploads volumineux). | |
| 157 | + | |
| 158 | +Référence : [`middleware/csrf.rs`](../../crates/rustwarden-core/src/middleware/csrf.rs) | |
| 159 | + | |
| 160 | +--- | |
| 161 | + | |
| 162 | +## Rate limiting | |
| 163 | + | |
| 164 | +```mermaid | |
| 165 | +flowchart TD | |
| 166 | + REQ[Requête entrante] --> EXTRACT[Extraire client_id\nIP ou user_id] | |
| 167 | + EXTRACT --> LOOKUP[Lookup dans HashMap en mémoire\nMutex<HashMap<String, WindowCounter>>] | |
| 168 | + LOOKUP --> WINDOW{Fenêtre expirée ?} | |
| 169 | + WINDOW -->|Oui| RESET[Réinitialiser compteur\nwindow_start = now] | |
| 170 | + WINDOW -->|Non| CHECK | |
| 171 | + RESET --> CHECK[count += 1\ncount > max ?] | |
| 172 | + CHECK -->|Non| OK[Passer la requête] | |
| 173 | + CHECK -->|Oui| REJECT[429 Too Many Requests\nRetry-After: N secondes] | |
| 174 | + LOOKUP --> CAPCHECK{Taille map >= 100 000 ?} | |
| 175 | + CAPCHECK -->|Oui et clé inconnue| REJECT | |
| 176 | +``` | |
| 177 | + | |
| 178 | +Deux modes disponibles : | |
| 179 | +- **Fenêtre fixe** (`check` / `check_window`) : compteur remis à zéro à chaque fin de fenêtre. Simple et peu coûteux. | |
| 180 | +- **Fenêtre glissante** (`check_sliding_window`) : timestamp de chaque tentative stocké, rejet si plus de N tentatives dans les derniers W secondes. Plus strict, utilisé pour les flows sensibles (reset de mot de passe, envoi de code TOTP). | |
| 181 | + | |
| 182 | +**Limitation connue** : le rate limiter est **in-process uniquement**. Dans un déploiement multi-réplicas, chaque instance maintient son propre compteur. Un attaquant qui répartit ses requêtes entre N réplicas peut multiplier son quota par N. La solution prévue en V2 est un backend partagé (Redis). Cette limitation est documentée dans le code source lui-même. | |
| 183 | + | |
| 184 | +Référence : [`services/rate_limit_service.rs`](../../crates/rustwarden-core/src/services/rate_limit_service.rs) | |
| 185 | + | |
| 186 | +--- | |
| 187 | + | |
| 188 | +## Complexité des mots de passe | |
| 189 | + | |
| 190 | +La politique de mot de passe est appliquée lors de la création et du changement de mot de passe via le service de validation. Le middleware `password_complexity` vérifie : | |
| 191 | + | |
| 192 | +- Longueur minimale configurable (défaut : 8 caractères) | |
| 193 | +- Au moins une majuscule, une minuscule, un chiffre (selon configuration) | |
| 194 | +- Pas de correspondance avec les placeholders connus | |
| 195 | + | |
| 196 | +Les administrateurs peuvent forcer un changement de mot de passe via le flag `must_change_password` sur la table `users`. Le middleware `auth_required` détecte ce flag et redirige vers le formulaire de changement avant de laisser passer toute autre requête. | |
| 197 | + | |
| 198 | +--- | |
| 199 | + | |
| 200 | +## TOTP / 2FA | |
| 201 | + | |
| 202 | +```mermaid | |
| 203 | +sequenceDiagram | |
| 204 | + participant U as Utilisateur | |
| 205 | + participant API as Rustwarden | |
| 206 | + participant DB as Base de données | |
| 207 | + | |
| 208 | + Note over U,DB: Enrôlement TOTP | |
| 209 | + | |
| 210 | + U->>API: POST /api/v1/totp/setup | |
| 211 | + API->>API: Secret::generate_secret() (totp-rs) | |
| 212 | + API->>API: CryptoService::encrypt(secret_base32) | |
| 213 | + API->>DB: INSERT user_totp (enabled=false, secret_encrypted=...) | |
| 214 | + API->>U: otpauth:// URI + QR code base64 | |
| 215 | + | |
| 216 | + U->>API: POST /api/v1/totp/verify-and-enable\n{ code: "123456" } | |
| 217 | + API->>DB: SELECT user_totp WHERE user_id = ... | |
| 218 | + API->>API: CryptoService::decrypt(secret_encrypted) | |
| 219 | + API->>API: TOTP::check_current(code) | |
| 220 | + API->>API: Générer 10 codes de backup (bcrypt hashés, AES-GCM chiffrés) | |
| 221 | + API->>DB: UPDATE user_totp SET enabled=true, backup_codes_encrypted=... | |
| 222 | + API->>U: 200 OK + codes de backup (affichage unique) | |
| 223 | + | |
| 224 | + Note over U,DB: Login avec 2FA | |
| 225 | + | |
| 226 | + U->>API: POST /api/v1/auth/login { username, password } | |
| 227 | + API->>API: Vérifier password (bcrypt) | |
| 228 | + API->>DB: SELECT user_totp WHERE user_id AND enabled=true | |
| 229 | + alt TOTP activé | |
| 230 | + API->>DB: INSERT totp_challenges (token, expires_at=+5min, max_attempts=5) | |
| 231 | + API->>U: 200 { totp_required: true, challenge_token: "..." } | |
| 232 | + U->>API: POST /api/v1/auth/totp { challenge_token, code } | |
| 233 | + API->>DB: SELECT totp_challenges WHERE challenge_token=... | |
| 234 | + API->>API: Vérifier expiration + tentatives | |
| 235 | + API->>API: TOTP::check_current(code) | |
| 236 | + API->>DB: DELETE totp_challenges (consommer le challenge) | |
| 237 | + API->>U: JWT + refresh token | |
| 238 | + else TOTP non activé | |
| 239 | + API->>U: JWT + refresh token | |
| 240 | + end | |
| 241 | +``` | |
| 242 | + | |
| 243 | +L'algorithme TOTP utilisé est SHA1, 6 chiffres, période 30 secondes — conformément à la spécification RFC 6238 et à la compatibilité avec tous les authenticators standard (Google Authenticator, Authy, etc.). | |
| 244 | + | |
| 245 | +Référence : [`services/totp_service.rs`](../../crates/rustwarden-core/src/services/totp_service.rs) | |
| 246 | + | |
| 247 | +--- | |
| 248 | + | |
| 249 | +## CSP (Content Security Policy) | |
| 250 | + | |
| 251 | +La politique CSP est appliquée sur toutes les réponses HTML dans [`app.rs`](../../crates/rustwarden-core/src/app.rs#L737) : | |
| 252 | + | |
| 253 | +``` | |
| 254 | +default-src 'self'; | |
| 255 | +script-src 'self' 'unsafe-inline' 'unsafe-eval'; | |
| 256 | +style-src 'self' 'unsafe-inline'; | |
| 257 | +img-src 'self' data:; | |
| 258 | +font-src 'self'; | |
| 259 | +connect-src 'self'; | |
| 260 | +frame-ancestors 'none'; | |
| 261 | +base-uri 'self'; | |
| 262 | +form-action 'self' | |
| 263 | +``` | |
| 264 | + | |
| 265 | +**Point d'attention** : la politique autorise actuellement `'unsafe-inline'` et `'unsafe-eval'` sur les scripts et les styles. Cela est dû à l'utilisation de HTMX et Alpine.js côté client. Ces directives affaiblissent significativement la protection XSS. La roadmap V2 prévoit leur suppression via une nonce-based CSP ou un hash-based CSP. | |
| 266 | + | |
| 267 | +Le header `frame-ancestors 'none'` empêche le clickjacking en refusant l'intégration dans des iframes depuis n'importe quel domaine. | |
| 268 | + | |
| 269 | +Le header HSTS (`Strict-Transport-Security: max-age=31536000; includeSubDomains`) est activé automatiquement sauf si `APP_DEBUG=true` — ce qui permet de développer en HTTP local sans erreur. | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +## Open redirect | |
| 274 | + | |
| 275 | +Le paramètre `redirect` du middleware `auth_required` est sanitisé avant utilisation. La protection appliquée : | |
| 276 | + | |
| 277 | +1. La valeur doit commencer par `/` (chemin absolu sur le même domaine). | |
| 278 | +2. La valeur ne doit pas commencer par `//` (protocole relatif — cela pointerait vers un domaine externe). | |
| 279 | +3. Les octets nuls sont rejetés. | |
| 280 | + | |
| 281 | +Toute valeur qui ne passe pas ces contrôles est remplacée par le chemin par défaut (`/`). Cette protection empêche les redirections vers des domaines externes après login. | |
| 282 | + | |
| 283 | +--- | |
| 284 | + | |
| 285 | +## Session storage | |
| 286 | + | |
| 287 | +Rustwarden utilise `tower-sessions` pour la gestion de session. Trois backends sont disponibles : | |
| 288 | + | |
| 289 | +| Backend | Feature Cargo | État | | |
| 290 | +|---|---|---| | |
| 291 | +| `MemoryStore` | (défaut, aucune feature) | Fonctionnel — perte des sessions au redémarrage | | |
| 292 | +| `SeaOrmSessionStore` | (intégré dans le core) | Fonctionnel — sessions persistantes en PostgreSQL | | |
| 293 | +| `RedisSessionStore` | `redis-session` | **Cassé** — ne compile pas, éviter `--all-features` | | |
| 294 | + | |
| 295 | +En production, le backend SeaORM est recommandé pour la persistance et la compatibilité multi-instance (à condition d'utiliser la session comme état de passage uniquement, pas comme cache d'autorisation). | |
| 296 | + | |
| 297 | +--- | |
| 298 | + | |
| 299 | +## Sécurité email | |
| 300 | + | |
| 301 | +Le transport SMTP et la lecture IMAP (détection des bounces) exposent deux options de configuration potentiellement dangereuses : | |
| 302 | + | |
| 303 | +- `accept_invalid_certs` (SMTP) : accepte les certificats TLS auto-signés ou expirés. | |
| 304 | +- `accept_invalid_certs` (IMAP) : même comportement pour la lecture des bounces. | |
| 305 | + | |
| 306 | +Quand ces options sont activées, un warning est journalisé au démarrage : | |
| 307 | + | |
| 308 | +``` | |
| 309 | +WARN rustwarden_core: SMTP accept_invalid_certs=true — do not use in production | |
| 310 | +``` | |
| 311 | + | |
| 312 | +Ces options ne doivent jamais être activées en production. Elles existent uniquement pour faciliter le développement local avec des serveurs SMTP internes (ex. MailHog) qui n'ont pas de certificat valide. | |
| 313 | + | |
| 314 | +--- | |
| 315 | + | |
| 316 | +## Audit trail | |
| 317 | + | |
| 318 | +Le service `auth_logging` enregistre les événements d'authentification et d'administration dans la table `audit_logs`. | |
| 319 | + | |
| 320 | +```mermaid | |
| 321 | +sequenceDiagram | |
| 322 | + participant H as Handler Axum | |
| 323 | + participant AL as auth_logging service | |
| 324 | + participant DB as audit_logs | |
| 325 | + | |
| 326 | + H->>H: Action métier (login, logout, changement de rôle...) | |
| 327 | + H->>AL: log_action(db, user_id, action, target_user_id, level, message) | |
| 328 | + AL->>DB: INSERT audit_logs (id, user_id, action, target_user_id, level, message, created_at) | |
| 329 | + Note right of DB: ON DELETE SET NULL sur user_id et target_user_id\nLes logs persistent après suppression d'un utilisateur | |
| 330 | +``` | |
| 331 | + | |
| 332 | +Les colonnes `actor_remote_identity_id` et `target_remote_identity_id` (ajoutées par la migration fediverse) permettent d'enregistrer des actions impliquant des identités distantes tout en maintenant la même structure de table. | |
| 333 | + | |
| 334 | +--- | |
| 335 | + | |
| 336 | +## Modèle de menace STRIDE | |
| 337 | + | |
| 338 | +| Catégorie STRIDE | Exemples de menaces | Contrôles en place | | |
| 339 | +|---|---|---| | |
| 340 | +| **Spoofing** (usurpation) | Token JWT forgé, réutilisation d'un token révoqué, phishing OAuth | Signature HS256, blacklist JWT, validation state OAuth | | |
| 341 | +| **Tampering** (altération) | Modification des claims JWT, corruption des secrets chiffrés | Signature HMAC, AES-GCM avec tag d'intégrité | | |
| 342 | +| **Repudiation** (répudiation) | Nier une action d'administration ou un login | Table `audit_logs` avec `ON DELETE SET NULL` | | |
| 343 | +| **Information Disclosure** (fuite d'info) | Exposition du hash de mot de passe, secrets OAuth en DB, timing oracles | bcrypt, AES-GCM, `subtle::ConstantTimeEq`, erreurs opaques | | |
| 344 | +| **Denial of Service** (déni de service) | Brute-force login, spam de reset password, remplissage de la blacklist | Rate limiter (max 100 000 entrées), cleanup tasks | | |
| 345 | +| **Elevation of Privilege** (élévation) | Contournement RBAC, lien OAuth sans email vérifié, PAT avec scope trop large | `require_permission!`, vérification email_verified, scopes explicites | | |
| 346 | + | |
| 347 | +--- | |
| 348 | + | |
| 349 | +## Référence aux audits existants | |
| 350 | + | |
| 351 | +- [ANSSI_COMPLIANCE_REPORT.md](../ANSSI_COMPLIANCE_REPORT.md) — conformité aux recommandations ANSSI Rust | |
| 352 | +- [ANSSI_FEDIVERSE_COMPLIANCE.md](../ANSSI_FEDIVERSE_COMPLIANCE.md) — conformité ANSSI pour le module fediverse | |
| 353 | +- [SECURITY_AUDIT_2026_05.md](../SECURITY_AUDIT_2026_05.md) — dernier audit de sécurité (mai 2026), 10 findings priorisés | |
| 354 | +- [fediverse-security-model.md](../fediverse-security-model.md) — modèle de sécurité spécifique à ActivityPub |
A
docs/developer-guide/08-fediverse-integration.md
+777
-0
@@ -0,0 +1,777 @@
| 1 | +# Intégration de la fédération ActivityPub | |
| 2 | + | |
| 3 | +> **Public visé** : développeurs qui consomment le framework Rustwarden et souhaitent comprendre l'architecture interne de `rustwarden-fediverse`, ses points d'extension, ses flux d'authentification et son modèle de données. | |
| 4 | +> | |
| 5 | +> **Hors périmètre** : déploiement opérationnel, supervision, variables d'environnement de production. Voir [`docs/fediverse-consumer-guide.md`](../fediverse-consumer-guide.md) et [`docs/fediverse-operations.md`](../fediverse-operations.md). | |
| 6 | +> | |
| 7 | +> **Type Diátaxis** : explication (comprendre pourquoi et comment le système est structuré). | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 1. Pourquoi la fédération ? | |
| 12 | + | |
| 13 | +Rustwarden est, au premier chef, un framework d'authentification local. La crate `rustwarden-fediverse` y greffe le protocole ActivityPub pour trois cas d'usage distincts : | |
| 14 | + | |
| 15 | +**SSO décentralisé entre instances.** Une instance Rustwarden B peut authentifier un utilisateur dont le compte existe sur une instance A, sans synchronisation de base de données ni provider OIDC central. Le protocole AP sert de canal de signal : l'IdP (instance A) répond à un challenge émis par le SP (instance B). Le résultat est un JWT local signé par B, avec `identity_kind = remote`. | |
| 16 | + | |
| 17 | +**Partage de ressources cross-instance.** Un utilisateur distant (identifié par `@alice@a.tld`) peut être membre d'une équipe hébergée sur B. La table `fediverse_resource_share` modélise ce lien. Les clés RSA d'acteur chiffrent les payloads d'invite avant leur envoi sur le réseau. | |
| 18 | + | |
| 19 | +**Gestion d'équipes multi-domaines.** Un admin sur B crée une invitation vers `@bob@c.tld`. L'activité AP `Invite{TeamMembership}` est envoyée à l'inbox de C. Quand Bob accepte, une ligne `team_members` est créée sur B avec `remote_identity_id` renseigné — sans que Bob ait jamais de mot de passe sur B. | |
| 20 | + | |
| 21 | +Ces trois cas reposent sur un contrat unique : `rustwarden-core` est **totalement ignorant** de tout ce qui précède. Il ne dépend d'aucune crate fediverse. Le branchement se fait exclusivement via trois traits définis dans `rustwarden-fediverse`. | |
| 22 | + | |
| 23 | +--- | |
| 24 | + | |
| 25 | +## 2. Architecture en quatre couches | |
| 26 | + | |
| 27 | +```mermaid | |
| 28 | +flowchart TB | |
| 29 | + subgraph UI ["rustwarden-fediverse-ui"] | |
| 30 | + UI_NODE["Handlers HTML\nlogin · consent · admin · invite"] | |
| 31 | + end | |
| 32 | + | |
| 33 | + subgraph FEDI ["rustwarden-fediverse"] | |
| 34 | + FEDI_NODE["Protocole ActivityPub headless\nacteur · inbox · auth · signatures · outbound"] | |
| 35 | + end | |
| 36 | + | |
| 37 | + subgraph CORE ["rustwarden-core"] | |
| 38 | + CORE_NODE["Framework agnostique fédération\nauth JWT · RBAC · services · middleware"] | |
| 39 | + end | |
| 40 | + | |
| 41 | + subgraph GUARD ["rustwarden-fediverse-guard"] | |
| 42 | + GUARD_NODE["Détection d'abus\nsignature_failure · replay · ban automatique"] | |
| 43 | + end | |
| 44 | + | |
| 45 | + UI_NODE -->|dépend de| FEDI_NODE | |
| 46 | + FEDI_NODE -->|dépend de| CORE_NODE | |
| 47 | + GUARD_NODE -->|se branche via FediverseEventObserver| FEDI_NODE | |
| 48 | +``` | |
| 49 | + | |
| 50 | +La règle fondamentale est que **les dépendances ne remontent jamais** : `rustwarden-core` n'importe rien de `rustwarden-fediverse`. Les crates compagnons s'y branchent via des traits exportés par `rustwarden-fediverse`. | |
| 51 | + | |
| 52 | +--- | |
| 53 | + | |
| 54 | +## 3. Diagramme des composants | |
| 55 | + | |
| 56 | +```mermaid | |
| 57 | +flowchart LR | |
| 58 | + APP([Application consommatrice]) | |
| 59 | + | |
| 60 | + APP -->|router_with_auth| ROUTER["routes::router_with_auth()"] | |
| 61 | + ROUTER --> WEBFINGER["/.well-known/webfinger"] | |
| 62 | + ROUTER --> ACTOR["/users/{username}"] | |
| 63 | + ROUTER --> INBOX["/inbox (POST)"] | |
| 64 | + ROUTER --> AUTH_ROUTES["auth_routes\n/initiate · /status · /consume\n/auth/inbox · /auth/consent"] | |
| 65 | + ROUTER --> INVITE_ROUTES["invite_routes\n/invite-fediverse · /invites · /accept · /reject"] | |
| 66 | + | |
| 67 | + INBOX --> SIGNAL["FediverseSignalService\nUpdate · Delete · Revoke"] | |
| 68 | + AUTH_ROUTES --> AUTH_SVC["FediverseAuthService\nchallenge · status · consume"] | |
| 69 | + INVITE_ROUTES --> INVITE_SVC["FediverseInviteService\nstate machine invite"] | |
| 70 | + | |
| 71 | + SIGNAL --> OBSERVER["FediverseEventObserver"] | |
| 72 | + AUTH_SVC --> OBSERVER | |
| 73 | + INVITE_SVC --> OBSERVER | |
| 74 | + | |
| 75 | + OBSERVER -->|implémenté par| GUARD["rustwarden-fediverse-guard"] | |
| 76 | + OBSERVER -->|no-op par défaut| NOOP["NoopFediverseObserver"] | |
| 77 | + | |
| 78 | + INBOX --> VERIFIER["SignatureVerifier\nHttpSigVerifier"] | |
| 79 | + AUTH_ROUTES --> VERIFIER | |
| 80 | + | |
| 81 | + AUTH_SVC -->|JWT HS256| CORE_JWT["rustwarden_core::auth::JwtService"] | |
| 82 | + SIGNAL --> REMOTE_ID["remote_identities (DB)"] | |
| 83 | +``` | |
| 84 | + | |
| 85 | +--- | |
| 86 | + | |
| 87 | +## 4. Les trois traits d'extension | |
| 88 | + | |
| 89 | +`rustwarden-core` et `rustwarden-fediverse` communiquent via trois traits. Ils sont tous définis dans `rustwarden-fediverse` pour que la direction de dépendance reste unidirectionnelle. | |
| 90 | + | |
| 91 | +### 4.1 `LocalUserResolver` | |
| 92 | + | |
| 93 | +**Fichier** : [`crates/rustwarden-fediverse/src/resolver.rs`](../../crates/rustwarden-fediverse/src/resolver.rs) | |
| 94 | + | |
| 95 | +Ce trait répond à la question : « qui est `@alice` sur cette instance ? ». Il est utilisé par les handlers WebFinger, Actor et NodeInfo pour exposer les profils locaux au réseau AP. | |
| 96 | + | |
| 97 | +```rust | |
| 98 | +#[async_trait] | |
| 99 | +pub trait LocalUserResolver: Send + Sync { | |
| 100 | + /// Cherche un utilisateur local par username. | |
| 101 | + /// Retourne Ok(None) si inexistant ou soft-deleted. | |
| 102 | + async fn find_local_user(&self, username: &str) | |
| 103 | + -> Result<Option<UserSummary>, AppError>; | |
| 104 | + | |
| 105 | + /// Nombre total d'utilisateurs (NodeInfo usage.users.total). | |
| 106 | + async fn count_users_total(&self) -> Result<u64, AppError>; | |
| 107 | + | |
| 108 | + /// Utilisateurs actifs depuis `days` jours (last_login_at). | |
| 109 | + async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError>; | |
| 110 | + | |
| 111 | + /// Posts locaux (toujours 0 jusqu'à un RFC futur introduisant les posts). | |
| 112 | + async fn count_local_posts(&self) -> Result<u64, AppError>; | |
| 113 | +} | |
| 114 | +``` | |
| 115 | + | |
| 116 | +**Implémentation fournie** : `SeaOrmLocalUserResolver` — lit directement les tables SeaORM de `rustwarden-core` et génère les clés RSA d'acteur à la demande (RFC 0005) si `CryptoService` est disponible. | |
| 117 | + | |
| 118 | +**Implémentation custom** : brancher un store de comptes existant sans SeaORM. | |
| 119 | + | |
| 120 | +```rust | |
| 121 | +use async_trait::async_trait; | |
| 122 | +use rustwarden_fediverse::resolver::{LocalUserResolver, UserSummary}; | |
| 123 | +use rustwarden_core::error::AppError; | |
| 124 | + | |
| 125 | +struct MonStoreResolver { /* ... */ } | |
| 126 | + | |
| 127 | +#[async_trait] | |
| 128 | +impl LocalUserResolver for MonStoreResolver { | |
| 129 | + async fn find_local_user(&self, username: &str) | |
| 130 | + -> Result<Option<UserSummary>, AppError> | |
| 131 | + { | |
| 132 | + // Requête vers votre propre base de données. | |
| 133 | + todo!() | |
| 134 | + } | |
| 135 | + async fn count_users_total(&self) -> Result<u64, AppError> { Ok(0) } | |
| 136 | + async fn count_users_active_since(&self, _: u32) -> Result<u64, AppError> { Ok(0) } | |
| 137 | + async fn count_local_posts(&self) -> Result<u64, AppError> { Ok(0) } | |
| 138 | +} | |
| 139 | +``` | |
| 140 | + | |
| 141 | +### 4.2 `SignatureVerifier` | |
| 142 | + | |
| 143 | +**Fichier** : [`crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs`](../../crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs) | |
| 144 | + | |
| 145 | +Ce trait délègue la vérification cryptographique des signatures HTTP entrantes. Il est appelé par `inbox::handle` et `auth_routes::auth_inbox` avant tout traitement des activités. | |
| 146 | + | |
| 147 | +```rust | |
| 148 | +#[async_trait] | |
| 149 | +pub trait SignatureVerifier: Send + Sync { | |
| 150 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>; | |
| 151 | +} | |
| 152 | + | |
| 153 | +pub struct VerifyCtx<'a> { | |
| 154 | + pub actor_ap_id: &'a str, | |
| 155 | + pub method: &'a str, // "post" | |
| 156 | + pub path: &'a str, // "/inbox" | |
| 157 | + pub headers: &'a HeaderMap, | |
| 158 | + pub body: &'a [u8], | |
| 159 | + pub public_key_pem: &'a str, // vide = auto-résolution via remote_identities | |
| 160 | +} | |
| 161 | +``` | |
| 162 | + | |
| 163 | +**Implémentation fournie** : | |
| 164 | +- `HttpSigVerifier` — verifier de production (RFC 0016 + RFC 0017). Auto-résout la clé publique depuis `remote_identities` quand `public_key_pem` est vide. Valide la fenêtre temporelle (`Date` ±300 s) et le `Digest` SHA-256 du corps (constant-time). | |
| 165 | +- `AlwaysAcceptVerifier` — accepte toute signature, **dev/test uniquement**. | |
| 166 | + | |
| 167 | +**Implémentation custom** : brancher un cache de clés externe ou une PKI d'entreprise. | |
| 168 | + | |
| 169 | +```rust | |
| 170 | +use async_trait::async_trait; | |
| 171 | +use rustwarden_fediverse::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; | |
| 172 | +use rustwarden_core::error::AppError; | |
| 173 | + | |
| 174 | +struct MonVerifier; | |
| 175 | + | |
| 176 | +#[async_trait] | |
| 177 | +impl SignatureVerifier for MonVerifier { | |
| 178 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 179 | + // Appel à votre service de PKI interne. | |
| 180 | + todo!() | |
| 181 | + } | |
| 182 | +} | |
| 183 | +``` | |
| 184 | + | |
| 185 | +### 4.3 `FediverseEventObserver` | |
| 186 | + | |
| 187 | +**Fichier** : [`crates/rustwarden-fediverse/src/observer.rs`](../../crates/rustwarden-fediverse/src/observer.rs) | |
| 188 | + | |
| 189 | +Ce trait reçoit des notifications asynchrones aux points chauds du protocole. Toutes les méthodes ont une implémentation par défaut vide — une implémentation partielle est valide. | |
| 190 | + | |
| 191 | +```rust | |
| 192 | +#[async_trait] | |
| 193 | +pub trait FediverseEventObserver: Send + Sync { | |
| 194 | + /// Vérification cryptographique échouée. | |
| 195 | + async fn on_signature_failure( | |
| 196 | + &self, _domain: &str, _actor_uri: Option<&str>, _reason: &str, | |
| 197 | + ) {} | |
| 198 | + | |
| 199 | + /// Replay détecté (Date périmée, nonce dupliqué, signature rejouée). | |
| 200 | + async fn on_replay_attempt( | |
| 201 | + &self, _domain: &str, _actor_uri: Option<&str>, _details: Value, | |
| 202 | + ) {} | |
| 203 | + | |
| 204 | + /// Garde SSRF déclenchée sur une URL privée/loopback. | |
| 205 | + async fn on_fetch_ssrf(&self, _target_host: &str, _attempted_ip: Option<&str>) {} | |
| 206 | + | |
| 207 | + /// Payload inbox structurellement invalide (corps trop grand, JSON corrompu, type inconnu). | |
| 208 | + async fn on_inbox_abuse( | |
| 209 | + &self, _domain: &str, _actor_uri: Option<&str>, _reason: &str, | |
| 210 | + ) {} | |
| 211 | + | |
| 212 | + /// Un challenge d'authentification AP a été initié. | |
| 213 | + async fn on_auth_challenge(&self, _domain: &str, _handle: &str) {} | |
| 214 | + | |
| 215 | + /// Une invite a été acceptée, rejetée ou révoquée. | |
| 216 | + async fn on_invite_action(&self, _domain: &str, _decision: &str) {} | |
| 217 | + | |
| 218 | + /// Un Delete{Actor} a été traité : la ligne remote_identities est soft-deleted. | |
| 219 | + /// Invoqué après la mutation de base ; idempotent (jamais déclenché si déjà disabled). | |
| 220 | + async fn on_delete_actor( | |
| 221 | + &self, _domain: &str, _ap_id: &str, _remote_identity_id: Uuid, | |
| 222 | + ) {} | |
| 223 | +} | |
| 224 | +``` | |
| 225 | + | |
| 226 | +**Implémentation fournie** : `NoopFediverseObserver` — implémentation vide, câblée par défaut quand aucun observer n'est enregistré. | |
| 227 | + | |
| 228 | +**Règle contractuelle** : les erreurs internes à un observer ne doivent jamais remonter sur le chemin de requête. L'observer est best-effort ; utiliser `tracing::warn!` plutôt que de propager. | |
| 229 | + | |
| 230 | +**Exemple** : observer qui compte les signatures échouées. | |
| 231 | + | |
| 232 | +```rust | |
| 233 | +use std::sync::atomic::{AtomicU64, Ordering}; | |
| 234 | +use std::sync::Arc; | |
| 235 | +use async_trait::async_trait; | |
| 236 | +use rustwarden_fediverse::observer::FediverseEventObserver; | |
| 237 | + | |
| 238 | +#[derive(Default)] | |
| 239 | +struct SigFailCounter { | |
| 240 | + count: AtomicU64, | |
| 241 | +} | |
| 242 | + | |
| 243 | +#[async_trait] | |
| 244 | +impl FediverseEventObserver for SigFailCounter { | |
| 245 | + async fn on_signature_failure( | |
| 246 | + &self, domain: &str, _actor_uri: Option<&str>, reason: &str, | |
| 247 | + ) { | |
| 248 | + let n = self.count.fetch_add(1, Ordering::Relaxed) + 1; | |
| 249 | + tracing::warn!(domain, reason, total = n, "signature failure"); | |
| 250 | + } | |
| 251 | + // Toutes les autres méthodes héritent du no-op par défaut. | |
| 252 | +} | |
| 253 | +``` | |
| 254 | + | |
| 255 | +--- | |
| 256 | + | |
| 257 | +## 5. Authentification fédérée — le flux complet | |
| 258 | + | |
| 259 | +```mermaid | |
| 260 | +sequenceDiagram | |
| 261 | + participant Client as Client (navigateur b.tld) | |
| 262 | + participant SP as SP — instance B | |
| 263 | + participant IdP as IdP — instance A | |
| 264 | + | |
| 265 | + Client->>SP: POST /api/v1/auth/login/fediverse/initiate<br/>{handle: "@alice@a.tld", redirect_url?} | |
| 266 | + SP->>SP: validate_redirect_url() — ANSSI F-NET-2 | |
| 267 | + SP->>SP: FediverseAuthService::initiate()<br/>→ challenge UUID + nonce 256-bit, TTL 5 min | |
| 268 | + SP-->>Client: 201 {challenge_id, nonce, expires_at} | |
| 269 | + | |
| 270 | + Note over Client: Affiche page d'attente, commence à poller /status | |
| 271 | + | |
| 272 | + IdP->>SP: POST /fediverse/auth/inbox<br/>{type:"AuthRequest", actor, nonce, sig} | |
| 273 | + SP->>SP: assert_domain_allowed() — trust policy | |
| 274 | + SP->>SP: SignatureVerifier::verify(sig) | |
| 275 | + alt signature invalide | |
| 276 | + SP->>SP: observer.on_signature_failure() | |
| 277 | + SP-->>IdP: 401 Unauthorized | |
| 278 | + else signature valide | |
| 279 | + SP->>SP: FediverseAuthService::record_inbound_request()<br/>→ stocke la requête (pending) | |
| 280 | + SP-->>IdP: 200 OK | |
| 281 | + end | |
| 282 | + | |
| 283 | + Note over IdP: L'utilisateur alice voit la demande de consentement | |
| 284 | + | |
| 285 | + IdP->>SP: POST /api/v1/fediverse/auth/consent<br/>{challenge_id, decision:"approve", remote_identity_id} | |
| 286 | + SP->>SP: FediverseAuthService::record_decision()<br/>→ challenge.status = approved | |
| 287 | + SP-->>IdP: 200 OK | |
| 288 | + | |
| 289 | + Client->>SP: GET /api/v1/auth/login/fediverse/status/{challenge_id} | |
| 290 | + SP-->>Client: 200 {status: "approved"} | |
| 291 | + | |
| 292 | + Client->>SP: POST /api/v1/auth/login/fediverse/consume/{challenge_id} | |
| 293 | + SP->>SP: FediverseAuthService::consume()<br/>→ vérifie status=approved, flip → consumed (one-shot) | |
| 294 | + SP->>SP: lookup/create remote_identity | |
| 295 | + SP->>SP: JwtService::mint() — identity_kind=remote, remote_id | |
| 296 | + SP-->>Client: 200 {jwt, remote_identity_id, handle} | |
| 297 | +``` | |
| 298 | + | |
| 299 | +Les cinq handlers correspondants sont dans [`crates/rustwarden-fediverse/src/auth_routes.rs`](../../crates/rustwarden-fediverse/src/auth_routes.rs). | |
| 300 | + | |
| 301 | +**Machine à états du challenge** : | |
| 302 | + | |
| 303 | +| État | Transition suivante | | |
| 304 | +|------|---------------------| | |
| 305 | +| `pending` | → `approved` (IdP consent) · → `rejected` · → `expired` (TTL dépassé) | | |
| 306 | +| `approved` | → `consumed` (consume one-shot) | | |
| 307 | +| `rejected` | terminal | | |
| 308 | +| `expired` | terminal | | |
| 309 | +| `consumed` | terminal | | |
| 310 | + | |
| 311 | +--- | |
| 312 | + | |
| 313 | +## 6. Modèle de données fédération | |
| 314 | + | |
| 315 | +Les huit tables fediverse sont toutes préfixées `fediverse_` ou nommées `remote_identity` : | |
| 316 | + | |
| 317 | +```mermaid | |
| 318 | +erDiagram | |
| 319 | + remote_identity { | |
| 320 | + uuid id PK | |
| 321 | + string ap_id | |
| 322 | + string username | |
| 323 | + string domain | |
| 324 | + string display_name | |
| 325 | + string summary_html | |
| 326 | + string inbox_url | |
| 327 | + string shared_inbox_url | |
| 328 | + string public_key_pem | |
| 329 | + string icon_url | |
| 330 | + string actor_type | |
| 331 | + json raw_json | |
| 332 | + timestamp last_fetched_at | |
| 333 | + timestamp disabled_at | |
| 334 | + timestamp created_at | |
| 335 | + timestamp updated_at | |
| 336 | + } | |
| 337 | + | |
| 338 | + fediverse_auth_challenge { | |
| 339 | + uuid id PK | |
| 340 | + string handle | |
| 341 | + string nonce | |
| 342 | + string status | |
| 343 | + string redirect_url | |
| 344 | + uuid remote_identity_id FK | |
| 345 | + timestamp expires_at | |
| 346 | + timestamp created_at | |
| 347 | + } | |
| 348 | + | |
| 349 | + fediverse_pending_invite { | |
| 350 | + uuid id PK | |
| 351 | + uuid team_id FK | |
| 352 | + string handle | |
| 353 | + string role | |
| 354 | + string status | |
| 355 | + uuid invited_by_user_id FK | |
| 356 | + string activity_id | |
| 357 | + timestamp expires_at | |
| 358 | + timestamp created_at | |
| 359 | + } | |
| 360 | + | |
| 361 | + fediverse_outbound_queue { | |
| 362 | + uuid id PK | |
| 363 | + string activity_id | |
| 364 | + string payload_json | |
| 365 | + string target_inbox | |
| 366 | + string target_domain | |
| 367 | + string status | |
| 368 | + int attempts | |
| 369 | + int max_attempts | |
| 370 | + string last_error | |
| 371 | + timestamp next_retry_at | |
| 372 | + timestamp created_at | |
| 373 | + timestamp delivered_at | |
| 374 | + uuid actor_user_id FK | |
| 375 | + } | |
| 376 | + | |
| 377 | + fediverse_trusted_domain { | |
| 378 | + uuid id PK | |
| 379 | + string domain | |
| 380 | + string note | |
| 381 | + timestamp created_at | |
| 382 | + } | |
| 383 | + | |
| 384 | + fediverse_blocked_domain { | |
| 385 | + uuid id PK | |
| 386 | + string domain | |
| 387 | + string severity | |
| 388 | + string reason | |
| 389 | + timestamp created_at | |
| 390 | + } | |
| 391 | + | |
| 392 | + fediverse_personal_access_token { | |
| 393 | + uuid id PK | |
| 394 | + uuid remote_identity_id FK | |
| 395 | + string token_hash | |
| 396 | + string name | |
| 397 | + string scopes | |
| 398 | + timestamp expires_at | |
| 399 | + timestamp created_at | |
| 400 | + } | |
| 401 | + | |
| 402 | + fediverse_resource_share { | |
| 403 | + uuid id PK | |
| 404 | + uuid remote_identity_id FK | |
| 405 | + string resource_type | |
| 406 | + string resource_id | |
| 407 | + string permissions | |
| 408 | + timestamp created_at | |
| 409 | + } | |
| 410 | + | |
| 411 | + remote_identity ||--o{ fediverse_auth_challenge : "approuve" | |
| 412 | + remote_identity ||--o{ fediverse_personal_access_token : "possède" | |
| 413 | + remote_identity ||--o{ fediverse_resource_share : "accède à" | |
| 414 | +``` | |
| 415 | + | |
| 416 | +**Soft-delete** : `remote_identity.disabled_at` est renseigné quand un `Delete{Actor}` est reçu. Tous les artifacts liés (PATs, shares, memberships) sont cascadés via le hook `on_delete_actor`. | |
| 417 | + | |
| 418 | +--- | |
| 419 | + | |
| 420 | +## 7. Traitement de l'inbox | |
| 421 | + | |
| 422 | +```mermaid | |
| 423 | +flowchart TD | |
| 424 | + RECV["POST /inbox\n(body ≤ 1 MiB — ANSSI F-NET-1)"] | |
| 425 | + RECV --> PARSE_ACTOR["Extraire actor AP id depuis JSON"] | |
| 426 | + PARSE_ACTOR --> TRUST["FediverseTrustService::assert_domain_allowed()"] | |
| 427 | + TRUST -->|domaine bloqué / non autorisé| FORBIDDEN["403 Forbidden"] | |
| 428 | + TRUST -->|OK| SIG_HEADER["Extraire header Signature"] | |
| 429 | + SIG_HEADER -->|absent| UNAUTH1["401 Unauthorized\nmissing Signature header"] | |
| 430 | + SIG_HEADER -->|présent| DESERIALIZE["Désérialiser JSON-LD → InboxActivity"] | |
| 431 | + DESERIALIZE -->|erreur JSON| ABUSE["observer.on_inbox_abuse('malformed_json')\n→ 400 Bad Request"] | |
| 432 | + DESERIALIZE -->|OK| VERIFY["SignatureVerifier::verify(VerifyCtx)"] | |
| 433 | + VERIFY -->|échec| SIG_FAIL["observer.on_signature_failure()\n→ 401 Unauthorized"] | |
| 434 | + VERIFY -->|OK| DISPATCH["Dispatch par activity.type"] | |
| 435 | + | |
| 436 | + DISPATCH -->|Update| UPDATE["FediverseSignalService::apply_update_actor()\nupsert remote_identities"] | |
| 437 | + DISPATCH -->|Delete| DELETE["FediverseSignalService::apply_delete_actor_observed()\nsoft-delete + observer.on_delete_actor()"] | |
| 438 | + DISPATCH -->|Revoke| REVOKE["FediverseSignalService::apply_revoke_membership()\nretrait team_members"] | |
| 439 | + DISPATCH -->|autre| UNSUPPORTED["observer.on_inbox_abuse('unknown_activity_type')\n→ 501 Not Implemented"] | |
| 440 | + | |
| 441 | + UPDATE --> ACCEPTED["200 Accepted"] | |
| 442 | + DELETE --> ACCEPTED | |
| 443 | + REVOKE --> ACCEPTED | |
| 444 | +``` | |
| 445 | + | |
| 446 | +**Limite de corps** : 1 MiB, imposée par `DefaultBodyLimit::max(1024 * 1024)` dans le router — conforme au plafond Mastodon (ANSSI F-NET-1). | |
| 447 | + | |
| 448 | +**Trust policy** : la vérification de domaine précède toute vérification cryptographique. Une instance non autorisée ne consomme même pas de cycles RSA. | |
| 449 | + | |
| 450 | +--- | |
| 451 | + | |
| 452 | +## 8. HTTP Signatures (draft-cavage-12) | |
| 453 | + | |
| 454 | +Le protocole implémenté est [draft-cavage-http-signatures-12](https://tools.ietf.org/html/draft-cavage-http-signatures-12), l'algorithme `rsa-sha256` (PKCS#1 v1.5 + SHA-256). | |
| 455 | + | |
| 456 | +```mermaid | |
| 457 | +sequenceDiagram | |
| 458 | + participant Sender as Expéditeur (instance A) | |
| 459 | + participant Receiver as Récepteur (instance B) | |
| 460 | + | |
| 461 | + Sender->>Sender: Calculer SHA-256(body) | |
| 462 | + Sender->>Sender: Encoder en base64 → valeur Digest header | |
| 463 | + Sender->>Sender: Construire signing-string<br/>= "(request-target): post /inbox\nhost: b.tld\ndate: …\ndigest: SHA-256=…" | |
| 464 | + Sender->>Sender: rsa_sha256_sign(private_pem, signing-string) | |
| 465 | + Sender->>Sender: Encoder signature en base64 | |
| 466 | + Sender->>Receiver: POST /inbox\nSignature: keyId="…#main-key",algorithm="rsa-sha256",<br/>headers="(request-target) host date digest",signature="…"\nDate: …\nDigest: SHA-256=… | |
| 467 | + | |
| 468 | + Receiver->>Receiver: parse_signature_header() → ParsedSignature | |
| 469 | + Receiver->>Receiver: Vérifier algorithm == "rsa-sha256" | |
| 470 | + Receiver->>Receiver: validate_date_header() — drift ≤ 300 s | |
| 471 | + Receiver->>Receiver: validate_digest() — SHA-256 constant-time (subtle) | |
| 472 | + Receiver->>Receiver: build_signature_input() → re-construire signing-string | |
| 473 | + Receiver->>Receiver: Résoudre public key PEM (remote_identities ou fetch actor) | |
| 474 | + Receiver->>Receiver: rsa_sha256_verify(public_pem, signing-string, sig) | |
| 475 | + alt vérification OK | |
| 476 | + Receiver-->>Sender: 200 Accepted | |
| 477 | + else vérification KO | |
| 478 | + Receiver->>Receiver: observer.on_signature_failure() | |
| 479 | + Receiver-->>Sender: 401 Unauthorized | |
| 480 | + end | |
| 481 | +``` | |
| 482 | + | |
| 483 | +**Protections implémentées** : | |
| 484 | + | |
| 485 | +| Vecteur | Contre-mesure | | |
| 486 | +|---------|---------------| | |
| 487 | +| Replay temporel | `Date` header validé ±300 s (ANSSI F-CRYPTO-2) | | |
| 488 | +| Substitution de corps | `Digest: SHA-256=…` vérifié en constant-time via `subtle` (ANSSI F-CRYPTO-1) | | |
| 489 | +| Algorithme faible | Seul `rsa-sha256` accepté | | |
| 490 | +| Clé invalide | `RsaPublicKey::from_public_key_pem` échoue explicitement | | |
| 491 | + | |
| 492 | +Les primitives bas niveau sont dans [`crates/rustwarden-fediverse/src/http_signatures.rs`](../../crates/rustwarden-fediverse/src/http_signatures.rs) : `parse_signature_header`, `build_signature_input`, `rsa_sha256_sign`, `rsa_sha256_verify`, `parse_and_verify_cavage`. | |
| 493 | + | |
| 494 | +--- | |
| 495 | + | |
| 496 | +## 9. File de livraison sortante | |
| 497 | + | |
| 498 | +```mermaid | |
| 499 | +stateDiagram-v2 | |
| 500 | + [*] --> pending : FediverseOutboundService::enqueue() | |
| 501 | + | |
| 502 | + pending --> in_flight : drain_one() — worker tokio | |
| 503 | + | |
| 504 | + in_flight --> delivered : OutboundDelivery::deliver() → Ok | |
| 505 | + in_flight --> pending : deliver() → Err\nattempts < max_attempts\nnext_retry_at = now + backoff | |
| 506 | + | |
| 507 | + pending --> dead : attempts >= max_attempts (8) | |
| 508 | + | |
| 509 | + delivered --> [*] | |
| 510 | + dead --> [*] | |
| 511 | +``` | |
| 512 | + | |
| 513 | +**Backoff exponentiel** (RFC 0010) : | |
| 514 | + | |
| 515 | +| Tentative | Délai avant retry | | |
| 516 | +|-----------|-------------------| | |
| 517 | +| 1 | 5 min | | |
| 518 | +| 2 | 30 min | | |
| 519 | +| 3 | 2 h | | |
| 520 | +| 4 | 6 h | | |
| 521 | +| 5 | 24 h | | |
| 522 | +| 6+ | 48 h (plafonné) | | |
| 523 | +| 8 (max) | → `dead` | | |
| 524 | + | |
| 525 | +Le worker tokio appelle `drain_one()` en boucle. La colonne `next_retry_at` sert de garde temporel : le `find()` ne sélectionne que les lignes où `next_retry_at IS NULL OR next_retry_at <= now`. | |
| 526 | + | |
| 527 | +**Transport** : | |
| 528 | +- `NoopDelivery` — prétend que chaque livraison réussit. Utilisé en dev et dans les tests. | |
| 529 | +- `HttpSignedDelivery` — signe la requête sortante avec la clé privée RSA de l'acteur local, puis POSTe vers `target_inbox`. Introduit par RFC 0010. | |
| 530 | + | |
| 531 | +Le service est dans [`crates/rustwarden-fediverse/src/services/fediverse_outbound_service.rs`](../../crates/rustwarden-fediverse/src/services/fediverse_outbound_service.rs). | |
| 532 | + | |
| 533 | +--- | |
| 534 | + | |
| 535 | +## 10. Gestion de la liste de confiance | |
| 536 | + | |
| 537 | +`FediverseTrustService` implémente la politique de domaine (RFC 0004). La configuration est stockée dans `app_settings` (clés `fediverse_trust_mode`, `fediverse_enabled`, `fediverse_interop_scope`). | |
| 538 | + | |
| 539 | +**Modes de confiance** : | |
| 540 | + | |
| 541 | +| Mode | Comportement | | |
| 542 | +|------|-------------| | |
| 543 | +| `allowlist` (défaut) | Seuls les domaines explicitement ajoutés à `fediverse_trusted_domains` sont acceptés. Politique zéro-confiance. | | |
| 544 | +| `open_with_blocklist` | Tout domaine est accepté sauf ceux listés dans `fediverse_blocked_domains`. | | |
| 545 | + | |
| 546 | +**Sévérités de blocage** : | |
| 547 | + | |
| 548 | +| Sévérité | Effet | | |
| 549 | +|----------|-------| | |
| 550 | +| `suspend` | Rejette toutes les activités et demandes d'auth. | | |
| 551 | +| `silence` | Accepte mais signale silencieusement. | | |
| 552 | +| `reject_auth` | Rejette l'auth uniquement ; les lookups WebFinger passent. | | |
| 553 | + | |
| 554 | +**Scope d'interopérabilité** : | |
| 555 | + | |
| 556 | +| Scope | Effet | | |
| 557 | +|-------|-------| | |
| 558 | +| `rustwarden_only` (défaut) | Auth et invitations réservées aux instances Rustwarden. | | |
| 559 | +| `rustwarden_plus_universal_read` | Auth Rustwarden + lectures WebFinger depuis n'importe quelle instance AP. | | |
| 560 | +| `open` | Tout protocole AP accepté (déconseillé en production). | | |
| 561 | + | |
| 562 | +**Configuration minimale** : | |
| 563 | + | |
| 564 | +```bash | |
| 565 | +# Variables d'environnement obligatoires | |
| 566 | +FEDIVERSE_DOMAIN=mon-instance.example.org | |
| 567 | + | |
| 568 | +# Variables optionnelles (valeurs par défaut) | |
| 569 | +FEDIVERSE_AUTHORIZED_FETCH=false | |
| 570 | +FEDIVERSE_OUTBOUND_WORKERS=4 | |
| 571 | +FEDIVERSE_OUTBOUND_MAX_ATTEMPTS=8 | |
| 572 | +FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS=30 | |
| 573 | +FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS=86400 | |
| 574 | +FEDIVERSE_AUTH_CHALLENGE_TTL_SECS=300 | |
| 575 | +FEDIVERSE_DEBUG_LOCAL_FEDERATION=false # true uniquement pour tests locaux HTTP | |
| 576 | +``` | |
| 577 | + | |
| 578 | +--- | |
| 579 | + | |
| 580 | +## 11. Invitations cross-instance | |
| 581 | + | |
| 582 | +```mermaid | |
| 583 | +sequenceDiagram | |
| 584 | + participant Admin as Admin (instance B) | |
| 585 | + participant SP as SP — instance B | |
| 586 | + participant IdP as IdP — instance C | |
| 587 | + | |
| 588 | + Admin->>SP: POST /api/v1/teams/{team_id}/members/invite-fediverse<br/>{handle: "@bob@c.tld", role: "member"} | |
| 589 | + SP->>SP: FediverseInviteService::invite_remote()<br/>→ fediverse_pending_invite (pending, TTL 7 j) | |
| 590 | + SP->>SP: FediverseOutboundService::enqueue()<br/>→ Invite{TeamMembership} JSON-LD | |
| 591 | + SP-->>Admin: 201 {invite_id, activity_id, expires_at} | |
| 592 | + | |
| 593 | + SP->>IdP: POST c.tld/inbox — Invite{TeamMembership} (HTTP-Sig) | |
| 594 | + | |
| 595 | + Note over IdP: Bob voit l'invitation dans son interface | |
| 596 | + | |
| 597 | + IdP->>SP: POST /api/v1/fediverse/invites/{invite_id}/accept | |
| 598 | + SP->>SP: FediverseInviteService::accept()<br/>→ lookup/create remote_identity pour @bob@c.tld<br/>→ insert team_members (remote_identity_id, role=member) | |
| 599 | + SP->>SP: observer.on_invite_action(domain, "accept") | |
| 600 | + SP-->>IdP: 200 OK | |
| 601 | + | |
| 602 | + Note over SP: Bob est maintenant membre de l'équipe sur B<br/>sans avoir de compte local | |
| 603 | +``` | |
| 604 | + | |
| 605 | +**Machine à états des invitations** : | |
| 606 | + | |
| 607 | +| État | Transition | | |
| 608 | +|------|-----------| | |
| 609 | +| `pending` | → `accepted` · → `rejected` · → `revoked` (admin) · → `expired` (TTL) | | |
| 610 | +| `accepted` | terminal | | |
| 611 | +| `rejected` | terminal | | |
| 612 | +| `revoked` | terminal | | |
| 613 | +| `expired` | terminal | | |
| 614 | + | |
| 615 | +--- | |
| 616 | + | |
| 617 | +## 12. Observers personnalisés — exemple `rustwarden-fediverse-guard` | |
| 618 | + | |
| 619 | +```mermaid | |
| 620 | +flowchart LR | |
| 621 | + FEDI["rustwarden-fediverse\nFediverseEventObserver trait"] | |
| 622 | + | |
| 623 | + subgraph GUARD ["rustwarden-fediverse-guard"] | |
| 624 | + G_IMPL["GuardObserver\nimpl FediverseEventObserver"] | |
| 625 | + G_COUNTER["Compteurs atomiques\npar domaine"] | |
| 626 | + G_BAN["Auto-ban\n(blocklist DB)"] | |
| 627 | + end | |
| 628 | + | |
| 629 | + FEDI -->|on_signature_failure| G_IMPL | |
| 630 | + FEDI -->|on_replay_attempt| G_IMPL | |
| 631 | + FEDI -->|on_inbox_abuse| G_IMPL | |
| 632 | + FEDI -->|on_fetch_ssrf| G_IMPL | |
| 633 | + | |
| 634 | + G_IMPL --> G_COUNTER | |
| 635 | + G_COUNTER -->|seuil dépassé| G_BAN | |
| 636 | + G_BAN -->|insert| BLOCKED_DB[(fediverse_blocked_domains)] | |
| 637 | +``` | |
| 638 | + | |
| 639 | +`rustwarden-fediverse-guard` dépend de `rustwarden-fediverse` (et non l'inverse). Le seul couplage est l'implémentation du trait `FediverseEventObserver`. La crate guard peut ainsi évoluer indépendamment — ajouter de nouveaux heuristiques, modifier les seuils — sans toucher au protocole. | |
| 640 | + | |
| 641 | +Voir [`docs/fediverse-guard.md`](../fediverse-guard.md) pour la documentation opérationnelle du guard. | |
| 642 | + | |
| 643 | +--- | |
| 644 | + | |
| 645 | +## 13. Configuration d'un consommateur — exemple complet | |
| 646 | + | |
| 647 | +L'API de branchement expose plusieurs variantes de router builder : | |
| 648 | + | |
| 649 | +| Fonction | Usage | | |
| 650 | +|----------|-------| | |
| 651 | +| `routes::router(config, db)` | **Déprécié** depuis 0.2.0 — utilise `AlwaysAcceptVerifier`. Dev/test uniquement. | | |
| 652 | +| `routes::router_with(config, resolver, verifier)` | Injecte resolver et verifier custom. Lit `AuthConfig` depuis les variables d'environnement. | | |
| 653 | +| `routes::router_with_auth(config, resolver, verifier, auth_config)` | Injection complète sans lecture d'env. À préférer en production. | | |
| 654 | + | |
| 655 | +```rust | |
| 656 | +use std::sync::Arc; | |
| 657 | +use rustwarden_fediverse::{ | |
| 658 | + config::FediverseConfig, | |
| 659 | + routes::router_with_auth, | |
| 660 | + resolver::SeaOrmLocalUserResolver, | |
| 661 | + http_signatures::HttpSigVerifier, | |
| 662 | + observer::NoopFediverseObserver, | |
| 663 | +}; | |
| 664 | +use rustwarden_core::config::auth::AuthConfig; | |
| 665 | + | |
| 666 | +// Chargement de la configuration (validation stricte au démarrage). | |
| 667 | +let fedi_config = FediverseConfig::from_env()?; | |
| 668 | +let auth_config = Arc::new(AuthConfig::from_env()?); | |
| 669 | + | |
| 670 | +// Composants par défaut. | |
| 671 | +let resolver = Arc::new(SeaOrmLocalUserResolver::new(db.clone())); | |
| 672 | +let verifier = Arc::new(HttpSigVerifier::with_resolver(db.clone())); | |
| 673 | + | |
| 674 | +// Router fédération. | |
| 675 | +let fedi_router = router_with_auth(fedi_config, resolver, verifier, auth_config); | |
| 676 | + | |
| 677 | +// Merge dans le routeur principal Axum. | |
| 678 | +let app = main_router.merge(fedi_router); | |
| 679 | +``` | |
| 680 | + | |
| 681 | +Pour brancher un observer (ex. guard) : | |
| 682 | + | |
| 683 | +```rust | |
| 684 | +use rustwarden_fediverse::observer::FediverseEventObserver; | |
| 685 | + | |
| 686 | +let observer: Arc<dyn FediverseEventObserver> = Arc::new(mon_guard_observer); | |
| 687 | + | |
| 688 | +let fedi_router = router_with_auth(fedi_config, resolver, verifier, auth_config) | |
| 689 | + .layer(axum::Extension(observer)); | |
| 690 | +``` | |
| 691 | + | |
| 692 | +--- | |
| 693 | + | |
| 694 | +## 14. Routes exposées | |
| 695 | + | |
| 696 | +### Routes protocole (rustwarden-fediverse) | |
| 697 | + | |
| 698 | +| Méthode | Chemin | Handler | Auth | Description | | |
| 699 | +|---------|--------|---------|------|-------------| | |
| 700 | +| `GET` | `/.well-known/webfinger` | `webfinger::handle` | Non | Découverte JRD RFC 7033 | | |
| 701 | +| `GET` | `/.well-known/nodeinfo` | `nodeinfo::discovery` | Non | Lien NodeInfo 2.1 | | |
| 702 | +| `GET` | `/nodeinfo/2.1` | `nodeinfo::detail` | Non | Stats instance | | |
| 703 | +| `GET` | `/users/{username}` | `actor::handle` | Non (Accept-neg) | Profil AP Person | | |
| 704 | +| `POST` | `/inbox` | `inbox::handle` | Sig HTTP | Activités AP entrantes | | |
| 705 | + | |
| 706 | +### Routes authentification AP-native (rustwarden-fediverse) | |
| 707 | + | |
| 708 | +| Méthode | Chemin | Handler | Auth | Description | | |
| 709 | +|---------|--------|---------|------|-------------| | |
| 710 | +| `POST` | `/api/v1/auth/login/fediverse/initiate` | `auth_routes::initiate` | Non | Initie un challenge (SP side) | | |
| 711 | +| `GET` | `/api/v1/auth/login/fediverse/status/{id}` | `auth_routes::status` | Non | Sonde l'état du challenge | | |
| 712 | +| `POST` | `/api/v1/auth/login/fediverse/consume/{id}` | `auth_routes::consume` | Non | Échange le challenge approuvé contre un JWT | | |
| 713 | +| `POST` | `/fediverse/auth/inbox` | `auth_routes::auth_inbox` | Sig HTTP | Reçoit AuthRequest de l'IdP | | |
| 714 | +| `POST` | `/api/v1/fediverse/auth/consent` | `auth_routes::consent` | JWT (IdP user) | Décision approve/reject de l'IdP | | |
| 715 | + | |
| 716 | +### Routes invitations cross-instance (rustwarden-fediverse) | |
| 717 | + | |
| 718 | +| Méthode | Chemin | Handler | Auth | Description | | |
| 719 | +|---------|--------|---------|------|-------------| | |
| 720 | +| `POST` | `/api/v1/teams/{team_id}/members/invite-fediverse` | `invite_routes::invite` | JWT admin | Crée une invitation vers un handle distant | | |
| 721 | +| `GET` | `/api/v1/teams/{team_id}/invites` | `invite_routes::list_invites` | JWT | Liste les invitations en cours | | |
| 722 | +| `POST` | `/api/v1/teams/{team_id}/invites/{invite_id}/revoke` | `invite_routes::revoke` | JWT admin | Révoque une invitation | | |
| 723 | +| `GET` | `/api/v1/fediverse/invites` | `invite_routes::idp_list` | JWT | Liste les invitations pour un handle (IdP side) | | |
| 724 | +| `POST` | `/api/v1/fediverse/invites/{invite_id}/accept` | `invite_routes::accept` | JWT | Accepte une invitation | | |
| 725 | +| `POST` | `/api/v1/fediverse/invites/{invite_id}/reject` | `invite_routes::reject` | JWT | Rejette une invitation | | |
| 726 | + | |
| 727 | +### Routes UI (rustwarden-fediverse-ui) | |
| 728 | + | |
| 729 | +Les handlers HTML (login, consentement, panneau admin) sont dans `rustwarden-fediverse-ui`. Voir [`docs/fediverse-consumer-guide.md`](../fediverse-consumer-guide.md) §5 pour la liste complète. | |
| 730 | + | |
| 731 | +--- | |
| 732 | + | |
| 733 | +## 15. Fédération AP vs OAuth/OIDC | |
| 734 | + | |
| 735 | +| Dimension | OAuth 2.0 / OIDC | ActivityPub (fédération) | | |
| 736 | +|-----------|-----------------|--------------------------| | |
| 737 | +| Topologie | Client → serveur central | Pair à pair, décentralisé | | |
| 738 | +| Protocole auth | Authorization Code + PKCE, Token endpoint | Challenge/consent via activités AP | | |
| 739 | +| Transport identité | Access token (JWT opaque ou structuré) | JWT local émis après `consume` | | |
| 740 | +| Dépendance infrastructure | Provider OIDC central disponible | Instances AP mutuellement joignables | | |
| 741 | +| Révocation | Blacklist token ou introspection | `Delete{Actor}` + cascade `on_delete_actor` | | |
| 742 | +| Cas d'usage Rustwarden | SSO via Google/GitHub/Microsoft/Discord | SSO inter-instances Rustwarden, invitations multi-domaines | | |
| 743 | + | |
| 744 | +Les deux mécanismes coexistent. Un même utilisateur peut s'authentifier via Google OAuth sur son instance A, puis être invité (en tant que `remote_identity`) dans une équipe sur l'instance B via AP. Voir [09-hooks-and-events.md](09-hooks-and-events.md) pour la documentation des hooks qui couvrent les deux flux. | |
| 745 | + | |
| 746 | +--- | |
| 747 | + | |
| 748 | +## 16. Sécurité | |
| 749 | + | |
| 750 | +La sécurité de la couche fédération est documentée dans deux fichiers dédiés : | |
| 751 | + | |
| 752 | +- [`docs/fediverse-security-model.md`](../fediverse-security-model.md) — modèle de menace, choix de conception, surfaces d'attaque et contre-mesures. | |
| 753 | +- [`docs/ANSSI_FEDIVERSE_COMPLIANCE.md`](../ANSSI_FEDIVERSE_COMPLIANCE.md) — conformité aux recommandations ANSSI : F-CRYPTO-1 (digest corps), F-CRYPTO-2 (replay Date), F-NET-1 (limite taille body), F-NET-2 (validation redirect URL), F-NET-5 (verifier production). | |
| 754 | + | |
| 755 | +Points d'attention actuels (état 2026-05-17) : | |
| 756 | + | |
| 757 | +| Référence | Description | | |
| 758 | +|-----------|-------------| | |
| 759 | +| F-NET-5 | `routes::router()` dépréciée utilise `AlwaysAcceptVerifier`. Utiliser `router_with_auth` en production. | | |
| 760 | +| RFC 0005 | Les clés RSA d'acteur sont générées à la demande. La rotation de clé n'est pas encore implémentée. | | |
| 761 | +| RFC 0015 | Fenêtre de replay à 300 s. Un attaquant avec capture réseau dispose de 5 minutes. Une fenêtre plus courte (60 s) sera envisagée en V2. | | |
| 762 | + | |
| 763 | +--- | |
| 764 | + | |
| 765 | +## 17. Limites actuelles et roadmap V2 | |
| 766 | + | |
| 767 | +| Limite | Impact | Plan V2 | | |
| 768 | +|--------|--------|---------| | |
| 769 | +| Pas de rotation des clés RSA d'acteur | Compromission longue durée non détectable | RFC futur — rotation avec période de grâce | | |
| 770 | +| Fenêtre de replay 300 s | Attaque en 5 min sur lien capturé | Réduire à 60 s + nonce unique par requête | | |
| 771 | +| Zéro `#[instrument]` sur les services | Traces non structurées, debugging difficile | Instrumenter auth, token, team, totp (voir `10-testing-and-observability.md`) | | |
| 772 | +| Zéro métriques Prometheus | Pas de SLO possible sur le protocole AP | Compteurs `rustwarden_fediverse_*` exposés sur `/metrics` | | |
| 773 | +| Worker outbound sans supervision | Panne silencieuse si worker bloqué | Gauge `rustwarden_outbound_queue_depth` + alerting | | |
| 774 | +| `NoopDelivery` en production possible | Activités perdues silencieusement | `HttpSignedDelivery` à rendre obligatoire en prod | | |
| 775 | +| `AlwaysAcceptVerifier` accessible via `router()` | Risque de mise en prod accidentelle | Supprimer l'export public en V2 | | |
| 776 | + | |
| 777 | +Pour la roadmap complète, voir `docs/V2_ROADMAP.md`. |
A
docs/developer-guide/09-oauth-sso.md
+296
-0
@@ -0,0 +1,296 @@
| 1 | +# 09 — OAuth, OIDC et SSO | |
| 2 | + | |
| 3 | +Ce document explique comment Rustwarden agit en **client** d'un fournisseur d'identité OAuth2/OIDC externe (Google, GitHub, Microsoft, Discord), comment les comptes sont liés, et comment ce mécanisme se distingue des Personal Access Tokens et de la fédération ActivityPub. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## Providers supportés | |
| 8 | + | |
| 9 | +| Provider | Type de protocole | Particularités | | |
| 10 | +|---|---|---| | |
| 11 | +| **Google** | OIDC (OpenID Connect) | PKCE S256 + nonce, discovery automatique via `/.well-known/openid-configuration` | | |
| 12 | +| **GitHub** | OAuth 2.0 | PKCE S256, email récupéré via endpoint séparé `/user/emails`, email_verified basé sur `verified=true` | | |
| 13 | +| **Microsoft** | OIDC | PKCE S256, tenant configurable (`OAUTH_MICROSOFT_TENANT`, défaut `common`) | | |
| 14 | +| **Discord** | OAuth 2.0 | PKCE S256, email_verified basé sur le champ `verified` de l'API utilisateur | | |
| 15 | + | |
| 16 | +--- | |
| 17 | + | |
| 18 | +## Activation | |
| 19 | + | |
| 20 | +La feature Cargo `oauth` doit être activée. Elle active les dépendances `openidconnect` et `reqwest` : | |
| 21 | + | |
| 22 | +```toml | |
| 23 | +# Cargo.toml consommateur | |
| 24 | +rustwarden-core = { version = "...", features = ["oauth"] } | |
| 25 | +``` | |
| 26 | + | |
| 27 | +Chaque provider est activé individuellement via des variables d'environnement : | |
| 28 | + | |
| 29 | +| Variable | Description | Défaut | | |
| 30 | +|---|---|---| | |
| 31 | +| `OAUTH_GOOGLE_ENABLED` | Active le provider Google | `false` | | |
| 32 | +| `OAUTH_GOOGLE_CLIENT_ID` | Client ID Google | — | | |
| 33 | +| `OAUTH_GOOGLE_CLIENT_SECRET` | Client Secret Google | — | | |
| 34 | +| `OAUTH_GITHUB_ENABLED` | Active le provider GitHub | `false` | | |
| 35 | +| `OAUTH_GITHUB_CLIENT_ID` | Client ID GitHub | — | | |
| 36 | +| `OAUTH_GITHUB_CLIENT_SECRET` | Client Secret GitHub | — | | |
| 37 | +| `OAUTH_DISCORD_ENABLED` | Active le provider Discord | `false` | | |
| 38 | +| `OAUTH_DISCORD_CLIENT_ID` | Client ID Discord | — | | |
| 39 | +| `OAUTH_DISCORD_CLIENT_SECRET` | Client Secret Discord | — | | |
| 40 | +| `OAUTH_MICROSOFT_ENABLED` | Active le provider Microsoft | `false` | | |
| 41 | +| `OAUTH_MICROSOFT_CLIENT_ID` | Client ID Microsoft | — | | |
| 42 | +| `OAUTH_MICROSOFT_CLIENT_SECRET` | Client Secret Microsoft | — | | |
| 43 | +| `OAUTH_MICROSOFT_TENANT` | Tenant Azure AD | `common` | | |
| 44 | + | |
| 45 | +L'URL de callback est construite par le handler OAuth à partir de la base URL de l'application. Le pattern est `https://<base_url>/api/v1/oauth/<provider>/callback`. | |
| 46 | + | |
| 47 | +--- | |
| 48 | + | |
| 49 | +## Flow OAuth2 Authorization Code + PKCE | |
| 50 | + | |
| 51 | +```mermaid | |
| 52 | +sequenceDiagram | |
| 53 | + participant U as Utilisateur | |
| 54 | + participant APP as Rustwarden | |
| 55 | + participant IDP as Provider (ex. Google) | |
| 56 | + participant DB as Base de données | |
| 57 | + | |
| 58 | + U->>APP: Clic "Se connecter avec Google" | |
| 59 | + APP->>APP: Générer state (CSRF), nonce (OIDC), PKCE verifier + challenge (S256) | |
| 60 | + APP->>APP: Stocker state, nonce, pkce_verifier en session | |
| 61 | + APP->>U: 302 → https://accounts.google.com/o/oauth2/v2/auth\n?client_id=...&state=...&nonce=...&code_challenge=...&code_challenge_method=S256 | |
| 62 | + | |
| 63 | + U->>IDP: Consentement utilisateur | |
| 64 | + IDP->>U: 302 → /api/v1/oauth/google/callback?code=...&state=... | |
| 65 | + | |
| 66 | + U->>APP: GET /api/v1/oauth/google/callback?code=...&state=... | |
| 67 | + APP->>APP: Récupérer state, nonce, pkce_verifier depuis session | |
| 68 | + APP->>APP: Vérifier state (ConstantTimeEq) | |
| 69 | + APP->>IDP: POST token_endpoint { code, redirect_uri, pkce_verifier } | |
| 70 | + IDP->>APP: { access_token, id_token, refresh_token } | |
| 71 | + APP->>APP: Valider id_token (signature, nonce, issuer) | |
| 72 | + APP->>IDP: GET userinfo endpoint (access_token) | |
| 73 | + IDP->>APP: { sub, email, email_verified, name, picture } | |
| 74 | + | |
| 75 | + APP->>DB: SELECT oauth_accounts WHERE provider='google' AND provider_user_id=sub | |
| 76 | + alt Compte lié existant | |
| 77 | + DB->>APP: oauth_accounts row | |
| 78 | + APP->>DB: SELECT users WHERE id=oauth_accounts.user_id | |
| 79 | + else Aucun lien — email déjà enregistré | |
| 80 | + APP->>DB: SELECT users WHERE email=email (si email_verified) | |
| 81 | + APP->>DB: INSERT oauth_accounts (lier le compte existant) | |
| 82 | + else Nouvel utilisateur | |
| 83 | + APP->>DB: INSERT users (username=email, email, email_verified=true) | |
| 84 | + APP->>DB: INSERT user_roles (role='user') | |
| 85 | + APP->>DB: INSERT oauth_accounts | |
| 86 | + end | |
| 87 | + | |
| 88 | + APP->>APP: Émettre hook on_oauth_login(user) | |
| 89 | + APP->>APP: JwtService::generate_token(user_id, username, roles, permissions) | |
| 90 | + APP->>U: 200 OK + JWT (header Authorization ou cookie HttpOnly) | |
| 91 | +``` | |
| 92 | + | |
| 93 | +Référence implémentation : [`services/oauth/service.rs`](../../crates/rustwarden-core/src/services/oauth/service.rs), [`services/oauth/provider_trait.rs`](../../crates/rustwarden-core/src/services/oauth/provider_trait.rs) | |
| 94 | + | |
| 95 | +--- | |
| 96 | + | |
| 97 | +## State et PKCE | |
| 98 | + | |
| 99 | +```mermaid | |
| 100 | +flowchart LR | |
| 101 | + subgraph Génération de la requête | |
| 102 | + S1[CsrfToken::new_random] --> STATE[state\nstocké en session] | |
| 103 | + S2[Nonce::new_random] --> NONCE[nonce\nstocké en session] | |
| 104 | + S3[PkceCodeChallenge::new_random_sha256] --> CHALLENGE[code_challenge S256\nenvoyé au provider] | |
| 105 | + S3 --> VERIFIER[code_verifier\nstocké en session] | |
| 106 | + end | |
| 107 | + | |
| 108 | + subgraph Validation du callback | |
| 109 | + CB[callback reçu\ncode + state] --> VS[Comparer state\nConstantTimeEq] | |
| 110 | + VS -->|OK| EX[Échanger code\n+ code_verifier] | |
| 111 | + EX --> VN[Valider nonce\ndans id_token] | |
| 112 | + VN -->|OK| UI[Appeler userinfo] | |
| 113 | + end | |
| 114 | +``` | |
| 115 | + | |
| 116 | +**Pourquoi le state protège-t-il contre le CSRF ?** Sans state, un attaquant pourrait initier un flow OAuth depuis son propre compte et faire en sorte que la victime consomme le callback avec le code de l'attaquant, liant le compte de l'attaquant à la session de la victime (attaque de liaison de compte). Le state est lié à la session du navigateur qui a initié la requête. | |
| 117 | + | |
| 118 | +**Pourquoi le PKCE ?** Pour les providers qui ne supportent pas les clients confidentiels (GitHub notamment), PKCE garantit que même si le code d'autorisation est intercepté, il est inutilisable sans le `code_verifier` qui n'a jamais quitté le serveur. | |
| 119 | + | |
| 120 | +--- | |
| 121 | + | |
| 122 | +## Chiffrement des tokens OAuth en base | |
| 123 | + | |
| 124 | +Les tokens de rafraîchissement OAuth reçus d'un provider et stockés en base (si le provider les renvoie) sont chiffrés avec `CryptoService::encrypt()` (AES-256-GCM) avant insertion. Le format stocké est `enc:<base64(nonce || ciphertext)>`. | |
| 125 | + | |
| 126 | +La clé de chiffrement est lue depuis `ENCRYPTION_KEY` ou `OAUTH_ENCRYPTION_KEY`. | |
| 127 | + | |
| 128 | +--- | |
| 129 | + | |
| 130 | +## Liens compte multi-provider | |
| 131 | + | |
| 132 | +Un utilisateur peut lier plusieurs providers OAuth à son compte. La table `oauth_accounts` stocke un enregistrement par lien : | |
| 133 | + | |
| 134 | +``` | |
| 135 | +users (1) ──── (N) oauth_accounts | |
| 136 | + ├── provider='google', provider_user_id='10762...' | |
| 137 | + ├── provider='github', provider_user_id='42' | |
| 138 | + └── provider='discord', provider_user_id='8230...' | |
| 139 | +``` | |
| 140 | + | |
| 141 | +L'index `UNIQUE (provider, provider_user_id)` garantit qu'un compte provider donné ne peut être lié qu'à un seul utilisateur Rustwarden. | |
| 142 | + | |
| 143 | +Pour délier un compte, l'utilisateur utilise l'endpoint de gestion de profil. La protection métier : on ne peut pas délier le dernier moyen d'authentification si l'utilisateur n'a pas de mot de passe local (`OAuthService::unlink_account_by_id` vérifie `has_password || linked_accounts.len() > 1`). | |
| 144 | + | |
| 145 | +--- | |
| 146 | + | |
| 147 | +## Comportement au premier login (first-login) | |
| 148 | + | |
| 149 | +Lors du premier login OAuth, le flow `OAuthService::find_or_create_user` suit cette logique : | |
| 150 | + | |
| 151 | +1. **Lien existant** (`oauth_accounts` match) → retourner l'utilisateur lié, `is_new_user = false`. | |
| 152 | +2. **Email déjà enregistré** (compte local avec le même email) → lier le compte OAuth à l'utilisateur existant, `is_new_user = false`. **Prérequis** : le provider doit avoir renvoyé `email_verified = true`. Une adresse non vérifiée par le provider est rejetée (`AppError::Validation`). | |
| 153 | +3. **Aucun compte existant** → créer un nouvel utilisateur avec `username = email`, assigner le rôle `user`, créer le lien OAuth, `is_new_user = true`. | |
| 154 | + | |
| 155 | +Il n'y a pas de flag de configuration pour désactiver la création automatique d'utilisateur en V1. Un consommateur qui souhaite restreindre les inscriptions OAuth peut implémenter le hook `on_oauth_login` et rejeter les utilisateurs qui ne sont pas dans une liste d'autorisation. | |
| 156 | + | |
| 157 | +--- | |
| 158 | + | |
| 159 | +## Diagramme d'état du lien OAuth | |
| 160 | + | |
| 161 | +```mermaid | |
| 162 | +stateDiagram-v2 | |
| 163 | + [*] --> Linked : OAuthService::link_account()\nou find_or_create_user() | |
| 164 | + | |
| 165 | + Linked --> Unlinked : OAuthService::unlink_account()\n(si pas dernier moyen d'auth) | |
| 166 | + | |
| 167 | + Unlinked --> [*] | |
| 168 | + | |
| 169 | + note right of Linked | |
| 170 | + La row oauth_accounts existe | |
| 171 | + Le provider_user_id est lié à un user_id local | |
| 172 | + end note | |
| 173 | + | |
| 174 | + note right of Unlinked | |
| 175 | + DELETE oauth_accounts WHERE id = ... | |
| 176 | + La row n'existe plus en base | |
| 177 | + end note | |
| 178 | +``` | |
| 179 | + | |
| 180 | +Il n'existe pas d'état "suspendu" ou "révoqué" pour un lien OAuth en V1 : un lien est soit actif (row présente), soit absent (supprimé). La révocation de l'accès au niveau du provider (ex. l'utilisateur retire l'accès depuis son compte Google) ne met pas à jour la base Rustwarden — le prochain callback échouera simplement à récupérer les tokens. | |
| 181 | + | |
| 182 | +--- | |
| 183 | + | |
| 184 | +## Différence OAuth/OIDC vs Fédération ActivityPub | |
| 185 | + | |
| 186 | +Ces deux systèmes sont souvent confondus. Ils répondent à des besoins fondamentalement différents. | |
| 187 | + | |
| 188 | +```mermaid | |
| 189 | +flowchart LR | |
| 190 | + subgraph OAuth_OIDC ["OAuth 2.0 / OIDC (module oauth)"] | |
| 191 | + direction TB | |
| 192 | + USER1[Utilisateur] -->|"Login via Google"| RW1[Rustwarden\nclient OAuth] | |
| 193 | + RW1 -->|"Échange code → tokens"| GOOGLE[Google\nIdP centralisé] | |
| 194 | + GOOGLE -->|"Infos utilisateur"| RW1 | |
| 195 | + RW1 -->|"Crée compte local\nou lie compte"| DB1[(oauth_accounts)] | |
| 196 | + end | |
| 197 | + | |
| 198 | + subgraph AP ["Fédération ActivityPub (rustwarden-fediverse)"] | |
| 199 | + direction TB | |
| 200 | + USER2[Acteur @alice@autre.instance] -->|"HTTP Signature"| RW2[Rustwarden\npair AP] | |
| 201 | + RW2 -->|"Fetch actor JSON"| REMOTE[autre.instance\npair AP] | |
| 202 | + REMOTE -->|"Clé publique RSA"| RW2 | |
| 203 | + RW2 -->|"Cache identité distante"| DB2[(remote_identities)] | |
| 204 | + end | |
| 205 | + | |
| 206 | + style OAuth_OIDC fill:#e8f4fd,stroke:#4a90d9 | |
| 207 | + style AP fill:#f0ebfa,stroke:#7c5cbf | |
| 208 | +``` | |
| 209 | + | |
| 210 | +| Critère | OAuth 2.0 / OIDC | Fédération ActivityPub | | |
| 211 | +|---|---|---| | |
| 212 | +| **Rôle de Rustwarden** | Client d'un IdP tiers | Pair dans un réseau décentralisé | | |
| 213 | +| **Protocole** | OAuth 2.0 + optionnel OIDC | ActivityPub + HTTP Signatures | | |
| 214 | +| **Autorité centrale** | Oui (Google, GitHub, etc.) | Non (réseau pair-à-pair) | | |
| 215 | +| **Identité résultante** | Compte local `users` avec lien `oauth_accounts` | Identité distante `remote_identities` | | |
| 216 | +| **JWT émis** | `kind: local` | `kind: remote` (RFC 0007) | | |
| 217 | +| **Module** | `rustwarden-core` feature `oauth` | `rustwarden-fediverse` | | |
| 218 | + | |
| 219 | +Pour la fédération ActivityPub, voir [08-fediverse-integration.md](08-fediverse-integration.md). | |
| 220 | + | |
| 221 | +--- | |
| 222 | + | |
| 223 | +## Sécurité OAuth | |
| 224 | + | |
| 225 | +**Rotation des secrets** : les `CLIENT_SECRET` ne sont jamais stockés en base. Ils sont lus depuis les variables d'environnement au démarrage. La rotation d'un secret implique uniquement la mise à jour de la variable et le redémarrage de l'application. | |
| 226 | + | |
| 227 | +**Scopes minimaux** : chaque provider demande uniquement les scopes nécessaires à l'identification : | |
| 228 | +- Google : `openid email profile` | |
| 229 | +- GitHub : `read:user user:email` | |
| 230 | +- Microsoft : `openid email profile` | |
| 231 | +- Discord : `identify email` | |
| 232 | + | |
| 233 | +**Validation de l'issuer** : pour les providers OIDC (Google, Microsoft), l'issuer du `id_token` est validé contre la valeur attendue issue du discovery document. Un token émis par un autre issuer est rejeté. | |
| 234 | + | |
| 235 | +**Expiration des tokens** : les `access_token` reçus du provider ne sont pas mis en cache — ils sont utilisés immédiatement pour appeler `userinfo` puis abandonnés. Il n'y a pas de logique de rafraîchissement de token provider en V1. | |
| 236 | + | |
| 237 | +--- | |
| 238 | + | |
| 239 | +## Personal Access Tokens (PAT) | |
| 240 | + | |
| 241 | +Les PAT sont distincts d'OAuth. Ce sont des tokens locaux à Rustwarden, gérés directement par l'utilisateur, sans impliquer un provider externe. | |
| 242 | + | |
| 243 | +| Caractéristique | PAT | OAuth token | | |
| 244 | +|---|---|---| | |
| 245 | +| **Émetteur** | Rustwarden | Provider externe (Google, etc.) | | |
| 246 | +| **Stockage** | Hash SHA-256 uniquement | Row `oauth_accounts` (lien) | | |
| 247 | +| **Scopes** | Définis par l'application consommatrice (`TokenScope` trait) | Définis par le provider | | |
| 248 | +| **Révocation** | `PersonalTokenService::revoke()` | Suppression du lien OAuth | | |
| 249 | +| **Durée de vie** | Configurable, optionnelle | Dépend du provider | | |
| 250 | +| **Usage typique** | Scripts CI/CD, accès API sans navigateur | Login utilisateur via navigateur | | |
| 251 | + | |
| 252 | +**Génération** : 32 octets aléatoires générés avec `rand::rng().fill_bytes()`, encodés en hex (64 caractères). Le plaintext est retourné une seule fois à la création. | |
| 253 | + | |
| 254 | +**Stockage** : seul le digest SHA-256 (hex, 64 caractères) est persisté dans la colonne `token_hash`. Le plaintext n'est jamais journalisé. | |
| 255 | + | |
| 256 | +**Vérification** : `PersonalTokenService::verify()` hache le token présenté, cherche le digest en base, puis effectue une comparaison constant-time (`subtle::ConstantTimeEq`) sur le hash pour se protéger contre les timing oracles. | |
| 257 | + | |
| 258 | +**Scopes** : le trait `TokenScope` est générique — chaque application consommatrice définit son propre vocabulaire de scopes. Les scopes sont stockés comme un tableau `text[]` en PostgreSQL. | |
| 259 | + | |
| 260 | +```rust | |
| 261 | +// Exemple de définition de scopes dans une application consommatrice | |
| 262 | +#[derive(Clone, Debug)] | |
| 263 | +enum MyScope { ReadRepo, WriteRepo } | |
| 264 | + | |
| 265 | +impl TokenScope for MyScope { | |
| 266 | + fn as_str(&self) -> &str { | |
| 267 | + match self { MyScope::ReadRepo => "read:repo", MyScope::WriteRepo => "write:repo" } | |
| 268 | + } | |
| 269 | + fn parse(s: &str) -> Option<Self> { | |
| 270 | + match s { "read:repo" => Some(Self::ReadRepo), _ => None } | |
| 271 | + } | |
| 272 | +} | |
| 273 | + | |
| 274 | +// Création d'un PAT | |
| 275 | +let (record, plaintext) = PersonalTokenService::<MyScope>::create( | |
| 276 | + &db, user_id, "ci-bot", &[MyScope::ReadRepo], None | |
| 277 | +).await?; | |
| 278 | +// plaintext à afficher une seule fois à l'utilisateur | |
| 279 | +``` | |
| 280 | + | |
| 281 | +Référence : [`services/personal_token_service.rs`](../../crates/rustwarden-core/src/services/personal_token_service.rs) | |
| 282 | + | |
| 283 | +--- | |
| 284 | + | |
| 285 | +## Troubleshooting | |
| 286 | + | |
| 287 | +| Erreur | Cause probable | Solution | | |
| 288 | +|---|---|---| | |
| 289 | +| `OAuth provider did not return an email address` | Le provider n'a pas inclus l'email dans le token ou le scope manque | Vérifier que les scopes incluent `email` ; pour GitHub, vérifier que l'email est public ou que le scope `user:email` est demandé | | |
| 290 | +| `Email address is not verified by the OAuth provider` | Le provider a renvoyé `email_verified=false` | L'utilisateur doit vérifier son email auprès du provider avant de pouvoir se connecter | | |
| 291 | +| `invalid_state` ou état non trouvé en session | La session a expiré entre l'initiation et le callback, ou cookies bloqués | Vérifier la durée de vie de la session, vérifier que les cookies tiers ne sont pas bloqués | | |
| 292 | +| `Failed to exchange code` | Le code a expiré (codes OAuth valides ~10 minutes) ou la `redirect_uri` ne correspond pas | Vérifier que la `redirect_uri` dans les credentials du provider correspond exactement à l'URL du callback Rustwarden | | |
| 293 | +| `redirect_uri_mismatch` (erreur provider) | L'URL de callback Rustwarden n'est pas enregistrée dans la console du provider | Ajouter l'URL exacte (schéma, domaine, chemin) dans la console Google/GitHub/Microsoft/Discord | | |
| 294 | +| `ENCRYPTION_KEY is not set` | La variable d'environnement manque au démarrage | Définir `ENCRYPTION_KEY` avec une clé hex de 64 caractères (32 octets) | | |
| 295 | +| Token PAT rejeté avec `InvalidToken` | Token révoqué, expiré, ou hash ne correspond pas | Vérifier `revoked_at` et `expires_at` dans `personal_access_tokens` ; régénérer le token si nécessaire | | |
| 296 | +| Lien OAuth impossible (`Cannot unlink the last authentication method`) | L'utilisateur n'a ni mot de passe local ni autre provider lié | L'utilisateur doit définir un mot de passe local avant de délier le dernier provider OAuth | |
A
docs/developer-guide/10-testing-and-observability.md
+587
-0
@@ -0,0 +1,587 @@
| 1 | +# Tests et observabilité | |
| 2 | + | |
| 3 | +> **Public visé** : développeurs qui contribuent au framework Rustwarden ou qui l'intègrent dans un projet et souhaitent comprendre la stratégie de test, l'état actuel de l'observabilité et les recommandations V2. | |
| 4 | +> | |
| 5 | +> **Type Diátaxis** : explication + guide pratique (comprendre la stratégie, savoir exécuter les tests). | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## Partie A — Tests | |
| 10 | + | |
| 11 | +### A.1 Panorama | |
| 12 | + | |
| 13 | +Le workspace Rustwarden compte **538 tests automatisés** au 17 mai 2026 : | |
| 14 | + | |
| 15 | +| Type | Annotation | Quantité | | |
| 16 | +|------|-----------|---------| | |
| 17 | +| Tests unitaires synchrones | `#[test]` | 239 | | |
| 18 | +| Tests asynchrones (Tokio) | `#[tokio::test]` | 299 | | |
| 19 | +| **Total** | | **538** | | |
| 20 | + | |
| 21 | +**Stack de test** : | |
| 22 | +- `tokio::test` pour tout le code asynchrone (services SeaORM, handlers Axum). | |
| 23 | +- `sea-orm` MockDatabase pour les tests unitaires de services sans base réelle. | |
| 24 | +- `axum::Router` + `tower::ServiceExt::oneshot` pour les tests d'intégration HTTP. | |
| 25 | +- `reqwest` + testbed Docker pour les tests e2e fédération. | |
| 26 | +- `wiremock` (RFC 0020) pour les tests d'interopérabilité AP avec un faux serveur distant. | |
| 27 | + | |
| 28 | +**Commande pour tout exécuter** : | |
| 29 | + | |
| 30 | +```bash | |
| 31 | +cargo test --workspace | |
| 32 | +``` | |
| 33 | + | |
| 34 | +Les tests e2e fédération s'auto-ignorent si le testbed n'est pas démarré — ils n'échouent pas, ils s'omettent avec un message d'explication. | |
| 35 | + | |
| 36 | +--- | |
| 37 | + | |
| 38 | +### A.2 Pyramide de tests | |
| 39 | + | |
| 40 | +```mermaid | |
| 41 | +flowchart TB | |
| 42 | + E2E["Tests e2e fédération\n(testbed Docker 3 instances)\ncrates/rustwarden-fediverse/tests/e2e_fediverse.rs\n~2000 lignes"] | |
| 43 | + INTEG["Tests d'intégration HTTP\n(axum::Router + tower::oneshot)\npar module de handler"] | |
| 44 | + UNIT["Tests unitaires\n(services, modèles, utilitaires)\n#[test] + #[tokio::test]"] | |
| 45 | + | |
| 46 | + E2E --> INTEG --> UNIT | |
| 47 | +``` | |
| 48 | + | |
| 49 | +- **Unitaires** : testent un service ou une fonction isolément, avec MockDatabase SeaORM ou des données en mémoire. Rapides, sans dépendance externe. | |
| 50 | +- **Intégration** : testent un handler HTTP complet avec la stack Axum, mais sans base PostgreSQL réelle (MockDatabase ou DB en mémoire SQLite pour certains cas). | |
| 51 | +- **E2E fédération** : testent deux ou trois instances Rustwarden réelles communiquant via ActivityPub sur des ports localhost. Nécessitent le testbed Docker. | |
| 52 | + | |
| 53 | +--- | |
| 54 | + | |
| 55 | +### A.3 Tests unitaires de services | |
| 56 | + | |
| 57 | +Le pattern recommandé utilise `sea_orm::MockDatabase` pour simuler la couche de persistance sans lancer PostgreSQL. | |
| 58 | + | |
| 59 | +```rust | |
| 60 | +use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 61 | +use rustwarden_fediverse::services::fediverse_auth_service::{ | |
| 62 | + FediverseAuthService, ChallengeStatus, InitiateRequest, | |
| 63 | +}; | |
| 64 | +use std::time::Duration; | |
| 65 | + | |
| 66 | +#[tokio::test] | |
| 67 | +async fn test_initiate_creates_pending_challenge() { | |
| 68 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 69 | + .append_exec_results([MockExecResult { | |
| 70 | + last_insert_id: 1, | |
| 71 | + rows_affected: 1, | |
| 72 | + }]) | |
| 73 | + .into_connection(); | |
| 74 | + | |
| 75 | + let result = FediverseAuthService::initiate( | |
| 76 | + &db, | |
| 77 | + InitiateRequest { | |
| 78 | + handle: "@alice@a.tld".to_string(), | |
| 79 | + redirect_url: None, | |
| 80 | + }, | |
| 81 | + Duration::from_secs(300), | |
| 82 | + ) | |
| 83 | + .await; | |
| 84 | + | |
| 85 | + assert!(result.is_ok()); | |
| 86 | +} | |
| 87 | +``` | |
| 88 | + | |
| 89 | +Pour les fonctions pures (parsing, validation) — pas de mock nécessaire : | |
| 90 | + | |
| 91 | +```rust | |
| 92 | +#[test] | |
| 93 | +fn test_parse_signature_header_rejects_missing_key_id() { | |
| 94 | + use rustwarden_fediverse::http_signatures::parse_signature_header; | |
| 95 | + let result = parse_signature_header(r#"algorithm="rsa-sha256",signature="abc""#); | |
| 96 | + assert!(result.is_err()); | |
| 97 | +} | |
| 98 | +``` | |
| 99 | + | |
| 100 | +--- | |
| 101 | + | |
| 102 | +### A.4 Tests d'intégration HTTP | |
| 103 | + | |
| 104 | +Le pattern `tower::ServiceExt::oneshot` permet de tester un handler Axum sans lancer de serveur TCP : | |
| 105 | + | |
| 106 | +```rust | |
| 107 | +use axum::{ | |
| 108 | + body::Body, | |
| 109 | + http::{Request, StatusCode}, | |
| 110 | +}; | |
| 111 | +use tower::ServiceExt; | |
| 112 | + | |
| 113 | +#[tokio::test] | |
| 114 | +async fn test_initiate_returns_201() { | |
| 115 | + // Construire le router avec un MockDatabase injecté. | |
| 116 | + let app = build_test_router(mock_db()); | |
| 117 | + | |
| 118 | + let req = Request::builder() | |
| 119 | + .method("POST") | |
| 120 | + .uri("/api/v1/auth/login/fediverse/initiate") | |
| 121 | + .header("content-type", "application/json") | |
| 122 | + .body(Body::from(r#"{"handle":"@alice@a.tld"}"#)) | |
| 123 | + .unwrap(); | |
| 124 | + | |
| 125 | + let response = app.oneshot(req).await.unwrap(); | |
| 126 | + assert_eq!(response.status(), StatusCode::CREATED); | |
| 127 | +} | |
| 128 | +``` | |
| 129 | + | |
| 130 | +Ce pattern évite de démarrer un port TCP et reste rapide. Il convient pour valider la structure des réponses, les codes de statut HTTP et la sérialisation JSON. | |
| 131 | + | |
| 132 | +--- | |
| 133 | + | |
| 134 | +### A.5 Tests e2e fédération | |
| 135 | + | |
| 136 | +**Fichier** : [`crates/rustwarden-fediverse/tests/e2e_fediverse.rs`](../../crates/rustwarden-fediverse/tests/e2e_fediverse.rs) (~2000 lignes) | |
| 137 | + | |
| 138 | +Ce fichier couvre les scénarios complets entre trois instances : | |
| 139 | + | |
| 140 | +| Constante | URL | Domaine | | |
| 141 | +|-----------|-----|---------| | |
| 142 | +| `ALPHA` | `http://localhost:3001` | `alpha.local` | | |
| 143 | +| `BETA` | `http://localhost:3002` | `beta.local` | | |
| 144 | +| `GAMMA` | `http://localhost:3003` | `gamma.local` | | |
| 145 | + | |
| 146 | +**Comportement en l'absence du testbed** : chaque test appelle `testbed_or_skip()` qui sonde `/health` sur les trois instances. Si l'une est inaccessible, le test s'arrête proprement (`return`) sans `panic!`. Les tests sont donc transparents dans une CI standard. | |
| 147 | + | |
| 148 | +**Démarrer le testbed** : | |
| 149 | + | |
| 150 | +```bash | |
| 151 | +# Depuis la racine du projet | |
| 152 | +cd tests/fediverse-testbed | |
| 153 | +./scripts/setup.sh | |
| 154 | + | |
| 155 | +# Puis lancer les tests e2e | |
| 156 | +cargo test -p rustwarden-fediverse --test e2e_fediverse | |
| 157 | +``` | |
| 158 | + | |
| 159 | +**Exemples de scénarios couverts** : | |
| 160 | +- `webfinger_resolves_local_user_on_each_instance` — WebFinger retourne un JRD valide avec un lien `self` AP. | |
| 161 | +- `auth_challenge_full_flow` — initiate → auth/inbox → consent → status → consume → JWT valide. | |
| 162 | +- `cross_instance_team_invite` — invite depuis ALPHA vers @bob@beta.local → accept → membership créé. | |
| 163 | +- `inbox_rejects_invalid_signature` — POST /inbox sans signature → 401. | |
| 164 | +- `inbox_rejects_oversized_body` — corps > 1 MiB → 413. | |
| 165 | + | |
| 166 | +--- | |
| 167 | + | |
| 168 | +### A.6 Prérequis base de données pour les tests | |
| 169 | + | |
| 170 | +Certains tests de `rustwarden-core` accèdent à une vraie base PostgreSQL. Sans base lancée, **31 tests environ échouent** avec une erreur de connexion. | |
| 171 | + | |
| 172 | +**Procédure** : | |
| 173 | + | |
| 174 | +```bash | |
| 175 | +# Démarrer PostgreSQL via Docker Compose | |
| 176 | +cd /home/monsieurb/Documents/devel/Rustwarden/database | |
| 177 | +docker compose up -d | |
| 178 | + | |
| 179 | +# Vérifier que la DB est prête | |
| 180 | +docker compose ps | |
| 181 | + | |
| 182 | +# Lancer les migrations | |
| 183 | +cargo run --bin migrate | |
| 184 | + | |
| 185 | +# Lancer les tests | |
| 186 | +cargo test --workspace | |
| 187 | +``` | |
| 188 | + | |
| 189 | +La `DATABASE_URL` doit être définie dans l'environnement ou dans un fichier `.env` à la racine : | |
| 190 | + | |
| 191 | +```bash | |
| 192 | +DATABASE_URL=postgres://rustwarden:password@localhost:5432/rustwarden | |
| 193 | +``` | |
| 194 | + | |
| 195 | +Les tests qui n'utilisent que `MockDatabase` (majoritaires dans `rustwarden-fediverse`) fonctionnent sans base réelle. | |
| 196 | + | |
| 197 | +--- | |
| 198 | + | |
| 199 | +### A.7 Mocks vs base de données réelle | |
| 200 | + | |
| 201 | +| Contexte | Recommandation | | |
| 202 | +|----------|---------------| | |
| 203 | +| Tests unitaires de services métier | `MockDatabase` SeaORM — rapide, pas de dépendance externe | | |
| 204 | +| Tests de logique de migration | Base PostgreSQL réelle — les migrations doivent être rejouées | | |
| 205 | +| Tests d'intégration HTTP | `MockDatabase` suffit pour valider les handlers sans effet de bord | | |
| 206 | +| Tests e2e fédération | Base réelle + testbed Docker — le protocole AP nécessite des instances vraies | | |
| 207 | +| CI par défaut | `MockDatabase` — pas de Docker requis, tests en < 30 s | | |
| 208 | +| CI dédiée intégration | Base PostgreSQL dans service Docker Compose | | |
| 209 | + | |
| 210 | +--- | |
| 211 | + | |
| 212 | +### A.8 Convention de nommage des tests | |
| 213 | + | |
| 214 | +Le projet suit le pattern `test_<feature>_<scénario>_<attente>` : | |
| 215 | + | |
| 216 | +```rust | |
| 217 | +// Bon : précis, lisible sans contexte | |
| 218 | +#[tokio::test] | |
| 219 | +async fn test_consume_returns_error_when_challenge_not_approved() { } | |
| 220 | + | |
| 221 | +#[tokio::test] | |
| 222 | +async fn test_signature_header_rejects_missing_algorithm() { } | |
| 223 | + | |
| 224 | +#[test] | |
| 225 | +fn test_trust_mode_parse_defaults_to_allowlist_on_unknown_value() { } | |
| 226 | +``` | |
| 227 | + | |
| 228 | +```rust | |
| 229 | +// À éviter : trop vague | |
| 230 | +#[tokio::test] | |
| 231 | +async fn test_consume() { } | |
| 232 | +``` | |
| 233 | + | |
| 234 | +--- | |
| 235 | + | |
| 236 | +### A.9 Couverture de code | |
| 237 | + | |
| 238 | +Aucun seuil de couverture n'est imposé actuellement. Pour mesurer : | |
| 239 | + | |
| 240 | +```bash | |
| 241 | +# Avec cargo-tarpaulin (Linux uniquement) | |
| 242 | +cargo install cargo-tarpaulin | |
| 243 | +cargo tarpaulin --workspace --out Html | |
| 244 | + | |
| 245 | +# Avec cargo-llvm-cov (multi-plateforme) | |
| 246 | +cargo install cargo-llvm-cov | |
| 247 | +cargo llvm-cov --workspace --html | |
| 248 | +``` | |
| 249 | + | |
| 250 | +L'objectif V2 est de définir un seuil minimum par crate (proposition : 70 % pour `rustwarden-core`, 60 % pour `rustwarden-fediverse`). | |
| 251 | + | |
| 252 | +--- | |
| 253 | + | |
| 254 | +### A.10 CI | |
| 255 | + | |
| 256 | +La chaîne CI standard exécute : | |
| 257 | + | |
| 258 | +```bash | |
| 259 | +# Compilation sans erreur | |
| 260 | +cargo build --workspace | |
| 261 | + | |
| 262 | +# Tests (sans la feature redis-session — elle ne compile pas) | |
| 263 | +cargo test --workspace | |
| 264 | + | |
| 265 | +# Lint zéro warning | |
| 266 | +cargo clippy --workspace -- -D warnings | |
| 267 | + | |
| 268 | +# Documentation compilable | |
| 269 | +cargo doc --workspace --no-deps -D warnings | |
| 270 | +``` | |
| 271 | + | |
| 272 | +> **Attention** : ne pas utiliser `--all-features`. La feature `redis-session` de `rustwarden-core` ne compile pas actuellement. Voir la note dans [`docs/`](../) sur la feature cassée. | |
| 273 | + | |
| 274 | +--- | |
| 275 | + | |
| 276 | +## Partie B — Observabilité | |
| 277 | + | |
| 278 | +### B.1 État actuel (2026-05-17) | |
| 279 | + | |
| 280 | +| Aspect | Situation actuelle | | |
| 281 | +|--------|-------------------| | |
| 282 | +| Bibliothèque de traces | `tracing` utilisé (394 appels dans les crates, 5 dans `app/`) | | |
| 283 | +| `#[instrument]` | **0** usage — aucun span structuré automatique | | |
| 284 | +| OpenTelemetry | Absent | | |
| 285 | +| Métriques Prometheus | Absentes | | |
| 286 | +| Endpoint `/health` | Présent, retourne `{"status":"ok"}` sans vérifier la DB ni le worker | | |
| 287 | +| Format des logs | Texte brut (format `tracing_subscriber` par défaut) | | |
| 288 | +| Logs JSON | Absents | | |
| 289 | +| Audit trail | `auth_logging` service du core — présent pour les événements d'authentification | | |
| 290 | + | |
| 291 | +Les 394 appels `tracing::` couvrent des cas utiles (`tracing::warn!` dans les services clés, `tracing::info!` sur les livraisons outbound) mais sans spans imbriqués, la corrélation entre une requête entrante et ses effets de bord est impossible. | |
| 292 | + | |
| 293 | +--- | |
| 294 | + | |
| 295 | +### B.2 Architecture cible V2 | |
| 296 | + | |
| 297 | +```mermaid | |
| 298 | +flowchart LR | |
| 299 | + APP["Application Rustwarden\n(tracing-subscriber\n+ tracing-opentelemetry)"] | |
| 300 | + | |
| 301 | + APP -->|OTLP gRPC| OTEL_COL["OpenTelemetry Collector"] | |
| 302 | + | |
| 303 | + OTEL_COL -->|traces| TEMPO["Tempo"] | |
| 304 | + OTEL_COL -->|logs| LOKI["Loki"] | |
| 305 | + OTEL_COL -->|métriques| MIMIR["Mimir / Prometheus"] | |
| 306 | + | |
| 307 | + TEMPO --> GRAFANA["Grafana"] | |
| 308 | + LOKI --> GRAFANA | |
| 309 | + MIMIR --> GRAFANA | |
| 310 | + | |
| 311 | + APP -->|/metrics scrape| PROM["Prometheus (scrape direct)"] | |
| 312 | + PROM --> GRAFANA | |
| 313 | +``` | |
| 314 | + | |
| 315 | +--- | |
| 316 | + | |
| 317 | +### B.3 Recommandations V2 — Tracing structuré | |
| 318 | + | |
| 319 | +**Priorité 1** : ajouter `#[tracing::instrument]` sur les méthodes de services critiques. | |
| 320 | + | |
| 321 | +```rust | |
| 322 | +use tracing::instrument; | |
| 323 | + | |
| 324 | +impl AuthService { | |
| 325 | + #[instrument( | |
| 326 | + skip(db, password), // Ne jamais tracer les secrets | |
| 327 | + fields(user_id, ip_address) // Champs enrichis dynamiquement | |
| 328 | + )] | |
| 329 | + pub async fn login( | |
| 330 | + db: &DatabaseConnection, | |
| 331 | + username: &str, | |
| 332 | + password: &str, | |
| 333 | + ) -> Result<LoginResult, AppError> { | |
| 334 | + // tracing::Span::current().record("user_id", &id.to_string()); | |
| 335 | + todo!() | |
| 336 | + } | |
| 337 | +} | |
| 338 | +``` | |
| 339 | + | |
| 340 | +**Services prioritaires** (dans l'ordre) : | |
| 341 | + | |
| 342 | +1. `AuthService::login`, `AuthService::logout`, `AuthService::refresh_token` | |
| 343 | +2. `JwtService::mint`, `JwtService::validate` | |
| 344 | +3. `TeamService::add_member`, `TeamService::remove_member` | |
| 345 | +4. `TotpService::verify` | |
| 346 | +5. `FediverseAuthService::initiate`, `FediverseAuthService::consume` | |
| 347 | +6. `FediverseOutboundService::drain_one` | |
| 348 | + | |
| 349 | +**Niveaux recommandés** : | |
| 350 | + | |
| 351 | +| Niveau | Usage | | |
| 352 | +|--------|-------| | |
| 353 | +| `ERROR` | Panic-worthy : corruption de données, secret manquant, DB unreachable | | |
| 354 | +| `WARN` | Anomalie récupérable : signature échouée, retry outbound, token expiré | | |
| 355 | +| `INFO` | Transactions métier : login réussi, invitation créée, équipe modifiée | | |
| 356 | +| `DEBUG` | Détails de flux : état du challenge, payload sérialisé, clé résolue | | |
| 357 | +| `TRACE` | Verbose : chaque header inspecté, chaque champ DB lu | | |
| 358 | + | |
| 359 | +--- | |
| 360 | + | |
| 361 | +### B.4 Recommandations V2 — Métriques Prometheus | |
| 362 | + | |
| 363 | +**Compteurs** : | |
| 364 | + | |
| 365 | +``` | |
| 366 | +rustwarden_auth_login_total{result="ok|fail|2fa_required"} | |
| 367 | +rustwarden_auth_logout_total | |
| 368 | +rustwarden_jwt_blacklist_hits_total | |
| 369 | +rustwarden_totp_attempts_total{result="ok|fail"} | |
| 370 | +rustwarden_oauth_callback_total{provider, result} | |
| 371 | +rustwarden_fediverse_inbox_total{type="Update|Delete|Revoke|unknown", result} | |
| 372 | +rustwarden_fediverse_outbound_delivered_total{domain} | |
| 373 | +rustwarden_fediverse_outbound_dead_total{domain} | |
| 374 | +rustwarden_fediverse_signature_failures_total{domain, reason} | |
| 375 | +rustwarden_fediverse_auth_challenge_total{result="consumed|rejected|expired"} | |
| 376 | +``` | |
| 377 | + | |
| 378 | +**Histogrammes** : | |
| 379 | + | |
| 380 | +``` | |
| 381 | +rustwarden_http_request_duration_seconds{route, method, status} | |
| 382 | +rustwarden_fediverse_outbound_delivery_duration_seconds{domain} | |
| 383 | +``` | |
| 384 | + | |
| 385 | +**Jauges** : | |
| 386 | + | |
| 387 | +``` | |
| 388 | +rustwarden_active_sessions | |
| 389 | +rustwarden_email_queue_depth | |
| 390 | +rustwarden_fediverse_outbound_queue_depth{status="pending|failed"} | |
| 391 | +rustwarden_fediverse_trusted_domains_count | |
| 392 | +``` | |
| 393 | + | |
| 394 | +**Exposition** : nouvel endpoint `/metrics` (format texte Prometheus), protégé par middleware admin ou par allowlist IP. Ne jamais exposer `/metrics` sur l'interface publique sans authentification. | |
| 395 | + | |
| 396 | +**Crates recommandées** : | |
| 397 | + | |
| 398 | +```toml | |
| 399 | +[dependencies] | |
| 400 | +metrics = "0.23" | |
| 401 | +metrics-exporter-prometheus = "0.15" | |
| 402 | +``` | |
| 403 | + | |
| 404 | +--- | |
| 405 | + | |
| 406 | +### B.5 Recommandations V2 — Health endpoints | |
| 407 | + | |
| 408 | +Remplacer le `/health` minimal par deux endpoints distincts : | |
| 409 | + | |
| 410 | +| Endpoint | Type | Comportement | | |
| 411 | +|----------|------|-------------| | |
| 412 | +| `/healthz` | Liveness | Retourne 200 si le processus répond. Aucune vérification de dépendance. | | |
| 413 | +| `/readyz` | Readiness | Vérifie DB (ping SeaORM), session store, worker outbound actif. Retourne 503 si une dépendance est KO. | | |
| 414 | + | |
| 415 | +**Format JSON cible pour `/readyz`** : | |
| 416 | + | |
| 417 | +```json | |
| 418 | +{ | |
| 419 | + "status": "ok", | |
| 420 | + "checks": { | |
| 421 | + "database": { "status": "ok", "latency_ms": 3 }, | |
| 422 | + "session_store": { "status": "ok" }, | |
| 423 | + "outbound_worker": { "status": "ok", "queue_depth": 0 }, | |
| 424 | + "email_queue": { "status": "ok", "depth": 2 } | |
| 425 | + } | |
| 426 | +} | |
| 427 | +``` | |
| 428 | + | |
| 429 | +En cas de dépendance KO : | |
| 430 | + | |
| 431 | +```json | |
| 432 | +{ | |
| 433 | + "status": "degraded", | |
| 434 | + "checks": { | |
| 435 | + "database": { "status": "error", "error": "connection refused" }, | |
| 436 | + "session_store": { "status": "ok" } | |
| 437 | + } | |
| 438 | +} | |
| 439 | +``` | |
| 440 | + | |
| 441 | +--- | |
| 442 | + | |
| 443 | +### B.6 Recommandations V2 — Logs JSON structurés | |
| 444 | + | |
| 445 | +En production, activer le format JSON dans `tracing_subscriber` : | |
| 446 | + | |
| 447 | +```rust | |
| 448 | +use tracing_subscriber::fmt; | |
| 449 | + | |
| 450 | +fn init_logging() { | |
| 451 | + if std::env::var("RUST_LOG_FORMAT").as_deref() == Ok("json") { | |
| 452 | + fmt() | |
| 453 | + .json() | |
| 454 | + .with_target(true) | |
| 455 | + .with_thread_ids(false) | |
| 456 | + .init(); | |
| 457 | + } else { | |
| 458 | + fmt().compact().init(); | |
| 459 | + } | |
| 460 | +} | |
| 461 | +``` | |
| 462 | + | |
| 463 | +**Champs obligatoires par ligne de log** : | |
| 464 | + | |
| 465 | +| Champ | Type | Note | | |
| 466 | +|-------|------|------| | |
| 467 | +| `timestamp` | RFC3339 | Généré par `tracing_subscriber` | | |
| 468 | +| `level` | string | ERROR/WARN/INFO/DEBUG/TRACE | | |
| 469 | +| `target` | string | Module Rust (`rustwarden::auth::service`) | | |
| 470 | +| `message` | string | Message lisible | | |
| 471 | +| `request_id` | UUID | Injecté par middleware en tête de requête | | |
| 472 | +| `user_id` | UUID (opt.) | Uniquement si l'utilisateur est authentifié | | |
| 473 | + | |
| 474 | +**Règle PII** : ne jamais logger d'adresse email en clair, de mot de passe, de token JWT ou de clé privée. Les champs sensibles sont soit omis, soit tronqués (4 premiers caractères + `***`). | |
| 475 | + | |
| 476 | +--- | |
| 477 | + | |
| 478 | +### B.7 Trace d'une requête login avec OTel | |
| 479 | + | |
| 480 | +```mermaid | |
| 481 | +sequenceDiagram | |
| 482 | + participant MW as Middleware (span racine) | |
| 483 | + participant HANDLER as Handler POST /login | |
| 484 | + participant AUTH as AuthService::login | |
| 485 | + participant BCRYPT as bcrypt::verify | |
| 486 | + participant DB as SeaORM query | |
| 487 | + participant HOOKS as RustwardenHooks::on_login | |
| 488 | + | |
| 489 | + MW->>HANDLER: span "http.server" {method, route, request_id} | |
| 490 | + HANDLER->>AUTH: span "auth.login" {username_hash} | |
| 491 | + AUTH->>DB: span "db.query" {table="users", op="find_by_username"} | |
| 492 | + DB-->>AUTH: UserModel | |
| 493 | + AUTH->>BCRYPT: span "crypto.bcrypt_verify" | |
| 494 | + BCRYPT-->>AUTH: ok | |
| 495 | + AUTH->>DB: span "db.query" {table="auth_logs", op="insert"} | |
| 496 | + AUTH->>HOOKS: span "hooks.on_login" {user_id} | |
| 497 | + HOOKS-->>AUTH: ok | |
| 498 | + AUTH-->>HANDLER: LoginResult {jwt, refresh_token} | |
| 499 | + HANDLER-->>MW: 200 OK | |
| 500 | + Note over MW: Durée totale enregistrée dans l'histogramme<br/>rustwarden_http_request_duration_seconds | |
| 501 | +``` | |
| 502 | + | |
| 503 | +Chaque span enfant hérite du `trace_id` de la span racine HTTP. Dans Grafana Tempo, cela permet de naviguer depuis une ligne de log vers la trace complète. | |
| 504 | + | |
| 505 | +--- | |
| 506 | + | |
| 507 | +### B.8 Outils recommandés | |
| 508 | + | |
| 509 | +| Besoin | Crate | | |
| 510 | +|--------|-------| | |
| 511 | +| Traces structurées | `tracing` + `tracing-subscriber` | | |
| 512 | +| Export OTLP vers Collector | `tracing-opentelemetry` + `opentelemetry-otlp` | | |
| 513 | +| Métriques Prometheus | `metrics` + `metrics-exporter-prometheus` | | |
| 514 | +| Logs JSON | `tracing-subscriber` avec `.json()` | | |
| 515 | + | |
| 516 | +**Exemple de configuration V2** : | |
| 517 | + | |
| 518 | +```toml | |
| 519 | +[dependencies] | |
| 520 | +tracing = "0.1" | |
| 521 | +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } | |
| 522 | +tracing-opentelemetry = "0.24" | |
| 523 | +opentelemetry-otlp = { version = "0.17", features = ["grpc-tonic"] } | |
| 524 | +metrics = "0.23" | |
| 525 | +metrics-exporter-prometheus = "0.15" | |
| 526 | +``` | |
| 527 | + | |
| 528 | +```rust | |
| 529 | +use opentelemetry_otlp::WithExportConfig; | |
| 530 | +use tracing_subscriber::{layer::SubscriberExt, Registry}; | |
| 531 | + | |
| 532 | +fn init_telemetry(otlp_endpoint: &str) -> anyhow::Result<()> { | |
| 533 | + let tracer = opentelemetry_otlp::new_pipeline() | |
| 534 | + .tracing() | |
| 535 | + .with_exporter( | |
| 536 | + opentelemetry_otlp::new_exporter() | |
| 537 | + .tonic() | |
| 538 | + .with_endpoint(otlp_endpoint), | |
| 539 | + ) | |
| 540 | + .install_batch(opentelemetry_sdk::runtime::Tokio)?; | |
| 541 | + | |
| 542 | + let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer); | |
| 543 | + let fmt_layer = tracing_subscriber::fmt::layer().json(); | |
| 544 | + | |
| 545 | + let subscriber = Registry::default() | |
| 546 | + .with(tracing_subscriber::EnvFilter::from_default_env()) | |
| 547 | + .with(otel_layer) | |
| 548 | + .with(fmt_layer); | |
| 549 | + | |
| 550 | + tracing::subscriber::set_global_default(subscriber)?; | |
| 551 | + Ok(()) | |
| 552 | +} | |
| 553 | +``` | |
| 554 | + | |
| 555 | +--- | |
| 556 | + | |
| 557 | +### B.9 Audit et logs de conformité | |
| 558 | + | |
| 559 | +Le service `auth_logging` de `rustwarden-core` est déjà opérationnel. Il enregistre les événements d'authentification (login, logout, échec, 2FA) dans la table `auth_logs`. | |
| 560 | + | |
| 561 | +**Événements à ajouter en V2** pour couvrir les actions admin : | |
| 562 | + | |
| 563 | +| Événement | Table | Déclencheur | | |
| 564 | +|-----------|-------|------------| | |
| 565 | +| Création d'utilisateur | `audit_logs` | `UserService::create` | | |
| 566 | +| Modification de rôle | `audit_logs` | `RbacService::assign_role` | | |
| 567 | +| Suppression d'utilisateur | `audit_logs` | `UserService::delete` | | |
| 568 | +| Ajout à une équipe | `audit_logs` | `TeamService::add_member` | | |
| 569 | +| Révocation d'un token | `audit_logs` | `JwtBlacklistService::blacklist` | | |
| 570 | +| Delete{Actor} AP reçu | `audit_logs` | `FediverseSignalService::apply_delete_actor_observed` | | |
| 571 | + | |
| 572 | +Le format cible est une table `audit_logs` avec colonnes `id`, `actor_user_id`, `action`, `target_type`, `target_id`, `details_json`, `created_at`. | |
| 573 | + | |
| 574 | +--- | |
| 575 | + | |
| 576 | +### B.10 Référence roadmap | |
| 577 | + | |
| 578 | +Les améliorations d'observabilité sont suivies dans `docs/V2_ROADMAP.md`. | |
| 579 | + | |
| 580 | +La priorité recommandée est : | |
| 581 | + | |
| 582 | +1. Instrumenter `AuthService` et `JwtService` avec `#[instrument]`. | |
| 583 | +2. Activer les logs JSON en production via variable d'environnement `RUST_LOG_FORMAT=json`. | |
| 584 | +3. Implémenter `/readyz` avec vérification DB. | |
| 585 | +4. Ajouter les métriques Prometheus sur le chemin d'authentification. | |
| 586 | +5. Brancher `tracing-opentelemetry` + Collector OTLP. | |
| 587 | +6. Compléter les audit logs sur les actions admin. |
A
docs/developer-guide/README.md
+84
-0
@@ -0,0 +1,84 @@
| 1 | +# Guide développeur Rustwarden | |
| 2 | + | |
| 3 | +## Qu'est-ce que Rustwarden ? | |
| 4 | + | |
| 5 | +Rustwarden est un **framework backend Rust de gestion des utilisateurs et de l'authentification**. Il fournit, sous forme de crates Cargo réutilisables, tout ce qu'un service web a besoin : authentification JWT, RBAC granulaire, OAuth2/OIDC, 2FA/TOTP, gestion d'équipes, sessions avec refresh token, file d'envoi d'emails, rate limiting et panneau d'administration optionnel. Le point d'entrée est `RustwardenApp::builder()`, un builder Rust idiomatique qui compose les couches et lance le serveur Axum. | |
| 6 | + | |
| 7 | +Rustwarden **n'est pas** un gestionnaire de mots de passe. Malgré la ressemblance de nom, il n'a aucun lien avec Bitwarden ou Vaultwarden : pas de coffre-fort, pas de chiffrement de secrets personnels, pas d'extension navigateur. Les modules `auth`, `oauth`, `totp`, `team`, `permission`, `resource` sont des **primitives génériques** conçues pour être branchées dans n'importe quelle application backend SaaS, API REST ou service interne. | |
| 8 | + | |
| 9 | +Le public cible est double. D'un côté, les **développeurs consommateurs** qui veulent intégrer Rustwarden dans leur propre projet Rust (SPA frontale séparée, application mobile, micro-service) sans réécrire la couche auth. De l'autre, les **contributeurs** qui souhaitent étendre le framework — ajouter un provider OAuth, affiner le RBAC, ou contribuer au protocole ActivityPub via `rustwarden-fediverse`. | |
| 10 | + | |
| 11 | +--- | |
| 12 | + | |
| 13 | +## Navigation du guide | |
| 14 | + | |
| 15 | +| Fichier | Contenu | | |
| 16 | +|---|---| | |
| 17 | +| [01-architecture-overview.md](01-architecture-overview.md) | Vision globale, carte des crates, couches techniques, axes d'extension, features Cargo | | |
| 18 | +| [02-request-lifecycle.md](02-request-lifecycle.md) | Cycle de vie complet d'une requête authentifiée, login, refresh, logout, extracteur `AuthUser` | | |
| 19 | +| [03-configuration.md](03-configuration.md) | Variables d'environnement, `AuthConfig`, `ServerConfig`, `EmailConfig` | | |
| 20 | +| [04-builder-api.md](04-builder-api.md) | `RustwardenApp::builder()` — toutes les méthodes, exemples d'assemblage | | |
| 21 | +| [05-consuming-the-framework.md](05-consuming-the-framework.md) | Intégration pas-à-pas dans un projet Rust existant, ajout de routes custom | | |
| 22 | +| [06-contributing.md](06-contributing.md) | Conventions de code, règles Clippy, tests, processus de revue | | |
| 23 | +| [07-security-model.md](07-security-model.md) | Modèle de menace, JWT blacklist, CSRF, rate limiting, bonnes pratiques ANSSI | | |
| 24 | +| [08-fediverse-integration.md](08-fediverse-integration.md) | Branchement de `rustwarden-fediverse` via traits, ActivityPub headless | | |
| 25 | +| [09-hooks-and-events.md](09-hooks-and-events.md) | Trait `RustwardenHooks`, liste des événements, exemple d'implémentation | | |
| 26 | +| [10-testing-and-observability.md](10-testing-and-observability.md) | Stratégie de test (538 tests, pyramide, e2e fédération), état de l'observabilité et recommandations V2 | | |
| 27 | + | |
| 28 | +--- | |
| 29 | + | |
| 30 | +## Par où commencer ? | |
| 31 | + | |
| 32 | +```mermaid | |
| 33 | +flowchart TD | |
| 34 | + START([Je découvre Rustwarden]) --> Q1{Quel est mon objectif ?} | |
| 35 | + | |
| 36 | + Q1 -->|Intégrer dans mon projet| CONSO[01 — Architecture overview] | |
| 37 | + CONSO --> CONSO2[05 — Consuming the framework] | |
| 38 | + CONSO2 --> CONSO3[04 — Builder API] | |
| 39 | + | |
| 40 | + Q1 -->|Contribuer au framework| CONTRIB[01 — Architecture overview] | |
| 41 | + CONTRIB --> CONTRIB2[02 — Request lifecycle] | |
| 42 | + CONTRIB2 --> CONTRIB3[06 — Contributing] | |
| 43 | + | |
| 44 | + Q1 -->|Brancher la fédération ActivityPub| FEDI[08 — Fediverse integration] | |
| 45 | + | |
| 46 | + Q1 -->|Auditer la sécurité| SEC[07 — Security model] | |
| 47 | + SEC --> SEC2[02 — Request lifecycle] | |
| 48 | + | |
| 49 | + style START fill:#4a90d9,color:#fff | |
| 50 | + style FEDI fill:#7c5cbf,color:#fff | |
| 51 | + style SEC fill:#d94a4a,color:#fff | |
| 52 | +``` | |
| 53 | + | |
| 54 | +--- | |
| 55 | + | |
| 56 | +## Glossaire express | |
| 57 | + | |
| 58 | +| Terme | Définition | | |
| 59 | +|---|---| | |
| 60 | +| **Principal** | Entité qui effectue une action — peut être un utilisateur local, un token personnel ou une identité fédérée. Voir `resource_service::Principal`. | | |
| 61 | +| **Identity / IdentityKind** | Discriminant dans le JWT (`kind: local | remote`) pour distinguer un compte local d'un acteur fédéré sans toucher la base. | | |
| 62 | +| **AuthUser** | Extracteur Axum qui valide le JWT entrant et injecte `user_id`, `username`, `roles`, `permissions` dans le handler. Retourne 401 si le token est absent ou invalide. | | |
| 63 | +| **Claims** | Structure sérialisée dans le JWT : `sub`, `jti`, `exp`, `iat`, `iss`, `aud`, `username`, `roles`, `permissions`, `kind`. | | |
| 64 | +| **jti** | JWT ID — identifiant unique de chaque token, utilisé par `JwtBlacklistService` pour la révocation individuelle. | | |
| 65 | +| **Hook** | Méthode du trait `RustwardenHooks` appelée par le framework à un événement métier (login, logout, inscription, changement de rôle…). Le consumer implémente le trait et passe l'objet au builder. | | |
| 66 | +| **Observer** | Synonyme de hook dans le contexte de la fédération ActivityPub : `FediverseEventObserver` reçoit les activités (Follow, Like, etc.) depuis `rustwarden-fediverse`. | | |
| 67 | +| **Refresh token** | Token opaque (64 chars alphanumériques, stocké haché en SHA-256) échangé contre une nouvelle paire JWT. Rotation automatique à chaque usage ; révocation globale en cas de réutilisation détectée. | | |
| 68 | +| **Blacklist JWT** | Table `jwt_blacklist` qui stocke le hash SHA-256 des tokens révoqués. Consultée à chaque requête par `JwtBlacklistService::is_blacklisted`. | | |
| 69 | +| **RustwardenApp** | Application entièrement configurée, résultat de `builder().build().await?`. Expose `database()`, `auth_config()`, `i18n_service()` et la méthode `run()`. | | |
| 70 | +| **RustwardenBuilder** | Builder fluide permettant de composer le framework : routes, hooks, UI, fédération, migrations auto, localisation. | | |
| 71 | +| **headless** | Mode sans interface HTML — API JSON pure. Activé par `.headless()` sur le builder ou simplement en n'appelant pas `.with_ui()`. | | |
| 72 | +| **DashboardCardDTO** | DTO permettant aux crates compagnons (`rustwarden-fediverse-ui`) d'injecter une carte dans le tableau de bord d'administration via `add_dashboard_card()`. | | |
| 73 | +| **AppError** | Type d'erreur central du framework, converti automatiquement en réponse HTTP appropriée par `IntoResponse`. | | |
| 74 | +| **LocalUserResolver** | Trait fédération que `rustwarden-core` expose pour que `rustwarden-fediverse` puisse résoudre un acteur AP en compte local sans dépendance inversée. | | |
| 75 | + | |
| 76 | +--- | |
| 77 | + | |
| 78 | +## Liens utiles | |
| 79 | + | |
| 80 | +- [SERVICES_REFERENCE.md](../SERVICES_REFERENCE.md) — référence exhaustive de tous les services publics du framework | |
| 81 | +- [constitution.md](../constitution.md) — règles architecturales et de sécurité du projet | |
| 82 | +- [auth-middleware-layering.md](../auth-middleware-layering.md) — détail de l'ordre des couches middleware d'authentification | |
| 83 | +- [fediverse.md](../fediverse.md) — documentation du protocole ActivityPub headless | |
| 84 | +- [SECURITY_AUDIT_2026_05.md](../SECURITY_AUDIT_2026_05.md) — dernier audit de sécurité |
A
docs/plans/2026-05-17-storage-ha-roadmap.md
+403
-0
@@ -0,0 +1,403 @@
| 1 | +# Plan — Stockage distant & Haute Disponibilité pour Rustwarden | |
| 2 | + | |
| 3 | +## Contexte | |
| 4 | + | |
| 5 | +**Problème.** Rustwarden est aujourd'hui un framework d'authentification mono-instance : tout est en PostgreSQL relationnel, plusieurs composants critiques sont en mémoire process, aucune abstraction de stockage objet n'existe, la feature `redis-session` est cassée, l'auto-migration au démarrage crée une *race condition* en cluster, et les background tasks n'ont aucun verrou distribué (double exécution garantie en HA). Aucune RFC n'a encore traité ces sujets — la roadmap V2 mentionne uniquement un rate-limit Redis prévu. | |
| 6 | + | |
| 7 | +**Cible.** Préparer Rustwarden et ses consommateurs (apps construites par les devs tiers) à 3 profils de déploiement (single-node, multi-instances self-hosted, SaaS managé) avec une couche de stockage extensible, S3-compatible générique, et une vraie HA documentée. | |
| 8 | + | |
| 9 | +**Outcome attendu.** Une série de RFCs + un guide d'opération qui permettent à un dev tiers de partir de zéro et de scaler verticalement puis horizontalement sans refondre son code applicatif. | |
| 10 | + | |
| 11 | +--- | |
| 12 | + | |
| 13 | +## Synthèse de l'état de l'art (mai 2026) | |
| 14 | + | |
| 15 | +| Sujet | Recommandation 2026 | Source | | |
| 16 | +|---|---|---| | |
| 17 | +| Abstraction object storage Rust | [`object_store`](https://crates.io/crates/object_store) (Apache Arrow, prod chez crates.io & InfluxDB) ou [`opendal`](https://opendal.apache.org/docs/rust/opendal/) (plus de backends) | crates.io/InfluxDB IOx | | |
| 18 | +| Backend objet self-hosted | **MinIO communauté ARCHIVÉE en février 2026** → migrer vers [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (Apache 2.0, billions de petits fichiers) ou [Garage](https://garagehq.deuxfleurs.fr/) (Rust, AGPL — attention licence si on distribue) | dev.to/arash, rilavek.com | | |
| 19 | +| Backend objet managé EU | Scaleway Object Storage, OVH, Hetzner (DE/FI, GDPR natif) | lowcloud.io | | |
| 20 | +| PG HA sur Kubernetes | **[CloudNativePG](https://cloudnative-pg.io/)** (opérateur moderne, PodMonitor + Grafana out-of-the-box) | oneuptime.com | | |
| 21 | +| PG HA sur VM/bare-metal | **[Patroni](https://github.com/patroni/patroni)** (etcd/Consul/ZK, REST API, le plus mature) | dev.to/philip | | |
| 22 | +| PG HA simple (2 nœuds) | **[pg_auto_failover](https://github.com/citusdata/pg_auto_failover)** (turnkey, monitor centralisé) | dohost.us | | |
| 23 | +| Cache/sessions distribués | **[Valkey 8.1](https://valkey.io/)** (fork BSD de Redis, +8% perf, −20% RAM, drop-in pour `fred.rs` et `redis-rs`) | betterstack.com | | |
| 24 | +| Client Rust Valkey/Redis | [`fred.rs`](https://github.com/aembke/fred.rs) (clustered, sentinel, pool round-robin) | github.com/aembke | | |
| 25 | +| Backup PostgreSQL | **[pgBackRest](https://pgbackrest.org/)** (block-level incrémental, parallèle, chiffré, PITR, cible S3) — `WAL-G` si IaC-first | severalnines, christophepettus.com | | |
| 26 | +| Locks distribués / leader election | **PG advisory locks** quand on a déjà PG (zéro infra en plus) — Redlock seulement si Redis déjà présent ET besoin cross-DB | dev.to/vibhanshu909, konradreiche.com | | |
| 27 | + | |
| 28 | +**Décision structurante :** Rustwarden a déjà PostgreSQL → les advisory locks couvrent leader election, coordination migration et background tasks sans imposer Redis. Redis/Valkey reste optionnel pour sessions & rate-limit distribués (où PG est trop lourd). | |
| 29 | + | |
| 30 | +--- | |
| 31 | + | |
| 32 | +## Architecture cible — 3 profils | |
| 33 | + | |
| 34 | +``` | |
| 35 | +SINGLE-NODE MULTI-INSTANCES SAAS CLOUD | |
| 36 | +(Docker Compose) (K8s ou Compose Swarm) (managé) | |
| 37 | +───────────────── ───────────────────────── ───────────────── | |
| 38 | +1× rustwarden N× rustwarden derrière LB N× rustwarden (HPA) | |
| 39 | +1× PostgreSQL local 1× primary + 1..N replicas RDS / Cloud SQL / Aiven | |
| 40 | +sessions = memory Valkey/Redis cluster ElastiCache / managed | |
| 41 | +MinIO local (compat) SeaweedFS ou Garage cluster S3 / R2 / Scaleway | |
| 42 | +backups → MinIO local pgBackRest → bucket distant Backups managés + export | |
| 43 | + CloudNativePG / Patroni Multi-AZ + read replicas | |
| 44 | +``` | |
| 45 | + | |
| 46 | +Toutes les fonctionnalités passent par la **même API applicative** : changer de profil = changer la conf, pas le code. | |
| 47 | + | |
| 48 | +--- | |
| 49 | + | |
| 50 | +## Axe transversal — Standardisation de l'exploitation (OpsX / DX) | |
| 51 | + | |
| 52 | +C'est le **fil rouge** qui doit s'imposer aux 4 axes ci-dessous : sans lui, chaque axe inventerait sa propre micro-grammaire de config, ses propres binaires, ses propres erreurs — et l'exploitant aurait 4 façons différentes de faire la même chose. Le principe : **un seul vocabulaire, un seul binaire, un seul fichier de conf, une seule documentation d'erreur, une seule procédure de diagnostic.** | |
| 53 | + | |
| 54 | +### 1. Source unique de configuration | |
| 55 | + | |
| 56 | +- **Format canonique :** un fichier `rustwarden.toml` (parsé via [`config-rs`](https://crates.io/crates/config) + `serde` + `validator`), **surcharge par env vars** (priorité plus forte) selon la convention `RUSTWARDEN__<SECTION>__<CLE>` (double underscore = nesting). Compatible 12-factor pour Docker/K8s tout en restant lisible pour qui édite un fichier. | |
| 57 | +- **Validation au démarrage** : refus immédiat avec message clair si la config est incohérente (ex: `profile=multi-instance` + `sessions.backend=memory` → refus avant d'ouvrir le port HTTP). | |
| 58 | +- **Génération auto** : à chaque release, `rustwarden config dump-example > rustwarden.example.toml` produit un fichier annoté avec toutes les options, leurs défauts, et un commentaire « pourquoi ». C'est la doc opérationnelle qui ne ment jamais. | |
| 59 | +- **`rustwarden config validate`** : commande dédiée à exécuter en CI / pre-deploy. | |
| 60 | +- **`rustwarden config explain <clé>`** : explique la signification d'une option, son défaut, son impact par profil. | |
| 61 | + | |
| 62 | +### 2. Profils prédéfinis (`RUSTWARDEN_PROFILE`) | |
| 63 | + | |
| 64 | +Trois profils nommés qui chargent un **bundle de défauts cohérents** : | |
| 65 | + | |
| 66 | +| Profil | Sessions | Rate-limit | Storage | DB pool | Auto-migrate | Healthchecks | OTel | | |
| 67 | +|---|---|---|---|---|---|---|---| | |
| 68 | +| `single-node` | Memory | Memory | LocalFs | 10 conns | true | basiques | local stdout | | |
| 69 | +| `multi-instance` | Valkey/Postgres | Postgres/Valkey | S3 | 25 conns + replica | **false** (init container) | actifs | OTLP requis | | |
| 70 | +| `saas` | Valkey managé | Valkey | S3 managé | 50 conns + replica | false | actifs + RGPD | OTLP + Sentry-like | | |
| 71 | + | |
| 72 | +L'utilisateur peut surcharger n'importe quelle valeur — le profil ne fait que poser des défauts opinionnés. Aucune magie cachée : `rustwarden config explain` montre toujours d'où vient la valeur effective (`from profile`, `from env`, `from file`, `default`). | |
| 73 | + | |
| 74 | +### 3. Trait unifié `RustwardenBackend` | |
| 75 | + | |
| 76 | +Chaque axe (storage, shared-state, db, backup) expose ses backends derrière le même contrat minimal : | |
| 77 | + | |
| 78 | +```rust | |
| 79 | +pub trait RustwardenBackend: Send + Sync + 'static { | |
| 80 | + fn name(&self) -> &'static str; // "s3", "valkey", "postgres", "memory" | |
| 81 | + fn kind(&self) -> BackendKind; // Storage | SharedState | Database | Backup | |
| 82 | + async fn healthcheck(&self) -> ReadinessReport; | |
| 83 | + fn config_summary(&self) -> serde_json::Value; // pour `ops doctor`, sans secrets | |
| 84 | +} | |
| 85 | +``` | |
| 86 | + | |
| 87 | +Conséquence : `/ready` agrège mécaniquement toutes les readiness de tous les backends actifs, sans code spécifique par backend. | |
| 88 | + | |
| 89 | +### 4. Builder unique `RustwardenApp` | |
| 90 | + | |
| 91 | +Aujourd'hui le routeur se construit dans [crates/rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs) (12 références dans la mémoire de session). Cible : un builder fluent qui assemble tout ce qui touche aux 4 axes, avec **validation finale au `.build()`** : | |
| 92 | + | |
| 93 | +```rust | |
| 94 | +RustwardenApp::from_config("rustwarden.toml")? // ou ::from_env()? | |
| 95 | + .with_dashboard_card(my_card) | |
| 96 | + .with_event_listener::<MyEvent>(my_handler) | |
| 97 | + .build() // valide profil ↔ backends ↔ secrets | |
| 98 | + .await? | |
| 99 | + .serve(addr).await | |
| 100 | +``` | |
| 101 | + | |
| 102 | +Toute consommation tierce passe par ce point d'entrée. Plus de `app::AppState` instancié à la main avec 12 paramètres. | |
| 103 | + | |
| 104 | +### 5. CLI unique `rustwarden` | |
| 105 | + | |
| 106 | +Remplace les 4 binaires actuels éparpillés (`migrate`, `seed`, `hash_password`, `change_password` — cf. observation S132). Convention sous-commande verbe-objet : | |
| 107 | + | |
| 108 | +``` | |
| 109 | +rustwarden migrate up # remplace bin/migrate | |
| 110 | +rustwarden migrate status # nouveau : où en est-on ? | |
| 111 | +rustwarden seed admin --email … # remplace bin/seed | |
| 112 | +rustwarden user hash-password … # remplace bin/hash_password | |
| 113 | +rustwarden user change-password … # remplace bin/change_password | |
| 114 | +rustwarden storage ls / get / put … # nouveau (axe 1) | |
| 115 | +rustwarden backup run / restore / drill # nouveau (axe 4) | |
| 116 | +rustwarden config validate / explain … # nouveau | |
| 117 | +rustwarden ops doctor # nouveau : diagnose toute la stack | |
| 118 | +rustwarden ops failover-test # nouveau : simule des pannes | |
| 119 | +``` | |
| 120 | + | |
| 121 | +`rustwarden ops doctor` est le pivot UX : il liste tous les backends actifs, leur santé, leur version, leurs secrets manquants, leurs incohérences (ex: « profil=multi-instance mais auto_migrate=true → ⚠️ »), et produit un rapport coloré + un JSON exportable. C'est l'outil que l'exploitant lance en premier quand quelque chose ne va pas. | |
| 122 | + | |
| 123 | +Les binaires actuels deviennent des **aliases dépréciés** qui appellent la nouvelle CLI (transition douce, à supprimer en V3). | |
| 124 | + | |
| 125 | +### 6. Observabilité standardisée (alignée OTel) | |
| 126 | + | |
| 127 | +- **Conventions de spans** strictement définies : `rustwarden.storage.{put,get,delete}`, `rustwarden.shared_state.{get,set,lock_acquire}`, `rustwarden.background_task.run`, `rustwarden.migration.run`. Tous les backends respectent ces noms — l'opérateur écrit ses dashboards/alertes une fois pour toutes. | |
| 128 | +- **Labels stables** : `backend={memory|fs|s3|valkey|postgres}`, `profile={single|multi|saas}`, `crate={core|fediverse|fediverse-ui}`. | |
| 129 | +- **Dashboards Grafana de référence** dans `deploy/observability/grafana/` (JSON versionnés). | |
| 130 | +- **Logs structurés JSON** par défaut en `multi-instance` et `saas` (via `tracing-subscriber` + `tracing-bunyan-formatter` ou `tracing-stackdriver`). | |
| 131 | +- **Healthchecks différenciés** : `/health` = process vivant (cheap, pas de DB), `/ready` = toutes les readiness des backends OK (coût modéré). | |
| 132 | + | |
| 133 | +### 7. Manifestes de déploiement officiels par profil | |
| 134 | + | |
| 135 | +Un seul endroit, une seule arborescence, **versionnés avec le code** : | |
| 136 | + | |
| 137 | +``` | |
| 138 | +deploy/ | |
| 139 | +├── compose/ | |
| 140 | +│ ├── single-node.yml # rustwarden + PG local + LocalFs | |
| 141 | +│ └── multi-instance.yml # 2× rustwarden + PG primary/replica + Valkey + SeaweedFS + HAProxy | |
| 142 | +├── kubernetes/ | |
| 143 | +│ ├── helm/ # chart Helm unique, `profile=...` dans values.yaml | |
| 144 | +│ └── manifests/ # variantes brutes commentées | |
| 145 | +├── terraform/ | |
| 146 | +│ ├── scaleway-saas/ # exemple cible souveraine EU | |
| 147 | +│ ├── hetzner-saas/ | |
| 148 | +│ └── aws-saas/ | |
| 149 | +└── observability/ | |
| 150 | + ├── grafana/ # dashboards JSON | |
| 151 | + ├── prometheus/ # rules + alerts | |
| 152 | + └── otel-collector.yaml | |
| 153 | +``` | |
| 154 | + | |
| 155 | +Chacun est testable en CI (`compose up` + smoke tests). Si on casse un manifest, on casse la CI. | |
| 156 | + | |
| 157 | +### 8. Codes d'erreur stables (pattern Stripe) | |
| 158 | + | |
| 159 | +Chaque erreur retournée par Rustwarden a un **code stable** que l'on n'a pas le droit de renommer : | |
| 160 | + | |
| 161 | +- `RW-STORAGE-001 Backend unreachable` | |
| 162 | +- `RW-DB-014 Migration lock timeout` | |
| 163 | +- `RW-CONFIG-003 Incompatible profile/backend combination` | |
| 164 | +- `RW-SHARED-007 Leader election lost during execution` | |
| 165 | + | |
| 166 | +Chaque code = un fichier `docs/errors/RW-STORAGE-001.md` (symptôme, cause probable, fix, lien CLI `ops doctor`). Les logs et les réponses HTTP `5xx` portent le code. Les utilisateurs Googlent le code, tombent sur la doc. | |
| 167 | + | |
| 168 | +### 9. Suite de conformité backend (`backend-conformance`) | |
| 169 | + | |
| 170 | +Une crate de tests **générique paramétrée** : tout nouveau backend (object storage, shared state, db) doit passer la même suite. Garantit qu'on peut substituer un backend pour un autre sans surprise. | |
| 171 | + | |
| 172 | +```rust | |
| 173 | +backend_conformance::run_storage_suite(my_backend).await?; | |
| 174 | +``` | |
| 175 | + | |
| 176 | +Critère pour qu'un backend soit listé dans la doc officielle : passer la suite à 100%. | |
| 177 | + | |
| 178 | +### 10. Wizard `rustwarden-cli` repensé | |
| 179 | + | |
| 180 | +L'actuel [tools/rustwarden-cli/src/start_project.rs](tools/rustwarden-cli/src/start_project.rs) (`start-project`) génère un projet — il faut qu'il devienne **profile-aware** : | |
| 181 | + | |
| 182 | +``` | |
| 183 | +$ rustwarden-cli start-project | |
| 184 | +? Nom du projet › my-saas | |
| 185 | +? Profil cible › multi-instance | |
| 186 | +? Backend objet › SeaweedFS (self-hosted) / S3 (cloud) / LocalFs (dev) | |
| 187 | +? Backend état partagé › Valkey / Postgres advisory / Memory | |
| 188 | +? PG HA › CloudNativePG / Patroni / single primary | |
| 189 | +? Backup › pgBackRest vers le bucket choisi / désactivé | |
| 190 | +``` | |
| 191 | + | |
| 192 | +Sortie : `rustwarden.toml` adapté + `deploy/compose/` ou `deploy/kubernetes/` adapté + README de déploiement personnalisé + scripts `.envrc`/`.env.example` cohérents. | |
| 193 | + | |
| 194 | +### 11. Migration progressive single-node → multi-instance documentée | |
| 195 | + | |
| 196 | +Un seul document `docs/operations/scale-out.md` qui décrit la séquence sans downtime : | |
| 197 | + | |
| 198 | +1. Démarrer Valkey à côté → `STORAGE_BACKEND=valkey` pour sessions → restart roulant. | |
| 199 | +2. Ajouter une replica PG, configurer `DATABASE_URL_REPLICA` → restart roulant. | |
| 200 | +3. Désactiver `auto_migrate`, basculer migrations en init container/CronJob. | |
| 201 | +4. Scaler à 2 instances, vérifier `ops doctor` puis test de bascule. | |
| 202 | +5. Passer `RUSTWARDEN_PROFILE` à `multi-instance` (verrouille les défauts). | |
| 203 | + | |
| 204 | +Chaque étape testable, observable, réversible. | |
| 205 | + | |
| 206 | +### 12. Alignement 12-factor / cloud-native | |
| 207 | + | |
| 208 | +Règles non-négociables que tous les axes doivent respecter : | |
| 209 | + | |
| 210 | +- **Stateless** : aucune écriture locale mutable (sauf cache temporaire optionnel). | |
| 211 | +- **Config via env** (avec override fichier pour confort dev). | |
| 212 | +- **Logs vers stdout** (jamais de fichier de log applicatif). | |
| 213 | +- **Port d'écoute via env** (`PORT`). | |
| 214 | +- **Graceful shutdown** : SIGTERM → drain connexions → quit < 30s. | |
| 215 | +- **Secrets jamais en dur** (règle ANSSI déjà dans la constitution, à étendre aux nouveaux axes). | |
| 216 | +- **Idempotence** sur toutes les opérations de write distribuées (en particulier outbox fediverse, background tasks). | |
| 217 | + | |
| 218 | +### Conséquence sur les 4 axes ci-dessous | |
| 219 | + | |
| 220 | +Chacun des 4 axes doit produire : | |
| 221 | +- une **section config** dans `rustwarden.toml` (sous-clé propre) + variables `RUSTWARDEN__<AXE>__*` ; | |
| 222 | +- des **backends qui implémentent `RustwardenBackend`** ; | |
| 223 | +- des **sous-commandes CLI** sous le binaire unique `rustwarden` ; | |
| 224 | +- des **codes d'erreur `RW-<AXE>-NNN`** documentés ; | |
| 225 | +- des **spans/labels OTel** alignés sur la convention ; | |
| 226 | +- une **entrée dans `ops doctor`** ; | |
| 227 | +- un **passage de la suite `backend-conformance`** ; | |
| 228 | +- une **section dans `docs/operations/`** + des **manifests de déploiement** dans `deploy/`. | |
| 229 | + | |
| 230 | +Cette grille est la **definition of done** pour chaque RFC. | |
| 231 | + | |
| 232 | +--- | |
| 233 | + | |
| 234 | +## Plan d'amélioration en 4 axes | |
| 235 | + | |
| 236 | +### Axe 1 — Object Storage abstraction (RFC 0034 à rédiger) | |
| 237 | + | |
| 238 | +**Nouvelle crate `rustwarden-storage`** (pattern de `rustwarden-fediverse` qui est headless). | |
| 239 | + | |
| 240 | +- Choix de la lib : **`object_store`** (Apache Arrow) en premier lieu — plus mince, prod-proven chez crates.io. Fallback `opendal` si on a besoin de backends exotiques (Azure, GDrive, FTP). | |
| 241 | +- Trait public `ObjectStorageService` exposant : `put(key, bytes, content_type)`, `get(key)`, `delete(key)`, `presigned_url(key, ttl, op)`, `list(prefix)`, `exists(key)`. | |
| 242 | +- Backends fournis : `MemoryBackend` (tests), `LocalFsBackend` (dev/single-node), `S3Backend` (générique, fonctionne avec AWS, MinIO, SeaweedFS, Garage, R2, Scaleway, OVH via `endpoint_url`). | |
| 243 | +- Config env : `STORAGE_BACKEND`, `STORAGE_ENDPOINT`, `STORAGE_BUCKET`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_REGION`, `STORAGE_PRESIGNED_TTL_SECS`. | |
| 244 | +- **Chiffrement côté serveur** (SSE-C ou enveloppe via `aes-gcm` déjà présent dans `crypto_service.rs`) pour secrets sensibles. | |
| 245 | +- **Quota & rate-limit upload** : intégrer dans `RateLimitService` (axe 3). | |
| 246 | +- Pas de cache disque — les backends managés ont le leur. | |
| 247 | + | |
| 248 | +**Cas d'usage immédiats à brancher :** | |
| 249 | +1. **Avatars utilisateurs uploadés** (aujourd'hui : URL externe seulement, voir [oauth_account.rs:17](crates/rustwarden-core/src/models/oauth_account.rs#L17)). Champ `users.avatar_storage_key` + endpoint `POST /api/v1/users/me/avatar` (multipart). | |
| 250 | +2. **Cache des avatars fediverse distants** (aujourd'hui : `icon_url` non téléchargé, voir [remote_identity.rs:44](crates/rustwarden-fediverse/src/models/remote_identity.rs#L44)). Téléchargement lazy + TTL aligné sur `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS`. | |
| 251 | +3. **Exports admin** (CSV utilisateurs, logs d'audit) — streaming vers storage avec `presigned_url`. | |
| 252 | +4. **Backups pgBackRest** (axe 4) — même bucket, préfixe `backups/`. | |
| 253 | + | |
| 254 | +**Fichiers cibles :** | |
| 255 | +- `crates/rustwarden-storage/` (nouvelle crate) | |
| 256 | +- `crates/rustwarden-core/src/services/avatar_service.rs` (nouveau) | |
| 257 | +- `crates/rustwarden-fediverse/src/services/remote_avatar_cache.rs` (nouveau) | |
| 258 | +- Migration SeaORM : `m20260601_000001_user_avatar_storage.rs` | |
| 259 | + | |
| 260 | +--- | |
| 261 | + | |
| 262 | +### Axe 2 — PostgreSQL HA (RFC 0035) | |
| 263 | + | |
| 264 | +**Côté code (Rustwarden) :** | |
| 265 | +1. **Pool de connexion explicitement configuré** dans [config/database.rs](crates/rustwarden-core/src/config/database.rs) : `DATABASE_POOL_MAX_CONNS`, `DATABASE_POOL_MIN_CONNS`, `DATABASE_CONNECT_TIMEOUT_SECS`, `DATABASE_ACQUIRE_TIMEOUT_SECS`, `DATABASE_IDLE_TIMEOUT_SECS`, `DATABASE_SSLMODE`. | |
| 266 | +2. **Séparation read/write** : variables `DATABASE_URL_PRIMARY` + `DATABASE_URL_REPLICA` (optionnelle, fallback primary). Le `AppState` expose `DbConn` (write) et `DbConn` (read). Documenter quels services peuvent passer en read-replica (lectures de profil, listings d'audit, dashboards) vs write only (auth, tokens, mutations). | |
| 267 | +3. **Migrations sous lock distribué** : encadrer `CoreMigrator::up(...)` dans [app.rs:341](crates/rustwarden-core/src/app.rs#L341) par `pg_advisory_lock(MIGRATIONS_LOCK_ID)` — résout la race condition multi-instances startup. | |
| 268 | +4. **Healthchecks** : nouveaux endpoints `/health` (process vivant) + `/ready` (DB reachable, sessions store reachable). Indispensable pour LB/K8s probes — actuellement absents. | |
| 269 | +5. **Désactivation `auto_migrate` par défaut en HA** : doc claire « en multi-instances, lancer `migrate` en init container, désactiver `auto_migrate(false)` sur les replicas ». | |
| 270 | + | |
| 271 | +**Côté ops (guide de déploiement) :** | |
| 272 | +- Profil single-node : Postgres local, pas de réplication. | |
| 273 | +- Profil multi-instances K8s : **CloudNativePG** (manifest fourni dans `docs/deployment/k8s/`). | |
| 274 | +- Profil multi-instances VM : **Patroni + etcd + HAProxy** (pour ceux qui n'utilisent pas K8s). | |
| 275 | +- Profil SaaS : RDS Multi-AZ, Aiven, Scaleway PostgreSQL managé. | |
| 276 | + | |
| 277 | +**Fichiers cibles :** | |
| 278 | +- [crates/rustwarden-core/src/config/database.rs](crates/rustwarden-core/src/config/database.rs) (refonte pool + replica) | |
| 279 | +- [crates/rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs) (advisory lock migrations, séparation read/write dans AppState) | |
| 280 | +- `crates/rustwarden-core/src/middleware/health.rs` (nouveau — `/health`, `/ready`) | |
| 281 | +- `docs/deployment/postgresql-ha.md` (nouveau) | |
| 282 | +- `docs/deployment/k8s/cloudnativepg.yaml` (nouveau) | |
| 283 | + | |
| 284 | +--- | |
| 285 | + | |
| 286 | +### Axe 3 — État partagé pour scale-out (RFC 0036) | |
| 287 | + | |
| 288 | +**Trait `SharedStateBackend`** — abstraction par-dessus 3 implémentations : `Memory` (dev), `Postgres` (production light, advisory locks + tables dédiées), `Valkey` (production HA réelle). | |
| 289 | + | |
| 290 | +**Composants à migrer (par ordre de criticité) :** | |
| 291 | + | |
| 292 | +| Composant | État actuel | Backend Memory | Backend Postgres | Backend Valkey | | |
| 293 | +|---|---|---|---|---| | |
| 294 | +| Sessions tower-sessions | `MemoryStore` défaut, feature `redis-session` **cassée** | OK | nouveau `PgSessionStore` via SeaORM | corriger compat `tower-sessions-redis-store` 0.14 | | |
| 295 | +| Rate limit ([rate_limit.rs](crates/rustwarden-core/src/middleware/rate_limit.rs)) | `HashMap` in-process, contournable multi-IP × N replicas | OK | sliding window via table + index TTL | sliding window via ZADD/ZREMRANGEBYSCORE | | |
| 296 | +| InMemoryBus ([notifications/mod.rs](crates/rustwarden-core/src/notifications/mod.rs)) | broadcast in-process — événements perdus inter-instance | OK | LISTEN/NOTIFY PG | Pub/Sub Valkey | | |
| 297 | +| Background task scheduling | timer local par instance → **double exécution** | OK | leader election via advisory lock par tâche | SET NX EX | | |
| 298 | +| CSRF tokens ([csrf.rs](crates/rustwarden-core/src/middleware/csrf.rs)) | session-bound | automatique via session | automatique | automatique | | |
| 299 | +| Fediverse outbox retries | DB queue sans coordination | OK | advisory lock par batch | SET NX EX | | |
| 300 | + | |
| 301 | +**Décisions :** | |
| 302 | +- **Postgres advisory locks** suffisent pour les tâches déjà ancrées DB (migrations, background tasks, outbox). Pas besoin d'imposer Redis. | |
| 303 | +- **Valkey** devient nécessaire si on veut : sessions partagées performantes + rate-limit haute fréquence + pub/sub temps réel. Optionnel sinon. | |
| 304 | +- **Corriger ou retirer** la feature `redis-session` (item #10 V2 RC) : la corriger en migrant vers `fred.rs` qui a un meilleur support des deux protocoles, et qui est compatible Valkey nativement. | |
| 305 | + | |
| 306 | +**Fichiers cibles :** | |
| 307 | +- `crates/rustwarden-core/src/shared_state/` (nouveau module : `mod.rs`, `memory.rs`, `postgres.rs`, `valkey.rs`) | |
| 308 | +- [crates/rustwarden-core/src/middleware/rate_limit.rs](crates/rustwarden-core/src/middleware/rate_limit.rs) (refactor pour `SharedStateBackend`) | |
| 309 | +- [crates/rustwarden-core/src/notifications/mod.rs](crates/rustwarden-core/src/notifications/mod.rs) (idem) | |
| 310 | +- `crates/rustwarden-core/src/tasks/scheduler.rs` (nouveau wrapper avec leader election) | |
| 311 | +- `Cargo.toml` : remplacer ou compléter `tower-sessions-redis-store` par `fred` ; renommer feature `redis-session` en `valkey-session` (compatible Redis). | |
| 312 | + | |
| 313 | +--- | |
| 314 | + | |
| 315 | +### Axe 4 — Backups & Disaster Recovery (RFC 0037) | |
| 316 | + | |
| 317 | +Pas de code applicatif lourd — c'est essentiellement une stack ops + un guide. | |
| 318 | + | |
| 319 | +**Choix de stack :** **pgBackRest** (référence) avec WAL-G en alternative IaC-first. | |
| 320 | + | |
| 321 | +**Livrables :** | |
| 322 | +1. **Image Docker `rustwarden-backup`** : pgBackRest + cron + cible S3-compatible (réutilise variables `STORAGE_*` de l'axe 1). | |
| 323 | +2. **Schéma de rétention recommandé** : full hebdo, différentiel quotidien, WAL continu → 30 jours PITR. | |
| 324 | +3. **Chiffrement at-rest** systématique (pgBackRest `--repo-cipher-type=aes-256-cbc`). | |
| 325 | +4. **Test de restore automatisé** : script `restore-drill.sh` à lancer en staging mensuel + alerte si raté (cf. observabilité OTel déjà roadmappée). | |
| 326 | +5. **Manifest K8s CronJob** pour le profil multi-instances. | |
| 327 | +6. **Documentation du RTO/RPO** atteint par profil (single-node: RPO 24h / RTO 1h ; multi-instances: RPO 1min via WAL / RTO 10min via réplica failover ; SaaS: dépend du provider). | |
| 328 | + | |
| 329 | +**Fichiers cibles :** | |
| 330 | +- `docker/backup/Dockerfile` (nouveau) | |
| 331 | +- `docker/backup/pgbackrest.conf.template` | |
| 332 | +- `docs/deployment/backup-restore.md` | |
| 333 | +- `docs/deployment/k8s/backup-cronjob.yaml` | |
| 334 | +- `scripts/restore-drill.sh` | |
| 335 | + | |
| 336 | +--- | |
| 337 | + | |
| 338 | +## Guide pour les devs tiers consommant Rustwarden | |
| 339 | + | |
| 340 | +Section dédiée dans `docs/developer-guide/11-storage-and-ha.md` (chapitre 11, à créer — la suite logique des chapitres déjà rédigés 1-10). | |
| 341 | + | |
| 342 | +Doit couvrir : | |
| 343 | +1. **Comment ranger ses propres médias applicatifs** en réutilisant `rustwarden-storage` (ex: `ObjectStorageService::for_namespace("myapp-uploads")`). | |
| 344 | +2. **Quels services Rustwarden sont read-replica-safe** vs write-only — table de référence. | |
| 345 | +3. **Pattern pour publier ses propres événements sur le bus partagé** (axe 3) — exemple `MyAppEvent::Order { id }`. | |
| 346 | +4. **Pattern de background task avec leader election** — wrapper `scheduler::run_singleton(name, interval, async fn)`. | |
| 347 | +5. **Cookbook de déploiement** : `docker-compose.yml` complet pour profil single-node, manifests K8s commentés pour multi-instances, Terraform stub pour SaaS sur Scaleway/Hetzner. | |
| 348 | +6. **Migration progressive** : single-node → multi-instances sans downtime (étapes : ajouter Valkey, migrer sessions, ajouter replica DB, scaler à 2 instances). | |
| 349 | + | |
| 350 | +--- | |
| 351 | + | |
| 352 | +## Phasage proposé (ordre conseillé d'implémentation) | |
| 353 | + | |
| 354 | +L'axe transversal **doit passer en premier** : sans cadre commun (config, CLI, trait, erreurs, OTel), chaque RFC suivante repartirait dans une direction divergente et l'exploitation deviendrait ingérable. Les 4 axes métier viennent ensuite en se conformant à ce cadre. | |
| 355 | + | |
| 356 | +| Phase | RFC | Pourquoi maintenant | Effort | | |
| 357 | +|---|---|---|---| | |
| 358 | +| **P-1 — Cadre transversal** (1 mois) | 0033bis (« Operational Standards ») | Définit `RustwardenBackend`, `RustwardenApp::builder`, `rustwarden` CLI unique, `rustwarden.toml`, profils, codes d'erreur `RW-*`, conventions OTel. **Pré-requis bloquant** des 4 axes. | M | | |
| 359 | +| **P0 — Fondations HA** (1 mois, partiellement // P-1) | 0035 partiel + 0036 partiel | Healthchecks `/health` `/ready` (consomment déjà les readiness du trait), advisory lock sur migrations, pool DB configurable. Casse rien, débloque K8s. | S | | |
| 360 | +| **P1 — Object Storage** (1-2 mois) | 0034 complet | Crate `rustwarden-storage` + `S3Backend` (1er backend qui implémente `RustwardenBackend` réel) + branchement avatars locaux + sous-commande `rustwarden storage`. Pré-requis pour backups distants. | M | | |
| 361 | +| **P2 — État partagé** (2 mois) | 0036 complet | Corriger `redis-session` (→ `fred.rs`, compatible Valkey), refactor rate limit, leader election background tasks, LISTEN/NOTIFY bus PG. Tous derrière `RustwardenBackend`. | M-L | | |
| 362 | +| **P3 — Cache fediverse + uploads avancés** | 0034 ext | Cache d'avatars fediverse distants, exports admin streaming. | S-M | | |
| 363 | +| **P4 — PG HA réelle** | 0035 complet | Séparation read/write SeaORM, manifests CloudNativePG/Patroni dans `deploy/`, sous-commande `rustwarden ops failover-test`. | M | | |
| 364 | +| **P5 — Backup/DR** | 0037 | Image `rustwarden-backup`, sous-commandes `rustwarden backup run/restore/drill`, doc RTO/RPO. | M | | |
| 365 | +| **P6 — Wizard CLI repensé + guide devs tiers** | — | `rustwarden-cli start-project` profile-aware (axe transversal §10), `docs/developer-guide/11-storage-and-ha.md`, manifests `deploy/` complets pour les 3 profils. | M | | |
| 366 | + | |
| 367 | +P-1 et P0 peuvent se chevaucher partiellement (les healthchecks ont besoin du trait `RustwardenBackend::healthcheck` posé par P-1, mais la migration sous advisory lock est indépendante). P1 ne démarre **pas** avant que P-1 soit mergée — sinon le premier backend ne suivra pas le contrat commun et il faudra le refondre. | |
| 368 | + | |
| 369 | +--- | |
| 370 | + | |
| 371 | +## Verification (comment tester end-to-end) | |
| 372 | + | |
| 373 | +Pour chaque axe, plan de test reproductible : | |
| 374 | + | |
| 375 | +1. **Object storage** : `cargo test -p rustwarden-storage` (suite contre `MemoryBackend` + `LocalFsBackend` + `S3Backend` pointé sur MinIO/SeaweedFS local via testcontainers). Test d'intégration upload avatar → assert `presigned_url` accessible. | |
| 376 | +2. **PG HA** : `docker compose -f docker/ha-test/compose.yml up` lance primary + replica + 2 instances Rustwarden + HAProxy ; script `tests/integration/ha_failover.sh` tue le primary et vérifie qu'une session reste utilisable < 30s. | |
| 377 | +3. **État partagé** : test parallèle qui lance N tâches concurrentes sur 2 instances avec leader election, asserte que la tâche n'est exécutée que sur une seule. | |
| 378 | +4. **Backups** : `scripts/restore-drill.sh` (à créer) ; doit créer un cluster vide, restaurer le dernier backup, vérifier qu'on retrouve une checksum connue d'une table de référence. | |
| 379 | +5. **Healthchecks** : tests Axum classiques + `kubectl describe pod` montre les probes OK/KO selon l'état de la dépendance. | |
| 380 | +6. **Migrations sous lock** : test qui lance simultanément `migrate` × 5 sur la même DB, vérifie qu'il n'y a pas d'erreur SQL et que les migrations sont appliquées une seule fois. | |
| 381 | + | |
| 382 | +Aucune des phases ne doit casser la suite existante : `cargo test --workspace` doit rester vert à chaque PR (exclure features cassées connues comme `redis-session` jusqu'à P2). | |
| 383 | + | |
| 384 | +--- | |
| 385 | + | |
| 386 | +## Hors-scope (volontairement) | |
| 387 | + | |
| 388 | +- Multi-région active/active (latence + conflits écriture trop complexes — à reposer après V2). | |
| 389 | +- Sharding PostgreSQL (Citus) — pas justifié pour un framework d'auth, table users rarement > 10M lignes. | |
| 390 | +- CDN devant les avatars/médias — laissé au déployeur (Cloudflare, Bunny, ou rien selon volume). | |
| 391 | +- Migration des données existantes (avatars uploadés ailleurs avant) — chaque consommateur écrira son propre script. | |
| 392 | + | |
| 393 | +--- | |
| 394 | + | |
| 395 | +## Suite immédiate après approbation | |
| 396 | + | |
| 397 | +Une fois `ExitPlanMode` validé, **avant toute autre action** (CLAUDE.md projet impose) : | |
| 398 | + | |
| 399 | +1. Copier ce plan vers `docs/plans/storage-ha-roadmap.md` (nom descriptif). | |
| 400 | +2. Supprimer le fichier original `~/.claude/plans/j-ai-besoin-d-un-plan-woolly-wilkes.md`. | |
| 401 | +3. Toute itération ultérieure se fait uniquement sur `docs/plans/storage-ha-roadmap.md`. | |
| 402 | + | |
| 403 | +Ensuite, première action concrète recommandée : **rédiger la RFC 0034 (Object Storage Abstraction)** comme document standalone dans `docs/rfcs/0034-object-storage.md` — c'est le point d'entrée le plus indépendant et le plus structurant pour la suite. |
A
docs/rfcs/0034-edition-2024-fmt-cleanup.md
+211
-0
@@ -0,0 +1,211 @@
| 1 | +# RFC 0034 — Cleanup du drift rustfmt suite à la migration édition 2021 → 2024 | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (gitrust) | |
| 4 | +- **Date** : 2026-05-18 | |
| 5 | +- **Statut** : Draft | |
| 6 | +- **Apps consommatrices concernées** : gitrust (immédiat — blocage CI sur `cargo fmt --all -- --check`), tout futur consommateur qui pose une CI stricte sur le formatage du workspace. | |
| 7 | +- **Crate cible** : `rustwarden-core`, `rustwarden-fediverse`, `rustwarden-fediverse-guard` (les 3 crates upstream vendorées dans le workspace gitrust) | |
| 8 | + | |
| 9 | +> Cette RFC vit dans le dépôt gitrust pour tracking. À pousser dans le dépôt upstream du framework (`docs/rfcs/`) après revue par l'utilisateur. | |
| 10 | + | |
| 11 | +## Summary | |
| 12 | + | |
| 13 | +Demander aux mainteneurs des crates `rustwarden-*` d'exécuter un passage `cargo fmt --all` sur l'ensemble des fichiers de chaque crate avec le rustfmt courant et l'édition Rust 2024 active. Le code source actuel a été écrit sous l'édition 2021 (ou avec un rustfmt antérieur) et ne passe plus le check fmt strict des consommateurs en édition 2024. Pas de changement d'API, pas de changement sémantique : opération de pure cosmétique pour aligner le baseline avec les versions courantes de rustfmt/édition. | |
| 14 | + | |
| 15 | +## Motivation | |
| 16 | + | |
| 17 | +### Problème concret | |
| 18 | + | |
| 19 | +Le consommateur gitrust met en place une CI Dagger (`docs/plans/dagger-ci-gitrust.md`, lot L0/L1) qui invoque `cargo fmt --all -- --check` sur le workspace. La commande échoue **systématiquement** avec des diffs **uniquement** sur les crates upstream, exemple sur `crates/rustwarden-fediverse-guard/tests/integration_pipeline.rs:15` : | |
| 20 | + | |
| 21 | +```rust | |
| 22 | +// État actuel (édition 2021 / rustfmt antérieur) | |
| 23 | +use rustwarden_fediverse_guard::{ | |
| 24 | + default_detectors, BanDecision, BanManager, GuardConfig, GuardObserver, GuardStore, | |
| 25 | + MemoryStore, | |
| 26 | +}; | |
| 27 | + | |
| 28 | +// Ce que rustfmt courant + édition 2024 veulent produire | |
| 29 | +use rustwarden_fediverse_guard::{ | |
| 30 | + BanDecision, BanManager, GuardConfig, GuardObserver, GuardStore, MemoryStore, | |
| 31 | + default_detectors, | |
| 32 | +}; | |
| 33 | +``` | |
| 34 | + | |
| 35 | +Différences observées dans plusieurs fichiers (résumé non-exhaustif) : | |
| 36 | +- **Tri alphabétique des imports groupés** (rule par défaut de l'édition 2024). | |
| 37 | +- **Compactage de chaînes d'appels multi-lignes** que rustfmt courant juge inférieures à `max_width=100`. | |
| 38 | +- **Style des macros** (`assert!`, `panic!`) — argument placement sur une ligne quand ça tient. | |
| 39 | + | |
| 40 | +### Qui est impacté aujourd'hui | |
| 41 | + | |
| 42 | +- **gitrust** (consommateur principal) : sa CI fmt restera rouge tant que la drift existe dans les crates upstream qu'il vendore mais n'a pas le droit de modifier (cf. règle "Never modify files under `crates/rustwarden-*/`" dans `CLAUDE.md`). | |
| 43 | +- **Tout futur consommateur** rustwarden-* qui adoptera l'édition 2024 et activera un check fmt strict en CI rencontrera le même blocage. | |
| 44 | +- Les **mainteneurs upstream** eux-mêmes lorsqu'ils basculeront leur propre CI sur l'édition 2024 (incontournable à court terme : édition 2021 marquée comme stable mais l'écosystème migre). | |
| 45 | + | |
| 46 | +### Conséquences si on ne fait rien | |
| 47 | + | |
| 48 | +- Chaque consommateur duplique un workaround local (`cargo fmt --workspace --exclude rustwarden-*`), ce qui : | |
| 49 | + - Crée un trou aveugle : la drift upstream n'est jamais détectée côté consommateur, alors qu'elle se propage à chaque rebase. | |
| 50 | + - Multiplie le mainteance burden de la liste d'exclusion à chaque ajout de crate upstream. | |
| 51 | + - Empêche tout audit fmt cross-workspace cohérent. | |
| 52 | +- À terme, lorsque les mainteneurs upstream migreront leur propre CI sur l'édition 2024, ils découvriront la drift par accident et devront résorber des centaines de fichiers d'un coup. Un cleanup planifié maintenant évite cette dette. | |
| 53 | + | |
| 54 | +### Urgence | |
| 55 | + | |
| 56 | +Non bloquant techniquement (workaround `--exclude` faisable côté consommateur), mais **bloque la finalisation propre du lot L0** de la CI Dagger gitrust. Idéalement livré dans la prochaine patch release `rustwarden-*` (le changement est non-breaking). | |
| 57 | + | |
| 58 | +## Non-goals | |
| 59 | + | |
| 60 | +- **Pas de changement d'API publique**. Aucune signature `pub`, aucun trait, aucun module ne bouge. Pur reformatting du source disque. | |
| 61 | +- **Pas de bump d'édition**. Cette RFC ne demande pas la migration des crates vers l'édition 2024 — uniquement l'alignement du formatage tel que rustfmt courant le veut, quelle que soit l'édition active dans `Cargo.toml`. | |
| 62 | +- **Pas de changement de `rustfmt.toml`** côté upstream. Le rustfmt.toml de chaque crate reste la source de vérité ; la RFC demande juste un passage `cargo fmt` cohérent avec ce fichier + version rustfmt stable courante. | |
| 63 | +- **Pas de modification des doctests**. Les blocs ` ```rust ` dans les rustdocs sont laissés tels quels (rustfmt ne les touche pas par défaut, et certains servent d'illustration "non-idiomatique"). | |
| 64 | +- **Pas de refactor**. Pas de renommage, pas d'extraction de helpers, pas de réorganisation de modules — le diff doit pouvoir être entièrement produit par `cargo fmt --all` sans intervention humaine. | |
| 65 | + | |
| 66 | +## Action demandée upstream | |
| 67 | + | |
| 68 | +Pour chacune des 3 crates concernées (`rustwarden-core`, `rustwarden-fediverse`, `rustwarden-fediverse-guard`) : | |
| 69 | + | |
| 70 | +```bash | |
| 71 | +# Sur une branche dédiée | |
| 72 | +cd <framework>/crates/rustwarden-core # puis idem pour les deux autres | |
| 73 | +rustup component add rustfmt | |
| 74 | +cargo fmt --all # applique sans --check, génère le diff | |
| 75 | +git diff --stat # vérifier que c'est purement cosmétique | |
| 76 | +cargo test --all # non-régression : aucun test ne bouge | |
| 77 | +git commit -m "chore: cargo fmt --all on edition 2024 baseline" | |
| 78 | +``` | |
| 79 | + | |
| 80 | +Pour valider la non-régression : | |
| 81 | + | |
| 82 | +```bash | |
| 83 | +cargo build --all | |
| 84 | +cargo test --all | |
| 85 | +cargo clippy --all -- -D warnings # doit rester vert | |
| 86 | +``` | |
| 87 | + | |
| 88 | +Idéalement consolidé dans un **seul commit** par crate (un "format-only commit") taggé clairement dans le CHANGELOG pour rendre les futurs `git blame` lisibles (les outils Git modernes — `git blame --ignore-rev` — savent sauter ce commit). | |
| 89 | + | |
| 90 | +## Analyse de compatibilité | |
| 91 | + | |
| 92 | +- [x] **API existante non modifiée** → non-breaking (patch possible). | |
| 93 | +- [x] **API existante étendue avec défaut** → N/A (rien d'étendu). | |
| 94 | +- [ ] **API existante modifiée ou supprimée** → N/A. | |
| 95 | +- [x] **Nouveau behavior par défaut** → N/A (purement cosmétique). | |
| 96 | +- [x] **Changement de schéma DB** → N/A. | |
| 97 | +- [x] **Changement de format sérialisé** → N/A. | |
| 98 | + | |
| 99 | +**Risque résiduel** : un `git blame` sur les fichiers reformatés perd l'info du dernier auteur fonctionnel. Atténué par l'usage de `git blame --ignore-rev <fmt-commit-sha>` ou en consignant le SHA du commit fmt dans un fichier `.git-blame-ignore-revs` versionné (convention GitHub native). | |
| 100 | + | |
| 101 | +## Alternatives considérées | |
| 102 | + | |
| 103 | +### Alternative A (recommandée) : `cargo fmt --all` upstream, commit unique par crate | |
| 104 | + | |
| 105 | +**Pros** : | |
| 106 | +- Résout le problème à la racine pour **tous** les consommateurs, présents et futurs. | |
| 107 | +- Zéro maintenance ensuite : la CI fmt upstream gardera le baseline aligné. | |
| 108 | +- `git blame --ignore-rev` neutralise le coût d'historique. | |
| 109 | +- Effort upstream : ~10 minutes par crate. | |
| 110 | + | |
| 111 | +**Cons** : | |
| 112 | +- Diff initial bruyant (probablement des centaines de lignes touchées). | |
| 113 | +- Si un autre branch en cours dépend du formatage actuel, conflits de merge ; à coordonner avec les autres mainteneurs. | |
| 114 | + | |
| 115 | +### Alternative B : Chaque consommateur exclut les crates upstream du check fmt | |
| 116 | + | |
| 117 | +Implémentation côté gitrust : | |
| 118 | + | |
| 119 | +```bash | |
| 120 | +cargo fmt --workspace --exclude rustwarden-core --exclude rustwarden-fediverse \ | |
| 121 | + --exclude rustwarden-fediverse-guard -- --check | |
| 122 | +``` | |
| 123 | + | |
| 124 | +**Pros** : | |
| 125 | +- Aucun changement upstream nécessaire. | |
| 126 | +- Débloque immédiatement la CI consommateur. | |
| 127 | + | |
| 128 | +**Cons** : | |
| 129 | +- Trou aveugle permanent dans la CI consommateur. | |
| 130 | +- Liste `--exclude` à maintenir à chaque ajout de crate upstream. | |
| 131 | +- Si plusieurs consommateurs émergent, chacun duplique l'exclusion (dette × N). | |
| 132 | +- Ne résout pas le problème quand les mainteneurs upstream migreront eux-mêmes sur édition 2024. | |
| 133 | + | |
| 134 | +**Statut côté gitrust** : adoptée temporairement dans le module Dagger en attendant que la RFC 0034 soit livrée upstream (cf. lot L0 du plan CI). Le `cargo fmt` du module est explicitement scopé aux crates `gitrust-*`. | |
| 135 | + | |
| 136 | +### Alternative C : Ajouter `ignore = ["crates/rustwarden-*"]` dans le rustfmt.toml du workspace gitrust | |
| 137 | + | |
| 138 | +**Pros** : | |
| 139 | +- Local au consommateur, n'impose rien upstream. | |
| 140 | + | |
| 141 | +**Cons** : | |
| 142 | +- L'option `ignore` de rustfmt est **unstable** sur le canal stable (silencieusement ignorée). Nécessite nightly ou bricolage de wrapper. | |
| 143 | +- Cache la drift au lieu de la résoudre. | |
| 144 | +- Inutilisable comme stratégie générique. | |
| 145 | + | |
| 146 | +**Verdict** : non retenue. | |
| 147 | + | |
| 148 | +### Alternative D (ne rien faire / garder la drift) : laisser le job fmt CI rouge | |
| 149 | + | |
| 150 | +**Pros** : zéro coût, zéro risque immédiat. | |
| 151 | + | |
| 152 | +**Cons** : | |
| 153 | +- Always-red rend le signal du job inutile : on s'habitue au rouge et on rate les vraies dérives de code gitrust. | |
| 154 | +- Casse la promesse "parité dev/CI" — les consommateurs développeurs locaux n'ont plus d'incentive à `cargo fmt` avant push si la CI est rouge de toute façon. | |
| 155 | +- Dette accumulée qui sera coûteuse à résorber plus tard. | |
| 156 | + | |
| 157 | +**Verdict** : non retenue. | |
| 158 | + | |
| 159 | +## Testing plan | |
| 160 | + | |
| 161 | +Tests obligatoires côté upstream après application du `cargo fmt --all` : | |
| 162 | + | |
| 163 | +1. `cargo build --all` → compile sans warning. | |
| 164 | +2. `cargo test --all` → 100 % des tests existants passent (aucun test ne dépend du formatage source). | |
| 165 | +3. `cargo clippy --all -- -D warnings` → reste vert. | |
| 166 | +4. `cargo fmt --all -- --check` → exit 0 (preuve que la drift est résolue). | |
| 167 | +5. `cargo doc --workspace --no-deps` avec `RUSTDOCFLAGS="-D warnings"` → reste vert (rustdoc ne dépend pas du formatage). | |
| 168 | + | |
| 169 | +Non-régression côté consommateur (gitrust) : | |
| 170 | + | |
| 171 | +- Rebase le repo gitrust sur la version upstream contenant le commit fmt. | |
| 172 | +- Lancer `dagger call fmt --src=.` (avec la fonction Dagger restaurée à `cargo fmt --all -- --check` global, sans `--exclude`). | |
| 173 | +- Doit retourner exit 0. | |
| 174 | + | |
| 175 | +## Migration guide | |
| 176 | + | |
| 177 | +Aucune migration côté API. À copier dans le CHANGELOG upstream au moment du merge : | |
| 178 | + | |
| 179 | +```markdown | |
| 180 | +### Internal | |
| 181 | + | |
| 182 | +- `chore: cargo fmt --all` baseline aligned with stable rustfmt + Rust edition 2024. | |
| 183 | + No API change, no behavioral change. Commit SHAs added to `.git-blame-ignore-revs` | |
| 184 | + for `git blame` continuity. | |
| 185 | +``` | |
| 186 | + | |
| 187 | +Côté gitrust, retirer le workaround `--exclude` dans `.dagger/src/gitrust_ci/main.py` (lot L0) une fois la version upstream contenant ce cleanup intégrée au workspace. | |
| 188 | + | |
| 189 | +## Open questions | |
| 190 | + | |
| 191 | +- [ ] Le mainteneur upstream préfère-t-il **un** commit fmt cross-crate ou **un** commit fmt par crate ? Influence la lisibilité de `.git-blame-ignore-revs`. | |
| 192 | +- [ ] Faut-il bump rustfmt à la version exacte du `rust-toolchain.toml` du repo upstream avant l'application, pour garantir la reproductibilité du diff ? | |
| 193 | +- [ ] Une CI nightly upstream qui ferait tourner `cargo fmt --all -- --check` est-elle envisageable pour empêcher le drift de revenir ? Si oui, c'est l'objet d'une RFC suivante (out of scope ici). | |
| 194 | + | |
| 195 | +## Tracking issue | |
| 196 | + | |
| 197 | +À créer sur le repo framework après revue gitrust : label `rfc` + label `chore` + corps = Summary + lien vers ce fichier. | |
| 198 | + | |
| 199 | +## Évaluation | |
| 200 | + | |
| 201 | +| Critère | Note | Justification | | |
| 202 | +|---|---|---| | |
| 203 | +| **Généralité** | 4 / 5 | Sert immédiatement gitrust, anticipe tout futur consommateur en édition 2024 + CI fmt stricte. | | |
| 204 | +| **Stabilité API** | 5 / 5 | Zéro changement d'API publique. Purement cosmétique. | | |
| 205 | +| **Performance** | 3 / 5 | Neutre : aucun impact runtime, aucun impact compile time mesurable. | | |
| 206 | +| **Sécurité** | 3 / 5 | Neutre vs l'existant. Pas de hardening, pas de régression possible. | | |
| 207 | +| **Complexité impl** | 5 / 5 | < 5 lignes : `cargo fmt --all`. Trivial. | | |
| 208 | +| **Charge maintenance** | 5 / 5 | One-shot. Aucune dette ajoutée si l'upstream pose un check fmt nightly (optionnel). | | |
| 209 | +| **Documentation** | 4 / 5 | Entry CHANGELOG explicite + `.git-blame-ignore-revs` documenté. Pas de rustdoc à ajouter (rien de neuf). | | |
| 210 | + | |
| 211 | +**Total : 29 / 35** → passage en `Accepted` recommandé sans second avis (seuil > 25 atteint, >= 30 sans atteindre le fast-track). |
GitRust