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&lt;date&gt;_&lt;N&gt;_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: &lt;input name="csrf_token" value="..."&gt;<br/>ou header X-CSRF-Token injecté par JS
141 +
142 + B->>S: POST /submit<br/>X-CSRF-Token: &lt;token&gt; (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&lt;HashMap&lt;String, WindowCounter&gt;&gt;]
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).