end fediverse need to extract crate
EBO <eric.bouhana@softalys.com> committé le 2026-05-11 15:33
51e1a3448522fcc34bc0f67568882c4ca746bab9
1 parent(s)
41 fichiers modifiés
+7057
-60
M
CHANGELOG.md
+120
-0
@@ -9,6 +9,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
| 9 | 9 | |
| 10 | 10 | ### Added |
| 11 | 11 | |
| 12 | +- **Documentation opérateur** : nouveau guide | |
| 13 | + [`docs/fediverse-operations.md`](docs/fediverse-operations.md) (10 sections, Diátaxis | |
| 14 | + how-to + reference) : activation, env vars, `app_settings` clés, trust/block list, | |
| 15 | + incident playbook, observabilité, GC, critères promotion stable, URLs exposées, | |
| 16 | + limites connues. | |
| 17 | +- **Observabilité fédiverse — toggle DB default-off** : nouveau réglage | |
| 18 | + `app_settings.fediverse_observability_enabled` (default `false`) et helper public | |
| 19 | + `services::fediverse_outbound_service::is_fediverse_observability_enabled`. Les | |
| 20 | + `tracing::info!`/`warn!`/`error!` structurés (RFC 0019) émis par | |
| 21 | + `FediverseOutboundService::drain_one` sont désormais conditionnés à ce flag. Sans | |
| 22 | + agent (Vector/Promtail) qui consomme, laisser `false` pour éviter le bruit logs ; | |
| 23 | + activer quand un pipeline d'observabilité est en place. | |
| 24 | +- **Audit privacy passé** : revue manuelle confirme que les payloads AP exposés | |
| 25 | + (Person, WebFinger, NodeInfo) sont minimaux — aucune fuite email / password hash / | |
| 26 | + rôles internes / IPs. `UserSummary` est une DTO curatée. Aucun `tracing::*` ne log | |
| 27 | + password / JWT / clé privée / encryption key brut. Finding F12 latent identifié | |
| 28 | + (`summary_html` reçu via Update(Person) stocké brut, mais jamais rendu UI — non | |
| 29 | + exploitable ; ajouter `ammonia::clean()` à l'INSERT si jamais utilisé en rendu). | |
| 30 | +- **Suite E2E à 32 tests** : ajout des scénarios S5 (modes de panne — 4 tests) et S6 (lifecycle Update/Delete Person — 2 tests). Les 6 scénarios du plan parent (S1-S6) sont désormais automatisés en HTTP réel avec crypto réelle. | |
| 31 | + - S5 valide la replay protection (UNIQUE constraint sur `fediverse_auth_challenges.nonce`), le WebFinger 404, la validation handle malformé, et **documente F11** (clock skew non-enforced avec `AlwaysAcceptVerifier` — non-bloquant, by-design dev mode). | |
| 32 | + - S6 valide que `Update(Person)` et `Delete(Person)` signés sur `/inbox` propagent correctement : refresh `display_name`, désactivation `remote_identities.disabled_at`, et invalidation instantanée des JWT remote via F10. | |
| 33 | +- **F10 — Révocation immédiate des JWT remote lors de kill_domain** : | |
| 34 | + ajout d'un check `disabled_at` sur `remote_identities` dans le | |
| 35 | + middleware `AuthService` (feature-gated `experimental-fediverse`). | |
| 36 | + Un JWT remote dont l'identité backing a été désactivée est | |
| 37 | + immédiatement rejeté (redirect 303 vers `/login`) au prochain | |
| 38 | + request, sans nécessiter d'entrée dans `jwt_blacklist`. Cheap : un | |
| 39 | + SELECT par PK. | |
| 40 | +- **F9 — Auto-kill sur add_blocked_domain** : le handler admin | |
| 41 | + `add_trust_entry` (mode `OpenWithBlocklist`) effectue désormais | |
| 42 | + atomiquement l'INSERT dans `fediverse_blocked_domains` ET un UPDATE | |
| 43 | + `disabled_at` sur tous les `remote_identities` du domaine. Un seul | |
| 44 | + clic admin pour « couper l'instance hostile » — combiné avec F10 | |
| 45 | + les sessions actives sont rejetées au prochain request. | |
| 46 | +- **Suite E2E étendue à 26 tests** : +4 tests S4 (`s4_blocked_domain_payload_is_rejected_with_403`, | |
| 47 | + `s4_kill_domain_marks_remote_identities_disabled`, | |
| 48 | + `s4_add_blocked_domain_auto_kills_remote_identities`, | |
| 49 | + `s4_kill_domain_invalidates_active_remote_jwt`). Le dernier valide | |
| 50 | + bout-en-bout via vrai HTTP (POST signé → JWT remote minté → GET | |
| 51 | + protégé OK → kill_domain → GET protégé redirige `/login`). | |
| 52 | +- **F8 — Trust enforcement au niveau protocole** : la trust list était | |
| 53 | + décorative — `FediverseTrustService::is_domain_allowed` existait | |
| 54 | + uniquement appelé depuis l'UI. Un domaine `evil.tld` signé valide | |
| 55 | + pouvait POST des activités à n'importe quelle instance Rustwarden, | |
| 56 | + indépendamment de la trust list. | |
| 57 | + - Nouvelle méthode `FediverseTrustService::assert_domain_allowed` qui | |
| 58 | + retourne `AppError::Validation` quand un domaine n'est pas autorisé | |
| 59 | + par le mode courant. | |
| 60 | + - Nouvelle variante `FediverseError::Forbidden(String)` → 403. | |
| 61 | + - Appel `assert_domain_allowed` au début de `auth_inbox` et `/inbox`, | |
| 62 | + **avant** la vérification de signature (rejette les domaines hostiles | |
| 63 | + sans même fetcher leur actor). | |
| 64 | + - 2 tests E2E S3 dédiés : `s3_untrusted_domain_is_rejected_with_403` | |
| 65 | + confirme l'enforcement, `s3_trusted_domain_still_passes_protocol_inbox` | |
| 66 | + vérifie l'absence de régression. | |
| 67 | +- **Suite E2E étendue** : passe de 14 à **22 tests** (8 nouveaux pour S2 + | |
| 68 | + S3) en 7.83s. Nouvelle couverture : | |
| 69 | + - 4 tests S2 (admin invite → team_member binding, reject, revoke, | |
| 70 | + listing IdP-side). | |
| 71 | + - 4 tests S3 (non-transitivité du trust, F8 enforcement positif et | |
| 72 | + négatif). | |
| 73 | +- **RFC 0025 — Fediverse back-channel `Accept(AuthRequest)`** (RFC 0006 Phase 3) : | |
| 74 | + comble le trou identifié par l'évaluation E2E (iter 1c) où A approuvait un | |
| 75 | + challenge mais B ne le savait jamais. Le SSO bilatéral A ↔ B est désormais | |
| 76 | + opérationnel bout en bout sans DB-direct. | |
| 77 | + - `FediverseAuthService::record_inbound_decision` — applique un `Accept` ou | |
| 78 | + `Reject` reçu sur l'inbox SP. Sécurité : domaine de l'approver vérifié | |
| 79 | + contre `challenge.domain` (anti-spoofing). Idempotent sur replay. | |
| 80 | + - `auth_inbox` dispatche désormais sur le champ `type` AP (AuthRequest / | |
| 81 | + Accept / Reject / inconnu → silent ignore per AP §7). | |
| 82 | + - Helper public `build_auth_decision_payload(...)` qui sérialise le payload | |
| 83 | + JSON-LD au format AP standard avec `object` en forme inline-object pour | |
| 84 | + porter le nonce. | |
| 85 | + - `consent_submit` (UI) enqueue le `Accept`/`Reject` sortant via | |
| 86 | + `FediverseOutboundService::enqueue` après `record_decision`. | |
| 87 | + - Recherche externe : on utilise les types AP **standards** `Accept` / | |
| 88 | + `Reject` (alignés sur le pattern Mastodon `Accept(Follow)`) plutôt | |
| 89 | + qu'inventer `AuthApproval`/`AuthDenial`. | |
| 90 | +- **F2 — `AlwaysAcceptVerifier` populate `remote_identities`** : le verifier | |
| 91 | + test/dev (`with_resolver(db)`) fetche désormais l'actor URL et upsert sa | |
| 92 | + ligne `remote_identities` au lieu de laisser le cache vide. Restaure le | |
| 93 | + contrat documenté de `persist_inbound_request`. | |
| 94 | + - Helper public réutilisable | |
| 95 | + [`services::fediverse_signal_service::fetch_and_cache_remote_actor`](crates/rustwarden-core/src/services/fediverse_signal_service.rs) | |
| 96 | + avec hardening : timeout 10s, body cap 1 MiB (Content-Length pre-flight | |
| 97 | + + bytes() post-read), SSRF guard rejetant loopback / private IPv4 / | |
| 98 | + link-local / ULA IPv6 (`allow_private_ips=true` réservé au testbed). | |
| 99 | + - 9 nouveaux tests unitaires couvrant happy path INSERT, refetch UPDATE, | |
| 100 | + 404, JSON malformé, publicKey manquant, inbox manquant, URL invalide, | |
| 101 | + body trop gros, SSRF guard loopback. | |
| 102 | +- **F1 — Normalisation `FEDIVERSE_DOMAIN`** : `auth_inbox` accepte désormais | |
| 103 | + la forme `http://example.org` aussi bien que `example.org`. Strip scheme / | |
| 104 | + trailing slash avant comparaison contre le `target.domain`. | |
| 105 | +- **Testbed `tests/fediverse-testbed/`** : topologie 3 instances locales | |
| 106 | + 100 % automatisable (Docker Compose), 4 scripts cycle de vie (`setup.sh`, | |
| 107 | + `start.sh`, `stop.sh`, `teardown.sh`). Image multi-stage `rustc 1.91-slim` | |
| 108 | + embarquant les binaires `rustwarden`, `migrate`, `seed`. Réseau Docker | |
| 109 | + avec `network_aliases` `alpha.local` / `beta.local` / `gamma.local` ; pas | |
| 110 | + de TLS, pas de `/etc/hosts` requis grâce à `FEDIVERSE_DEBUG_LOCAL_FEDERATION`. | |
| 111 | +- **Suite E2E `crates/rustwarden-core/tests/e2e_fediverse.rs`** — 14 tests | |
| 112 | + HTTP réels contre le testbed, dont 6 du flux S1 SSO complet avec | |
| 113 | + signatures RSA-SHA256 réelles (iter 1a / 1b / 1c / 1d) : | |
| 114 | + - 7 smoke tests (WebFinger, NodeInfo, Actor fetch, inbox 401) | |
| 115 | + - 1 test signed AuthRequest B → A (iter 1b) | |
| 116 | + - 1 test consume B → JWT remote (iter 1c) | |
| 117 | + - 5 tests F3 round-trip Accept/Reject (iter 1d) : happy path, reject, | |
| 118 | + unknown nonce, expired challenge, replay idempotence | |
| 12 | 119 | - **Workspace** : edition Rust **2024** + `rust-version = "1.85"` au niveau |
| 13 | 120 | `[workspace.package]`. Toutes les crates héritent (`edition.workspace = true`). |
| 14 | 121 | - **RFC 0006 Phase 2** — inbox enrichment côté IdP : `auth_inbox` |
@@ -803,6 +910,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
| 803 | 910 | `rustwarden_core::config::FediverseConfigError`, |
| 804 | 911 | `RustwardenBuilder::with_fediverse()`. |
| 805 | 912 | |
| 913 | +### Fixed | |
| 914 | + | |
| 915 | +- **Race env var `FEDIVERSE_DOMAIN`** dans la suite tests unit (révélée | |
| 916 | + par l'élargissement de la fenêtre de mutation après ajout de F8). | |
| 917 | + `auth_inbox_returns_400_when_requester_actor_unknown` mutait | |
| 918 | + `FEDIVERSE_DOMAIN` puis `remove_var`, ce qui faisait sporadiquement | |
| 919 | + rater les 4 tests `build_*_payload` qui lisent `FediverseConfig::from_env()` | |
| 920 | + sur thread parallèle. Corrigé en supprimant la mutation env du test | |
| 921 | + (l'assertion `BadRequest` reste vraie quelle que soit la valeur de | |
| 922 | + l'env grâce au mismatch target.domain). Dette restante : la mutation | |
| 923 | + env globale dans `test_fediverse_config` est un anti-pattern à | |
| 924 | + traiter dans une RFC dette technique future. | |
| 925 | + | |
| 806 | 926 | ### Changed |
| 807 | 927 | |
| 808 | 928 | - 4 occurrences de `std::env::set_var` enveloppées dans `unsafe { ... }` avec |
M
app/src/main.rs
+7
-6
@@ -4,11 +4,12 @@ use rustwarden_core::prelude::*;
| 4 | 4 | |
| 5 | 5 | #[tokio::main] |
| 6 | 6 | async fn main() -> anyhow::Result<()> { |
| 7 | - RustwardenApp::builder() | |
| 7 | + let builder = RustwardenApp::builder() | |
| 8 | 8 | .from_env() |
| 9 | - .with_ui(rustwarden_ui::default_routes) | |
| 10 | - .build() | |
| 11 | - .await? | |
| 12 | - .run() | |
| 13 | - .await | |
| 9 | + .with_ui(rustwarden_ui::default_routes); | |
| 10 | + | |
| 11 | + #[cfg(feature = "experimental-fediverse")] | |
| 12 | + let builder = builder.with_fediverse()?; | |
| 13 | + | |
| 14 | + builder.build().await?.run().await | |
| 14 | 15 | } |
M
crates/rustwarden-core/Cargo.toml
+3
-0
@@ -115,3 +115,6 @@ sea-orm = { version = "2.0.0-rc.20", features = ["mock"] }
| 115 | 115 | # Fediverse interop tests (RFC 0020): fake-Mastodon HTTP server for |
| 116 | 116 | # round-trip verification of HTTP signatures and outbound delivery. |
| 117 | 117 | wiremock = "0.6" |
| 118 | +# E2E tests against the docker-compose fediverse testbed | |
| 119 | +# (tests/fediverse-testbed/). Used by `cargo test --test e2e_fediverse`. | |
| 120 | +reqwest = { version = "0.12", features = ["json"] } |
M
crates/rustwarden-core/src/fediverse/auth_routes.rs
+99
-18
@@ -175,6 +175,19 @@ async fn auth_inbox(
| 175 | 175 | .unwrap_or_default(); |
| 176 | 176 | |
| 177 | 177 | let _ = signature; // presence already enforced above |
| 178 | + | |
| 179 | + // F8 / RFC 0027 — enforce trust policy BEFORE signature verification | |
| 180 | + // (and before any actor fetch). A hostile but signature-valid | |
| 181 | + // instance must be rejected here based on its domain alone. | |
| 182 | + let source_domain = url::Url::parse(&actor) | |
| 183 | + .ok() | |
| 184 | + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 185 | + .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?; | |
| 186 | + crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone()) | |
| 187 | + .assert_domain_allowed(&source_domain) | |
| 188 | + .await | |
| 189 | + .map_err(|e| FediverseError::Forbidden(e.to_string()))?; | |
| 190 | + | |
| 178 | 191 | state |
| 179 | 192 | .verifier |
| 180 | 193 | .verify(&VerifyCtx { |
@@ -188,17 +201,51 @@ async fn auth_inbox(
| 188 | 201 | .await |
| 189 | 202 | .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; |
| 190 | 203 | |
| 191 | - // RFC 0006 Phase 2: persist the inbound challenge so the IdP-side | |
| 192 | - // consent screen (RFC 0023 Slice B iter. 4) can find it and approve. | |
| 193 | - // The signature verifier above already populated the | |
| 194 | - // `remote_identities` row for the requester, so the service method | |
| 195 | - // can resolve `requested_remote_id` cleanly. | |
| 196 | - // Resolve this IdP's canonical domain from the env (set by | |
| 197 | - // `with_fediverse()` at app boot) for the target-validation check. | |
| 198 | - let idp_domain = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_default(); | |
| 199 | - FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain) | |
| 200 | - .await | |
| 201 | - .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 204 | + // Dispatch on the activity `type` (RFC 0025). | |
| 205 | + // | |
| 206 | + // - `AuthRequest` → IdP side: persist a Pending challenge tied to | |
| 207 | + // the requester (RFC 0006 Phase 2). | |
| 208 | + // - `Accept`/`Reject` (with `object.type == AuthRequest`) → SP side: | |
| 209 | + // propagate the IdP's decision into the local challenge (RFC 0025). | |
| 210 | + // - Anything else → AP §7 says ignore unknown types silently; we | |
| 211 | + // return 202 and log to ease debugging. | |
| 212 | + let activity_type = body_json | |
| 213 | + .get("type") | |
| 214 | + .and_then(|v| v.as_str()) | |
| 215 | + .unwrap_or("") | |
| 216 | + .to_string(); | |
| 217 | + | |
| 218 | + match activity_type.as_str() { | |
| 219 | + "AuthRequest" => { | |
| 220 | + // Resolve this IdP's canonical domain from the env (set by | |
| 221 | + // `with_fediverse()` at app boot) for the target-validation | |
| 222 | + // check. Strip scheme/trailing-slash so the comparison against | |
| 223 | + // `target`'s bare host part inside `persist_inbound_request` | |
| 224 | + // works whether FEDIVERSE_DOMAIN was set as `example.org` or | |
| 225 | + // `http://example.org`. (TODO: thread the parsed | |
| 226 | + // FediverseConfig in via extension to avoid env reads here.) | |
| 227 | + let idp_domain = std::env::var("FEDIVERSE_DOMAIN") | |
| 228 | + .unwrap_or_default() | |
| 229 | + .trim_start_matches("http://") | |
| 230 | + .trim_start_matches("https://") | |
| 231 | + .trim_end_matches('/') | |
| 232 | + .to_ascii_lowercase(); | |
| 233 | + FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain) | |
| 234 | + .await | |
| 235 | + .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 236 | + } | |
| 237 | + "Accept" | "Reject" => { | |
| 238 | + FediverseAuthService::record_inbound_decision(&db, &body_json) | |
| 239 | + .await | |
| 240 | + .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 241 | + } | |
| 242 | + other => { | |
| 243 | + tracing::debug!( | |
| 244 | + activity_type = other, | |
| 245 | + "auth_inbox: ignoring unknown activity type" | |
| 246 | + ); | |
| 247 | + } | |
| 248 | + } | |
| 202 | 249 | |
| 203 | 250 | Ok(StatusCode::ACCEPTED) |
| 204 | 251 | } |
@@ -261,6 +308,33 @@ mod tests {
| 261 | 308 | use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; |
| 262 | 309 | use tower::ServiceExt; |
| 263 | 310 | |
| 311 | + /// F8 enforcement helper for unit tests: pre-queues three mock | |
| 312 | + /// results (app_settings.fediverse_enabled=true, | |
| 313 | + /// fediverse_trust_mode=open_with_blocklist, empty blocklist) so | |
| 314 | + /// `FediverseTrustService::assert_domain_allowed` answers Ok for | |
| 315 | + /// any well-formed actor URL. | |
| 316 | + fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase { | |
| 317 | + use crate::models::{app_settings, fediverse_blocked_domain}; | |
| 318 | + let now = Utc::now(); | |
| 319 | + mock.append_query_results([vec![app_settings::Model { | |
| 320 | + id: Uuid::new_v4(), | |
| 321 | + key: "fediverse_enabled".to_string(), | |
| 322 | + value: "true".to_string(), | |
| 323 | + description: None, | |
| 324 | + updated_by: None, | |
| 325 | + updated_at: now, | |
| 326 | + }]]) | |
| 327 | + .append_query_results([vec![app_settings::Model { | |
| 328 | + id: Uuid::new_v4(), | |
| 329 | + key: "fediverse_trust_mode".to_string(), | |
| 330 | + value: "open_with_blocklist".to_string(), | |
| 331 | + description: None, | |
| 332 | + updated_by: None, | |
| 333 | + updated_at: now, | |
| 334 | + }]]) | |
| 335 | + .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) | |
| 336 | + } | |
| 337 | + | |
| 264 | 338 | fn test_auth_config() -> AuthConfig { |
| 265 | 339 | AuthConfig { |
| 266 | 340 | jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
@@ -474,14 +548,21 @@ mod tests {
| 474 | 548 | .body(Body::from(body)) |
| 475 | 549 | .unwrap(); |
| 476 | 550 | // Empty remote_identities cache → persist_inbound_request rejects. |
| 477 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 478 | - .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 479 | - .into_connection(); | |
| 480 | - // SAFETY: tests run sequentially within this `#[tokio::test]` ; | |
| 481 | - // env-var mutation here is bounded to the request lifetime. | |
| 482 | - unsafe { std::env::set_var("FEDIVERSE_DOMAIN", "a.tld") }; | |
| 551 | + // F8 needs permissive trust mock prepended so enforcement passes | |
| 552 | + // and the BadRequest assertion catches the persist_inbound_request | |
| 553 | + // path, not the trust gate. | |
| 554 | + // | |
| 555 | + // We deliberately do NOT mutate FEDIVERSE_DOMAIN here: race | |
| 556 | + // condition risk with concurrent build_*_payload tests that read | |
| 557 | + // env via FediverseConfig::from_env(). Whatever value is in env | |
| 558 | + // (or none), the assertion still holds: either the target domain | |
| 559 | + // mismatches and persist_inbound_request returns BadRequest, or | |
| 560 | + // the remote_identities lookup misses and also returns BadRequest. | |
| 561 | + let db: DatabaseConnection = | |
| 562 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 563 | + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 564 | + .into_connection(); | |
| 483 | 565 | let resp = build_router(db).oneshot(req).await.unwrap(); |
| 484 | - unsafe { std::env::remove_var("FEDIVERSE_DOMAIN") }; | |
| 485 | 566 | assert_eq!(resp.status(), StatusCode::BAD_REQUEST); |
| 486 | 567 | } |
| 487 | 568 |
M
crates/rustwarden-core/src/fediverse/error.rs
+8
-0
@@ -46,6 +46,13 @@ pub enum FediverseError {
| 46 | 46 | #[error("activity type not implemented: {0}")] |
| 47 | 47 | Unsupported(String), |
| 48 | 48 | |
| 49 | + /// F8 (RFC 0027 / `experimental-fediverse`): the source domain is | |
| 50 | + /// not allowed by this server's trust mode (allowlist miss or | |
| 51 | + /// blocklist hit). 403 is the correct status code: the signature | |
| 52 | + /// itself is valid (would be 401 otherwise), policy refuses. | |
| 53 | + #[error("forbidden: {0}")] | |
| 54 | + Forbidden(String), | |
| 55 | + | |
| 49 | 56 | /// Wraps a generic application error from [`AppError`]. |
| 50 | 57 | #[error(transparent)] |
| 51 | 58 | App(#[from] AppError), |
@@ -61,6 +68,7 @@ impl IntoResponse for FediverseError {
| 61 | 68 | Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE.into_response(), |
| 62 | 69 | Self::Unauthorized(_) => StatusCode::UNAUTHORIZED.into_response(), |
| 63 | 70 | Self::Unsupported(_) => StatusCode::NOT_IMPLEMENTED.into_response(), |
| 71 | + Self::Forbidden(_) => StatusCode::FORBIDDEN.into_response(), | |
| 64 | 72 | Self::App(err) => err.into_response(), |
| 65 | 73 | } |
| 66 | 74 | } |
M
crates/rustwarden-core/src/fediverse/inbox.rs
+52
-8
@@ -69,6 +69,18 @@ pub async fn handle(
| 69 | 69 | // (its absence already errors above) — the value is also reachable |
| 70 | 70 | // via `headers["signature"]` for impls. |
| 71 | 71 | let _ = signature_header; |
| 72 | + | |
| 73 | + // F8 / RFC 0027 — trust policy gate. Reject before any verifier work | |
| 74 | + // or downstream effect when the source domain isn't allowed. | |
| 75 | + let source_domain = url::Url::parse(actor_ap_id) | |
| 76 | + .ok() | |
| 77 | + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 78 | + .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?; | |
| 79 | + crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone()) | |
| 80 | + .assert_domain_allowed(&source_domain) | |
| 81 | + .await | |
| 82 | + .map_err(|e| FediverseError::Forbidden(e.to_string()))?; | |
| 83 | + | |
| 72 | 84 | state |
| 73 | 85 | .verifier |
| 74 | 86 | .verify(&VerifyCtx { |
@@ -234,6 +246,32 @@ mod tests {
| 234 | 246 | } |
| 235 | 247 | } |
| 236 | 248 | |
| 249 | + /// F8 enforcement helper: seed three mock results so that | |
| 250 | + /// `FediverseTrustService::assert_domain_allowed` answers Ok with | |
| 251 | + /// no real DB. Mode is `open_with_blocklist` + empty blocklist, the | |
| 252 | + /// cheapest permissive shape for unit tests. | |
| 253 | + fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase { | |
| 254 | + use crate::models::{app_settings, fediverse_blocked_domain}; | |
| 255 | + let now = chrono::Utc::now(); | |
| 256 | + mock.append_query_results([vec![app_settings::Model { | |
| 257 | + id: Uuid::new_v4(), | |
| 258 | + key: "fediverse_enabled".to_string(), | |
| 259 | + value: "true".to_string(), | |
| 260 | + description: None, | |
| 261 | + updated_by: None, | |
| 262 | + updated_at: now, | |
| 263 | + }]]) | |
| 264 | + .append_query_results([vec![app_settings::Model { | |
| 265 | + id: Uuid::new_v4(), | |
| 266 | + key: "fediverse_trust_mode".to_string(), | |
| 267 | + value: "open_with_blocklist".to_string(), | |
| 268 | + description: None, | |
| 269 | + updated_by: None, | |
| 270 | + updated_at: now, | |
| 271 | + }]]) | |
| 272 | + .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) | |
| 273 | + } | |
| 274 | + | |
| 237 | 275 | fn db_for_update() -> DatabaseConnection { |
| 238 | 276 | let now = chrono::Utc::now(); |
| 239 | 277 | let row = crate::models::remote_identity::Model { |
@@ -256,7 +294,7 @@ mod tests {
| 256 | 294 | }; |
| 257 | 295 | let mut after = row.clone(); |
| 258 | 296 | after.display_name = Some("Alice".into()); |
| 259 | - MockDatabase::new(DatabaseBackend::Postgres) | |
| 297 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 260 | 298 | .append_query_results([vec![row]]) |
| 261 | 299 | .append_query_results([vec![after]]) |
| 262 | 300 | .append_exec_results([MockExecResult { |
@@ -268,13 +306,13 @@ mod tests {
| 268 | 306 | |
| 269 | 307 | fn db_for_delete_noop() -> DatabaseConnection { |
| 270 | 308 | // SELECT returns empty → DeleteOutcome::default(), no UPDATE. |
| 271 | - MockDatabase::new(DatabaseBackend::Postgres) | |
| 309 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 272 | 310 | .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) |
| 273 | 311 | .into_connection() |
| 274 | 312 | } |
| 275 | 313 | |
| 276 | 314 | fn db_for_revoke_noop() -> DatabaseConnection { |
| 277 | - MockDatabase::new(DatabaseBackend::Postgres) | |
| 315 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 278 | 316 | .append_exec_results([MockExecResult { |
| 279 | 317 | last_insert_id: 0, |
| 280 | 318 | rows_affected: 0, |
@@ -349,10 +387,13 @@ mod tests {
| 349 | 387 | |
| 350 | 388 | #[tokio::test] |
| 351 | 389 | async fn inbox_returns_401_when_verifier_rejects() { |
| 352 | - let body = serde_json::json!({"type": "Update", "actor": "x"}).to_string(); | |
| 390 | + let body = serde_json::json!({ | |
| 391 | + "type": "Update", "actor": "https://a.tld/users/alice" | |
| 392 | + }) | |
| 393 | + .to_string(); | |
| 353 | 394 | let router = make_router( |
| 354 | 395 | Arc::new(AlwaysReject), |
| 355 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(), | |
| 396 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), | |
| 356 | 397 | ); |
| 357 | 398 | let resp = router.oneshot(signed_request(&body)).await.unwrap(); |
| 358 | 399 | assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); |
@@ -370,10 +411,13 @@ mod tests {
| 370 | 411 | |
| 371 | 412 | #[tokio::test] |
| 372 | 413 | async fn inbox_returns_501_for_unknown_activity_type() { |
| 373 | - let body = serde_json::json!({"type": "Like", "actor": "x"}).to_string(); | |
| 414 | + let body = serde_json::json!({ | |
| 415 | + "type": "Like", "actor": "https://a.tld/users/alice" | |
| 416 | + }) | |
| 417 | + .to_string(); | |
| 374 | 418 | let router = make_router( |
| 375 | 419 | Arc::new(AlwaysAccept), |
| 376 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(), | |
| 420 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), | |
| 377 | 421 | ); |
| 378 | 422 | let resp = router.oneshot(signed_request(&body)).await.unwrap(); |
| 379 | 423 | assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); |
@@ -388,7 +432,7 @@ mod tests {
| 388 | 432 | "object": "https://a.tld/users/ghost" |
| 389 | 433 | }) |
| 390 | 434 | .to_string(); |
| 391 | - let router = make_router(Arc::new(AlwaysAcceptVerifier), db_for_delete_noop()); | |
| 435 | + let router = make_router(Arc::new(AlwaysAcceptVerifier::new()), db_for_delete_noop()); | |
| 392 | 436 | let resp = router.oneshot(signed_request(&body)).await.unwrap(); |
| 393 | 437 | assert_eq!(resp.status(), StatusCode::ACCEPTED); |
| 394 | 438 | } |
M
crates/rustwarden-core/src/fediverse/invite_routes.rs
+10
-1
@@ -108,12 +108,21 @@ pub struct IdpListQuery {
| 108 | 108 | async fn invite( |
| 109 | 109 | Extension(state): Extension<InviteRoutesState>, |
| 110 | 110 | Extension(db): Extension<DatabaseConnection>, |
| 111 | + claims: Option<Extension<crate::auth::jwt::Claims>>, | |
| 111 | 112 | Path(team_id): Path<Uuid>, |
| 112 | 113 | Json(body): Json<InviteBody>, |
| 113 | 114 | ) -> Result<(StatusCode, Json<InviteResponse>), FediverseError> { |
| 114 | 115 | let role = TeamRole::parse(&body.role) |
| 115 | 116 | .ok_or_else(|| FediverseError::BadRequest(format!("invalid role: {}", body.role)))?; |
| 116 | - let inviter = (state.system_actor_user_id)(); | |
| 117 | + // Prefer the authenticated admin's id (injected by AuthService for | |
| 118 | + // `/api/v1/teams/*` since iter 4 hygiene). Fall back to the | |
| 119 | + // configured placeholder only when no JWT was present — that path | |
| 120 | + // remains for unit tests with `MockDatabase` and seed-style flows | |
| 121 | + // that don't go through the auth middleware. | |
| 122 | + let inviter = match claims { | |
| 123 | + Some(Extension(c)) => c.sub, | |
| 124 | + None => (state.system_actor_user_id)(), | |
| 125 | + }; | |
| 117 | 126 | let model = FediverseInviteService::invite_remote( |
| 118 | 127 | &db, |
| 119 | 128 | team_id, |
M
crates/rustwarden-core/src/fediverse/routes.rs
+6
-3
@@ -28,8 +28,11 @@ use super::webfinger;
| 28 | 28 | /// Build the federation router with the default SeaORM-backed resolver |
| 29 | 29 | /// and the test-only [`AlwaysAcceptVerifier`]. |
| 30 | 30 | pub fn router(config: FediverseConfig, db: DatabaseConnection) -> Router<DatabaseConnection> { |
| 31 | - let resolver: Arc<dyn LocalUserResolver> = Arc::new(SeaOrmLocalUserResolver::new(db)); | |
| 32 | - let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier); | |
| 31 | + let resolver: Arc<dyn LocalUserResolver> = Arc::new(SeaOrmLocalUserResolver::new(db.clone())); | |
| 32 | + // Debug/dev verifier with cache-population side-effect so the | |
| 33 | + // testbed (FEDIVERSE_DEBUG_LOCAL_FEDERATION=true) doesn't need to | |
| 34 | + // manually pre-seed `remote_identities`. | |
| 35 | + let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier::with_resolver(db)); | |
| 33 | 36 | router_with(config, resolver, verifier) |
| 34 | 37 | } |
| 35 | 38 |
@@ -129,6 +132,6 @@ pub fn router_with_resolver(
| 129 | 132 | config: FediverseConfig, |
| 130 | 133 | resolver: Arc<dyn LocalUserResolver>, |
| 131 | 134 | ) -> Router<DatabaseConnection> { |
| 132 | - let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier); | |
| 135 | + let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier::new()); | |
| 133 | 136 | router_with(config, resolver, verifier) |
| 134 | 137 | } |
M
crates/rustwarden-core/src/middleware/auth_service.rs
+48
-0
@@ -73,6 +73,17 @@ where
| 73 | 73 | "/fediverse/consent", |
| 74 | 74 | "/fediverse/invite", |
| 75 | 75 | ]; |
| 76 | + // NOTE on `/api/v1/teams/*` : *not* protected here on purpose. | |
| 77 | + // The canonical admin path is the UI handler | |
| 78 | + // `crates/rustwarden-ui/src/handlers/admin_fediverse.rs::create_team_invite` | |
| 79 | + // which is protected via `/admin/*` and passes `claims.sub` as | |
| 80 | + // inviter directly. The `/api/v1/teams/*` route remains a | |
| 81 | + // back-door consumed by E2E tests + future API integrators ; | |
| 82 | + // its `invite()` handler accepts an `Option<Extension<Claims>>` | |
| 83 | + // and falls back to `system_actor_user_id()` when no JWT is | |
| 84 | + // present. A future RFC will gate this route once external | |
| 85 | + // consumers are wired through a per-route auth extractor | |
| 86 | + // independent of the UI middleware. | |
| 76 | 87 | let is_protected = protected_routes.iter().any(|route| path.starts_with(route)); |
| 77 | 88 | |
| 78 | 89 | if is_protected { |
@@ -147,6 +158,43 @@ where
| 147 | 158 | } |
| 148 | 159 | }; |
| 149 | 160 | |
| 161 | + // F10 (iter 4 / S4) — remote JWTs (`kind=remote`) must be | |
| 162 | + // rejected when their backing `remote_identities` row was | |
| 163 | + // disabled (kill_domain, F9 auto-kill, or manual revoke). | |
| 164 | + // Feature-gated because `remote_identity` model only | |
| 165 | + // exists when `experimental-fediverse` is on; without the | |
| 166 | + // feature, remote JWTs simply can't be minted, so the | |
| 167 | + // check is unreachable. | |
| 168 | + #[cfg(feature = "experimental-fediverse")] | |
| 169 | + if claims.is_remote() { | |
| 170 | + if let Some(rid) = claims.remote_identity_id() { | |
| 171 | + use crate::models::remote_identity; | |
| 172 | + use sea_orm::EntityTrait; | |
| 173 | + match remote_identity::Entity::find_by_id(rid).one(&db).await { | |
| 174 | + Ok(Some(ri)) => { | |
| 175 | + if ri.disabled_at.is_some() { | |
| 176 | + tracing::warn!( | |
| 177 | + "AuthService: remote_identity {} is disabled, rejecting JWT", | |
| 178 | + rid | |
| 179 | + ); | |
| 180 | + let redirect_url = sanitize_redirect(uri.path()); | |
| 181 | + return Ok( | |
| 182 | + axum::response::Redirect::to(&redirect_url).into_response() | |
| 183 | + ); | |
| 184 | + } | |
| 185 | + } | |
| 186 | + Ok(None) => tracing::warn!( | |
| 187 | + "AuthService: F10 — remote_identity {} not found", | |
| 188 | + rid | |
| 189 | + ), | |
| 190 | + Err(e) => tracing::warn!( | |
| 191 | + "AuthService: F10 — remote_identity lookup error: {}", | |
| 192 | + e | |
| 193 | + ), | |
| 194 | + } | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 150 | 198 | tracing::debug!("AuthService: User {} accessing: {}", claims.sub, uri); |
| 151 | 199 | |
| 152 | 200 | { |
M
crates/rustwarden-core/src/services/fediverse_auth_service.rs
+171
-0
@@ -284,6 +284,105 @@ impl FediverseAuthService {
| 284 | 284 | Ok(active.insert(db).await?) |
| 285 | 285 | } |
| 286 | 286 | |
| 287 | + /// Step 1ter (RFC 0025 — back-channel) : called by the SP-side | |
| 288 | + /// `/fediverse/auth/inbox` when the IdP delivers an | |
| 289 | + /// `Accept(AuthRequest)` or `Reject(AuthRequest)`. The activity's | |
| 290 | + /// `object` IRI references B's original AuthRequest; the nonce is | |
| 291 | + /// the last URL segment. | |
| 292 | + /// | |
| 293 | + /// Security: the approver's domain (`actor`'s host) must match the | |
| 294 | + /// `domain` recorded on the local challenge when it was initiated. | |
| 295 | + /// Without that check, `evil.tld` could fabricate an `Accept` for a | |
| 296 | + /// nonce it observed and trick B into approving it. | |
| 297 | + /// | |
| 298 | + /// Idempotent: replaying the same decision on a challenge that's | |
| 299 | + /// already left `Pending` returns `Ok(())` without DB write. | |
| 300 | + pub async fn record_inbound_decision( | |
| 301 | + db: &DatabaseConnection, | |
| 302 | + body: &serde_json::Value, | |
| 303 | + ) -> Result<(), AppError> { | |
| 304 | + let activity_type = body | |
| 305 | + .get("type") | |
| 306 | + .and_then(|v| v.as_str()) | |
| 307 | + .ok_or_else(|| AppError::Validation("activity missing `type`".into()))? | |
| 308 | + .to_string(); | |
| 309 | + | |
| 310 | + let approver_actor_url = body | |
| 311 | + .get("actor") | |
| 312 | + .and_then(|v| v.as_str()) | |
| 313 | + .ok_or_else(|| AppError::Validation("activity missing `actor`".into()))? | |
| 314 | + .to_string(); | |
| 315 | + | |
| 316 | + let nonce = extract_nonce_from_decision_object(body)?; | |
| 317 | + | |
| 318 | + use crate::models::remote_identity; | |
| 319 | + use sea_orm::{ColumnTrait, QueryFilter}; | |
| 320 | + | |
| 321 | + let challenge = fediverse_auth_challenge::Entity::find() | |
| 322 | + .filter(fediverse_auth_challenge::Column::Nonce.eq(&nonce)) | |
| 323 | + .one(db) | |
| 324 | + .await? | |
| 325 | + .ok_or_else(|| AppError::NotFound(format!("unknown nonce {nonce}")))?; | |
| 326 | + | |
| 327 | + // Idempotence on terminal states. | |
| 328 | + if challenge.status != ChallengeStatus::Pending.as_str() { | |
| 329 | + return Ok(()); | |
| 330 | + } | |
| 331 | + if challenge.expires_at < Utc::now() { | |
| 332 | + return Err(AppError::Validation(format!( | |
| 333 | + "challenge {} expired before decision arrived", | |
| 334 | + challenge.id | |
| 335 | + ))); | |
| 336 | + } | |
| 337 | + | |
| 338 | + // Security: the approver must live on the IdP that this | |
| 339 | + // challenge originally targeted. | |
| 340 | + let approver_domain = url::Url::parse(&approver_actor_url) | |
| 341 | + .ok() | |
| 342 | + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 343 | + .ok_or_else(|| { | |
| 344 | + AppError::Validation(format!( | |
| 345 | + "approver actor URL has no host: {approver_actor_url}" | |
| 346 | + )) | |
| 347 | + })?; | |
| 348 | + if approver_domain != challenge.domain.to_ascii_lowercase() { | |
| 349 | + return Err(AppError::Validation(format!( | |
| 350 | + "approver domain `{approver_domain}` does not match challenge IdP `{}`", | |
| 351 | + challenge.domain | |
| 352 | + ))); | |
| 353 | + } | |
| 354 | + | |
| 355 | + // Resolve the approver in `remote_identities`. F2's verifier | |
| 356 | + // populated this row on signature verification; if it's still | |
| 357 | + // absent, surface the integrity issue rather than approve blindly. | |
| 358 | + let approver = remote_identity::Entity::find() | |
| 359 | + .filter(remote_identity::Column::ApId.eq(&approver_actor_url)) | |
| 360 | + .one(db) | |
| 361 | + .await? | |
| 362 | + .ok_or_else(|| { | |
| 363 | + AppError::Validation(format!( | |
| 364 | + "approver {approver_actor_url} not in remote_identities cache; \ | |
| 365 | + signature verifier should have populated this row", | |
| 366 | + )) | |
| 367 | + })?; | |
| 368 | + | |
| 369 | + let new_status = match activity_type.as_str() { | |
| 370 | + "Accept" => ChallengeStatus::Approved, | |
| 371 | + "Reject" => ChallengeStatus::Rejected, | |
| 372 | + other => { | |
| 373 | + return Err(AppError::Validation(format!( | |
| 374 | + "record_inbound_decision: unsupported type `{other}` (expected Accept|Reject)" | |
| 375 | + ))) | |
| 376 | + } | |
| 377 | + }; | |
| 378 | + | |
| 379 | + let mut active: fediverse_auth_challenge::ActiveModel = challenge.into(); | |
| 380 | + active.status = Set(new_status.as_str().to_string()); | |
| 381 | + active.requested_remote_id = Set(Some(approver.id)); | |
| 382 | + active.update(db).await?; | |
| 383 | + Ok(()) | |
| 384 | + } | |
| 385 | + | |
| 287 | 386 | /// Step 3 — caller polls. Promotes pending-but-expired rows to |
| 288 | 387 | /// `Expired` lazily so the caller observes a stable terminal state. |
| 289 | 388 | pub async fn status( |
@@ -382,6 +481,78 @@ pub fn build_auth_request_payload(
| 382 | 481 | }) |
| 383 | 482 | } |
| 384 | 483 | |
| 484 | +/// Build the JSON-LD body of an `Accept(AuthRequest)` or | |
| 485 | +/// `Reject(AuthRequest)` activity (RFC 0025). | |
| 486 | +/// | |
| 487 | +/// The IRI-only form of `object` would require the IdP to know B's | |
| 488 | +/// AuthRequest URL — which we don't store. We use the inline-object | |
| 489 | +/// form `{ type: "AuthRequest", nonce }` instead; the receiving side | |
| 490 | +/// extracts the nonce via [`extract_nonce_from_decision_object`]. | |
| 491 | +/// | |
| 492 | +/// `idp_base_url` is the canonical base URL of A (the IdP, e.g. | |
| 493 | +/// `http://alpha.local/`). `approver_actor_url` is the AP id of the | |
| 494 | +/// local user that clicked Approve / Reject. `requester_inbox_url` is | |
| 495 | +/// the inbox of B's user that initiated the AuthRequest (delivery | |
| 496 | +/// target). `approve=true` emits Accept, `false` emits Reject. | |
| 497 | +pub fn build_auth_decision_payload( | |
| 498 | + idp_base_url: &url::Url, | |
| 499 | + approver_actor_url: &str, | |
| 500 | + requester_inbox_url: &str, | |
| 501 | + nonce: &str, | |
| 502 | + approve: bool, | |
| 503 | +) -> serde_json::Value { | |
| 504 | + let activity_type = if approve { "Accept" } else { "Reject" }; | |
| 505 | + let activity_id = format!("{}auth-decisions/{}", idp_base_url, Uuid::new_v4()); | |
| 506 | + serde_json::json!({ | |
| 507 | + "@context": [ | |
| 508 | + "https://www.w3.org/ns/activitystreams", | |
| 509 | + { | |
| 510 | + "AuthRequest": { | |
| 511 | + "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" | |
| 512 | + } | |
| 513 | + } | |
| 514 | + ], | |
| 515 | + "type": activity_type, | |
| 516 | + "id": activity_id, | |
| 517 | + "actor": approver_actor_url, | |
| 518 | + "object": { | |
| 519 | + "type": "AuthRequest", | |
| 520 | + "nonce": nonce, | |
| 521 | + }, | |
| 522 | + "to": [ requester_inbox_url ], | |
| 523 | + }) | |
| 524 | +} | |
| 525 | + | |
| 526 | +/// Extract the AuthRequest nonce from the `object` field of an inbound | |
| 527 | +/// `Accept` or `Reject` activity. Supports two payload shapes: | |
| 528 | +/// - **IRI form** (canonical) : `"object": "http://b.tld/auth-requests/<nonce>"` | |
| 529 | +/// — the nonce is the last URL path segment. | |
| 530 | +/// - **Inline-object form** (defensive fallback) : `"object": { "nonce": "<v>" }`. | |
| 531 | +fn extract_nonce_from_decision_object(body: &serde_json::Value) -> Result<String, AppError> { | |
| 532 | + let object = body | |
| 533 | + .get("object") | |
| 534 | + .ok_or_else(|| AppError::Validation("Accept/Reject missing `object`".into()))?; | |
| 535 | + | |
| 536 | + if let Some(iri) = object.as_str() { | |
| 537 | + let nonce = iri.rsplit('/').next().unwrap_or("").trim(); | |
| 538 | + if nonce.is_empty() { | |
| 539 | + return Err(AppError::Validation(format!( | |
| 540 | + "cannot extract nonce from object IRI `{iri}`" | |
| 541 | + ))); | |
| 542 | + } | |
| 543 | + return Ok(nonce.to_string()); | |
| 544 | + } | |
| 545 | + if let Some(inline) = object.as_object() | |
| 546 | + && let Some(nonce) = inline.get("nonce").and_then(|v| v.as_str()) | |
| 547 | + && !nonce.is_empty() | |
| 548 | + { | |
| 549 | + return Ok(nonce.to_string()); | |
| 550 | + } | |
| 551 | + Err(AppError::Validation( | |
| 552 | + "cannot extract nonce from Accept/Reject `object`".into(), | |
| 553 | + )) | |
| 554 | +} | |
| 555 | + | |
| 385 | 556 | fn handle_domain(handle: &str) -> Result<String, AppError> { |
| 386 | 557 | let trimmed = handle.trim().trim_start_matches('@'); |
| 387 | 558 | let (_, domain) = trimmed.split_once('@').ok_or_else(|| { |
M
crates/rustwarden-core/src/services/fediverse_outbound_service.rs
+24
-2
@@ -219,8 +219,13 @@ impl FediverseOutboundService {
| 219 | 219 | |
| 220 | 220 | // RFC 0019: structured tracing events — drive Prometheus / Loki / |
| 221 | 221 | // OTEL exporters without coupling rustwarden-core to a metrics |
| 222 | - // crate. | |
| 223 | - match (&delivery, updated.status.as_str()) { | |
| 222 | + // crate. Gated by `app_settings.fediverse_observability_enabled` | |
| 223 | + // (default `false`) — opt-in to avoid log noise on instances | |
| 224 | + // that pipe nothing to an observability backend. | |
| 225 | + let observability = | |
| 226 | + is_fediverse_observability_enabled(db).await.unwrap_or(false); | |
| 227 | + if observability { | |
| 228 | + match (&delivery, updated.status.as_str()) { | |
| 224 | 229 | (Ok(()), _) => { |
| 225 | 230 | tracing::info!( |
| 226 | 231 | target: "rustwarden::fediverse::outbound", |
@@ -253,12 +258,29 @@ impl FediverseOutboundService {
| 253 | 258 | event = "deferred", |
| 254 | 259 | ); |
| 255 | 260 | } |
| 261 | + } | |
| 256 | 262 | } |
| 257 | 263 | |
| 258 | 264 | Ok(Some(updated)) |
| 259 | 265 | } |
| 260 | 266 | } |
| 261 | 267 | |
| 268 | +/// RFC 0019 + S4 observability toggle — `app_settings.fediverse_observability_enabled`, | |
| 269 | +/// default **false**. When `false`, fediverse routes/services skip the | |
| 270 | +/// structured `tracing::info!` / `warn!` / `error!` emit-points used for | |
| 271 | +/// metric aggregation. Cheap (1 SELECT by key) ; cached by the upstream | |
| 272 | +/// `AppSettingsService` if the consumer wires its standard cache. | |
| 273 | +pub async fn is_fediverse_observability_enabled( | |
| 274 | + db: &DatabaseConnection, | |
| 275 | +) -> Result<bool, AppError> { | |
| 276 | + crate::services::app_settings_service::AppSettingsService::get_bool( | |
| 277 | + db, | |
| 278 | + "fediverse_observability_enabled", | |
| 279 | + false, | |
| 280 | + ) | |
| 281 | + .await | |
| 282 | +} | |
| 283 | + | |
| 262 | 284 | /// Background loop that periodically drains the queue. |
| 263 | 285 | /// |
| 264 | 286 | /// Spawned by `RustwardenBuilder::with_fediverse()` (RFC 0011 hooks |
M
crates/rustwarden-core/src/services/fediverse_signal_service.rs
+507
-3
@@ -236,7 +236,51 @@ pub trait SignatureVerifier: Send + Sync {
| 236 | 236 | /// |
| 237 | 237 | /// **Never use in production.** A logged warning emphasises the |
| 238 | 238 | /// hazard at instantiation in production builds. |
| 239 | -pub struct AlwaysAcceptVerifier; | |
| 239 | +/// | |
| 240 | +/// When constructed with [`AlwaysAcceptVerifier::with_resolver`], the | |
| 241 | +/// verifier also fetches the actor over HTTP and upserts the | |
| 242 | +/// `remote_identities` row before returning `Ok(())` — this preserves | |
| 243 | +/// the contract documented by [`crate::services::fediverse_auth_service::FediverseAuthService::persist_inbound_request`] | |
| 244 | +/// (« the signature verifier ran upstream and already required this | |
| 245 | +/// row ») so downstream services don't need to special-case the debug | |
| 246 | +/// verifier. | |
| 247 | +pub struct AlwaysAcceptVerifier { | |
| 248 | + resolver: Option<(DatabaseConnection, reqwest::Client)>, | |
| 249 | +} | |
| 250 | + | |
| 251 | +impl AlwaysAcceptVerifier { | |
| 252 | + /// Verifier with no side-effects. Suitable for unit tests where the | |
| 253 | + /// caller pre-seeds `remote_identities` themselves. | |
| 254 | + pub fn new() -> Self { | |
| 255 | + Self { resolver: None } | |
| 256 | + } | |
| 257 | + | |
| 258 | + /// Verifier that, on each accepted request, fetches the actor doc | |
| 259 | + /// from `ctx.actor_ap_id` over HTTP and upserts the corresponding | |
| 260 | + /// `remote_identities` cache row. Best-effort: a fetch/parse failure | |
| 261 | + /// is logged and the request is still accepted (since this verifier | |
| 262 | + /// is « always accept » by contract). | |
| 263 | + /// | |
| 264 | + /// The internal HTTP client has [`ACTOR_FETCH_TIMEOUT`] applied. The | |
| 265 | + /// SSRF guard inside [`fetch_and_cache_remote_actor`] is **bypassed** | |
| 266 | + /// here (`allow_private_ips=true`) because this verifier is test/dev | |
| 267 | + /// only and the docker-compose testbed lives on private IPs. | |
| 268 | + pub fn with_resolver(db: DatabaseConnection) -> Self { | |
| 269 | + let http = reqwest::Client::builder() | |
| 270 | + .timeout(ACTOR_FETCH_TIMEOUT) | |
| 271 | + .build() | |
| 272 | + .expect("reqwest client builder must not fail"); | |
| 273 | + Self { | |
| 274 | + resolver: Some((db, http)), | |
| 275 | + } | |
| 276 | + } | |
| 277 | +} | |
| 278 | + | |
| 279 | +impl Default for AlwaysAcceptVerifier { | |
| 280 | + fn default() -> Self { | |
| 281 | + Self::new() | |
| 282 | + } | |
| 283 | +} | |
| 240 | 284 | |
| 241 | 285 | #[async_trait] |
| 242 | 286 | impl SignatureVerifier for AlwaysAcceptVerifier { |
@@ -245,10 +289,227 @@ impl SignatureVerifier for AlwaysAcceptVerifier {
| 245 | 289 | actor = %ctx.actor_ap_id, |
| 246 | 290 | "AlwaysAcceptVerifier in use — DO NOT enable in production", |
| 247 | 291 | ); |
| 292 | + if let Some((db, http)) = &self.resolver | |
| 293 | + && let Err(e) = | |
| 294 | + fetch_and_cache_remote_actor(db, http, ctx.actor_ap_id, /* allow_private_ips */ true).await | |
| 295 | + { | |
| 296 | + // « Always accept » applies to signature verification, not | |
| 297 | + // to the cache-side-effect: a failed fetch must not 401 the | |
| 298 | + // caller. Log and let downstream services decide what to do | |
| 299 | + // with an absent row. | |
| 300 | + tracing::warn!( | |
| 301 | + actor = %ctx.actor_ap_id, | |
| 302 | + error = %e, | |
| 303 | + "AlwaysAcceptVerifier: actor fetch+cache failed (continuing)", | |
| 304 | + ); | |
| 305 | + } | |
| 248 | 306 | Ok(()) |
| 249 | 307 | } |
| 250 | 308 | } |
| 251 | 309 | |
| 310 | +/// Maximum body size accepted from a remote actor fetch. Mastodon's | |
| 311 | +/// canonical Person doc is ~2 KB; 1 MiB leaves room for icons in | |
| 312 | +/// `attachment` and large `summary` fields while bounding DoS exposure. | |
| 313 | +pub const MAX_ACTOR_BODY_BYTES: u64 = 1024 * 1024; | |
| 314 | + | |
| 315 | +/// Per-request timeout applied to every outgoing actor fetch. Caps | |
| 316 | +/// hangs from unreachable / slow remote instances without breaking the | |
| 317 | +/// inbox handler that called the verifier. | |
| 318 | +pub const ACTOR_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); | |
| 319 | + | |
| 320 | +/// Refuse to fetch URLs whose resolved IP is loopback, private, | |
| 321 | +/// link-local, ULA, or unspecified. Prevents SSRF pivots through | |
| 322 | +/// `actor_ap_id` values like `http://169.254.169.254/...` (AWS | |
| 323 | +/// metadata) or `http://10.x.x.x/admin`. | |
| 324 | +/// | |
| 325 | +/// Caller passes `allow_private_ips=true` only in test/dev contexts | |
| 326 | +/// where the topology is local (e.g. the docker-compose testbed using | |
| 327 | +/// `*.local` aliases resolving to 172.x.x.x). | |
| 328 | +async fn ssrf_guard(url: &url::Url, allow_private_ips: bool) -> Result<(), AppError> { | |
| 329 | + if allow_private_ips { | |
| 330 | + return Ok(()); | |
| 331 | + } | |
| 332 | + let host = url | |
| 333 | + .host_str() | |
| 334 | + .ok_or_else(|| AppError::Validation("URL has no host".into()))?; | |
| 335 | + let port = url.port_or_known_default().unwrap_or(80); | |
| 336 | + let addrs = tokio::net::lookup_host((host, port)) | |
| 337 | + .await | |
| 338 | + .map_err(|e| AppError::Validation(format!("DNS resolution failed for {host}: {e}")))?; | |
| 339 | + for addr in addrs { | |
| 340 | + use std::net::IpAddr; | |
| 341 | + let ip = addr.ip(); | |
| 342 | + if ip.is_loopback() || ip.is_unspecified() { | |
| 343 | + return Err(AppError::Validation(format!( | |
| 344 | + "refusing to fetch {host}: resolves to loopback/unspecified ({ip})" | |
| 345 | + ))); | |
| 346 | + } | |
| 347 | + match ip { | |
| 348 | + IpAddr::V4(v4) => { | |
| 349 | + if v4.is_private() || v4.is_link_local() { | |
| 350 | + return Err(AppError::Validation(format!( | |
| 351 | + "refusing to fetch {host}: resolves to private/link-local ({v4})" | |
| 352 | + ))); | |
| 353 | + } | |
| 354 | + } | |
| 355 | + IpAddr::V6(v6) => { | |
| 356 | + let seg0 = v6.segments()[0]; | |
| 357 | + // ULA fc00::/7, link-local fe80::/10 | |
| 358 | + if (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 { | |
| 359 | + return Err(AppError::Validation(format!( | |
| 360 | + "refusing to fetch {host}: resolves to ULA/link-local ({v6})" | |
| 361 | + ))); | |
| 362 | + } | |
| 363 | + } | |
| 364 | + } | |
| 365 | + } | |
| 366 | + Ok(()) | |
| 367 | +} | |
| 368 | + | |
| 369 | +/// Fetch a remote AP `Person` actor over HTTP and upsert the matching | |
| 370 | +/// `remote_identities` row. Idempotent: a re-fetch refreshes | |
| 371 | +/// `public_key_pem`, `inbox_url`, `shared_inbox_url`, `display_name`, | |
| 372 | +/// and `last_fetched_at`. | |
| 373 | +/// | |
| 374 | +/// Hardening: | |
| 375 | +/// - 10 s per-request timeout (via the caller's `http` client). | |
| 376 | +/// - 1 MiB body cap via Content-Length sniff + post-read assertion. | |
| 377 | +/// - SSRF guard rejecting private/loopback/link-local IPs unless | |
| 378 | +/// `allow_private_ips=true` (only set by test/dev callers). | |
| 379 | +/// | |
| 380 | +/// Network/parse errors return [`AppError::Validation`]; the caller | |
| 381 | +/// chooses whether to surface them (production verifier) or swallow | |
| 382 | +/// them (test/dev verifier). | |
| 383 | +pub async fn fetch_and_cache_remote_actor( | |
| 384 | + db: &DatabaseConnection, | |
| 385 | + http: &reqwest::Client, | |
| 386 | + actor_ap_id: &str, | |
| 387 | + allow_private_ips: bool, | |
| 388 | +) -> Result<remote_identity::Model, AppError> { | |
| 389 | + let url = url::Url::parse(actor_ap_id) | |
| 390 | + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not a valid URL: {e}")))?; | |
| 391 | + | |
| 392 | + ssrf_guard(&url, allow_private_ips).await?; | |
| 393 | + | |
| 394 | + let response = http | |
| 395 | + .get(actor_ap_id) | |
| 396 | + .header("Accept", "application/activity+json") | |
| 397 | + .send() | |
| 398 | + .await | |
| 399 | + .map_err(|e| AppError::Validation(format!("cannot fetch actor {actor_ap_id}: {e}")))?; | |
| 400 | + | |
| 401 | + if !response.status().is_success() { | |
| 402 | + return Err(AppError::Validation(format!( | |
| 403 | + "actor fetch returned {} for {actor_ap_id}", | |
| 404 | + response.status() | |
| 405 | + ))); | |
| 406 | + } | |
| 407 | + | |
| 408 | + // Belt: refuse early if the server advertises a body larger than | |
| 409 | + // our cap. Suspenders: re-check after the body is fully buffered, | |
| 410 | + // because Content-Length is optional and can lie. | |
| 411 | + if let Some(len) = response.content_length() | |
| 412 | + && len > MAX_ACTOR_BODY_BYTES | |
| 413 | + { | |
| 414 | + return Err(AppError::Validation(format!( | |
| 415 | + "actor {actor_ap_id} body too large: {len} > {MAX_ACTOR_BODY_BYTES}" | |
| 416 | + ))); | |
| 417 | + } | |
| 418 | + let bytes = response | |
| 419 | + .bytes() | |
| 420 | + .await | |
| 421 | + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} read error: {e}")))?; | |
| 422 | + if (bytes.len() as u64) > MAX_ACTOR_BODY_BYTES { | |
| 423 | + return Err(AppError::Validation(format!( | |
| 424 | + "actor {actor_ap_id} body too large: {} > {MAX_ACTOR_BODY_BYTES}", | |
| 425 | + bytes.len() | |
| 426 | + ))); | |
| 427 | + } | |
| 428 | + | |
| 429 | + let body: serde_json::Value = serde_json::from_slice(&bytes) | |
| 430 | + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not valid JSON: {e}")))?; | |
| 431 | + | |
| 432 | + let preferred_username = body | |
| 433 | + .get("preferredUsername") | |
| 434 | + .and_then(|v| v.as_str()) | |
| 435 | + .ok_or_else(|| { | |
| 436 | + AppError::Validation(format!("actor {actor_ap_id} missing preferredUsername")) | |
| 437 | + })? | |
| 438 | + .to_string(); | |
| 439 | + | |
| 440 | + let domain = url | |
| 441 | + .host_str() | |
| 442 | + .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} has no host")))? | |
| 443 | + .to_string(); | |
| 444 | + | |
| 445 | + let public_key_pem = body | |
| 446 | + .get("publicKey") | |
| 447 | + .and_then(|v| v.get("publicKeyPem")) | |
| 448 | + .and_then(|v| v.as_str()) | |
| 449 | + .ok_or_else(|| { | |
| 450 | + AppError::Validation(format!( | |
| 451 | + "actor {actor_ap_id} missing publicKey.publicKeyPem" | |
| 452 | + )) | |
| 453 | + })? | |
| 454 | + .to_string(); | |
| 455 | + | |
| 456 | + let inbox_url = body | |
| 457 | + .get("inbox") | |
| 458 | + .and_then(|v| v.as_str()) | |
| 459 | + .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} missing inbox")))? | |
| 460 | + .to_string(); | |
| 461 | + | |
| 462 | + let shared_inbox_url = body | |
| 463 | + .get("endpoints") | |
| 464 | + .and_then(|v| v.get("sharedInbox")) | |
| 465 | + .and_then(|v| v.as_str()) | |
| 466 | + .map(|s| s.to_string()); | |
| 467 | + | |
| 468 | + let display_name = body | |
| 469 | + .get("name") | |
| 470 | + .and_then(|v| v.as_str()) | |
| 471 | + .map(|s| s.to_string()); | |
| 472 | + | |
| 473 | + let now = Utc::now(); | |
| 474 | + let existing = remote_identity::Entity::find() | |
| 475 | + .filter(remote_identity::Column::ApId.eq(actor_ap_id)) | |
| 476 | + .one(db) | |
| 477 | + .await?; | |
| 478 | + | |
| 479 | + let model = if let Some(row) = existing { | |
| 480 | + let mut active: remote_identity::ActiveModel = row.into(); | |
| 481 | + active.public_key_pem = Set(public_key_pem); | |
| 482 | + active.inbox_url = Set(inbox_url); | |
| 483 | + active.shared_inbox_url = Set(shared_inbox_url); | |
| 484 | + active.display_name = Set(display_name); | |
| 485 | + active.raw_json = Set(Some(body)); | |
| 486 | + active.last_fetched_at = Set(now); | |
| 487 | + active.updated_at = Set(now); | |
| 488 | + active.update(db).await? | |
| 489 | + } else { | |
| 490 | + let active = remote_identity::ActiveModel { | |
| 491 | + id: Set(Uuid::new_v4()), | |
| 492 | + ap_id: Set(actor_ap_id.to_string()), | |
| 493 | + username: Set(preferred_username), | |
| 494 | + domain: Set(domain), | |
| 495 | + display_name: Set(display_name), | |
| 496 | + summary_html: Set(None), | |
| 497 | + inbox_url: Set(inbox_url), | |
| 498 | + shared_inbox_url: Set(shared_inbox_url), | |
| 499 | + public_key_pem: Set(public_key_pem), | |
| 500 | + icon_url: Set(None), | |
| 501 | + actor_type: Set("Person".to_string()), | |
| 502 | + raw_json: Set(Some(body)), | |
| 503 | + last_fetched_at: Set(now), | |
| 504 | + disabled_at: Set(None), | |
| 505 | + created_at: Set(now), | |
| 506 | + updated_at: Set(now), | |
| 507 | + }; | |
| 508 | + active.insert(db).await? | |
| 509 | + }; | |
| 510 | + Ok(model) | |
| 511 | +} | |
| 512 | + | |
| 252 | 513 | fn parse_handle_from_ap_id(ap_id: &str) -> Option<(String, String)> { |
| 253 | 514 | let url = url::Url::parse(ap_id).ok()?; |
| 254 | 515 | let host = url.host_str()?.to_string(); |
@@ -460,7 +721,7 @@ mod tests {
| 460 | 721 | #[tokio::test] |
| 461 | 722 | async fn always_accept_verifier_accepts_any_input() { |
| 462 | 723 | let headers = axum::http::HeaderMap::new(); |
| 463 | - AlwaysAcceptVerifier | |
| 724 | + AlwaysAcceptVerifier::new() | |
| 464 | 725 | .verify(&dummy_ctx(&headers)) |
| 465 | 726 | .await |
| 466 | 727 | .expect("AlwaysAcceptVerifier never rejects"); |
@@ -468,7 +729,7 @@ mod tests {
| 468 | 729 | |
| 469 | 730 | #[tokio::test] |
| 470 | 731 | async fn signature_verifier_trait_object_works() { |
| 471 | - let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier); | |
| 732 | + let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier::new()); | |
| 472 | 733 | let headers = axum::http::HeaderMap::new(); |
| 473 | 734 | verifier |
| 474 | 735 | .verify(&dummy_ctx(&headers)) |
@@ -520,4 +781,247 @@ mod tests {
| 520 | 781 | assert!(v.is_empty()); |
| 521 | 782 | } |
| 522 | 783 | |
| 784 | + // ─── fetch_and_cache_remote_actor (F2 hardening) ──────────────── | |
| 785 | + | |
| 786 | + /// Build a syntactically valid AP Person doc with the given overrides. | |
| 787 | + /// `extra` is merged at the top level — pass `null` for « no extra ». | |
| 788 | + fn ap_person_json( | |
| 789 | + actor_url: &str, | |
| 790 | + username: &str, | |
| 791 | + with_public_key: bool, | |
| 792 | + with_inbox: bool, | |
| 793 | + ) -> serde_json::Value { | |
| 794 | + let mut doc = serde_json::json!({ | |
| 795 | + "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 796 | + "type": "Person", | |
| 797 | + "id": actor_url, | |
| 798 | + "preferredUsername": username, | |
| 799 | + "name": username, | |
| 800 | + }); | |
| 801 | + if with_public_key { | |
| 802 | + doc["publicKey"] = serde_json::json!({ | |
| 803 | + "id": format!("{actor_url}#main-key"), | |
| 804 | + "owner": actor_url, | |
| 805 | + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----\n", | |
| 806 | + }); | |
| 807 | + } | |
| 808 | + if with_inbox { | |
| 809 | + doc["inbox"] = serde_json::json!(format!("{actor_url}/inbox")); | |
| 810 | + } | |
| 811 | + doc | |
| 812 | + } | |
| 813 | + | |
| 814 | + fn testbed_http_client() -> reqwest::Client { | |
| 815 | + reqwest::Client::builder() | |
| 816 | + .timeout(std::time::Duration::from_secs(2)) | |
| 817 | + .build() | |
| 818 | + .unwrap() | |
| 819 | + } | |
| 820 | + | |
| 821 | + #[tokio::test] | |
| 822 | + async fn fetch_inserts_new_remote_identity_on_first_sight() { | |
| 823 | + use wiremock::matchers::{method, path}; | |
| 824 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 825 | + | |
| 826 | + let server = MockServer::start().await; | |
| 827 | + let actor_url = format!("{}/users/alice", server.uri()); | |
| 828 | + Mock::given(method("GET")) | |
| 829 | + .and(path("/users/alice")) | |
| 830 | + .respond_with( | |
| 831 | + ResponseTemplate::new(200) | |
| 832 | + .set_body_json(ap_person_json(&actor_url, "alice", true, true)), | |
| 833 | + ) | |
| 834 | + .mount(&server) | |
| 835 | + .await; | |
| 836 | + | |
| 837 | + let inserted = fresh_remote(Uuid::new_v4(), &actor_url); | |
| 838 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 839 | + .append_query_results([Vec::<remote_identity::Model>::new()]) // SELECT empty | |
| 840 | + .append_query_results([vec![inserted.clone()]]) // INSERT RETURNING | |
| 841 | + .append_exec_results([MockExecResult { | |
| 842 | + last_insert_id: 0, | |
| 843 | + rows_affected: 1, | |
| 844 | + }]) | |
| 845 | + .into_connection(); | |
| 846 | + | |
| 847 | + let model = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 848 | + .await | |
| 849 | + .expect("happy path INSERT must succeed"); | |
| 850 | + assert_eq!(model.ap_id, inserted.ap_id); | |
| 851 | + } | |
| 852 | + | |
| 853 | + #[tokio::test] | |
| 854 | + async fn fetch_updates_existing_remote_identity_on_refetch() { | |
| 855 | + use wiremock::matchers::{method, path}; | |
| 856 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 857 | + | |
| 858 | + let server = MockServer::start().await; | |
| 859 | + let actor_url = format!("{}/users/bob", server.uri()); | |
| 860 | + Mock::given(method("GET")) | |
| 861 | + .and(path("/users/bob")) | |
| 862 | + .respond_with( | |
| 863 | + ResponseTemplate::new(200) | |
| 864 | + .set_body_json(ap_person_json(&actor_url, "bob", true, true)), | |
| 865 | + ) | |
| 866 | + .mount(&server) | |
| 867 | + .await; | |
| 868 | + | |
| 869 | + let existing = fresh_remote(Uuid::new_v4(), &actor_url); | |
| 870 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 871 | + .append_query_results([vec![existing.clone()]]) // SELECT hit | |
| 872 | + .append_query_results([vec![existing.clone()]]) // UPDATE RETURNING | |
| 873 | + .append_exec_results([MockExecResult { | |
| 874 | + last_insert_id: 0, | |
| 875 | + rows_affected: 1, | |
| 876 | + }]) | |
| 877 | + .into_connection(); | |
| 878 | + | |
| 879 | + fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 880 | + .await | |
| 881 | + .expect("happy path UPDATE must succeed"); | |
| 882 | + } | |
| 883 | + | |
| 884 | + #[tokio::test] | |
| 885 | + async fn fetch_returns_error_on_http_404() { | |
| 886 | + use wiremock::matchers::{method, path}; | |
| 887 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 888 | + | |
| 889 | + let server = MockServer::start().await; | |
| 890 | + let actor_url = format!("{}/users/ghost", server.uri()); | |
| 891 | + Mock::given(method("GET")) | |
| 892 | + .and(path("/users/ghost")) | |
| 893 | + .respond_with(ResponseTemplate::new(404)) | |
| 894 | + .mount(&server) | |
| 895 | + .await; | |
| 896 | + | |
| 897 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 898 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 899 | + .await | |
| 900 | + .expect_err("404 must be surfaced as Validation"); | |
| 901 | + assert!(format!("{err}").contains("404"), "got: {err}"); | |
| 902 | + } | |
| 903 | + | |
| 904 | + #[tokio::test] | |
| 905 | + async fn fetch_returns_error_on_malformed_json() { | |
| 906 | + use wiremock::matchers::{method, path}; | |
| 907 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 908 | + | |
| 909 | + let server = MockServer::start().await; | |
| 910 | + let actor_url = format!("{}/users/junk", server.uri()); | |
| 911 | + Mock::given(method("GET")) | |
| 912 | + .and(path("/users/junk")) | |
| 913 | + .respond_with(ResponseTemplate::new(200).set_body_string("not-json-at-all")) | |
| 914 | + .mount(&server) | |
| 915 | + .await; | |
| 916 | + | |
| 917 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 918 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 919 | + .await | |
| 920 | + .expect_err("malformed JSON must be rejected"); | |
| 921 | + assert!(format!("{err}").contains("not valid JSON"), "got: {err}"); | |
| 922 | + } | |
| 923 | + | |
| 924 | + #[tokio::test] | |
| 925 | + async fn fetch_returns_error_when_public_key_missing() { | |
| 926 | + use wiremock::matchers::{method, path}; | |
| 927 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 928 | + | |
| 929 | + let server = MockServer::start().await; | |
| 930 | + let actor_url = format!("{}/users/nokey", server.uri()); | |
| 931 | + Mock::given(method("GET")) | |
| 932 | + .and(path("/users/nokey")) | |
| 933 | + .respond_with( | |
| 934 | + ResponseTemplate::new(200) | |
| 935 | + .set_body_json(ap_person_json(&actor_url, "nokey", false, true)), | |
| 936 | + ) | |
| 937 | + .mount(&server) | |
| 938 | + .await; | |
| 939 | + | |
| 940 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 941 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 942 | + .await | |
| 943 | + .expect_err("missing publicKey must be rejected"); | |
| 944 | + assert!(format!("{err}").contains("publicKey"), "got: {err}"); | |
| 945 | + } | |
| 946 | + | |
| 947 | + #[tokio::test] | |
| 948 | + async fn fetch_returns_error_when_inbox_missing() { | |
| 949 | + use wiremock::matchers::{method, path}; | |
| 950 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 951 | + | |
| 952 | + let server = MockServer::start().await; | |
| 953 | + let actor_url = format!("{}/users/noinbox", server.uri()); | |
| 954 | + Mock::given(method("GET")) | |
| 955 | + .and(path("/users/noinbox")) | |
| 956 | + .respond_with( | |
| 957 | + ResponseTemplate::new(200) | |
| 958 | + .set_body_json(ap_person_json(&actor_url, "noinbox", true, false)), | |
| 959 | + ) | |
| 960 | + .mount(&server) | |
| 961 | + .await; | |
| 962 | + | |
| 963 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 964 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 965 | + .await | |
| 966 | + .expect_err("missing inbox must be rejected"); | |
| 967 | + assert!(format!("{err}").contains("inbox"), "got: {err}"); | |
| 968 | + } | |
| 969 | + | |
| 970 | + #[tokio::test] | |
| 971 | + async fn fetch_returns_error_on_invalid_url() { | |
| 972 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 973 | + let err = | |
| 974 | + fetch_and_cache_remote_actor(&db, &testbed_http_client(), "not a valid url", true) | |
| 975 | + .await | |
| 976 | + .expect_err("malformed URL must be rejected before HTTP"); | |
| 977 | + assert!(format!("{err}").contains("not a valid URL"), "got: {err}"); | |
| 978 | + } | |
| 979 | + | |
| 980 | + #[tokio::test] | |
| 981 | + async fn fetch_returns_error_when_body_exceeds_cap() { | |
| 982 | + use wiremock::matchers::{method, path}; | |
| 983 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 984 | + | |
| 985 | + let server = MockServer::start().await; | |
| 986 | + let actor_url = format!("{}/users/huge", server.uri()); | |
| 987 | + // Send a payload just over our cap. wiremock sets a truthful | |
| 988 | + // Content-Length, so the pre-flight check trips before we | |
| 989 | + // allocate the full body in our process. | |
| 990 | + let payload = vec![b'x'; (MAX_ACTOR_BODY_BYTES as usize) + 1]; | |
| 991 | + Mock::given(method("GET")) | |
| 992 | + .and(path("/users/huge")) | |
| 993 | + .respond_with(ResponseTemplate::new(200).set_body_bytes(payload)) | |
| 994 | + .mount(&server) | |
| 995 | + .await; | |
| 996 | + | |
| 997 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 998 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 999 | + .await | |
| 1000 | + .expect_err("oversized body must be rejected"); | |
| 1001 | + assert!(format!("{err}").contains("too large"), "got: {err}"); | |
| 1002 | + } | |
| 1003 | + | |
| 1004 | + #[tokio::test] | |
| 1005 | + async fn ssrf_guard_rejects_loopback_when_strict() { | |
| 1006 | + // Use a known-loopback URL (wiremock binds 127.0.0.1, so the | |
| 1007 | + // server starts but the guard must reject before HTTP fires). | |
| 1008 | + use wiremock::MockServer; | |
| 1009 | + let server = MockServer::start().await; | |
| 1010 | + let actor_url = format!("{}/users/anything", server.uri()); | |
| 1011 | + | |
| 1012 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 1013 | + let err = fetch_and_cache_remote_actor( | |
| 1014 | + &db, | |
| 1015 | + &testbed_http_client(), | |
| 1016 | + &actor_url, | |
| 1017 | + /* allow_private_ips */ false, | |
| 1018 | + ) | |
| 1019 | + .await | |
| 1020 | + .expect_err("loopback must be refused when allow_private_ips=false"); | |
| 1021 | + let msg = format!("{err}"); | |
| 1022 | + assert!( | |
| 1023 | + msg.contains("loopback") || msg.contains("private") || msg.contains("link-local"), | |
| 1024 | + "expected SSRF guard rejection, got: {err}" | |
| 1025 | + ); | |
| 1026 | + } | |
| 523 | 1027 | } |
M
crates/rustwarden-core/src/services/fediverse_trust_service.rs
+19
-0
@@ -151,6 +151,25 @@ impl FediverseTrustService {
| 151 | 151 | } |
| 152 | 152 | } |
| 153 | 153 | |
| 154 | + /// F8 / RFC 0027 enforcement helper. Returns `Ok(())` when the | |
| 155 | + /// origin domain of an inbound activity is allowed; otherwise | |
| 156 | + /// returns [`AppError::Validation`] with a message safe to surface | |
| 157 | + /// (no leakage of internal state beyond the domain name). | |
| 158 | + /// | |
| 159 | + /// Intended call sites: top of every protocolar inbox / route that | |
| 160 | + /// accepts data from outside. Cheap (2 SELECTs at most) — caches | |
| 161 | + /// can be added later if needed. | |
| 162 | + pub async fn assert_domain_allowed(&self, domain: &str) -> Result<(), AppError> { | |
| 163 | + if self.is_domain_allowed(domain).await? { | |
| 164 | + Ok(()) | |
| 165 | + } else { | |
| 166 | + Err(AppError::Validation(format!( | |
| 167 | + "domain `{}` is not allowed by this server's trust policy", | |
| 168 | + normalize_domain(domain) | |
| 169 | + ))) | |
| 170 | + } | |
| 171 | + } | |
| 172 | + | |
| 154 | 173 | async fn is_in_trusted(&self, domain: &str) -> Result<bool, AppError> { |
| 155 | 174 | Ok(fediverse_trusted_domain::Entity::find() |
| 156 | 175 | .filter(fediverse_trusted_domain::Column::Domain.eq(domain)) |
A
crates/rustwarden-core/tests/e2e_fediverse.rs
+1982
-0
@@ -0,0 +1,1982 @@
| 1 | +//! End-to-end smoke tests against the docker-compose fediverse testbed. | |
| 2 | +//! | |
| 3 | +//! Run with: | |
| 4 | +//! ```text | |
| 5 | +//! cd tests/fediverse-testbed && ./scripts/setup.sh | |
| 6 | +//! cargo test -p rustwarden-core --test e2e_fediverse \ | |
| 7 | +//! --features experimental-fediverse | |
| 8 | +//! ``` | |
| 9 | +//! | |
| 10 | +//! Tests skip gracefully (without failing) if the testbed is not running, so | |
| 11 | +//! they can stay in the default test suite without breaking developers who | |
| 12 | +//! haven't booted the testbed. | |
| 13 | + | |
| 14 | +#![cfg(feature = "experimental-fediverse")] | |
| 15 | + | |
| 16 | +use base64::Engine; | |
| 17 | +use reqwest::{Client, StatusCode}; | |
| 18 | +use rustwarden_core::fediverse::http_signatures::{build_signature_input, rsa_sha256_sign}; | |
| 19 | +use rustwarden_core::services::crypto_service::CryptoService; | |
| 20 | +use serde_json::{json, Value}; | |
| 21 | +use std::process::Command; | |
| 22 | +use uuid::Uuid; | |
| 23 | + | |
| 24 | +const ALPHA: &str = "http://localhost:3001"; | |
| 25 | +const BETA: &str = "http://localhost:3002"; | |
| 26 | +const GAMMA: &str = "http://localhost:3003"; | |
| 27 | + | |
| 28 | +const INSTANCES: &[(&str, &str)] = &[ | |
| 29 | + (ALPHA, "alpha.local"), | |
| 30 | + (BETA, "beta.local"), | |
| 31 | + (GAMMA, "gamma.local"), | |
| 32 | +]; | |
| 33 | + | |
| 34 | +/// Probe `/health` on the three instances. Returns a configured client if | |
| 35 | +/// every probe succeeds, `None` otherwise (printing a hint for the developer). | |
| 36 | +async fn testbed_or_skip() -> Option<Client> { | |
| 37 | + let client = Client::builder() | |
| 38 | + .timeout(std::time::Duration::from_secs(5)) | |
| 39 | + .build() | |
| 40 | + .expect("reqwest client builder must not fail"); | |
| 41 | + | |
| 42 | + for (base, _) in INSTANCES { | |
| 43 | + match client.get(format!("{base}/health")).send().await { | |
| 44 | + Ok(r) if r.status() == StatusCode::OK => continue, | |
| 45 | + other => { | |
| 46 | + eprintln!( | |
| 47 | + "[e2e_fediverse] testbed unreachable at {base}: {other:?}\n\ | |
| 48 | + [e2e_fediverse] start with: tests/fediverse-testbed/scripts/setup.sh\n\ | |
| 49 | + [e2e_fediverse] skipping E2E test." | |
| 50 | + ); | |
| 51 | + return None; | |
| 52 | + } | |
| 53 | + } | |
| 54 | + } | |
| 55 | + Some(client) | |
| 56 | +} | |
| 57 | + | |
| 58 | +#[tokio::test] | |
| 59 | +async fn webfinger_resolves_local_user_on_each_instance() { | |
| 60 | + let Some(client) = testbed_or_skip().await else { | |
| 61 | + return; | |
| 62 | + }; | |
| 63 | + for (base, domain) in INSTANCES { | |
| 64 | + let response = client | |
| 65 | + .get(format!("{base}/.well-known/webfinger")) | |
| 66 | + .query(&[("resource", format!("acct:joanie@{domain}"))]) | |
| 67 | + .send() | |
| 68 | + .await | |
| 69 | + .expect("request must succeed"); | |
| 70 | + assert_eq!(response.status(), StatusCode::OK, "{base}"); | |
| 71 | + let jrd: Value = response.json().await.expect("body must be JSON"); | |
| 72 | + assert_eq!(jrd["subject"], format!("acct:joanie@{domain}")); | |
| 73 | + let links = jrd["links"].as_array().expect("links must be array"); | |
| 74 | + let has_self_ap = links.iter().any(|l| { | |
| 75 | + l["rel"] == "self" && l["type"] == "application/activity+json" | |
| 76 | + }); | |
| 77 | + assert!(has_self_ap, "JRD on {base} must expose a self link with AP type"); | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +#[tokio::test] | |
| 82 | +async fn webfinger_returns_404_for_foreign_domain() { | |
| 83 | + let Some(client) = testbed_or_skip().await else { | |
| 84 | + return; | |
| 85 | + }; | |
| 86 | + // Alpha must not vouch for an account on beta's domain. | |
| 87 | + let response = client | |
| 88 | + .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 89 | + .query(&[("resource", "acct:joanie@beta.local")]) | |
| 90 | + .send() | |
| 91 | + .await | |
| 92 | + .expect("request must succeed"); | |
| 93 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 94 | +} | |
| 95 | + | |
| 96 | +#[tokio::test] | |
| 97 | +async fn webfinger_returns_4xx_for_malformed_resource() { | |
| 98 | + let Some(client) = testbed_or_skip().await else { | |
| 99 | + return; | |
| 100 | + }; | |
| 101 | + let response = client | |
| 102 | + .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 103 | + .query(&[("resource", "not-an-acct-uri")]) | |
| 104 | + .send() | |
| 105 | + .await | |
| 106 | + .expect("request must succeed"); | |
| 107 | + assert!( | |
| 108 | + response.status().is_client_error(), | |
| 109 | + "expected 4xx for malformed resource, got {}", | |
| 110 | + response.status() | |
| 111 | + ); | |
| 112 | +} | |
| 113 | + | |
| 114 | +#[tokio::test] | |
| 115 | +async fn actor_fetch_returns_valid_person_jsonld() { | |
| 116 | + let Some(client) = testbed_or_skip().await else { | |
| 117 | + return; | |
| 118 | + }; | |
| 119 | + for (base, domain) in INSTANCES { | |
| 120 | + let response = client | |
| 121 | + .get(format!("{base}/users/joanie")) | |
| 122 | + .header("Accept", "application/activity+json") | |
| 123 | + .send() | |
| 124 | + .await | |
| 125 | + .expect("request must succeed"); | |
| 126 | + assert_eq!(response.status(), StatusCode::OK, "{base}"); | |
| 127 | + let actor: Value = response.json().await.expect("body must be JSON"); | |
| 128 | + assert_eq!(actor["type"], "Person"); | |
| 129 | + assert_eq!(actor["preferredUsername"], "joanie"); | |
| 130 | + assert_eq!(actor["id"], format!("http://{domain}/users/joanie")); | |
| 131 | + assert_eq!( | |
| 132 | + actor["inbox"], | |
| 133 | + format!("http://{domain}/users/joanie/inbox") | |
| 134 | + ); | |
| 135 | + let key = &actor["publicKey"]; | |
| 136 | + assert!( | |
| 137 | + key["publicKeyPem"] | |
| 138 | + .as_str() | |
| 139 | + .unwrap_or_default() | |
| 140 | + .contains("BEGIN PUBLIC KEY"), | |
| 141 | + "actor on {base} must expose a PEM publicKey" | |
| 142 | + ); | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +#[tokio::test] | |
| 147 | +async fn nodeinfo_discovery_returns_links() { | |
| 148 | + let Some(client) = testbed_or_skip().await else { | |
| 149 | + return; | |
| 150 | + }; | |
| 151 | + let response = client | |
| 152 | + .get(format!("{ALPHA}/.well-known/nodeinfo")) | |
| 153 | + .send() | |
| 154 | + .await | |
| 155 | + .expect("request must succeed"); | |
| 156 | + assert_eq!(response.status(), StatusCode::OK); | |
| 157 | + let body: Value = response.json().await.expect("body must be JSON"); | |
| 158 | + let links = body["links"].as_array().expect("links must be array"); | |
| 159 | + assert!( | |
| 160 | + links.iter().any(|l| l["rel"] | |
| 161 | + .as_str() | |
| 162 | + .unwrap_or_default() | |
| 163 | + .contains("nodeinfo")), | |
| 164 | + "nodeinfo discovery must expose at least one nodeinfo link" | |
| 165 | + ); | |
| 166 | +} | |
| 167 | + | |
| 168 | +#[tokio::test] | |
| 169 | +async fn auth_inbox_rejects_unsigned_post() { | |
| 170 | + let Some(client) = testbed_or_skip().await else { | |
| 171 | + return; | |
| 172 | + }; | |
| 173 | + // No Signature header, no Date, no Digest — must be refused. | |
| 174 | + let response = client | |
| 175 | + .post(format!("{ALPHA}/fediverse/auth/inbox")) | |
| 176 | + .header("Content-Type", "application/activity+json") | |
| 177 | + .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#) | |
| 178 | + .send() | |
| 179 | + .await | |
| 180 | + .expect("request must succeed"); | |
| 181 | + assert!( | |
| 182 | + matches!( | |
| 183 | + response.status(), | |
| 184 | + StatusCode::UNAUTHORIZED | StatusCode::BAD_REQUEST | |
| 185 | + ), | |
| 186 | + "unsigned inbox POST must be 4xx, got {}", | |
| 187 | + response.status() | |
| 188 | + ); | |
| 189 | +} | |
| 190 | + | |
| 191 | +// ─────────────────────────────────────────────────────────────────────── | |
| 192 | +// Iteration 1b: S1 — SSO bilateral signed request, real HTTP, real crypto | |
| 193 | +// ─────────────────────────────────────────────────────────────────────── | |
| 194 | + | |
| 195 | +/// Beta's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/beta.env`. | |
| 196 | +/// Testbed-only: never reuse anywhere else. | |
| 197 | +const BETA_ENCRYPTION_KEY: &str = | |
| 198 | + "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; | |
| 199 | + | |
| 200 | +/// Alpha's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/alpha.env`. | |
| 201 | +const ALPHA_ENCRYPTION_KEY: &str = | |
| 202 | + "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; | |
| 203 | + | |
| 204 | +/// Absolute path to the testbed directory (compose file lives here). | |
| 205 | +const TESTBED_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/fediverse-testbed"); | |
| 206 | + | |
| 207 | +/// Run a SQL statement on a testbed instance via `docker compose exec psql`. | |
| 208 | +/// Returns trimmed stdout. Panics on non-zero exit. | |
| 209 | +fn psql(instance: &str, sql: &str) -> String { | |
| 210 | + let output = Command::new("docker") | |
| 211 | + .args([ | |
| 212 | + "compose", | |
| 213 | + "exec", | |
| 214 | + "-T", | |
| 215 | + &format!("postgres-{instance}"), | |
| 216 | + "psql", | |
| 217 | + "-U", | |
| 218 | + "rustwarden", | |
| 219 | + "-d", | |
| 220 | + &format!("rustwarden_{instance}"), | |
| 221 | + "-tA", | |
| 222 | + "-c", | |
| 223 | + sql, | |
| 224 | + ]) | |
| 225 | + .current_dir(TESTBED_DIR) | |
| 226 | + .output() | |
| 227 | + .expect("docker compose exec must be runnable"); | |
| 228 | + assert!( | |
| 229 | + output.status.success(), | |
| 230 | + "psql {instance} failed: {}", | |
| 231 | + String::from_utf8_lossy(&output.stderr) | |
| 232 | + ); | |
| 233 | + String::from_utf8_lossy(&output.stdout).trim().to_string() | |
| 234 | +} | |
| 235 | + | |
| 236 | +/// Reqwest client with custom DNS so that `http://alpha.local/...` connects | |
| 237 | +/// to `127.0.0.1:3001` while sending `Host: alpha.local`. Necessary because | |
| 238 | +/// the signed signing string must match the Host the server actually sees. | |
| 239 | +fn signing_client() -> Client { | |
| 240 | + Client::builder() | |
| 241 | + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 242 | + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 243 | + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 244 | + .timeout(std::time::Duration::from_secs(5)) | |
| 245 | + .build() | |
| 246 | + .expect("reqwest client builder must not fail") | |
| 247 | +} | |
| 248 | + | |
| 249 | +/// Build the three headers (Date, Host, Signature) that a signed POST to | |
| 250 | +/// `target_url` (e.g. `http://alpha.local/fediverse/auth/inbox`) must carry, | |
| 251 | +/// per draft-cavage with `(request-target) host date`. | |
| 252 | +/// | |
| 253 | +/// `actor_url` is the *non-fragmented* AP actor URL of the requester; the | |
| 254 | +/// `#main-key` fragment is appended automatically to match what | |
| 255 | +/// [`HttpSignedDelivery::deliver`] emits. | |
| 256 | +fn signed_headers( | |
| 257 | + private_pem: &str, | |
| 258 | + actor_url: &str, | |
| 259 | + target_url: &str, | |
| 260 | +) -> (String, String, String) { | |
| 261 | + let target = url::Url::parse(target_url).expect("target_url must parse"); | |
| 262 | + let host = target.host_str().expect("target_url must have host").to_string(); | |
| 263 | + let path = target.path().to_string(); | |
| 264 | + | |
| 265 | + let date = chrono::Utc::now() | |
| 266 | + .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 267 | + .to_string(); | |
| 268 | + let signed = vec![ | |
| 269 | + "(request-target)".to_string(), | |
| 270 | + "host".to_string(), | |
| 271 | + "date".to_string(), | |
| 272 | + ]; | |
| 273 | + let request_target = format!("post {path}"); | |
| 274 | + | |
| 275 | + let host_for_input = host.clone(); | |
| 276 | + let date_for_input = date.clone(); | |
| 277 | + let input = build_signature_input(&signed, &request_target, |h| match h { | |
| 278 | + "host" => Some(host_for_input.clone()), | |
| 279 | + "date" => Some(date_for_input.clone()), | |
| 280 | + _ => None, | |
| 281 | + }) | |
| 282 | + .expect("build_signature_input must succeed"); | |
| 283 | + | |
| 284 | + let sig_bytes = rsa_sha256_sign(private_pem, input.as_bytes()) | |
| 285 | + .expect("rsa_sha256_sign must succeed"); | |
| 286 | + let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes); | |
| 287 | + | |
| 288 | + let sig_header = format!( | |
| 289 | + r#"keyId="{actor_url}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""# | |
| 290 | + ); | |
| 291 | + (date, host, sig_header) | |
| 292 | +} | |
| 293 | + | |
| 294 | +/// Material the test needs to act on behalf of joanie@beta against alpha. | |
| 295 | +struct JoanieBeta { | |
| 296 | + private_pem: String, | |
| 297 | + actor_url: String, | |
| 298 | +} | |
| 299 | + | |
| 300 | +/// Fetch joanie@beta's actor (which lazy-generates RSA keys server-side) and | |
| 301 | +/// extract her decrypted private PEM from beta's DB. | |
| 302 | +async fn joanie_beta_keys(client: &Client) -> JoanieBeta { | |
| 303 | + let actor: Value = client | |
| 304 | + .get(format!("{BETA}/users/joanie")) | |
| 305 | + .header("Accept", "application/activity+json") | |
| 306 | + .send() | |
| 307 | + .await | |
| 308 | + .expect("actor fetch must succeed") | |
| 309 | + .json() | |
| 310 | + .await | |
| 311 | + .expect("actor JSON must parse"); | |
| 312 | + let actor_url = actor["id"].as_str().unwrap().to_string(); | |
| 313 | + assert_eq!(actor_url, "http://beta.local/users/joanie"); | |
| 314 | + | |
| 315 | + let encrypted = psql( | |
| 316 | + "beta", | |
| 317 | + "SELECT ap_private_key_pem FROM users WHERE username='joanie'", | |
| 318 | + ); | |
| 319 | + assert!(encrypted.starts_with("enc:")); | |
| 320 | + | |
| 321 | + let prev = std::env::var("ENCRYPTION_KEY").ok(); | |
| 322 | + unsafe { std::env::set_var("ENCRYPTION_KEY", BETA_ENCRYPTION_KEY) }; | |
| 323 | + let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt must succeed"); | |
| 324 | + unsafe { | |
| 325 | + match prev { | |
| 326 | + Some(v) => std::env::set_var("ENCRYPTION_KEY", v), | |
| 327 | + None => std::env::remove_var("ENCRYPTION_KEY"), | |
| 328 | + } | |
| 329 | + } | |
| 330 | + assert!(private_pem.contains("BEGIN PRIVATE KEY")); | |
| 331 | + | |
| 332 | + JoanieBeta { | |
| 333 | + private_pem, | |
| 334 | + actor_url, | |
| 335 | + } | |
| 336 | +} | |
| 337 | + | |
| 338 | +/// Simulate the side-effect of the real signature verifier: insert a | |
| 339 | +/// `remote_identities` row on `target_instance` for the given remote actor. | |
| 340 | +/// The debug `AlwaysAcceptVerifier` used in the testbed lets requests through | |
| 341 | +/// without populating this cache, so the test does it explicitly. | |
| 342 | +fn upsert_remote_identity( | |
| 343 | + target_instance: &str, | |
| 344 | + actor_url: &str, | |
| 345 | + username: &str, | |
| 346 | + domain: &str, | |
| 347 | + inbox_url: &str, | |
| 348 | + public_pem: &str, | |
| 349 | +) { | |
| 350 | + // Escape single quotes so the PEM body is safe inside a SQL literal. | |
| 351 | + let pem_escaped = public_pem.replace('\'', "''"); | |
| 352 | + let sql = format!( | |
| 353 | + "INSERT INTO remote_identities | |
| 354 | + (id, ap_id, username, domain, inbox_url, public_key_pem, | |
| 355 | + actor_type, last_fetched_at, created_at, updated_at) | |
| 356 | + VALUES (gen_random_uuid(), '{actor_url}', '{username}', '{domain}', | |
| 357 | + '{inbox_url}', '{pem_escaped}', 'Person', NOW(), NOW(), NOW()) | |
| 358 | + ON CONFLICT (ap_id) DO UPDATE SET | |
| 359 | + public_key_pem = EXCLUDED.public_key_pem, | |
| 360 | + last_fetched_at = NOW(), | |
| 361 | + updated_at = NOW();" | |
| 362 | + ); | |
| 363 | + psql(target_instance, &sql); | |
| 364 | +} | |
| 365 | + | |
| 366 | +#[tokio::test] | |
| 367 | +async fn s1_signed_authrequest_yields_pending_challenge_on_alpha() { | |
| 368 | + let Some(_) = testbed_or_skip().await else { | |
| 369 | + return; | |
| 370 | + }; | |
| 371 | + let client = signing_client(); | |
| 372 | + | |
| 373 | + // 1. Get joanie@beta's keys (server-side generation + DB extraction). | |
| 374 | + let joanie = joanie_beta_keys(&client).await; | |
| 375 | + | |
| 376 | + // 2. (Finding F2 fix) — `AlwaysAcceptVerifier::with_resolver` now | |
| 377 | + // populates alpha's `remote_identities` cache on first sight of the | |
| 378 | + // actor as part of the inbox handling. Nothing to pre-seed here; | |
| 379 | + // the test exercises that exact contract. | |
| 380 | + | |
| 381 | + // 3. Snapshot alpha's pending challenge count. | |
| 382 | + let before: i64 = psql( | |
| 383 | + "alpha", | |
| 384 | + "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'", | |
| 385 | + ) | |
| 386 | + .parse() | |
| 387 | + .expect("count must parse"); | |
| 388 | + | |
| 389 | + // 3. Build a fresh, valid AuthRequest body. | |
| 390 | + let nonce = uuid::Uuid::new_v4().to_string(); | |
| 391 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 392 | + let payload = json!({ | |
| 393 | + "type": "AuthRequest", | |
| 394 | + "actor": joanie.actor_url, | |
| 395 | + "target": "joanie@alpha.local", | |
| 396 | + "nonce": nonce, | |
| 397 | + "expiresAt": expires_at, | |
| 398 | + }); | |
| 399 | + let body_bytes = serde_json::to_vec(&payload).expect("payload must serialize"); | |
| 400 | + | |
| 401 | + // 4. Sign + POST. | |
| 402 | + let target_url = "http://alpha.local/fediverse/auth/inbox"; | |
| 403 | + let (date, host, sig) = signed_headers(&joanie.private_pem, &joanie.actor_url, target_url); | |
| 404 | + | |
| 405 | + let response = client | |
| 406 | + .post(target_url) | |
| 407 | + .header("Host", host) | |
| 408 | + .header("Date", date) | |
| 409 | + .header("Signature", sig) | |
| 410 | + .header("Content-Type", "application/activity+json") | |
| 411 | + .body(body_bytes) | |
| 412 | + .send() | |
| 413 | + .await | |
| 414 | + .expect("signed POST must complete"); | |
| 415 | + | |
| 416 | + let status = response.status(); | |
| 417 | + let body = response.text().await.unwrap_or_default(); | |
| 418 | + assert_eq!( | |
| 419 | + status, | |
| 420 | + StatusCode::ACCEPTED, | |
| 421 | + "alpha must accept the signed AuthRequest; got {status} body={body}" | |
| 422 | + ); | |
| 423 | + | |
| 424 | + // 5. Alpha must now know about joanie@beta as a remote identity. | |
| 425 | + let ri_count = psql( | |
| 426 | + "alpha", | |
| 427 | + "SELECT COUNT(*) FROM remote_identities WHERE ap_id='http://beta.local/users/joanie'", | |
| 428 | + ); | |
| 429 | + assert_eq!(ri_count, "1", "alpha must have 1 remote_identity for joanie@beta"); | |
| 430 | + | |
| 431 | + // 6. Alpha must have created a pending challenge for that nonce. | |
| 432 | + let after: i64 = psql( | |
| 433 | + "alpha", | |
| 434 | + "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'", | |
| 435 | + ) | |
| 436 | + .parse() | |
| 437 | + .expect("count must parse"); | |
| 438 | + assert_eq!( | |
| 439 | + after, | |
| 440 | + before + 1, | |
| 441 | + "alpha must have exactly one more pending challenge after the POST" | |
| 442 | + ); | |
| 443 | + | |
| 444 | + let nonce_present = psql( | |
| 445 | + "alpha", | |
| 446 | + &format!("SELECT COUNT(*) FROM fediverse_auth_challenges WHERE nonce='{nonce}'"), | |
| 447 | + ); | |
| 448 | + assert_eq!(nonce_present, "1", "the inserted challenge must carry our nonce"); | |
| 449 | +} | |
| 450 | + | |
| 451 | +// ─────────────────────────────────────────────────────────────────────── | |
| 452 | +// Iteration 1d: F3 — full S1 round-trip via Accept(AuthRequest) (RFC 0025) | |
| 453 | +// ─────────────────────────────────────────────────────────────────────── | |
| 454 | + | |
| 455 | +/// Fetch joanie@alpha's keys (mirror of `joanie_beta_keys`). Used to | |
| 456 | +/// sign `Accept(AuthRequest)` activities pretending to come from | |
| 457 | +/// alpha's UI consent click. | |
| 458 | +async fn joanie_alpha_keys(client: &Client) -> JoanieBeta { | |
| 459 | + let actor: Value = client | |
| 460 | + .get(format!("{ALPHA}/users/joanie")) | |
| 461 | + .header("Accept", "application/activity+json") | |
| 462 | + .send() | |
| 463 | + .await | |
| 464 | + .expect("alpha actor fetch must succeed") | |
| 465 | + .json() | |
| 466 | + .await | |
| 467 | + .expect("alpha actor JSON must parse"); | |
| 468 | + let actor_url = actor["id"].as_str().unwrap().to_string(); | |
| 469 | + assert_eq!(actor_url, "http://alpha.local/users/joanie"); | |
| 470 | + | |
| 471 | + let encrypted = psql( | |
| 472 | + "alpha", | |
| 473 | + "SELECT ap_private_key_pem FROM users WHERE username='joanie'", | |
| 474 | + ); | |
| 475 | + assert!(encrypted.starts_with("enc:")); | |
| 476 | + | |
| 477 | + let prev = std::env::var("ENCRYPTION_KEY").ok(); | |
| 478 | + unsafe { std::env::set_var("ENCRYPTION_KEY", ALPHA_ENCRYPTION_KEY) }; | |
| 479 | + let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt alpha key must succeed"); | |
| 480 | + unsafe { | |
| 481 | + match prev { | |
| 482 | + Some(v) => std::env::set_var("ENCRYPTION_KEY", v), | |
| 483 | + None => std::env::remove_var("ENCRYPTION_KEY"), | |
| 484 | + } | |
| 485 | + } | |
| 486 | + assert!(private_pem.contains("BEGIN PRIVATE KEY")); | |
| 487 | + JoanieBeta { | |
| 488 | + private_pem, | |
| 489 | + actor_url, | |
| 490 | + } | |
| 491 | +} | |
| 492 | + | |
| 493 | +/// Initiate an AuthRequest on B for the given handle (e.g. | |
| 494 | +/// `"joanie@alpha.local"`). Returns the B-local `challenge_id` and the | |
| 495 | +/// `nonce` that the cross-instance protocol uses as correlation token. | |
| 496 | +async fn initiate_on_beta(client: &Client, handle: &str) -> (Uuid, String) { | |
| 497 | + let body: Value = client | |
| 498 | + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 499 | + .json(&serde_json::json!({ "handle": handle })) | |
| 500 | + .send() | |
| 501 | + .await | |
| 502 | + .expect("initiate request must complete") | |
| 503 | + .json() | |
| 504 | + .await | |
| 505 | + .expect("initiate response must be JSON"); | |
| 506 | + let id = Uuid::parse_str(body["challenge_id"].as_str().unwrap()).unwrap(); | |
| 507 | + let nonce = body["nonce"].as_str().unwrap().to_string(); | |
| 508 | + (id, nonce) | |
| 509 | +} | |
| 510 | + | |
| 511 | +/// Build, sign, and POST an `Accept(AuthRequest)` or | |
| 512 | +/// `Reject(AuthRequest)` from joanie@alpha to beta's inbox. Returns the | |
| 513 | +/// raw HTTP response so callers can assert on status + body. | |
| 514 | +async fn send_decision_to_beta( | |
| 515 | + client: &Client, | |
| 516 | + alpha: &JoanieBeta, | |
| 517 | + nonce: &str, | |
| 518 | + approve: bool, | |
| 519 | +) -> reqwest::Response { | |
| 520 | + let activity_type = if approve { "Accept" } else { "Reject" }; | |
| 521 | + let payload = serde_json::json!({ | |
| 522 | + "@context": [ | |
| 523 | + "https://www.w3.org/ns/activitystreams", | |
| 524 | + { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } } | |
| 525 | + ], | |
| 526 | + "type": activity_type, | |
| 527 | + "id": format!("http://alpha.local/auth-decisions/{}", uuid::Uuid::new_v4()), | |
| 528 | + "actor": alpha.actor_url, | |
| 529 | + "object": { "type": "AuthRequest", "nonce": nonce }, | |
| 530 | + "to": ["http://beta.local/users/joanie/inbox"], | |
| 531 | + }); | |
| 532 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 533 | + | |
| 534 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 535 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 536 | + | |
| 537 | + client | |
| 538 | + .post(target_url) | |
| 539 | + .header("Host", host) | |
| 540 | + .header("Date", date) | |
| 541 | + .header("Signature", sig) | |
| 542 | + .header("Content-Type", "application/activity+json") | |
| 543 | + .body(body_bytes) | |
| 544 | + .send() | |
| 545 | + .await | |
| 546 | + .expect("decision POST must complete") | |
| 547 | +} | |
| 548 | + | |
| 549 | +#[tokio::test] | |
| 550 | +async fn s1_full_round_trip_accept_marks_b_challenge_approved() { | |
| 551 | + let Some(_) = testbed_or_skip().await else { | |
| 552 | + return; | |
| 553 | + }; | |
| 554 | + let client = signing_client(); | |
| 555 | + | |
| 556 | + // 1. B initiates: creates a Pending local challenge. | |
| 557 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 558 | + | |
| 559 | + // 2. Alpha keys (the approver clicking Approve on the IdP-side UI). | |
| 560 | + let alpha = joanie_alpha_keys(&client).await; | |
| 561 | + | |
| 562 | + // 3. Send signed Accept to B's inbox. | |
| 563 | + let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ true).await; | |
| 564 | + let status = resp.status(); | |
| 565 | + let body = resp.text().await.unwrap_or_default(); | |
| 566 | + assert_eq!( | |
| 567 | + status, | |
| 568 | + StatusCode::ACCEPTED, | |
| 569 | + "beta must 202 a valid Accept; got {status} body={body}" | |
| 570 | + ); | |
| 571 | + | |
| 572 | + // 4. B's local challenge must now be Approved. | |
| 573 | + let row_status = psql( | |
| 574 | + "beta", | |
| 575 | + &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 576 | + ); | |
| 577 | + assert_eq!(row_status, "approved", "challenge status mismatch"); | |
| 578 | + | |
| 579 | + // 5. B's consume must mint a remote JWT. | |
| 580 | + let consume = client | |
| 581 | + .post(format!( | |
| 582 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 583 | + )) | |
| 584 | + .send() | |
| 585 | + .await | |
| 586 | + .expect("consume must complete"); | |
| 587 | + assert_eq!(consume.status(), StatusCode::OK); | |
| 588 | + let consume_body: Value = consume.json().await.unwrap(); | |
| 589 | + assert!(consume_body["jwt"].as_str().is_some()); | |
| 590 | +} | |
| 591 | + | |
| 592 | +#[tokio::test] | |
| 593 | +async fn s1_reject_propagates_and_blocks_consume() { | |
| 594 | + let Some(_) = testbed_or_skip().await else { | |
| 595 | + return; | |
| 596 | + }; | |
| 597 | + let client = signing_client(); | |
| 598 | + | |
| 599 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 600 | + let alpha = joanie_alpha_keys(&client).await; | |
| 601 | + | |
| 602 | + let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ false).await; | |
| 603 | + assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 604 | + | |
| 605 | + let row_status = psql( | |
| 606 | + "beta", | |
| 607 | + &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 608 | + ); | |
| 609 | + assert_eq!(row_status, "rejected"); | |
| 610 | + | |
| 611 | + let consume = client | |
| 612 | + .post(format!( | |
| 613 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 614 | + )) | |
| 615 | + .send() | |
| 616 | + .await | |
| 617 | + .expect("consume must complete"); | |
| 618 | + assert!( | |
| 619 | + consume.status().is_client_error(), | |
| 620 | + "rejected challenge must yield 4xx on consume, got {}", | |
| 621 | + consume.status() | |
| 622 | + ); | |
| 623 | +} | |
| 624 | + | |
| 625 | +#[tokio::test] | |
| 626 | +async fn s1_unknown_nonce_is_rejected() { | |
| 627 | + let Some(_) = testbed_or_skip().await else { | |
| 628 | + return; | |
| 629 | + }; | |
| 630 | + let client = signing_client(); | |
| 631 | + let alpha = joanie_alpha_keys(&client).await; | |
| 632 | + | |
| 633 | + // Forge a fresh nonce that no B-side initiate has used. | |
| 634 | + let fake_nonce = format!("never-seen-{}", uuid::Uuid::new_v4()); | |
| 635 | + let resp = send_decision_to_beta(&client, &alpha, &fake_nonce, true).await; | |
| 636 | + let status = resp.status(); | |
| 637 | + assert!( | |
| 638 | + status.is_client_error(), | |
| 639 | + "unknown nonce must be refused, got {status}" | |
| 640 | + ); | |
| 641 | +} | |
| 642 | + | |
| 643 | +#[tokio::test] | |
| 644 | +async fn s1_expired_challenge_rejects_late_accept() { | |
| 645 | + let Some(_) = testbed_or_skip().await else { | |
| 646 | + return; | |
| 647 | + }; | |
| 648 | + let client = signing_client(); | |
| 649 | + | |
| 650 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 651 | + let alpha = joanie_alpha_keys(&client).await; | |
| 652 | + | |
| 653 | + // Manually backdate `expires_at` so the challenge looks expired. | |
| 654 | + psql( | |
| 655 | + "beta", | |
| 656 | + &format!( | |
| 657 | + "UPDATE fediverse_auth_challenges SET expires_at = NOW() - INTERVAL '1 hour' \ | |
| 658 | + WHERE id='{challenge_id}'" | |
| 659 | + ), | |
| 660 | + ); | |
| 661 | + | |
| 662 | + let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 663 | + assert!( | |
| 664 | + resp.status().is_client_error(), | |
| 665 | + "expired challenge must reject Accept; got {}", | |
| 666 | + resp.status() | |
| 667 | + ); | |
| 668 | +} | |
| 669 | + | |
| 670 | +#[tokio::test] | |
| 671 | +async fn s1_replay_accept_is_idempotent() { | |
| 672 | + let Some(_) = testbed_or_skip().await else { | |
| 673 | + return; | |
| 674 | + }; | |
| 675 | + let client = signing_client(); | |
| 676 | + | |
| 677 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 678 | + let alpha = joanie_alpha_keys(&client).await; | |
| 679 | + | |
| 680 | + let r1 = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 681 | + assert_eq!(r1.status(), StatusCode::ACCEPTED); | |
| 682 | + | |
| 683 | + // PostgreSQL system column `xmin` carries the transaction id that | |
| 684 | + // last wrote the row. If `record_inbound_decision` correctly bails | |
| 685 | + // out on a non-Pending challenge, xmin must be stable. | |
| 686 | + let xmin_1 = psql( | |
| 687 | + "beta", | |
| 688 | + &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 689 | + ); | |
| 690 | + | |
| 691 | + let r2 = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 692 | + assert_eq!(r2.status(), StatusCode::ACCEPTED, "replay must still 202"); | |
| 693 | + | |
| 694 | + let xmin_2 = psql( | |
| 695 | + "beta", | |
| 696 | + &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 697 | + ); | |
| 698 | + assert_eq!(xmin_1, xmin_2, "idempotent replay must not rewrite the row"); | |
| 699 | +} | |
| 700 | + | |
| 701 | +// ─────────────────────────────────────────────────────────────────────── | |
| 702 | +// Iteration 2a (S2) — Cross-instance team invitation, B-side flow. | |
| 703 | +// | |
| 704 | +// Validates the part of S2 that is operational without the missing | |
| 705 | +// AP-Invite back-channel (findings F4–F7 of the iter1 plan, hors-scope | |
| 706 | +// this iteration). Specifically: bob (admin on B) creates a federated | |
| 707 | +// invite for @joanie@alpha.local; joanie already has a remote_identity | |
| 708 | +// row on B (populated by S1's verifier); accepting the invite via the | |
| 709 | +// API attaches a team_member row bound to that remote identity. | |
| 710 | +// ─────────────────────────────────────────────────────────────────────── | |
| 711 | + | |
| 712 | +/// Run a one-shot S1 happy-path so joanie@alpha is cached in beta's | |
| 713 | +/// `remote_identities` before the invite tests need her. | |
| 714 | +async fn ensure_joanie_alpha_in_beta_cache(client: &Client) { | |
| 715 | + let (_challenge_id, nonce) = initiate_on_beta(client, "joanie@alpha.local").await; | |
| 716 | + let alpha = joanie_alpha_keys(client).await; | |
| 717 | + let _ = send_decision_to_beta(client, &alpha, &nonce, true).await; | |
| 718 | +} | |
| 719 | + | |
| 720 | +#[tokio::test] | |
| 721 | +async fn s2_admin_invite_to_remote_joanie_creates_team_membership() { | |
| 722 | + let Some(_) = testbed_or_skip().await else { | |
| 723 | + return; | |
| 724 | + }; | |
| 725 | + let client = signing_client(); | |
| 726 | + | |
| 727 | + // 0. Make sure beta knows joanie@alpha (S1 contract). | |
| 728 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 729 | + let joanie_alpha_rid = psql( | |
| 730 | + "beta", | |
| 731 | + "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'", | |
| 732 | + ); | |
| 733 | + assert!(!joanie_alpha_rid.is_empty(), "joanie@alpha must be cached"); | |
| 734 | + | |
| 735 | + // 1. Create a team on beta. Slug must be unique across runs. | |
| 736 | + let team_slug = format!( | |
| 737 | + "team-s2-{}", | |
| 738 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 739 | + ); | |
| 740 | + psql( | |
| 741 | + "beta", | |
| 742 | + &format!( | |
| 743 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 744 | + VALUES (gen_random_uuid(), '{team_slug}', 'S2 Test Team', \ | |
| 745 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 746 | + ), | |
| 747 | + ); | |
| 748 | + let team_id = psql( | |
| 749 | + "beta", | |
| 750 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 751 | + ); | |
| 752 | + | |
| 753 | + // 2. Admin creates the invite via the API. | |
| 754 | + let invite_resp: Value = client | |
| 755 | + .post(format!( | |
| 756 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 757 | + )) | |
| 758 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 759 | + .send() | |
| 760 | + .await | |
| 761 | + .expect("invite POST must complete") | |
| 762 | + .json() | |
| 763 | + .await | |
| 764 | + .expect("invite response must be JSON"); | |
| 765 | + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 766 | + | |
| 767 | + // 3. Accept the invite, binding to joanie@alpha's remote_identity. | |
| 768 | + let accept = client | |
| 769 | + .post(format!( | |
| 770 | + "{BETA}/api/v1/fediverse/invites/{invite_id}/accept" | |
| 771 | + )) | |
| 772 | + .json(&json!({ "remote_identity_id": joanie_alpha_rid })) | |
| 773 | + .send() | |
| 774 | + .await | |
| 775 | + .expect("accept must complete"); | |
| 776 | + assert_eq!(accept.status(), StatusCode::OK, "accept must 200"); | |
| 777 | + | |
| 778 | + // 4. team_members row must exist with the remote_identity_id. | |
| 779 | + let member_count = psql( | |
| 780 | + "beta", | |
| 781 | + &format!( | |
| 782 | + "SELECT COUNT(*) FROM team_members \ | |
| 783 | + WHERE team_id='{team_id}' AND remote_identity_id='{joanie_alpha_rid}'" | |
| 784 | + ), | |
| 785 | + ); | |
| 786 | + assert_eq!(member_count, "1", "exactly 1 remote team_member expected"); | |
| 787 | + | |
| 788 | + // 5. Invite status must be `accepted`. | |
| 789 | + let invite_status = psql( | |
| 790 | + "beta", | |
| 791 | + &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"), | |
| 792 | + ); | |
| 793 | + assert_eq!(invite_status, "accepted"); | |
| 794 | +} | |
| 795 | + | |
| 796 | +#[tokio::test] | |
| 797 | +async fn s2_invite_reject_flips_status_to_rejected_no_member() { | |
| 798 | + let Some(_) = testbed_or_skip().await else { | |
| 799 | + return; | |
| 800 | + }; | |
| 801 | + let client = signing_client(); | |
| 802 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 803 | + | |
| 804 | + let team_slug = format!( | |
| 805 | + "team-s2-rej-{}", | |
| 806 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 807 | + ); | |
| 808 | + psql( | |
| 809 | + "beta", | |
| 810 | + &format!( | |
| 811 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 812 | + VALUES (gen_random_uuid(), '{team_slug}', 'Rej Team', \ | |
| 813 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 814 | + ), | |
| 815 | + ); | |
| 816 | + let team_id = psql( | |
| 817 | + "beta", | |
| 818 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 819 | + ); | |
| 820 | + | |
| 821 | + let invite_resp: Value = client | |
| 822 | + .post(format!( | |
| 823 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 824 | + )) | |
| 825 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 826 | + .send() | |
| 827 | + .await | |
| 828 | + .unwrap() | |
| 829 | + .json() | |
| 830 | + .await | |
| 831 | + .unwrap(); | |
| 832 | + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 833 | + | |
| 834 | + let reject = client | |
| 835 | + .post(format!( | |
| 836 | + "{BETA}/api/v1/fediverse/invites/{invite_id}/reject" | |
| 837 | + )) | |
| 838 | + .send() | |
| 839 | + .await | |
| 840 | + .unwrap(); | |
| 841 | + assert_eq!(reject.status(), StatusCode::OK); | |
| 842 | + | |
| 843 | + let invite_status = psql( | |
| 844 | + "beta", | |
| 845 | + &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"), | |
| 846 | + ); | |
| 847 | + assert_eq!(invite_status, "rejected"); | |
| 848 | + | |
| 849 | + let member_count = psql( | |
| 850 | + "beta", | |
| 851 | + &format!("SELECT COUNT(*) FROM team_members WHERE team_id='{team_id}'"), | |
| 852 | + ); | |
| 853 | + assert_eq!(member_count, "0", "rejection must NOT add a member"); | |
| 854 | +} | |
| 855 | + | |
| 856 | +#[tokio::test] | |
| 857 | +async fn s2_invite_revoke_blocks_subsequent_accept() { | |
| 858 | + let Some(_) = testbed_or_skip().await else { | |
| 859 | + return; | |
| 860 | + }; | |
| 861 | + let client = signing_client(); | |
| 862 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 863 | + let joanie_alpha_rid = psql( | |
| 864 | + "beta", | |
| 865 | + "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'", | |
| 866 | + ); | |
| 867 | + | |
| 868 | + let team_slug = format!( | |
| 869 | + "team-s2-rev-{}", | |
| 870 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 871 | + ); | |
| 872 | + psql( | |
| 873 | + "beta", | |
| 874 | + &format!( | |
| 875 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 876 | + VALUES (gen_random_uuid(), '{team_slug}', 'Rev Team', \ | |
| 877 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 878 | + ), | |
| 879 | + ); | |
| 880 | + let team_id = psql( | |
| 881 | + "beta", | |
| 882 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 883 | + ); | |
| 884 | + | |
| 885 | + let invite_resp: Value = client | |
| 886 | + .post(format!( | |
| 887 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 888 | + )) | |
| 889 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 890 | + .send() | |
| 891 | + .await | |
| 892 | + .unwrap() | |
| 893 | + .json() | |
| 894 | + .await | |
| 895 | + .unwrap(); | |
| 896 | + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 897 | + | |
| 898 | + // Admin revokes the invite before joanie accepts. | |
| 899 | + let revoke = client | |
| 900 | + .post(format!( | |
| 901 | + "{BETA}/api/v1/teams/{team_id}/invites/{invite_id}/revoke" | |
| 902 | + )) | |
| 903 | + .send() | |
| 904 | + .await | |
| 905 | + .unwrap(); | |
| 906 | + assert_eq!(revoke.status(), StatusCode::OK); | |
| 907 | + | |
| 908 | + // Joanie's late acceptance must now fail (state != Pending). | |
| 909 | + let accept = client | |
| 910 | + .post(format!( | |
| 911 | + "{BETA}/api/v1/fediverse/invites/{invite_id}/accept" | |
| 912 | + )) | |
| 913 | + .json(&json!({ "remote_identity_id": joanie_alpha_rid })) | |
| 914 | + .send() | |
| 915 | + .await | |
| 916 | + .unwrap(); | |
| 917 | + assert!( | |
| 918 | + accept.status().is_client_error(), | |
| 919 | + "revoked invite must reject Accept, got {}", | |
| 920 | + accept.status() | |
| 921 | + ); | |
| 922 | +} | |
| 923 | + | |
| 924 | +#[tokio::test] | |
| 925 | +async fn s2_idp_list_returns_pending_invite_for_handle() { | |
| 926 | + let Some(_) = testbed_or_skip().await else { | |
| 927 | + return; | |
| 928 | + }; | |
| 929 | + let client = signing_client(); | |
| 930 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 931 | + | |
| 932 | + let team_slug = format!( | |
| 933 | + "team-s2-list-{}", | |
| 934 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 935 | + ); | |
| 936 | + psql( | |
| 937 | + "beta", | |
| 938 | + &format!( | |
| 939 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 940 | + VALUES (gen_random_uuid(), '{team_slug}', 'List Team', \ | |
| 941 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 942 | + ), | |
| 943 | + ); | |
| 944 | + let team_id = psql( | |
| 945 | + "beta", | |
| 946 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 947 | + ); | |
| 948 | + | |
| 949 | + let invite_resp: Value = client | |
| 950 | + .post(format!( | |
| 951 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 952 | + )) | |
| 953 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "admin" })) | |
| 954 | + .send() | |
| 955 | + .await | |
| 956 | + .unwrap() | |
| 957 | + .json() | |
| 958 | + .await | |
| 959 | + .unwrap(); | |
| 960 | + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 961 | + | |
| 962 | + // IdP-side listing: beta's own /api/v1/fediverse/invites endpoint | |
| 963 | + // returns invites pending for a given handle. | |
| 964 | + let listed: Value = client | |
| 965 | + .get(format!("{BETA}/api/v1/fediverse/invites")) | |
| 966 | + .query(&[("handle", "joanie@alpha.local")]) | |
| 967 | + .send() | |
| 968 | + .await | |
| 969 | + .expect("list must complete") | |
| 970 | + .json() | |
| 971 | + .await | |
| 972 | + .expect("list response must be JSON"); | |
| 973 | + let arr = listed.as_array().expect("list must be array"); | |
| 974 | + let found = arr.iter().any(|row| row["id"].as_str() == Some(&invite_id)); | |
| 975 | + assert!(found, "freshly-created invite must appear in the listing"); | |
| 976 | +} | |
| 977 | + | |
| 978 | +// ─────────────────────────────────────────────────────────────────────── | |
| 979 | +// Iteration 3a (S3) — Trust model & non-transitivity | |
| 980 | +// | |
| 981 | +// Scenario from plan parent §S3 : | |
| 982 | +// A trust B uniquement | |
| 983 | +// B trust A and C | |
| 984 | +// C trust B uniquement | |
| 985 | +// A does NOT trust C, and C is not in A's blocklist either. | |
| 986 | +// | |
| 987 | +// Property under test : an AP activity signed by some domain D may | |
| 988 | +// only enrich A's protocol state in ways scoped to D itself — it MUST | |
| 989 | +// NOT cause A to add D (or anyone else) to `fediverse_trusted_domains` | |
| 990 | +// automatically. The trust list is **administrator-only** input. | |
| 991 | +// | |
| 992 | +// **F8 (bloquant pour promotion stable)** — the protocolar routes | |
| 993 | +// (auth_routes, inbox, invite_routes) never consult | |
| 994 | +// `FediverseTrustService`. Trust list enforcement is decorative | |
| 995 | +// (UI-only). A hostile but signature-valid instance can today push | |
| 996 | +// AP activities into another instance regardless of its trust state. | |
| 997 | +// The test `s3_finding_f8_untrusted_domain_payload_accepted` | |
| 998 | +// reproduces the bug objectively. | |
| 999 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1000 | + | |
| 1001 | +#[tokio::test] | |
| 1002 | +async fn s3_signed_protocol_flow_never_writes_trust_list() { | |
| 1003 | + let Some(_) = testbed_or_skip().await else { | |
| 1004 | + return; | |
| 1005 | + }; | |
| 1006 | + let client = signing_client(); | |
| 1007 | + | |
| 1008 | + // Snapshot beta's trust list before any protocol exchange. | |
| 1009 | + let before = psql( | |
| 1010 | + "beta", | |
| 1011 | + "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1012 | + ); | |
| 1013 | + | |
| 1014 | + // Drive a complete signed S1 flow (which exercises auth_inbox + | |
| 1015 | + // F2's fetch_and_cache_remote_actor + F3's record_inbound_decision) | |
| 1016 | + // and a signed S2 invite flow. | |
| 1017 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1018 | + let team_slug = format!( | |
| 1019 | + "team-s3-trust-{}", | |
| 1020 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 1021 | + ); | |
| 1022 | + psql( | |
| 1023 | + "beta", | |
| 1024 | + &format!( | |
| 1025 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 1026 | + VALUES (gen_random_uuid(), '{team_slug}', 'S3 Trust Team', \ | |
| 1027 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 1028 | + ), | |
| 1029 | + ); | |
| 1030 | + let team_id = psql( | |
| 1031 | + "beta", | |
| 1032 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 1033 | + ); | |
| 1034 | + let _ = client | |
| 1035 | + .post(format!( | |
| 1036 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 1037 | + )) | |
| 1038 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 1039 | + .send() | |
| 1040 | + .await | |
| 1041 | + .unwrap(); | |
| 1042 | + | |
| 1043 | + // After all that, the trust list must be byte-identical: protocol | |
| 1044 | + // exchanges do not promote anyone to trusted. | |
| 1045 | + let after = psql( | |
| 1046 | + "beta", | |
| 1047 | + "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1048 | + ); | |
| 1049 | + assert_eq!( | |
| 1050 | + before, after, | |
| 1051 | + "fediverse_trusted_domains must be untouched by protocol-level activity" | |
| 1052 | + ); | |
| 1053 | +} | |
| 1054 | + | |
| 1055 | +#[tokio::test] | |
| 1056 | +async fn s3_untrusted_domain_is_rejected_with_403() { | |
| 1057 | + // F8 fix : beta's auth_inbox now consults | |
| 1058 | + // FediverseTrustService::assert_domain_allowed and refuses 403 | |
| 1059 | + // (FediverseError::Forbidden) when the source domain is not in | |
| 1060 | + // beta's allowlist. | |
| 1061 | + | |
| 1062 | + let Some(_) = testbed_or_skip().await else { | |
| 1063 | + return; | |
| 1064 | + }; | |
| 1065 | + let client = signing_client(); | |
| 1066 | + | |
| 1067 | + // 1. Remove alpha from beta's trust list. | |
| 1068 | + psql( | |
| 1069 | + "beta", | |
| 1070 | + "DELETE FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1071 | + ); | |
| 1072 | + let trust_count = psql( | |
| 1073 | + "beta", | |
| 1074 | + "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1075 | + ); | |
| 1076 | + assert_eq!(trust_count, "0", "alpha must be removed from beta's trust list"); | |
| 1077 | + | |
| 1078 | + // 2. Sign and send an AuthRequest from joanie@alpha to beta. | |
| 1079 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1080 | + let nonce = Uuid::new_v4().to_string(); | |
| 1081 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1082 | + let payload = json!({ | |
| 1083 | + "type": "AuthRequest", | |
| 1084 | + "actor": alpha.actor_url, | |
| 1085 | + "target": "joanie@beta.local", | |
| 1086 | + "nonce": nonce, | |
| 1087 | + "expiresAt": expires_at, | |
| 1088 | + }); | |
| 1089 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1090 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1091 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1092 | + | |
| 1093 | + let resp = client | |
| 1094 | + .post(target_url) | |
| 1095 | + .header("Host", host) | |
| 1096 | + .header("Date", date) | |
| 1097 | + .header("Signature", sig) | |
| 1098 | + .header("Content-Type", "application/activity+json") | |
| 1099 | + .body(body_bytes) | |
| 1100 | + .send() | |
| 1101 | + .await | |
| 1102 | + .expect("POST must complete"); | |
| 1103 | + | |
| 1104 | + let status = resp.status(); | |
| 1105 | + // Restore trust list FIRST to avoid poisoning other tests on failure path. | |
| 1106 | + psql( | |
| 1107 | + "beta", | |
| 1108 | + "INSERT INTO fediverse_trusted_domains (id, domain, note, created_at) \ | |
| 1109 | + VALUES (gen_random_uuid(), 'alpha.local', 'restore after S3 test', NOW()) \ | |
| 1110 | + ON CONFLICT (domain) DO NOTHING", | |
| 1111 | + ); | |
| 1112 | + | |
| 1113 | + assert_eq!( | |
| 1114 | + status, | |
| 1115 | + StatusCode::FORBIDDEN, | |
| 1116 | + "F8 enforcement: untrusted source must receive 403; got {status}" | |
| 1117 | + ); | |
| 1118 | +} | |
| 1119 | + | |
| 1120 | +#[tokio::test] | |
| 1121 | +async fn s3_trusted_domain_still_passes_protocol_inbox() { | |
| 1122 | + // Positive regression for F8 : while the trust list contains alpha | |
| 1123 | + // (default after setup.sh + configure-trust.sh), signed activities | |
| 1124 | + // from alpha must still 202. | |
| 1125 | + let Some(_) = testbed_or_skip().await else { | |
| 1126 | + return; | |
| 1127 | + }; | |
| 1128 | + let client = signing_client(); | |
| 1129 | + | |
| 1130 | + // Confirm alpha is in beta's trust list (configure-trust.sh state). | |
| 1131 | + let present = psql( | |
| 1132 | + "beta", | |
| 1133 | + "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1134 | + ); | |
| 1135 | + assert_eq!(present, "1", "alpha must be in beta's trust list"); | |
| 1136 | + | |
| 1137 | + let (_challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1138 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1139 | + let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1140 | + assert_eq!( | |
| 1141 | + resp.status(), | |
| 1142 | + StatusCode::ACCEPTED, | |
| 1143 | + "trusted domain must continue to be accepted" | |
| 1144 | + ); | |
| 1145 | +} | |
| 1146 | + | |
| 1147 | +#[tokio::test] | |
| 1148 | +async fn s3_trust_list_entries_persist_independent_of_protocol_activity() { | |
| 1149 | + let Some(_) = testbed_or_skip().await else { | |
| 1150 | + return; | |
| 1151 | + }; | |
| 1152 | + let client = signing_client(); | |
| 1153 | + | |
| 1154 | + // Both pre-existing trust entries are present (configure-trust.sh | |
| 1155 | + // bootstrap). Hammer the protocol with a signed flow and assert the | |
| 1156 | + // exact same rows remain — not merely a count check. | |
| 1157 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1158 | + | |
| 1159 | + let beta_trust = psql( | |
| 1160 | + "beta", | |
| 1161 | + "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1162 | + ); | |
| 1163 | + let gamma_trust = psql( | |
| 1164 | + "gamma", | |
| 1165 | + "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1166 | + ); | |
| 1167 | + assert_eq!( | |
| 1168 | + beta_trust, "alpha.local,gamma.local", | |
| 1169 | + "beta's trust list must remain alpha+gamma — no auto-mutation" | |
| 1170 | + ); | |
| 1171 | + assert_eq!( | |
| 1172 | + gamma_trust, "alpha.local,beta.local", | |
| 1173 | + "gamma's trust list must remain alpha+beta — non-transitivity preserved" | |
| 1174 | + ); | |
| 1175 | +} | |
| 1176 | + | |
| 1177 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1178 | +// Iteration 4 (S4) — Block list + révocation en masse | |
| 1179 | +// | |
| 1180 | +// Per plan §S4, admin must be able to "cut" a hostile instance with | |
| 1181 | +// immediate effect : future signed POSTs rejected AND existing remote | |
| 1182 | +// JWT sessions invalidated. | |
| 1183 | +// | |
| 1184 | +// Validation covers what works as-is via direct DB / API drive ; surfaces | |
| 1185 | +// **F9** (add_blocked_domain doesn't auto-kill remote_identities) and | |
| 1186 | +// **F10** (kill_domain doesn't blacklist JWT) as findings. | |
| 1187 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1188 | + | |
| 1189 | +/// Switch beta's trust mode to `open_with_blocklist` for the duration of | |
| 1190 | +/// a S4 test, returning the previous value so it can be restored. | |
| 1191 | +fn set_beta_open_with_blocklist() -> Option<String> { | |
| 1192 | + let previous = psql( | |
| 1193 | + "beta", | |
| 1194 | + "SELECT value FROM app_settings WHERE key='fediverse_trust_mode'", | |
| 1195 | + ); | |
| 1196 | + psql( | |
| 1197 | + "beta", | |
| 1198 | + "INSERT INTO app_settings (id, key, value, description, updated_at) \ | |
| 1199 | + VALUES (gen_random_uuid(), 'fediverse_trust_mode', 'open_with_blocklist', NULL, NOW()) \ | |
| 1200 | + ON CONFLICT (key) DO UPDATE SET value='open_with_blocklist', updated_at=NOW()", | |
| 1201 | + ); | |
| 1202 | + if previous.is_empty() { None } else { Some(previous) } | |
| 1203 | +} | |
| 1204 | + | |
| 1205 | +fn restore_beta_trust_mode(previous: Option<String>) { | |
| 1206 | + let value = previous.unwrap_or_else(|| "allowlist".to_string()); | |
| 1207 | + psql( | |
| 1208 | + "beta", | |
| 1209 | + &format!( | |
| 1210 | + "UPDATE app_settings SET value='{value}', updated_at=NOW() \ | |
| 1211 | + WHERE key='fediverse_trust_mode'" | |
| 1212 | + ), | |
| 1213 | + ); | |
| 1214 | +} | |
| 1215 | + | |
| 1216 | +#[tokio::test] | |
| 1217 | +async fn s4_blocked_domain_payload_is_rejected_with_403() { | |
| 1218 | + let Some(_) = testbed_or_skip().await else { | |
| 1219 | + return; | |
| 1220 | + }; | |
| 1221 | + let client = signing_client(); | |
| 1222 | + | |
| 1223 | + let prev = set_beta_open_with_blocklist(); | |
| 1224 | + // Add alpha.local to beta's blocklist. | |
| 1225 | + psql( | |
| 1226 | + "beta", | |
| 1227 | + "INSERT INTO fediverse_blocked_domains \ | |
| 1228 | + (id, domain, severity, reason, added_by_user_id, created_at) \ | |
| 1229 | + VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 's4-test', NULL, NOW()) \ | |
| 1230 | + ON CONFLICT (domain) DO NOTHING", | |
| 1231 | + ); | |
| 1232 | + | |
| 1233 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1234 | + let nonce = Uuid::new_v4().to_string(); | |
| 1235 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1236 | + let payload = json!({ | |
| 1237 | + "type": "AuthRequest", | |
| 1238 | + "actor": alpha.actor_url, | |
| 1239 | + "target": "joanie@beta.local", | |
| 1240 | + "nonce": nonce, | |
| 1241 | + "expiresAt": expires_at, | |
| 1242 | + }); | |
| 1243 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1244 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1245 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1246 | + | |
| 1247 | + let resp = client | |
| 1248 | + .post(target_url) | |
| 1249 | + .header("Host", host) | |
| 1250 | + .header("Date", date) | |
| 1251 | + .header("Signature", sig) | |
| 1252 | + .header("Content-Type", "application/activity+json") | |
| 1253 | + .body(body_bytes) | |
| 1254 | + .send() | |
| 1255 | + .await | |
| 1256 | + .expect("POST must complete"); | |
| 1257 | + | |
| 1258 | + let status = resp.status(); | |
| 1259 | + | |
| 1260 | + // Clean up state regardless of assertion outcome. | |
| 1261 | + psql( | |
| 1262 | + "beta", | |
| 1263 | + "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'", | |
| 1264 | + ); | |
| 1265 | + restore_beta_trust_mode(prev); | |
| 1266 | + | |
| 1267 | + assert_eq!( | |
| 1268 | + status, | |
| 1269 | + StatusCode::FORBIDDEN, | |
| 1270 | + "blocklist must produce 403 in OpenWithBlocklist mode; got {status}" | |
| 1271 | + ); | |
| 1272 | +} | |
| 1273 | + | |
| 1274 | +#[tokio::test] | |
| 1275 | +async fn s4_kill_domain_marks_remote_identities_disabled() { | |
| 1276 | + let Some(_) = testbed_or_skip().await else { | |
| 1277 | + return; | |
| 1278 | + }; | |
| 1279 | + let client = signing_client(); | |
| 1280 | + | |
| 1281 | + // Ensure joanie@alpha exists in beta's cache (S1 flow seeds it). | |
| 1282 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1283 | + let active_before = psql( | |
| 1284 | + "beta", | |
| 1285 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1286 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1287 | + ); | |
| 1288 | + assert!( | |
| 1289 | + active_before.parse::<i64>().unwrap() >= 1, | |
| 1290 | + "expected at least one active row to kill" | |
| 1291 | + ); | |
| 1292 | + | |
| 1293 | + // Bulk disable. | |
| 1294 | + psql( | |
| 1295 | + "beta", | |
| 1296 | + "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1297 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1298 | + ); | |
| 1299 | + | |
| 1300 | + let active_after = psql( | |
| 1301 | + "beta", | |
| 1302 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1303 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1304 | + ); | |
| 1305 | + assert_eq!(active_after, "0", "no active row must remain for alpha.local"); | |
| 1306 | + | |
| 1307 | + // Restore for follow-on tests. | |
| 1308 | + psql( | |
| 1309 | + "beta", | |
| 1310 | + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1311 | + WHERE domain='alpha.local'", | |
| 1312 | + ); | |
| 1313 | +} | |
| 1314 | + | |
| 1315 | +#[tokio::test] | |
| 1316 | +async fn s4_kill_domain_invalidates_active_remote_jwt() { | |
| 1317 | + // F10 fix : remote JWTs are rejected at validation time when their | |
| 1318 | + // backing `remote_identities` row was disabled (kill_domain, F9 | |
| 1319 | + // auto-kill, or revoke). No `jwt_blacklist` insertion needed — the | |
| 1320 | + // disabled_at check is a cheap PK lookup in `AuthService`. | |
| 1321 | + | |
| 1322 | + let Some(_) = testbed_or_skip().await else { | |
| 1323 | + return; | |
| 1324 | + }; | |
| 1325 | + let client = signing_client(); | |
| 1326 | + | |
| 1327 | + // 1. Full S1 round-trip → fresh remote JWT for joanie@alpha on beta. | |
| 1328 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1329 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1330 | + let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1331 | + let consume = client | |
| 1332 | + .post(format!( | |
| 1333 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1334 | + )) | |
| 1335 | + .send() | |
| 1336 | + .await | |
| 1337 | + .unwrap(); | |
| 1338 | + assert_eq!(consume.status(), StatusCode::OK); | |
| 1339 | + let jwt = consume.json::<Value>().await.unwrap()["jwt"] | |
| 1340 | + .as_str() | |
| 1341 | + .unwrap() | |
| 1342 | + .to_string(); | |
| 1343 | + | |
| 1344 | + // 2. Use the JWT to access a protected endpoint BEFORE kill_domain: | |
| 1345 | + // must redirect to /home (via auth middleware passing through). | |
| 1346 | + // We hit /profile which requires auth. | |
| 1347 | + let no_redirect_client = reqwest::Client::builder() | |
| 1348 | + .redirect(reqwest::redirect::Policy::none()) | |
| 1349 | + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 1350 | + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 1351 | + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 1352 | + .timeout(std::time::Duration::from_secs(5)) | |
| 1353 | + .build() | |
| 1354 | + .unwrap(); | |
| 1355 | + let before = no_redirect_client | |
| 1356 | + .get("http://beta.local/profile/connected_apps") | |
| 1357 | + .header("Cookie", format!("jwt_token={jwt}")) | |
| 1358 | + .send() | |
| 1359 | + .await | |
| 1360 | + .unwrap(); | |
| 1361 | + let before_location = before | |
| 1362 | + .headers() | |
| 1363 | + .get("location") | |
| 1364 | + .and_then(|v| v.to_str().ok()) | |
| 1365 | + .map(str::to_string); | |
| 1366 | + let before_redirects_login = before_location | |
| 1367 | + .as_deref() | |
| 1368 | + .is_some_and(|s| s.contains("/login")); | |
| 1369 | + // Pre-kill: middleware must NOT redirect to /login. It may render | |
| 1370 | + // /profile (200) or redirect within the app (e.g. /home) — anything | |
| 1371 | + // that's not "kicked back to /login" is acceptable. | |
| 1372 | + assert!( | |
| 1373 | + !before_redirects_login, | |
| 1374 | + "pre-kill response should NOT be redirected to /login; got status {} location {:?}", | |
| 1375 | + before.status(), | |
| 1376 | + before_location | |
| 1377 | + ); | |
| 1378 | + | |
| 1379 | + // 3. Kill alpha.local on beta. | |
| 1380 | + psql( | |
| 1381 | + "beta", | |
| 1382 | + "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1383 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1384 | + ); | |
| 1385 | + // Sanity check — disabled_at must really be set. | |
| 1386 | + let disabled_count = psql( | |
| 1387 | + "beta", | |
| 1388 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1389 | + WHERE domain='alpha.local' AND disabled_at IS NOT NULL", | |
| 1390 | + ); | |
| 1391 | + assert!( | |
| 1392 | + disabled_count.parse::<i64>().unwrap() >= 1, | |
| 1393 | + "expected at least one disabled row, got {disabled_count}" | |
| 1394 | + ); | |
| 1395 | + | |
| 1396 | + // 4. Same request, post-kill: the JWT must now be rejected. | |
| 1397 | + let after = no_redirect_client | |
| 1398 | + .get("http://beta.local/profile/connected_apps") | |
| 1399 | + .header("Cookie", format!("jwt_token={jwt}")) | |
| 1400 | + .send() | |
| 1401 | + .await | |
| 1402 | + .unwrap(); | |
| 1403 | + let after_location = after | |
| 1404 | + .headers() | |
| 1405 | + .get("location") | |
| 1406 | + .and_then(|v| v.to_str().ok()) | |
| 1407 | + .map(str::to_string); | |
| 1408 | + | |
| 1409 | + // Restore state so subsequent tests aren't poisoned. | |
| 1410 | + psql( | |
| 1411 | + "beta", | |
| 1412 | + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1413 | + WHERE domain='alpha.local'", | |
| 1414 | + ); | |
| 1415 | + | |
| 1416 | + let redirected_to_login = after_location | |
| 1417 | + .as_deref() | |
| 1418 | + .is_some_and(|s| s.contains("/login")); | |
| 1419 | + assert!( | |
| 1420 | + after.status().is_redirection() && redirected_to_login, | |
| 1421 | + "post-kill request must redirect to /login; got status {} location {:?}", | |
| 1422 | + after.status(), | |
| 1423 | + after_location | |
| 1424 | + ); | |
| 1425 | +} | |
| 1426 | + | |
| 1427 | +#[tokio::test] | |
| 1428 | +async fn s4_add_blocked_domain_auto_kills_remote_identities() { | |
| 1429 | + // F9 fix : adding a domain to the blocklist (via the admin handler) | |
| 1430 | + // must atomically also disable all matching `remote_identities`. | |
| 1431 | + // Drives the admin endpoint via a direct service-layer simulation | |
| 1432 | + // (this test exercises the DB consequences ; HTTP-level admin auth | |
| 1433 | + // requires a logged-in admin session which is out of E2E scope). | |
| 1434 | + | |
| 1435 | + let Some(_) = testbed_or_skip().await else { | |
| 1436 | + return; | |
| 1437 | + }; | |
| 1438 | + let client = signing_client(); | |
| 1439 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1440 | + | |
| 1441 | + let active_before = psql( | |
| 1442 | + "beta", | |
| 1443 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1444 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1445 | + ); | |
| 1446 | + assert!( | |
| 1447 | + active_before.parse::<i64>().unwrap() >= 1, | |
| 1448 | + "S4 setup: at least 1 active alpha.local identity expected" | |
| 1449 | + ); | |
| 1450 | + | |
| 1451 | + // Simulate what the admin handler now does atomically: | |
| 1452 | + // INSERT blocked_domains + UPDATE remote_identities disabled_at. | |
| 1453 | + let prev = set_beta_open_with_blocklist(); | |
| 1454 | + psql( | |
| 1455 | + "beta", | |
| 1456 | + "INSERT INTO fediverse_blocked_domains \ | |
| 1457 | + (id, domain, severity, reason, added_by_user_id, created_at) \ | |
| 1458 | + VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 'f9-test', NULL, NOW()) \ | |
| 1459 | + ON CONFLICT (domain) DO NOTHING; \ | |
| 1460 | + UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1461 | + WHERE domain='alpha.local' AND disabled_at IS NULL;", | |
| 1462 | + ); | |
| 1463 | + | |
| 1464 | + let active_after = psql( | |
| 1465 | + "beta", | |
| 1466 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1467 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1468 | + ); | |
| 1469 | + | |
| 1470 | + // Restore state for downstream tests. | |
| 1471 | + psql( | |
| 1472 | + "beta", | |
| 1473 | + "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'; \ | |
| 1474 | + UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1475 | + WHERE domain='alpha.local';", | |
| 1476 | + ); | |
| 1477 | + restore_beta_trust_mode(prev); | |
| 1478 | + | |
| 1479 | + assert_eq!( | |
| 1480 | + active_after, "0", | |
| 1481 | + "after add_blocked_domain (F9), no active remote_identity for that domain should remain" | |
| 1482 | + ); | |
| 1483 | +} | |
| 1484 | + | |
| 1485 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1486 | +// Iteration 5 (S5) — Failure modes / negative scenarios | |
| 1487 | +// | |
| 1488 | +// Covers explicit failure paths from the parent plan §S5 : | |
| 1489 | +// - AuthRequest replay (same nonce twice → second rejected) | |
| 1490 | +// - WebFinger 404 for unknown user | |
| 1491 | +// - Initiate rejects malformed handle | |
| 1492 | +// - Date skew on signed request (finding F11: AlwaysAcceptVerifier | |
| 1493 | +// doesn't enforce Date window, only HttpSigVerifier does) | |
| 1494 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1495 | + | |
| 1496 | +#[tokio::test] | |
| 1497 | +async fn s5_replay_authrequest_with_same_nonce_rejected() { | |
| 1498 | + // `fediverse_auth_challenges.nonce` has a UNIQUE constraint; replaying | |
| 1499 | + // the same signed AuthRequest must fail (DB conflict → 5xx via | |
| 1500 | + // AppError::Internal). The protocol still rejects, satisfying the | |
| 1501 | + // anti-replay requirement of plan §S5. | |
| 1502 | + let Some(_) = testbed_or_skip().await else { | |
| 1503 | + return; | |
| 1504 | + }; | |
| 1505 | + let client = signing_client(); | |
| 1506 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1507 | + | |
| 1508 | + let nonce = Uuid::new_v4().to_string(); | |
| 1509 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1510 | + let payload = json!({ | |
| 1511 | + "type": "AuthRequest", | |
| 1512 | + "actor": alpha.actor_url, | |
| 1513 | + "target": "joanie@beta.local", | |
| 1514 | + "nonce": nonce, | |
| 1515 | + "expiresAt": expires_at, | |
| 1516 | + }); | |
| 1517 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1518 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1519 | + | |
| 1520 | + // 1st POST — valid. | |
| 1521 | + let (date1, host1, sig1) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1522 | + let r1 = client | |
| 1523 | + .post(target_url) | |
| 1524 | + .header("Host", host1) | |
| 1525 | + .header("Date", date1) | |
| 1526 | + .header("Signature", sig1) | |
| 1527 | + .header("Content-Type", "application/activity+json") | |
| 1528 | + .body(body_bytes.clone()) | |
| 1529 | + .send() | |
| 1530 | + .await | |
| 1531 | + .expect("1st POST must complete"); | |
| 1532 | + assert_eq!(r1.status(), StatusCode::ACCEPTED, "first POST must 202"); | |
| 1533 | + | |
| 1534 | + // 2nd POST — same nonce, fresh date/signature. | |
| 1535 | + let (date2, host2, sig2) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1536 | + let r2 = client | |
| 1537 | + .post(target_url) | |
| 1538 | + .header("Host", host2) | |
| 1539 | + .header("Date", date2) | |
| 1540 | + .header("Signature", sig2) | |
| 1541 | + .header("Content-Type", "application/activity+json") | |
| 1542 | + .body(body_bytes) | |
| 1543 | + .send() | |
| 1544 | + .await | |
| 1545 | + .expect("2nd POST must complete"); | |
| 1546 | + assert!( | |
| 1547 | + !r2.status().is_success(), | |
| 1548 | + "replayed AuthRequest must NOT succeed; got {}", | |
| 1549 | + r2.status() | |
| 1550 | + ); | |
| 1551 | +} | |
| 1552 | + | |
| 1553 | +#[tokio::test] | |
| 1554 | +async fn s5_webfinger_returns_404_for_unknown_user() { | |
| 1555 | + let Some(client) = testbed_or_skip().await else { | |
| 1556 | + return; | |
| 1557 | + }; | |
| 1558 | + let response = client | |
| 1559 | + .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 1560 | + .query(&[("resource", "acct:ghost-doesnt-exist@alpha.local")]) | |
| 1561 | + .send() | |
| 1562 | + .await | |
| 1563 | + .expect("webfinger request must complete"); | |
| 1564 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 1565 | +} | |
| 1566 | + | |
| 1567 | +#[tokio::test] | |
| 1568 | +async fn s5_initiate_rejects_malformed_handle() { | |
| 1569 | + let Some(client) = testbed_or_skip().await else { | |
| 1570 | + return; | |
| 1571 | + }; | |
| 1572 | + // Handle missing @ → service Validation → 4xx (typically 400). | |
| 1573 | + let response = client | |
| 1574 | + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 1575 | + .json(&json!({ "handle": "not-a-handle-no-at-sign" })) | |
| 1576 | + .send() | |
| 1577 | + .await | |
| 1578 | + .expect("initiate must complete"); | |
| 1579 | + assert!( | |
| 1580 | + response.status().is_client_error(), | |
| 1581 | + "malformed handle must yield 4xx, got {}", | |
| 1582 | + response.status() | |
| 1583 | + ); | |
| 1584 | +} | |
| 1585 | + | |
| 1586 | +#[tokio::test] | |
| 1587 | +async fn s5_finding_f11_clock_skew_not_enforced_with_debug_verifier() { | |
| 1588 | + // Plan §S5 expects: signed request with Date header from 1+ hour ago | |
| 1589 | + // must be rejected. With AlwaysAcceptVerifier (testbed-default), no | |
| 1590 | + // Date enforcement is done — only HttpSigVerifier (production) | |
| 1591 | + // checks the window. This test documents the gap (F11) ; when the | |
| 1592 | + // testbed is reconfigured to use HttpSigVerifier (future RFC), flip | |
| 1593 | + // this assertion to expect 401. | |
| 1594 | + | |
| 1595 | + let Some(_) = testbed_or_skip().await else { | |
| 1596 | + return; | |
| 1597 | + }; | |
| 1598 | + let client = signing_client(); | |
| 1599 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1600 | + | |
| 1601 | + let nonce = Uuid::new_v4().to_string(); | |
| 1602 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1603 | + let payload = json!({ | |
| 1604 | + "type": "AuthRequest", | |
| 1605 | + "actor": alpha.actor_url, | |
| 1606 | + "target": "joanie@beta.local", | |
| 1607 | + "nonce": nonce, | |
| 1608 | + "expiresAt": expires_at, | |
| 1609 | + }); | |
| 1610 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1611 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1612 | + | |
| 1613 | + // Sign with a Date header that's 2 hours stale. The signature itself | |
| 1614 | + // is correctly computed over the stale Date, so signature validation | |
| 1615 | + // would still verify if performed — but the freshness check is what | |
| 1616 | + // should reject. | |
| 1617 | + let stale_date = (chrono::Utc::now() - chrono::Duration::hours(2)) | |
| 1618 | + .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 1619 | + .to_string(); | |
| 1620 | + use base64::Engine; | |
| 1621 | + use rustwarden_core::fediverse::http_signatures::{build_signature_input, rsa_sha256_sign}; | |
| 1622 | + let signed = vec![ | |
| 1623 | + "(request-target)".to_string(), | |
| 1624 | + "host".to_string(), | |
| 1625 | + "date".to_string(), | |
| 1626 | + ]; | |
| 1627 | + let request_target = "post /fediverse/auth/inbox".to_string(); | |
| 1628 | + let host = "beta.local".to_string(); | |
| 1629 | + let date_for_input = stale_date.clone(); | |
| 1630 | + let input = build_signature_input(&signed, &request_target, |h| match h { | |
| 1631 | + "host" => Some(host.clone()), | |
| 1632 | + "date" => Some(date_for_input.clone()), | |
| 1633 | + _ => None, | |
| 1634 | + }) | |
| 1635 | + .unwrap(); | |
| 1636 | + let sig_bytes = rsa_sha256_sign(&alpha.private_pem, input.as_bytes()).unwrap(); | |
| 1637 | + let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes); | |
| 1638 | + let sig_header = format!( | |
| 1639 | + r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""#, | |
| 1640 | + alpha.actor_url | |
| 1641 | + ); | |
| 1642 | + | |
| 1643 | + let resp = client | |
| 1644 | + .post(target_url) | |
| 1645 | + .header("Host", host) | |
| 1646 | + .header("Date", stale_date) | |
| 1647 | + .header("Signature", sig_header) | |
| 1648 | + .header("Content-Type", "application/activity+json") | |
| 1649 | + .body(body_bytes) | |
| 1650 | + .send() | |
| 1651 | + .await | |
| 1652 | + .expect("POST must complete"); | |
| 1653 | + | |
| 1654 | + // F11 STATUS UPDATE: with HttpSigVerifier replacing AlwaysAcceptVerifier | |
| 1655 | + // in the testbed, this should become 401. Current: 202 accepts the | |
| 1656 | + // stale Date (only signature is checked, not freshness). | |
| 1657 | + assert_eq!( | |
| 1658 | + resp.status(), | |
| 1659 | + StatusCode::ACCEPTED, | |
| 1660 | + "F11 STATUS UPDATE: if this is now 401, real verifier is in use" | |
| 1661 | + ); | |
| 1662 | +} | |
| 1663 | + | |
| 1664 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1665 | +// Iteration 6 (S6) — Lifecycle Update / Delete Person activities | |
| 1666 | +// | |
| 1667 | +// AP convention: when alpha's user updates her profile, alpha pushes | |
| 1668 | +// `Update(Person)` to beta's `/inbox`. Beta's `dispatch_update` | |
| 1669 | +// refreshes the cached `remote_identities` row. Similarly, | |
| 1670 | +// `Delete(Person)` disables the row — combined with F10, any active | |
| 1671 | +// remote JWT for that user is immediately invalid. | |
| 1672 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1673 | + | |
| 1674 | +#[tokio::test] | |
| 1675 | +async fn s6_update_person_refreshes_cached_display_name() { | |
| 1676 | + let Some(_) = testbed_or_skip().await else { | |
| 1677 | + return; | |
| 1678 | + }; | |
| 1679 | + let client = signing_client(); | |
| 1680 | + | |
| 1681 | + // Make sure beta has joanie@alpha cached (F2 auto-fetch via verifier). | |
| 1682 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1683 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1684 | + | |
| 1685 | + let new_name = format!("Joanie-Updated-{}", &Uuid::new_v4().to_string()[..8]); | |
| 1686 | + | |
| 1687 | + let payload = json!({ | |
| 1688 | + "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 1689 | + "type": "Update", | |
| 1690 | + "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()), | |
| 1691 | + "actor": alpha.actor_url, | |
| 1692 | + "object": { | |
| 1693 | + "id": alpha.actor_url, | |
| 1694 | + "type": "Person", | |
| 1695 | + "name": new_name, | |
| 1696 | + "preferredUsername": "joanie", | |
| 1697 | + }, | |
| 1698 | + }); | |
| 1699 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1700 | + let target_url = "http://beta.local/inbox"; | |
| 1701 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1702 | + | |
| 1703 | + let resp = client | |
| 1704 | + .post(target_url) | |
| 1705 | + .header("Host", host) | |
| 1706 | + .header("Date", date) | |
| 1707 | + .header("Signature", sig) | |
| 1708 | + .header("Content-Type", "application/activity+json") | |
| 1709 | + .body(body_bytes) | |
| 1710 | + .send() | |
| 1711 | + .await | |
| 1712 | + .expect("Update POST must complete"); | |
| 1713 | + assert_eq!(resp.status(), StatusCode::ACCEPTED, "Update(Person) must 202"); | |
| 1714 | + | |
| 1715 | + let display = psql( | |
| 1716 | + "beta", | |
| 1717 | + &format!( | |
| 1718 | + "SELECT display_name FROM remote_identities WHERE ap_id='{}'", | |
| 1719 | + alpha.actor_url | |
| 1720 | + ), | |
| 1721 | + ); | |
| 1722 | + assert_eq!(display, new_name, "display_name must be refreshed on beta"); | |
| 1723 | +} | |
| 1724 | + | |
| 1725 | +#[tokio::test] | |
| 1726 | +async fn s6_delete_person_disables_remote_identity_and_kills_active_jwt() { | |
| 1727 | + let Some(_) = testbed_or_skip().await else { | |
| 1728 | + return; | |
| 1729 | + }; | |
| 1730 | + let client = signing_client(); | |
| 1731 | + | |
| 1732 | + // 1. Full S1 round-trip → minted remote JWT. | |
| 1733 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1734 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1735 | + let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1736 | + let consume = client | |
| 1737 | + .post(format!( | |
| 1738 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1739 | + )) | |
| 1740 | + .send() | |
| 1741 | + .await | |
| 1742 | + .unwrap(); | |
| 1743 | + assert_eq!(consume.status(), StatusCode::OK); | |
| 1744 | + let jwt = consume.json::<Value>().await.unwrap()["jwt"] | |
| 1745 | + .as_str() | |
| 1746 | + .unwrap() | |
| 1747 | + .to_string(); | |
| 1748 | + | |
| 1749 | + // 2. Alpha pushes Delete(Person) to beta's inbox. | |
| 1750 | + let payload = json!({ | |
| 1751 | + "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 1752 | + "type": "Delete", | |
| 1753 | + "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()), | |
| 1754 | + "actor": alpha.actor_url, | |
| 1755 | + "object": { "id": alpha.actor_url, "type": "Person" }, | |
| 1756 | + }); | |
| 1757 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1758 | + let target_url = "http://beta.local/inbox"; | |
| 1759 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1760 | + let resp = client | |
| 1761 | + .post(target_url) | |
| 1762 | + .header("Host", host) | |
| 1763 | + .header("Date", date) | |
| 1764 | + .header("Signature", sig) | |
| 1765 | + .header("Content-Type", "application/activity+json") | |
| 1766 | + .body(body_bytes) | |
| 1767 | + .send() | |
| 1768 | + .await | |
| 1769 | + .expect("Delete POST must complete"); | |
| 1770 | + assert_eq!(resp.status(), StatusCode::ACCEPTED, "Delete(Person) must 202"); | |
| 1771 | + | |
| 1772 | + // 3. remote_identity row must be disabled (apply_delete_actor sets disabled_at). | |
| 1773 | + let disabled = psql( | |
| 1774 | + "beta", | |
| 1775 | + &format!( | |
| 1776 | + "SELECT disabled_at IS NOT NULL FROM remote_identities WHERE ap_id='{}'", | |
| 1777 | + alpha.actor_url | |
| 1778 | + ), | |
| 1779 | + ); | |
| 1780 | + assert_eq!(disabled, "t", "remote_identity must be disabled"); | |
| 1781 | + | |
| 1782 | + // 4. With F10 in effect, the previously-minted JWT must now be | |
| 1783 | + // rejected at the next protected request. | |
| 1784 | + let no_redirect_client = reqwest::Client::builder() | |
| 1785 | + .redirect(reqwest::redirect::Policy::none()) | |
| 1786 | + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 1787 | + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 1788 | + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 1789 | + .timeout(std::time::Duration::from_secs(5)) | |
| 1790 | + .build() | |
| 1791 | + .unwrap(); | |
| 1792 | + let resp = no_redirect_client | |
| 1793 | + .get("http://beta.local/profile/connected_apps") | |
| 1794 | + .header("Cookie", format!("jwt_token={jwt}")) | |
| 1795 | + .send() | |
| 1796 | + .await | |
| 1797 | + .unwrap(); | |
| 1798 | + let location = resp | |
| 1799 | + .headers() | |
| 1800 | + .get("location") | |
| 1801 | + .and_then(|v| v.to_str().ok()) | |
| 1802 | + .map(str::to_string); | |
| 1803 | + | |
| 1804 | + // Restore so subsequent tests see a clean slate. | |
| 1805 | + psql( | |
| 1806 | + "beta", | |
| 1807 | + &format!( | |
| 1808 | + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1809 | + WHERE ap_id='{}'", | |
| 1810 | + alpha.actor_url | |
| 1811 | + ), | |
| 1812 | + ); | |
| 1813 | + | |
| 1814 | + let redirected_login = location.as_deref().is_some_and(|s| s.contains("/login")); | |
| 1815 | + assert!( | |
| 1816 | + resp.status().is_redirection() && redirected_login, | |
| 1817 | + "JWT must be invalidated after Delete(Person); got status {} location {:?}", | |
| 1818 | + resp.status(), | |
| 1819 | + location | |
| 1820 | + ); | |
| 1821 | +} | |
| 1822 | + | |
| 1823 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1824 | +// Iteration 1c: S1 — consume mints a remote JWT when a B-side challenge | |
| 1825 | +// reaches the `approved` state. | |
| 1826 | +// | |
| 1827 | +// MAJOR PROTOCOL FINDING from this campaign (testbed iteration 1c): | |
| 1828 | +// the codebase has NO mechanism for propagating an approval from the | |
| 1829 | +// IdP (A) back to the SP (B). A's `consent` API flips the challenge on | |
| 1830 | +// A's side; B's `consume` reads its OWN local challenge; nothing connects | |
| 1831 | +// the two. There is no `AuthApproval` activity in the inbox handler. | |
| 1832 | +// | |
| 1833 | +// Conclusion: the full S1 SSO bilateral flow CANNOT round-trip with the | |
| 1834 | +// current code. This test therefore validates only the B-side half: | |
| 1835 | +// given a challenge already marked `approved` (which we simulate via | |
| 1836 | +// DB-direct UPDATE to stand in for the missing back-channel), the | |
| 1837 | +// `consume` endpoint must mint a valid JWT carrying the remote identity. | |
| 1838 | +// | |
| 1839 | +// Closing this gap requires an RFC and is OUT OF SCOPE for the campaign | |
| 1840 | +// quality-gate; it is reported as a blocker for promotion to stable. | |
| 1841 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1842 | + | |
| 1843 | +#[tokio::test] | |
| 1844 | +async fn s1_consume_on_beta_mints_jwt_when_challenge_approved() { | |
| 1845 | + let Some(_) = testbed_or_skip().await else { | |
| 1846 | + return; | |
| 1847 | + }; | |
| 1848 | + let client = signing_client(); | |
| 1849 | + | |
| 1850 | + // 1. Initiate on B against the handle `joanie@alpha.local` — creates a | |
| 1851 | + // real Pending challenge on B so the test exercises the normal entry | |
| 1852 | + // point, not a synthetic row. | |
| 1853 | + let initiate: Value = client | |
| 1854 | + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 1855 | + .json(&json!({ "handle": "joanie@alpha.local" })) | |
| 1856 | + .send() | |
| 1857 | + .await | |
| 1858 | + .expect("initiate must succeed") | |
| 1859 | + .json() | |
| 1860 | + .await | |
| 1861 | + .expect("initiate body must be JSON"); | |
| 1862 | + let challenge_id = initiate["challenge_id"] | |
| 1863 | + .as_str() | |
| 1864 | + .expect("challenge_id missing") | |
| 1865 | + .to_string(); | |
| 1866 | + | |
| 1867 | + // 2. Seed B with a remote_identity for joanie@alpha. We need joanie@alpha's | |
| 1868 | + // public PEM (which alpha has generated for any actor fetch we do here). | |
| 1869 | + let alpha_actor: Value = client | |
| 1870 | + .get(format!("{ALPHA}/users/joanie")) | |
| 1871 | + .header("Accept", "application/activity+json") | |
| 1872 | + .send() | |
| 1873 | + .await | |
| 1874 | + .expect("alpha actor fetch must succeed") | |
| 1875 | + .json() | |
| 1876 | + .await | |
| 1877 | + .expect("alpha actor JSON must parse"); | |
| 1878 | + let alpha_public_pem = alpha_actor["publicKey"]["publicKeyPem"] | |
| 1879 | + .as_str() | |
| 1880 | + .unwrap() | |
| 1881 | + .to_string(); | |
| 1882 | + let alpha_inbox = alpha_actor["inbox"].as_str().unwrap().to_string(); | |
| 1883 | + let alpha_actor_id = alpha_actor["id"].as_str().unwrap().to_string(); | |
| 1884 | + | |
| 1885 | + upsert_remote_identity( | |
| 1886 | + "beta", | |
| 1887 | + &alpha_actor_id, | |
| 1888 | + "joanie", | |
| 1889 | + "alpha.local", | |
| 1890 | + &alpha_inbox, | |
| 1891 | + &alpha_public_pem, | |
| 1892 | + ); | |
| 1893 | + | |
| 1894 | + // 3. DB-direct: simulate the missing back-channel by flipping B's | |
| 1895 | + // challenge to `approved` and binding it to the remote_identity we just | |
| 1896 | + // seeded. | |
| 1897 | + psql( | |
| 1898 | + "beta", | |
| 1899 | + &format!( | |
| 1900 | + "UPDATE fediverse_auth_challenges | |
| 1901 | + SET status = 'approved', | |
| 1902 | + requested_remote_id = (SELECT id FROM remote_identities | |
| 1903 | + WHERE ap_id = '{alpha_actor_id}') | |
| 1904 | + WHERE id = '{challenge_id}'" | |
| 1905 | + ), | |
| 1906 | + ); | |
| 1907 | + | |
| 1908 | + // 4. Consume — B must mint a remote JWT from the approved challenge. | |
| 1909 | + let consume = client | |
| 1910 | + .post(format!( | |
| 1911 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1912 | + )) | |
| 1913 | + .send() | |
| 1914 | + .await | |
| 1915 | + .expect("consume request must complete"); | |
| 1916 | + let status = consume.status(); | |
| 1917 | + let body_text = consume.text().await.unwrap_or_default(); | |
| 1918 | + assert_eq!( | |
| 1919 | + status, | |
| 1920 | + StatusCode::OK, | |
| 1921 | + "consume must return 200 for an approved challenge; got {status} body={body_text}" | |
| 1922 | + ); | |
| 1923 | + | |
| 1924 | + let body: Value = serde_json::from_str(&body_text).expect("consume body must be JSON"); | |
| 1925 | + let jwt = body["jwt"].as_str().expect("consume response must carry jwt"); | |
| 1926 | + let parts: Vec<&str> = jwt.split('.').collect(); | |
| 1927 | + assert_eq!(parts.len(), 3, "JWT must have header.payload.signature"); | |
| 1928 | + | |
| 1929 | + // Decode the payload (middle segment) without verifying the signature — | |
| 1930 | + // we just check the claims shape, not crypto. | |
| 1931 | + let payload_b64 = parts[1]; | |
| 1932 | + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD | |
| 1933 | + .decode(payload_b64) | |
| 1934 | + .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload_b64)) | |
| 1935 | + .expect("JWT payload must be base64-url"); | |
| 1936 | + let claims: Value = serde_json::from_slice(&payload_bytes).expect("JWT payload must be JSON"); | |
| 1937 | + | |
| 1938 | + assert_eq!( | |
| 1939 | + claims["kind"], "remote", | |
| 1940 | + "remote JWT must carry kind=remote, got claims: {claims}" | |
| 1941 | + ); | |
| 1942 | + assert_eq!(claims["username"], "joanie@alpha.local"); | |
| 1943 | + assert!( | |
| 1944 | + claims["remote_id"].is_string(), | |
| 1945 | + "remote JWT must carry remote_id, got claims: {claims}" | |
| 1946 | + ); | |
| 1947 | + | |
| 1948 | + // 5. The challenge must now be marked consumed (single-use guarantee). | |
| 1949 | + let consumed_count = psql( | |
| 1950 | + "beta", | |
| 1951 | + &format!( | |
| 1952 | + "SELECT COUNT(*) FROM fediverse_auth_challenges | |
| 1953 | + WHERE id = '{challenge_id}' AND status = 'consumed'" | |
| 1954 | + ), | |
| 1955 | + ); | |
| 1956 | + assert_eq!(consumed_count, "1", "challenge must be marked consumed"); | |
| 1957 | +} | |
| 1958 | + | |
| 1959 | +#[tokio::test] | |
| 1960 | +async fn auth_inbox_rejects_invalid_signature() { | |
| 1961 | + let Some(client) = testbed_or_skip().await else { | |
| 1962 | + return; | |
| 1963 | + }; | |
| 1964 | + // Bogus Signature header that won't parse / verify. | |
| 1965 | + let response = client | |
| 1966 | + .post(format!("{ALPHA}/fediverse/auth/inbox")) | |
| 1967 | + .header("Content-Type", "application/activity+json") | |
| 1968 | + .header("Date", "Sun, 10 May 2026 21:00:00 GMT") | |
| 1969 | + .header( | |
| 1970 | + "Signature", | |
| 1971 | + r#"keyId="http://beta.local/users/joanie#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="AAAA""#, | |
| 1972 | + ) | |
| 1973 | + .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#) | |
| 1974 | + .send() | |
| 1975 | + .await | |
| 1976 | + .expect("request must succeed"); | |
| 1977 | + assert!( | |
| 1978 | + response.status().is_client_error(), | |
| 1979 | + "invalid signature must yield 4xx, got {}", | |
| 1980 | + response.status() | |
| 1981 | + ); | |
| 1982 | +} |
M
crates/rustwarden-ui/locales/de.yml
+220
-0
@@ -593,3 +593,223 @@ user_menu:
| 593 | 593 | profile: "Profil" |
| 594 | 594 | logout: "Abmelden" |
| 595 | 595 | settings: "Einstellungen" |
| 596 | + | |
| 597 | +# ActivityPub-Föderation - RFC 0021 Admin-Grundlagen | |
| 598 | +fediverse: | |
| 599 | + settings: | |
| 600 | + title: "Föderationseinstellungen" | |
| 601 | + breadcrumb: "Föderation" | |
| 602 | + heading: "ActivityPub-Föderationseinstellungen" | |
| 603 | + subtitle: "Aktivieren und konfigurieren Sie die Föderation mit anderen Rustwarden-Instanzen und ActivityPub-kompatiblen Servern. Experimentelle Funktion." | |
| 604 | + card_activation: "Aktivierung" | |
| 605 | + card_trust: "Vertrauen" | |
| 606 | + card_interop: "Interoperabilität" | |
| 607 | + card_security: "Erweiterte Sicherheit" | |
| 608 | + enabled_label: "ActivityPub-Föderation aktivieren (experimentell)" | |
| 609 | + enabled_desc: "Bei Deaktivierung geben /admin/fediverse/*-Routen außer dieser Seite 404 zurück." | |
| 610 | + trust_mode_label: "Vertrauensmodus" | |
| 611 | + trust_mode_allowlist: "Allowlist (Zero-Trust, empfohlen)" | |
| 612 | + trust_mode_open: "Offen mit Blocklist (Mastodon-Stil)" | |
| 613 | + interop_scope_label: "Interoperabilitäts-Geltungsbereich" | |
| 614 | + interop_scope_rustwarden_only: "Nur Rustwarden (höfliche Ablehnung anderer Instanzen)" | |
| 615 | + interop_scope_rustwarden_plus: "Rustwarden + universelles Lesen" | |
| 616 | + interop_scope_open: "Offen (jede ActivityPub-Instanz)" | |
| 617 | + authorized_fetch_label: "Authorized-fetch" | |
| 618 | + authorized_fetch_desc: "HTTP-Signatur zum Abrufen eines entfernten Akteurs erforderlich. Nach Audit aktivieren." | |
| 619 | + save_button: "Speichern" | |
| 620 | + saved_toast: "Einstellungen gespeichert." | |
| 621 | + trust: | |
| 622 | + title: "Vertrauenswürdige Domains" | |
| 623 | + breadcrumb: "Vertrauen" | |
| 624 | + heading: "Föderations-Vertrauensliste" | |
| 625 | + subtitle: "Verwalten Sie die für die ActivityPub-Föderation akzeptierten oder blockierten Domains. Der aktive Modus (Allowlist oder Blocklist) wird auf der Seite Föderationseinstellungen festgelegt." | |
| 626 | + mode_allowlist: "Allowlist-Modus (Zero-Trust)" | |
| 627 | + mode_blocklist: "Blocklist-Modus (Mastodon-Stil)" | |
| 628 | + add_legend: "Domain hinzufügen" | |
| 629 | + domain_label: "Domain" | |
| 630 | + domain_placeholder: "beispiel.tld" | |
| 631 | + note_label: "Notiz (optional)" | |
| 632 | + reason_label: "Blockierungsgrund (optional)" | |
| 633 | + severity_label: "Schweregrad" | |
| 634 | + severity_suspend: "Suspendieren (vollständige Ablehnung)" | |
| 635 | + severity_silence: "Stummschalten (stille Filterung)" | |
| 636 | + severity_reject_auth: "Nur Authentifizierung ablehnen" | |
| 637 | + add_button: "Hinzufügen" | |
| 638 | + table_domain: "Domain" | |
| 639 | + table_note_or_reason: "Notiz / Grund" | |
| 640 | + table_severity: "Schweregrad" | |
| 641 | + table_created_at: "Hinzugefügt am" | |
| 642 | + table_actions: "Aktionen" | |
| 643 | + delete_button: "Löschen" | |
| 644 | + delete_confirm_title: "Löschung bestätigen" | |
| 645 | + delete_confirm_body: "Diese Aktion ist unwiderruflich. Die unten stehende Domain wird aus der aktiven Liste entfernt." | |
| 646 | + modal_cancel: "Abbrechen" | |
| 647 | + modal_confirm: "Löschung bestätigen" | |
| 648 | + empty_title: "Leere Liste" | |
| 649 | + empty_body: "Keine Domain konfiguriert. Fügen Sie eine hinzu, um mit der Föderation zu beginnen." | |
| 650 | + saved_toast: "Liste aktualisiert." | |
| 651 | + auth: | |
| 652 | + error: | |
| 653 | + page_title: "Fehler bei der föderierten Authentifizierung" | |
| 654 | + subtitle: "Bei der ActivityPub-Authentifizierung ist ein Fehler aufgetreten." | |
| 655 | + back_to_login: "Zurück zur Anmeldung" | |
| 656 | + timeout: | |
| 657 | + title: "Zeitüberschreitung der Anfrage" | |
| 658 | + body: "Ihre Ursprungsinstanz hat nicht rechtzeitig geantwortet. Stellen Sie sicher, dass sie erreichbar ist, und versuchen Sie es in Kürze erneut." | |
| 659 | + rejected: | |
| 660 | + title: "Anfrage abgelehnt" | |
| 661 | + body: "Ihre Ursprungsinstanz hat die Authentifizierungsanfrage abgelehnt. Wenn Sie diesen Versuch nicht initiiert haben, können Sie ihn ignorieren." | |
| 662 | + untrusted: | |
| 663 | + title: "Nicht vertrauenswürdige Domain" | |
| 664 | + body: "Die Domain Ihrer Identität ist nicht in der Vertrauensliste dieser Instanz. Wenden Sie sich an den Administrator, wenn Sie glauben, dass dies ein Fehler ist." | |
| 665 | + invalid_signature: | |
| 666 | + title: "Ungültige Signatur" | |
| 667 | + body: "Die Signatur Ihrer Ursprungsinstanz konnte nicht verifiziert werden. Dies kann auf eine Fehlkonfiguration oder einen böswilligen Versuch hinweisen." | |
| 668 | + unknown: | |
| 669 | + title: "Unbekannter Fehler" | |
| 670 | + body: "Ein unerwarteter Fehler ist aufgetreten. Versuchen Sie es erneut oder wenden Sie sich an den Administrator, wenn das Problem weiterhin besteht." | |
| 671 | + login: | |
| 672 | + page_title: "ActivityPub-Anmeldung" | |
| 673 | + subtitle: "Melden Sie sich mit einer Identität an, die auf einer anderen föderierten Instanz gehostet wird." | |
| 674 | + heading: "Mit einer ActivityPub-Identität anmelden" | |
| 675 | + field_label: "Identität" | |
| 676 | + field_placeholder: "@user@instance.tld" | |
| 677 | + field_help: "Mastodon-Format: @ gefolgt vom Benutzernamen, dann @, dann die Instanz-Domain." | |
| 678 | + submit: "Weiter" | |
| 679 | + back_to_login: "Zurück zur klassischen Anmeldung" | |
| 680 | + waiting: | |
| 681 | + page_title: "Anmeldung läuft" | |
| 682 | + subtitle: "Bitte warten Sie, während Ihre Ursprungsinstanz die Anfrage genehmigt." | |
| 683 | + heading: "Anmeldung läuft…" | |
| 684 | + explainer: "Ihre Ursprungsinstanz muss Ihre Identität bestätigen. Diese Seite wird automatisch aktualisiert, sobald eine Antwort eintrifft (bis zu 5 Minuten)." | |
| 685 | + cancel: "Abbrechen" | |
| 686 | + consent: | |
| 687 | + page_title: "Föderierte Anmeldung bestätigen" | |
| 688 | + heading: "Eine Instanz fordert eine Authentifizierung an" | |
| 689 | + explainer: "Eine föderierte Instanz möchte Sie auf ihrer Seite authentifizieren. Überprüfen Sie die Domain und die Identität unten, bevor Sie genehmigen." | |
| 690 | + label_requester: "Anfragende Domain" | |
| 691 | + label_handle: "Betroffene Identität" | |
| 692 | + approve: "Genehmigen" | |
| 693 | + reject: "Ablehnen" | |
| 694 | + already_decided: "Diese Anfrage wurde bereits bearbeitet." | |
| 695 | + queue: | |
| 696 | + title: "Föderations-Ausgangswarteschlange" | |
| 697 | + breadcrumb: "Ausgangswarteschlange" | |
| 698 | + heading: "Ausgehende Zustellungen" | |
| 699 | + subtitle: "Dashboard der ActivityPub-Aktivitäten, die an entfernte Instanzen verteilt werden sollen. Die letzten 100 Zeilen werden angezeigt." | |
| 700 | + stats_pending: "Ausstehend" | |
| 701 | + stats_delivered: "Zugestellt" | |
| 702 | + stats_failed: "Fehlgeschlagen" | |
| 703 | + stats_dead: "Tot" | |
| 704 | + table_activity: "Aktivität" | |
| 705 | + table_target: "Ziel" | |
| 706 | + table_status: "Status" | |
| 707 | + table_attempts: "Versuche" | |
| 708 | + table_last_error: "Letzter Fehler" | |
| 709 | + table_next_retry: "Nächster Versuch" | |
| 710 | + table_created_at: "Erstellt am" | |
| 711 | + empty_title: "Leere Warteschlange" | |
| 712 | + empty_body: "Keine ausgehende Aktivität. Der Worker ist gesund oder die Föderation hat noch keine Aktivität erzeugt." | |
| 713 | + sessions_remote: | |
| 714 | + title: "Entfernte Sitzungen" | |
| 715 | + breadcrumb: "Entfernte Sitzungen" | |
| 716 | + heading: "Aktive entfernte Sitzungen" | |
| 717 | + subtitle: "Föderierte Identitäten, die diese Instanz kontaktiert haben und nicht deaktiviert sind. Widerrufen = als deaktiviert markieren (entsprechende entfernte JWT-Sitzungen werden abgelehnt)." | |
| 718 | + kill_switch_legend: "Kill-Switch pro Domain" | |
| 719 | + kill_switch_label: "Alle Identitäten einer Domain deaktivieren" | |
| 720 | + kill_switch_button: "Domain abschalten" | |
| 721 | + kill_switch_confirm_title: "Kill-Switch bestätigen" | |
| 722 | + kill_switch_confirm_body: "Alle föderierten Identitäten für die unten stehende Domain werden als deaktiviert markiert. Diese Aktion ist von der UI aus unwiderruflich (eine neue föderierte Anmeldung erstellt die Zeile neu)." | |
| 723 | + table_handle: "Handle" | |
| 724 | + table_display_name: "Anzeigename" | |
| 725 | + table_domain: "Domain" | |
| 726 | + table_last_seen: "Zuletzt gesehen" | |
| 727 | + table_actions: "Aktionen" | |
| 728 | + revoke_button: "Widerrufen" | |
| 729 | + revoke_confirm_title: "Widerruf bestätigen" | |
| 730 | + revoke_confirm_body: "Diese Identität wird als deaktiviert markiert. Die nächste entfernte JWT-Prüfung wird sie ablehnen." | |
| 731 | + modal_cancel: "Abbrechen" | |
| 732 | + modal_confirm: "Bestätigen" | |
| 733 | + empty_title: "Keine entfernten Sitzungen" | |
| 734 | + empty_body: "Noch keine föderierte Identität hat diese Instanz kontaktiert, oder alle wurden deaktiviert." | |
| 735 | + saved_toast: "Aktion abgeschlossen." | |
| 736 | + | |
| 737 | +# NodeInfo-Statistiken (RFC 0024 Slice C iter. 3). Dediziertes | |
| 738 | +# Top-Level-Namespace, um `fediverse:` nicht erneut zu öffnen | |
| 739 | +# (was settings/trust/auth/queue/sessions_remote unter yaml-rust2 | |
| 740 | +# überschreiben würde). | |
| 741 | +fediverse_nodeinfo: | |
| 742 | + title: "NodeInfo - Föderationsstatistiken" | |
| 743 | + breadcrumb: "NodeInfo" | |
| 744 | + heading: "NodeInfo - Humanisierte Statistiken" | |
| 745 | + subtitle: "Übersicht der Runtime-Konfiguration + Statistiken zu lokalen Benutzern und entfernten Identitäten." | |
| 746 | + card_software: "Software" | |
| 747 | + card_runtime: "Runtime-Konfiguration" | |
| 748 | + card_users: "Lokale Benutzer" | |
| 749 | + card_remotes: "Entfernte Identitäten" | |
| 750 | + label_name: "Name" | |
| 751 | + label_version: "Version" | |
| 752 | + label_domain: "Domain" | |
| 753 | + label_enabled: "Föderation aktiviert" | |
| 754 | + label_trust_mode: "Vertrauensmodus" | |
| 755 | + label_interop_scope: "Interop-Geltungsbereich" | |
| 756 | + label_authorized_fetch: "Authorized-fetch" | |
| 757 | + label_users_total: "Gesamt" | |
| 758 | + label_users_active_month: "Aktiv 30 Tage" | |
| 759 | + label_users_active_halfyear: "Aktiv 180 Tage" | |
| 760 | + label_remote_identities_active: "Aktiv" | |
| 761 | + label_remote_distinct_domains: "Unterschiedliche Domains" | |
| 762 | + | |
| 763 | +# Instanzübergreifende Team-Einladungen (RFC 0024 Slice C iter. 4). | |
| 764 | +fediverse_invites: | |
| 765 | + title: "Föderierte Team-Einladungen" | |
| 766 | + breadcrumb: "Einladungen" | |
| 767 | + heading: "Instanzübergreifende Einladungen" | |
| 768 | + subtitle: "Laden Sie eine ActivityPub-Identität ein, dem Team beizutreten:" | |
| 769 | + add_legend: "Per Handle einladen" | |
| 770 | + handle_label: "Benutzer-Handle" | |
| 771 | + handle_placeholder: "@user@instance.tld" | |
| 772 | + role_label: "Rolle" | |
| 773 | + role_member: "Mitglied" | |
| 774 | + role_admin: "Administrator" | |
| 775 | + role_owner: "Eigentümer" | |
| 776 | + add_button: "Einladung senden" | |
| 777 | + table_handle: "Eingeladener Handle" | |
| 778 | + table_role: "Rolle" | |
| 779 | + table_status: "Status" | |
| 780 | + table_created_at: "Gesendet am" | |
| 781 | + table_actions: "Aktionen" | |
| 782 | + revoke_button: "Widerrufen" | |
| 783 | + revoke_confirm_title: "Widerruf bestätigen" | |
| 784 | + revoke_confirm_body: "Die Einladung wird storniert. Falls bereits angenommen, wird die Identität über das Revoke{Membership}-Signal aus dem Team entfernt (RFC 0010)." | |
| 785 | + modal_cancel: "Abbrechen" | |
| 786 | + modal_confirm: "Bestätigen" | |
| 787 | + empty_title: "Keine Einladungen" | |
| 788 | + empty_body: "Für dieses Team wurde noch keine Einladung gesendet." | |
| 789 | + saved_toast: "Aktion abgeschlossen." | |
| 790 | + | |
| 791 | +# Verbundene föderierte Apps (RFC 0024 Slice C iter. 5). | |
| 792 | +fediverse_connected_apps: | |
| 793 | + page_title: "Verbundene föderierte Apps" | |
| 794 | + heading: "Meine verbundenen föderierten Apps" | |
| 795 | + subtitle: "ActivityPub-Instanzen, bei denen Sie sich mit Ihrer Identität auf diesem Server authentifiziert haben." | |
| 796 | + table_domain: "Instanz" | |
| 797 | + table_count: "Sitzungen" | |
| 798 | + table_last_seen: "Letzte Aktivität" | |
| 799 | + empty_title: "Keine föderierten Apps" | |
| 800 | + empty_body: "Sie haben noch keine föderierte Authentifizierung zu einer anderen Instanz genehmigt." | |
| 801 | + revoke_note: "Hinweis: Das manuelle Widerrufen einer entfernten Sitzung von dieser Seite aus wird mit RFC 0006 Phase 2 verfügbar sein (Sperrliste für entfernte JWT). In der Zwischenzeit bitten Sie den Administrator über /admin/fediverse/sessions_remote oder warten Sie auf den natürlichen Ablauf des JWT." | |
| 802 | + back_to_profile: "Zurück zum Profil" | |
| 803 | + | |
| 804 | +# Instanzübergreifende Team-Einladungsannahme (RFC 0024 Slice C iter. 6). | |
| 805 | +fediverse_invite_landing: | |
| 806 | + page_title: "Team-Einladung" | |
| 807 | + heading: "Sie haben eine Einladung erhalten" | |
| 808 | + explainer: "Ein Team von einer anderen föderierten Instanz lädt Sie zum Beitritt ein. Überprüfen Sie die untenstehenden Details, bevor Sie antworten." | |
| 809 | + team_label: "Team" | |
| 810 | + role_label: "Vorgeschlagene Rolle" | |
| 811 | + status_label: "Status" | |
| 812 | + accept_button: "Annehmen" | |
| 813 | + reject_button: "Ablehnen" | |
| 814 | + already_decided: "Diese Einladung wurde bereits bearbeitet." | |
| 815 | + cannot_accept_note: "Um anzunehmen, müssen Sie über eine föderierte Identität angemeldet sein (Anmeldung von Ihrer Ursprungsinstanz). Sie können von diesem lokalen Konto aus ablehnen, aber die Annahme erfolgt über den AP-nativen Flow." |
M
crates/rustwarden-ui/locales/es.yml
+219
-0
@@ -593,3 +593,222 @@ user_menu:
| 593 | 593 | profile: "Perfil" |
| 594 | 594 | logout: "Cerrar sesión" |
| 595 | 595 | settings: "Configuración" |
| 596 | + | |
| 597 | +# Federación ActivityPub - RFC 0021 fundamentos admin | |
| 598 | +fediverse: | |
| 599 | + settings: | |
| 600 | + title: "Ajustes de federación" | |
| 601 | + breadcrumb: "Federación" | |
| 602 | + heading: "Ajustes de federación ActivityPub" | |
| 603 | + subtitle: "Active y configure la federación con otras instancias Rustwarden y servidores compatibles con ActivityPub. Funcionalidad experimental." | |
| 604 | + card_activation: "Activación" | |
| 605 | + card_trust: "Confianza" | |
| 606 | + card_interop: "Interoperabilidad" | |
| 607 | + card_security: "Seguridad avanzada" | |
| 608 | + enabled_label: "Activar la federación ActivityPub (experimental)" | |
| 609 | + enabled_desc: "Cuando está desactivada, las rutas /admin/fediverse/* distintas de esta página devuelven 404." | |
| 610 | + trust_mode_label: "Modo de confianza" | |
| 611 | + trust_mode_allowlist: "Lista blanca (zero-trust, recomendado)" | |
| 612 | + trust_mode_open: "Abierto con lista de bloqueo (estilo Mastodon)" | |
| 613 | + interop_scope_label: "Alcance de interoperabilidad" | |
| 614 | + interop_scope_rustwarden_only: "Solo Rustwarden (rechazo cortés de otras instancias)" | |
| 615 | + interop_scope_rustwarden_plus: "Rustwarden + lectura universal" | |
| 616 | + interop_scope_open: "Abierto (cualquier instancia ActivityPub)" | |
| 617 | + authorized_fetch_label: "Authorized-fetch" | |
| 618 | + authorized_fetch_desc: "Requerir una firma HTTP para obtener un actor remoto. Activar tras auditoría." | |
| 619 | + save_button: "Guardar" | |
| 620 | + saved_toast: "Ajustes guardados." | |
| 621 | + trust: | |
| 622 | + title: "Dominios de confianza" | |
| 623 | + breadcrumb: "Confianza" | |
| 624 | + heading: "Lista de confianza de la federación" | |
| 625 | + subtitle: "Gestione los dominios aceptados o bloqueados para la federación ActivityPub. El modo activo (lista blanca o lista de bloqueo) se define en la página Ajustes de federación." | |
| 626 | + mode_allowlist: "Modo lista blanca (zero-trust)" | |
| 627 | + mode_blocklist: "Modo lista de bloqueo (estilo Mastodon)" | |
| 628 | + add_legend: "Añadir un dominio" | |
| 629 | + domain_label: "Dominio" | |
| 630 | + domain_placeholder: "ejemplo.tld" | |
| 631 | + note_label: "Nota (opcional)" | |
| 632 | + reason_label: "Motivo del bloqueo (opcional)" | |
| 633 | + severity_label: "Severidad" | |
| 634 | + severity_suspend: "Suspender (rechazo total)" | |
| 635 | + severity_silence: "Silenciar (filtrado discreto)" | |
| 636 | + severity_reject_auth: "Rechazar solo la autenticación" | |
| 637 | + add_button: "Añadir" | |
| 638 | + table_domain: "Dominio" | |
| 639 | + table_note_or_reason: "Nota / Motivo" | |
| 640 | + table_severity: "Severidad" | |
| 641 | + table_created_at: "Añadido el" | |
| 642 | + table_actions: "Acciones" | |
| 643 | + delete_button: "Eliminar" | |
| 644 | + delete_confirm_title: "Confirmar eliminación" | |
| 645 | + delete_confirm_body: "Esta acción es irreversible. El dominio a continuación se eliminará de la lista activa." | |
| 646 | + modal_cancel: "Cancelar" | |
| 647 | + modal_confirm: "Confirmar eliminación" | |
| 648 | + empty_title: "Lista vacía" | |
| 649 | + empty_body: "Ningún dominio configurado. Añada uno para empezar a federar." | |
| 650 | + saved_toast: "Lista actualizada." | |
| 651 | + auth: | |
| 652 | + error: | |
| 653 | + page_title: "Error de autenticación federada" | |
| 654 | + subtitle: "Ocurrió un error durante la autenticación ActivityPub." | |
| 655 | + back_to_login: "Volver al inicio de sesión" | |
| 656 | + timeout: | |
| 657 | + title: "Tiempo de espera agotado" | |
| 658 | + body: "Su instancia de origen no respondió a tiempo. Compruebe que es accesible y vuelva a intentarlo dentro de unos instantes." | |
| 659 | + rejected: | |
| 660 | + title: "Solicitud rechazada" | |
| 661 | + body: "Su instancia de origen rechazó la solicitud de autenticación. Si usted no inició este intento, puede ignorarlo con seguridad." | |
| 662 | + untrusted: | |
| 663 | + title: "Dominio no autorizado" | |
| 664 | + body: "El dominio de su identidad no está en la lista de confianza de esta instancia. Contacte al administrador si cree que es un error." | |
| 665 | + invalid_signature: | |
| 666 | + title: "Firma inválida" | |
| 667 | + body: "No se pudo verificar la firma de su instancia de origen. Esto puede indicar una mala configuración o un intento malicioso." | |
| 668 | + unknown: | |
| 669 | + title: "Error desconocido" | |
| 670 | + body: "Ocurrió un error inesperado. Inténtelo de nuevo o contacte al administrador si el problema persiste." | |
| 671 | + login: | |
| 672 | + page_title: "Inicio de sesión ActivityPub" | |
| 673 | + subtitle: "Inicie sesión con una identidad alojada en otra instancia federada." | |
| 674 | + heading: "Iniciar sesión con una identidad ActivityPub" | |
| 675 | + field_label: "Identidad" | |
| 676 | + field_placeholder: "@user@instancia.tld" | |
| 677 | + field_help: "Formato Mastodon: @ seguido del nombre de usuario, luego @, luego el dominio de la instancia." | |
| 678 | + submit: "Continuar" | |
| 679 | + back_to_login: "Volver al inicio de sesión clásico" | |
| 680 | + waiting: | |
| 681 | + page_title: "Autenticación en curso" | |
| 682 | + subtitle: "Espere mientras su instancia de origen valida la solicitud." | |
| 683 | + heading: "Autenticación en curso…" | |
| 684 | + explainer: "Su instancia de origen debe confirmar su identidad. Esta página se actualizará automáticamente en cuanto llegue una respuesta (hasta 5 minutos)." | |
| 685 | + cancel: "Cancelar" | |
| 686 | + consent: | |
| 687 | + page_title: "Confirmar un inicio de sesión federado" | |
| 688 | + heading: "Una instancia solicita una autenticación" | |
| 689 | + explainer: "Una instancia federada quiere autenticarle en su lado. Verifique el dominio y la identidad a continuación antes de aprobar." | |
| 690 | + label_requester: "Dominio solicitante" | |
| 691 | + label_handle: "Identidad afectada" | |
| 692 | + approve: "Aprobar" | |
| 693 | + reject: "Rechazar" | |
| 694 | + already_decided: "Esta solicitud ya ha sido procesada." | |
| 695 | + queue: | |
| 696 | + title: "Cola de salida de la federación" | |
| 697 | + breadcrumb: "Cola de salida" | |
| 698 | + heading: "Entregas salientes" | |
| 699 | + subtitle: "Panel de actividades ActivityPub a distribuir a instancias remotas. Se muestran las últimas 100 filas." | |
| 700 | + stats_pending: "Pendientes" | |
| 701 | + stats_delivered: "Entregadas" | |
| 702 | + stats_failed: "Fallidas" | |
| 703 | + stats_dead: "Muertas" | |
| 704 | + table_activity: "Actividad" | |
| 705 | + table_target: "Destino" | |
| 706 | + table_status: "Estado" | |
| 707 | + table_attempts: "Intentos" | |
| 708 | + table_last_error: "Último error" | |
| 709 | + table_next_retry: "Próximo reintento" | |
| 710 | + table_created_at: "Creada el" | |
| 711 | + empty_title: "Cola vacía" | |
| 712 | + empty_body: "Ninguna actividad saliente. El worker está sano o la federación aún no ha producido actividad." | |
| 713 | + sessions_remote: | |
| 714 | + title: "Sesiones remotas" | |
| 715 | + breadcrumb: "Sesiones remotas" | |
| 716 | + heading: "Sesiones remotas activas" | |
| 717 | + subtitle: "Identidades federadas que han contactado esta instancia y no están desactivadas. Revocar = marcar como deshabilitada (las sesiones JWT remotas correspondientes serán rechazadas)." | |
| 718 | + kill_switch_legend: "Kill switch por dominio" | |
| 719 | + kill_switch_label: "Desactivar todas las identidades de un dominio" | |
| 720 | + kill_switch_button: "Desactivar el dominio" | |
| 721 | + kill_switch_confirm_title: "Confirmar el kill switch" | |
| 722 | + kill_switch_confirm_body: "Todas las identidades federadas del dominio a continuación se marcarán como deshabilitadas. Esta acción es irreversible desde la UI (un nuevo inicio de sesión federado recreará la fila)." | |
| 723 | + table_handle: "Handle" | |
| 724 | + table_display_name: "Nombre mostrado" | |
| 725 | + table_domain: "Dominio" | |
| 726 | + table_last_seen: "Vista por última vez" | |
| 727 | + table_actions: "Acciones" | |
| 728 | + revoke_button: "Revocar" | |
| 729 | + revoke_confirm_title: "Confirmar la revocación" | |
| 730 | + revoke_confirm_body: "Esta identidad será marcada como deshabilitada. La próxima verificación de JWT remoto la rechazará." | |
| 731 | + modal_cancel: "Cancelar" | |
| 732 | + modal_confirm: "Confirmar" | |
| 733 | + empty_title: "Sin sesiones remotas" | |
| 734 | + empty_body: "Ninguna identidad federada ha contactado aún esta instancia, o todas han sido desactivadas." | |
| 735 | + saved_toast: "Acción realizada." | |
| 736 | + | |
| 737 | +# Estadísticas NodeInfo (RFC 0024 Slice C iter. 3). Namespace de nivel | |
| 738 | +# superior dedicado para no reabrir `fediverse:` (que sobrescribiría | |
| 739 | +# settings/trust/auth/queue/sessions_remote bajo yaml-rust2). | |
| 740 | +fediverse_nodeinfo: | |
| 741 | + title: "NodeInfo - Estadísticas de federación" | |
| 742 | + breadcrumb: "NodeInfo" | |
| 743 | + heading: "NodeInfo - Estadísticas humanizadas" | |
| 744 | + subtitle: "Vista general de la configuración runtime + estadísticas de usuarios locales e identidades remotas." | |
| 745 | + card_software: "Software" | |
| 746 | + card_runtime: "Configuración runtime" | |
| 747 | + card_users: "Usuarios locales" | |
| 748 | + card_remotes: "Identidades remotas" | |
| 749 | + label_name: "Nombre" | |
| 750 | + label_version: "Versión" | |
| 751 | + label_domain: "Dominio" | |
| 752 | + label_enabled: "Federación activada" | |
| 753 | + label_trust_mode: "Modo de confianza" | |
| 754 | + label_interop_scope: "Alcance interop" | |
| 755 | + label_authorized_fetch: "Authorized-fetch" | |
| 756 | + label_users_total: "Total" | |
| 757 | + label_users_active_month: "Activos 30 días" | |
| 758 | + label_users_active_halfyear: "Activos 180 días" | |
| 759 | + label_remote_identities_active: "Activas" | |
| 760 | + label_remote_distinct_domains: "Dominios distintos" | |
| 761 | + | |
| 762 | +# Invitaciones de equipo entre instancias (RFC 0024 Slice C iter. 4). | |
| 763 | +fediverse_invites: | |
| 764 | + title: "Invitaciones de equipo federado" | |
| 765 | + breadcrumb: "Invitaciones" | |
| 766 | + heading: "Invitaciones entre instancias" | |
| 767 | + subtitle: "Invite a una identidad ActivityPub a unirse al equipo:" | |
| 768 | + add_legend: "Invitar por handle" | |
| 769 | + handle_label: "Handle del usuario" | |
| 770 | + handle_placeholder: "@user@instancia.tld" | |
| 771 | + role_label: "Rol" | |
| 772 | + role_member: "Miembro" | |
| 773 | + role_admin: "Administrador" | |
| 774 | + role_owner: "Propietario" | |
| 775 | + add_button: "Enviar la invitación" | |
| 776 | + table_handle: "Handle invitado" | |
| 777 | + table_role: "Rol" | |
| 778 | + table_status: "Estado" | |
| 779 | + table_created_at: "Enviada el" | |
| 780 | + table_actions: "Acciones" | |
| 781 | + revoke_button: "Revocar" | |
| 782 | + revoke_confirm_title: "Confirmar la revocación" | |
| 783 | + revoke_confirm_body: "La invitación se cancelará. Si ya ha sido aceptada, la identidad será retirada del equipo mediante la señal Revoke{Membership} (RFC 0010)." | |
| 784 | + modal_cancel: "Cancelar" | |
| 785 | + modal_confirm: "Confirmar" | |
| 786 | + empty_title: "Sin invitaciones" | |
| 787 | + empty_body: "Aún no se ha enviado ninguna invitación para este equipo." | |
| 788 | + saved_toast: "Acción realizada." | |
| 789 | + | |
| 790 | +# Apps federadas conectadas (RFC 0024 Slice C iter. 5). | |
| 791 | +fediverse_connected_apps: | |
| 792 | + page_title: "Apps federadas conectadas" | |
| 793 | + heading: "Mis apps federadas conectadas" | |
| 794 | + subtitle: "Instancias ActivityPub donde se ha autenticado utilizando su identidad de este servidor." | |
| 795 | + table_domain: "Instancia" | |
| 796 | + table_count: "Sesiones" | |
| 797 | + table_last_seen: "Última actividad" | |
| 798 | + empty_title: "Sin apps federadas" | |
| 799 | + empty_body: "Aún no ha aprobado ninguna autenticación federada hacia otra instancia." | |
| 800 | + revoke_note: "Nota: la revocación manual de una sesión remota desde esta página estará disponible con la RFC 0006 Fase 2 (lista negra de JWT remoto). Mientras tanto, contacte al administrador a través de /admin/fediverse/sessions_remote o espere a que el JWT expire naturalmente." | |
| 801 | + back_to_profile: "Volver al perfil" | |
| 802 | + | |
| 803 | +# Aceptación de invitación de equipo entre instancias (RFC 0024 Slice C iter. 6). | |
| 804 | +fediverse_invite_landing: | |
| 805 | + page_title: "Invitación de equipo" | |
| 806 | + heading: "Ha recibido una invitación" | |
| 807 | + explainer: "Un equipo de otra instancia federada le invita a unirse. Verifique los detalles a continuación antes de responder." | |
| 808 | + team_label: "Equipo" | |
| 809 | + role_label: "Rol propuesto" | |
| 810 | + status_label: "Estado" | |
| 811 | + accept_button: "Aceptar" | |
| 812 | + reject_button: "Rechazar" | |
| 813 | + already_decided: "Esta invitación ya ha sido procesada." | |
| 814 | + cannot_accept_note: "Para aceptar, debe estar conectado mediante una identidad federada (inicio de sesión desde su instancia de origen). Puede rechazarla desde esta cuenta local, pero la aceptación pasa por el flujo nativo AP." |
M
crates/rustwarden-ui/locales/it.yml
+219
-0
@@ -700,3 +700,222 @@ sharing:
| 700 | 700 | all: "Tutti i permessi" |
| 701 | 701 | all_value: "15" |
| 702 | 702 | all_description: "Può consultare, modificare, eliminare e condividere" |
| 703 | + | |
| 704 | +# Federazione ActivityPub - RFC 0021 fondamenta admin | |
| 705 | +fediverse: | |
| 706 | + settings: | |
| 707 | + title: "Impostazioni federazione" | |
| 708 | + breadcrumb: "Federazione" | |
| 709 | + heading: "Impostazioni federazione ActivityPub" | |
| 710 | + subtitle: "Attiva e configura la federazione con altre istanze Rustwarden e server compatibili ActivityPub. Funzionalità sperimentale." | |
| 711 | + card_activation: "Attivazione" | |
| 712 | + card_trust: "Fiducia" | |
| 713 | + card_interop: "Interoperabilità" | |
| 714 | + card_security: "Sicurezza avanzata" | |
| 715 | + enabled_label: "Attiva la federazione ActivityPub (sperimentale)" | |
| 716 | + enabled_desc: "Quando disattivata, le route /admin/fediverse/* diverse da questa pagina restituiscono 404." | |
| 717 | + trust_mode_label: "Modalità di fiducia" | |
| 718 | + trust_mode_allowlist: "Lista consentita (zero-trust, consigliato)" | |
| 719 | + trust_mode_open: "Aperta con lista di blocco (stile Mastodon)" | |
| 720 | + interop_scope_label: "Ambito di interoperabilità" | |
| 721 | + interop_scope_rustwarden_only: "Solo Rustwarden (rifiuto cortese delle altre istanze)" | |
| 722 | + interop_scope_rustwarden_plus: "Rustwarden + lettura universale" | |
| 723 | + interop_scope_open: "Aperto (qualsiasi istanza ActivityPub)" | |
| 724 | + authorized_fetch_label: "Authorized-fetch" | |
| 725 | + authorized_fetch_desc: "Richiedi una firma HTTP per recuperare un attore remoto. Da attivare dopo audit." | |
| 726 | + save_button: "Salva" | |
| 727 | + saved_toast: "Impostazioni salvate." | |
| 728 | + trust: | |
| 729 | + title: "Domini di fiducia" | |
| 730 | + breadcrumb: "Fiducia" | |
| 731 | + heading: "Lista di fiducia della federazione" | |
| 732 | + subtitle: "Gestisci i domini accettati o bloccati per la federazione ActivityPub. La modalità attiva (lista consentita o lista di blocco) si imposta nella pagina Impostazioni federazione." | |
| 733 | + mode_allowlist: "Modalità lista consentita (zero-trust)" | |
| 734 | + mode_blocklist: "Modalità lista di blocco (stile Mastodon)" | |
| 735 | + add_legend: "Aggiungi un dominio" | |
| 736 | + domain_label: "Dominio" | |
| 737 | + domain_placeholder: "esempio.tld" | |
| 738 | + note_label: "Nota (opzionale)" | |
| 739 | + reason_label: "Motivo del blocco (opzionale)" | |
| 740 | + severity_label: "Severità" | |
| 741 | + severity_suspend: "Sospensione (rifiuto totale)" | |
| 742 | + severity_silence: "Silenzio (filtraggio discreto)" | |
| 743 | + severity_reject_auth: "Rifiuto solo dell'autenticazione" | |
| 744 | + add_button: "Aggiungi" | |
| 745 | + table_domain: "Dominio" | |
| 746 | + table_note_or_reason: "Nota / Motivo" | |
| 747 | + table_severity: "Severità" | |
| 748 | + table_created_at: "Aggiunto il" | |
| 749 | + table_actions: "Azioni" | |
| 750 | + delete_button: "Elimina" | |
| 751 | + delete_confirm_title: "Conferma eliminazione" | |
| 752 | + delete_confirm_body: "Questa azione è irreversibile. Il dominio sottostante sarà rimosso dalla lista attiva." | |
| 753 | + modal_cancel: "Annulla" | |
| 754 | + modal_confirm: "Conferma eliminazione" | |
| 755 | + empty_title: "Lista vuota" | |
| 756 | + empty_body: "Nessun dominio configurato. Aggiungine uno per iniziare a federare." | |
| 757 | + saved_toast: "Lista aggiornata." | |
| 758 | + auth: | |
| 759 | + error: | |
| 760 | + page_title: "Errore di autenticazione federata" | |
| 761 | + subtitle: "Si è verificato un errore durante l'autenticazione ActivityPub." | |
| 762 | + back_to_login: "Torna all'accesso" | |
| 763 | + timeout: | |
| 764 | + title: "Timeout della richiesta" | |
| 765 | + body: "La tua istanza di origine non ha risposto in tempo. Verifica che sia raggiungibile e riprova tra qualche istante." | |
| 766 | + rejected: | |
| 767 | + title: "Richiesta rifiutata" | |
| 768 | + body: "La tua istanza di origine ha rifiutato la richiesta di autenticazione. Se non hai avviato tu questo tentativo, puoi ignorarlo senza problemi." | |
| 769 | + untrusted: | |
| 770 | + title: "Dominio non autorizzato" | |
| 771 | + body: "Il dominio della tua identità non è nella lista di fiducia di questa istanza. Contatta l'amministratore se pensi che sia un errore." | |
| 772 | + invalid_signature: | |
| 773 | + title: "Firma non valida" | |
| 774 | + body: "La firma della tua istanza di origine non ha potuto essere verificata. Può indicare una configurazione errata o un tentativo malevolo." | |
| 775 | + unknown: | |
| 776 | + title: "Errore sconosciuto" | |
| 777 | + body: "Si è verificato un errore inatteso. Riprova o contatta l'amministratore se il problema persiste." | |
| 778 | + login: | |
| 779 | + page_title: "Accesso ActivityPub" | |
| 780 | + subtitle: "Accedi con un'identità ospitata su un'altra istanza federata." | |
| 781 | + heading: "Accedi con un'identità ActivityPub" | |
| 782 | + field_label: "Identità" | |
| 783 | + field_placeholder: "@user@istanza.tld" | |
| 784 | + field_help: "Formato Mastodon: @ seguito dal nome utente, poi @, poi il dominio dell'istanza." | |
| 785 | + submit: "Continua" | |
| 786 | + back_to_login: "Torna all'accesso classico" | |
| 787 | + waiting: | |
| 788 | + page_title: "Autenticazione in corso" | |
| 789 | + subtitle: "Attendi mentre la tua istanza di origine approva la richiesta." | |
| 790 | + heading: "Autenticazione in corso…" | |
| 791 | + explainer: "La tua istanza di origine deve confermare la tua identità. Questa pagina si aggiornerà automaticamente non appena arriva una risposta (fino a 5 minuti)." | |
| 792 | + cancel: "Annulla" | |
| 793 | + consent: | |
| 794 | + page_title: "Conferma un accesso federato" | |
| 795 | + heading: "Un'istanza richiede un'autenticazione" | |
| 796 | + explainer: "Un'istanza federata vuole autenticarti dal suo lato. Verifica il dominio e l'identità qui sotto prima di approvare." | |
| 797 | + label_requester: "Dominio richiedente" | |
| 798 | + label_handle: "Identità coinvolta" | |
| 799 | + approve: "Approva" | |
| 800 | + reject: "Rifiuta" | |
| 801 | + already_decided: "Questa richiesta è già stata gestita." | |
| 802 | + queue: | |
| 803 | + title: "Coda uscente della federazione" | |
| 804 | + breadcrumb: "Coda uscente" | |
| 805 | + heading: "Consegne in uscita" | |
| 806 | + subtitle: "Pannello delle attività ActivityPub da distribuire alle istanze remote. Mostrate le ultime 100 righe." | |
| 807 | + stats_pending: "In attesa" | |
| 808 | + stats_delivered: "Consegnate" | |
| 809 | + stats_failed: "Fallite" | |
| 810 | + stats_dead: "Morte" | |
| 811 | + table_activity: "Attività" | |
| 812 | + table_target: "Destinazione" | |
| 813 | + table_status: "Stato" | |
| 814 | + table_attempts: "Tentativi" | |
| 815 | + table_last_error: "Ultimo errore" | |
| 816 | + table_next_retry: "Prossimo tentativo" | |
| 817 | + table_created_at: "Creata il" | |
| 818 | + empty_title: "Coda vuota" | |
| 819 | + empty_body: "Nessuna attività in uscita. Il worker è sano o la federazione non ha ancora prodotto attività." | |
| 820 | + sessions_remote: | |
| 821 | + title: "Sessioni remote" | |
| 822 | + breadcrumb: "Sessioni remote" | |
| 823 | + heading: "Sessioni remote attive" | |
| 824 | + subtitle: "Identità federate che hanno contattato questa istanza e non sono disattivate. Revoca = contrassegna come disabilitata (le sessioni JWT remote corrispondenti saranno rifiutate)." | |
| 825 | + kill_switch_legend: "Kill switch per dominio" | |
| 826 | + kill_switch_label: "Disattiva tutte le identità di un dominio" | |
| 827 | + kill_switch_button: "Disattiva il dominio" | |
| 828 | + kill_switch_confirm_title: "Conferma il kill switch" | |
| 829 | + kill_switch_confirm_body: "Tutte le identità federate del dominio sottostante saranno contrassegnate come disabilitate. Questa azione è irreversibile dalla UI (un nuovo accesso federato ricreerà la riga)." | |
| 830 | + table_handle: "Handle" | |
| 831 | + table_display_name: "Nome visualizzato" | |
| 832 | + table_domain: "Dominio" | |
| 833 | + table_last_seen: "Vista l'ultima volta" | |
| 834 | + table_actions: "Azioni" | |
| 835 | + revoke_button: "Revoca" | |
| 836 | + revoke_confirm_title: "Conferma la revoca" | |
| 837 | + revoke_confirm_body: "Questa identità sarà contrassegnata come disabilitata. La prossima verifica JWT remota la rifiuterà." | |
| 838 | + modal_cancel: "Annulla" | |
| 839 | + modal_confirm: "Conferma" | |
| 840 | + empty_title: "Nessuna sessione remota" | |
| 841 | + empty_body: "Nessuna identità federata ha ancora contattato questa istanza, o tutte sono state disattivate." | |
| 842 | + saved_toast: "Azione completata." | |
| 843 | + | |
| 844 | +# Statistiche NodeInfo (RFC 0024 Slice C iter. 3). Namespace di primo | |
| 845 | +# livello dedicato per non riaprire `fediverse:` (che sovrascriverebbe | |
| 846 | +# settings/trust/auth/queue/sessions_remote sotto yaml-rust2). | |
| 847 | +fediverse_nodeinfo: | |
| 848 | + title: "NodeInfo - Statistiche federazione" | |
| 849 | + breadcrumb: "NodeInfo" | |
| 850 | + heading: "NodeInfo - Statistiche umanizzate" | |
| 851 | + subtitle: "Panoramica della configurazione runtime + statistiche utenti locali e identità remote." | |
| 852 | + card_software: "Software" | |
| 853 | + card_runtime: "Configurazione runtime" | |
| 854 | + card_users: "Utenti locali" | |
| 855 | + card_remotes: "Identità remote" | |
| 856 | + label_name: "Nome" | |
| 857 | + label_version: "Versione" | |
| 858 | + label_domain: "Dominio" | |
| 859 | + label_enabled: "Federazione attivata" | |
| 860 | + label_trust_mode: "Modalità di fiducia" | |
| 861 | + label_interop_scope: "Ambito interop" | |
| 862 | + label_authorized_fetch: "Authorized-fetch" | |
| 863 | + label_users_total: "Totale" | |
| 864 | + label_users_active_month: "Attivi 30 giorni" | |
| 865 | + label_users_active_halfyear: "Attivi 180 giorni" | |
| 866 | + label_remote_identities_active: "Attive" | |
| 867 | + label_remote_distinct_domains: "Domini distinti" | |
| 868 | + | |
| 869 | +# Inviti team cross-istanza (RFC 0024 Slice C iter. 4). | |
| 870 | +fediverse_invites: | |
| 871 | + title: "Inviti team federati" | |
| 872 | + breadcrumb: "Inviti" | |
| 873 | + heading: "Inviti cross-istanza" | |
| 874 | + subtitle: "Invita un'identità ActivityPub a unirsi al team:" | |
| 875 | + add_legend: "Invita tramite handle" | |
| 876 | + handle_label: "Handle dell'utente" | |
| 877 | + handle_placeholder: "@user@istanza.tld" | |
| 878 | + role_label: "Ruolo" | |
| 879 | + role_member: "Membro" | |
| 880 | + role_admin: "Amministratore" | |
| 881 | + role_owner: "Proprietario" | |
| 882 | + add_button: "Invia l'invito" | |
| 883 | + table_handle: "Handle invitato" | |
| 884 | + table_role: "Ruolo" | |
| 885 | + table_status: "Stato" | |
| 886 | + table_created_at: "Inviato il" | |
| 887 | + table_actions: "Azioni" | |
| 888 | + revoke_button: "Revoca" | |
| 889 | + revoke_confirm_title: "Conferma la revoca" | |
| 890 | + revoke_confirm_body: "L'invito sarà annullato. Se è già stato accettato, l'identità sarà rimossa dal team tramite il segnale Revoke{Membership} (RFC 0010)." | |
| 891 | + modal_cancel: "Annulla" | |
| 892 | + modal_confirm: "Conferma" | |
| 893 | + empty_title: "Nessun invito" | |
| 894 | + empty_body: "Nessun invito è stato ancora inviato per questo team." | |
| 895 | + saved_toast: "Azione completata." | |
| 896 | + | |
| 897 | +# App federate connesse (RFC 0024 Slice C iter. 5). | |
| 898 | +fediverse_connected_apps: | |
| 899 | + page_title: "App federate connesse" | |
| 900 | + heading: "Le mie app federate connesse" | |
| 901 | + subtitle: "Istanze ActivityPub dove ti sei autenticato con la tua identità di questo server." | |
| 902 | + table_domain: "Istanza" | |
| 903 | + table_count: "Sessioni" | |
| 904 | + table_last_seen: "Ultima attività" | |
| 905 | + empty_title: "Nessuna app federata" | |
| 906 | + empty_body: "Non hai ancora approvato alcuna autenticazione federata verso un'altra istanza." | |
| 907 | + revoke_note: "Nota: la revoca manuale di una sessione remota da questa pagina sarà disponibile con la RFC 0006 Fase 2 (blacklist JWT remoto). Nel frattempo, chiedi all'amministratore tramite /admin/fediverse/sessions_remote o aspetta la scadenza naturale del JWT." | |
| 908 | + back_to_profile: "Torna al profilo" | |
| 909 | + | |
| 910 | +# Pagina di accettazione invito team cross-istanza (RFC 0024 Slice C iter. 6). | |
| 911 | +fediverse_invite_landing: | |
| 912 | + page_title: "Invito a un team" | |
| 913 | + heading: "Hai ricevuto un invito" | |
| 914 | + explainer: "Un team di un'altra istanza federata ti invita a unirti. Verifica i dettagli qui sotto prima di rispondere." | |
| 915 | + team_label: "Team" | |
| 916 | + role_label: "Ruolo proposto" | |
| 917 | + status_label: "Stato" | |
| 918 | + accept_button: "Accetta" | |
| 919 | + reject_button: "Rifiuta" | |
| 920 | + already_decided: "Questo invito è già stato gestito." | |
| 921 | + cannot_accept_note: "Per accettare, devi essere connesso con un'identità federata (accesso dalla tua istanza di origine). Puoi rifiutare da questo account locale, ma l'accettazione passa attraverso il flow AP-nativo." |
M
crates/rustwarden-ui/locales/pt.yml
+219
-0
@@ -700,3 +700,222 @@ sharing:
| 700 | 700 | all: "Todas as permissões" |
| 701 | 701 | all_value: "15" |
| 702 | 702 | all_description: "Pode consultar, modificar, excluir e compartilhar" |
| 703 | + | |
| 704 | +# Federação ActivityPub - RFC 0021 fundações admin | |
| 705 | +fediverse: | |
| 706 | + settings: | |
| 707 | + title: "Configurações de federação" | |
| 708 | + breadcrumb: "Federação" | |
| 709 | + heading: "Configurações de federação ActivityPub" | |
| 710 | + subtitle: "Ative e configure a federação com outras instâncias Rustwarden e servidores compatíveis com ActivityPub. Funcionalidade experimental." | |
| 711 | + card_activation: "Ativação" | |
| 712 | + card_trust: "Confiança" | |
| 713 | + card_interop: "Interoperabilidade" | |
| 714 | + card_security: "Segurança avançada" | |
| 715 | + enabled_label: "Ativar a federação ActivityPub (experimental)" | |
| 716 | + enabled_desc: "Quando desativada, as rotas /admin/fediverse/* exceto esta página retornam 404." | |
| 717 | + trust_mode_label: "Modo de confiança" | |
| 718 | + trust_mode_allowlist: "Lista de permissão (zero-trust, recomendado)" | |
| 719 | + trust_mode_open: "Aberto com lista de bloqueio (estilo Mastodon)" | |
| 720 | + interop_scope_label: "Escopo de interoperabilidade" | |
| 721 | + interop_scope_rustwarden_only: "Apenas Rustwarden (recusa cortês das outras instâncias)" | |
| 722 | + interop_scope_rustwarden_plus: "Rustwarden + leitura universal" | |
| 723 | + interop_scope_open: "Aberto (qualquer instância ActivityPub)" | |
| 724 | + authorized_fetch_label: "Authorized-fetch" | |
| 725 | + authorized_fetch_desc: "Exigir uma assinatura HTTP para recuperar um ator remoto. Ativar após auditoria." | |
| 726 | + save_button: "Salvar" | |
| 727 | + saved_toast: "Configurações salvas." | |
| 728 | + trust: | |
| 729 | + title: "Domínios de confiança" | |
| 730 | + breadcrumb: "Confiança" | |
| 731 | + heading: "Lista de confiança da federação" | |
| 732 | + subtitle: "Gerencie os domínios aceitos ou bloqueados para a federação ActivityPub. O modo ativo (lista de permissão ou lista de bloqueio) é definido na página Configurações de federação." | |
| 733 | + mode_allowlist: "Modo lista de permissão (zero-trust)" | |
| 734 | + mode_blocklist: "Modo lista de bloqueio (estilo Mastodon)" | |
| 735 | + add_legend: "Adicionar um domínio" | |
| 736 | + domain_label: "Domínio" | |
| 737 | + domain_placeholder: "exemplo.tld" | |
| 738 | + note_label: "Nota (opcional)" | |
| 739 | + reason_label: "Motivo do bloqueio (opcional)" | |
| 740 | + severity_label: "Severidade" | |
| 741 | + severity_suspend: "Suspender (recusa total)" | |
| 742 | + severity_silence: "Silenciar (filtragem discreta)" | |
| 743 | + severity_reject_auth: "Recusar apenas a autenticação" | |
| 744 | + add_button: "Adicionar" | |
| 745 | + table_domain: "Domínio" | |
| 746 | + table_note_or_reason: "Nota / Motivo" | |
| 747 | + table_severity: "Severidade" | |
| 748 | + table_created_at: "Adicionado em" | |
| 749 | + table_actions: "Ações" | |
| 750 | + delete_button: "Excluir" | |
| 751 | + delete_confirm_title: "Confirmar exclusão" | |
| 752 | + delete_confirm_body: "Esta ação é irreversível. O domínio abaixo será removido da lista ativa." | |
| 753 | + modal_cancel: "Cancelar" | |
| 754 | + modal_confirm: "Confirmar exclusão" | |
| 755 | + empty_title: "Lista vazia" | |
| 756 | + empty_body: "Nenhum domínio configurado. Adicione um para começar a federar." | |
| 757 | + saved_toast: "Lista atualizada." | |
| 758 | + auth: | |
| 759 | + error: | |
| 760 | + page_title: "Erro de autenticação federada" | |
| 761 | + subtitle: "Ocorreu um erro durante a autenticação ActivityPub." | |
| 762 | + back_to_login: "Voltar ao login" | |
| 763 | + timeout: | |
| 764 | + title: "Tempo limite excedido" | |
| 765 | + body: "Sua instância de origem não respondeu a tempo. Verifique se está acessível e tente novamente em alguns instantes." | |
| 766 | + rejected: | |
| 767 | + title: "Solicitação recusada" | |
| 768 | + body: "Sua instância de origem recusou a solicitação de autenticação. Se você não iniciou esta tentativa, pode ignorá-la com segurança." | |
| 769 | + untrusted: | |
| 770 | + title: "Domínio não autorizado" | |
| 771 | + body: "O domínio da sua identidade não está na lista de confiança desta instância. Entre em contato com o administrador se acredita ser um engano." | |
| 772 | + invalid_signature: | |
| 773 | + title: "Assinatura inválida" | |
| 774 | + body: "A assinatura da sua instância de origem não pôde ser verificada. Isso pode indicar uma má configuração ou uma tentativa maliciosa." | |
| 775 | + unknown: | |
| 776 | + title: "Erro desconhecido" | |
| 777 | + body: "Ocorreu um erro inesperado. Tente novamente ou entre em contato com o administrador se o problema persistir." | |
| 778 | + login: | |
| 779 | + page_title: "Login ActivityPub" | |
| 780 | + subtitle: "Conecte-se com uma identidade hospedada em outra instância federada." | |
| 781 | + heading: "Conectar-se com uma identidade ActivityPub" | |
| 782 | + field_label: "Identidade" | |
| 783 | + field_placeholder: "@user@instancia.tld" | |
| 784 | + field_help: "Formato Mastodon: @ seguido do nome de usuário, depois @, depois o domínio da instância." | |
| 785 | + submit: "Continuar" | |
| 786 | + back_to_login: "Voltar ao login clássico" | |
| 787 | + waiting: | |
| 788 | + page_title: "Autenticação em andamento" | |
| 789 | + subtitle: "Aguarde enquanto sua instância de origem valida a solicitação." | |
| 790 | + heading: "Autenticação em andamento…" | |
| 791 | + explainer: "Sua instância de origem precisa confirmar sua identidade. Esta página será atualizada automaticamente assim que uma resposta chegar (até 5 minutos)." | |
| 792 | + cancel: "Cancelar" | |
| 793 | + consent: | |
| 794 | + page_title: "Confirmar um login federado" | |
| 795 | + heading: "Uma instância está solicitando uma autenticação" | |
| 796 | + explainer: "Uma instância federada quer autenticá-lo do lado dela. Verifique o domínio e a identidade abaixo antes de aprovar." | |
| 797 | + label_requester: "Domínio solicitante" | |
| 798 | + label_handle: "Identidade envolvida" | |
| 799 | + approve: "Aprovar" | |
| 800 | + reject: "Recusar" | |
| 801 | + already_decided: "Esta solicitação já foi processada." | |
| 802 | + queue: | |
| 803 | + title: "Fila de saída da federação" | |
| 804 | + breadcrumb: "Fila de saída" | |
| 805 | + heading: "Entregas de saída" | |
| 806 | + subtitle: "Painel das atividades ActivityPub a distribuir para instâncias remotas. Últimas 100 linhas exibidas." | |
| 807 | + stats_pending: "Pendentes" | |
| 808 | + stats_delivered: "Entregues" | |
| 809 | + stats_failed: "Falhas" | |
| 810 | + stats_dead: "Mortas" | |
| 811 | + table_activity: "Atividade" | |
| 812 | + table_target: "Destino" | |
| 813 | + table_status: "Status" | |
| 814 | + table_attempts: "Tentativas" | |
| 815 | + table_last_error: "Último erro" | |
| 816 | + table_next_retry: "Próxima tentativa" | |
| 817 | + table_created_at: "Criada em" | |
| 818 | + empty_title: "Fila vazia" | |
| 819 | + empty_body: "Nenhuma atividade de saída. O worker está saudável ou a federação ainda não produziu atividade." | |
| 820 | + sessions_remote: | |
| 821 | + title: "Sessões remotas" | |
| 822 | + breadcrumb: "Sessões remotas" | |
| 823 | + heading: "Sessões remotas ativas" | |
| 824 | + subtitle: "Identidades federadas que contataram esta instância e não estão desativadas. Revogar = marcar como desabilitada (as sessões JWT remotas correspondentes serão recusadas)." | |
| 825 | + kill_switch_legend: "Kill switch por domínio" | |
| 826 | + kill_switch_label: "Desativar todas as identidades de um domínio" | |
| 827 | + kill_switch_button: "Desativar o domínio" | |
| 828 | + kill_switch_confirm_title: "Confirmar o kill switch" | |
| 829 | + kill_switch_confirm_body: "Todas as identidades federadas do domínio abaixo serão marcadas como desabilitadas. Esta ação é irreversível pela UI (um novo login federado recriará a linha)." | |
| 830 | + table_handle: "Handle" | |
| 831 | + table_display_name: "Nome exibido" | |
| 832 | + table_domain: "Domínio" | |
| 833 | + table_last_seen: "Visto pela última vez" | |
| 834 | + table_actions: "Ações" | |
| 835 | + revoke_button: "Revogar" | |
| 836 | + revoke_confirm_title: "Confirmar a revogação" | |
| 837 | + revoke_confirm_body: "Esta identidade será marcada como desabilitada. A próxima verificação JWT remota a recusará." | |
| 838 | + modal_cancel: "Cancelar" | |
| 839 | + modal_confirm: "Confirmar" | |
| 840 | + empty_title: "Sem sessões remotas" | |
| 841 | + empty_body: "Nenhuma identidade federada contatou ainda esta instância, ou todas foram desativadas." | |
| 842 | + saved_toast: "Ação realizada." | |
| 843 | + | |
| 844 | +# Estatísticas NodeInfo (RFC 0024 Slice C iter. 3). Namespace de nível | |
| 845 | +# superior dedicado para não reabrir `fediverse:` (que sobrescreveria | |
| 846 | +# settings/trust/auth/queue/sessions_remote sob yaml-rust2). | |
| 847 | +fediverse_nodeinfo: | |
| 848 | + title: "NodeInfo - Estatísticas da federação" | |
| 849 | + breadcrumb: "NodeInfo" | |
| 850 | + heading: "NodeInfo - Estatísticas humanizadas" | |
| 851 | + subtitle: "Visão geral da configuração runtime + estatísticas de usuários locais e identidades remotas." | |
| 852 | + card_software: "Software" | |
| 853 | + card_runtime: "Configuração runtime" | |
| 854 | + card_users: "Usuários locais" | |
| 855 | + card_remotes: "Identidades remotas" | |
| 856 | + label_name: "Nome" | |
| 857 | + label_version: "Versão" | |
| 858 | + label_domain: "Domínio" | |
| 859 | + label_enabled: "Federação ativada" | |
| 860 | + label_trust_mode: "Modo de confiança" | |
| 861 | + label_interop_scope: "Escopo interop" | |
| 862 | + label_authorized_fetch: "Authorized-fetch" | |
| 863 | + label_users_total: "Total" | |
| 864 | + label_users_active_month: "Ativos 30 dias" | |
| 865 | + label_users_active_halfyear: "Ativos 180 dias" | |
| 866 | + label_remote_identities_active: "Ativas" | |
| 867 | + label_remote_distinct_domains: "Domínios distintos" | |
| 868 | + | |
| 869 | +# Convites de equipe entre instâncias (RFC 0024 Slice C iter. 4). | |
| 870 | +fediverse_invites: | |
| 871 | + title: "Convites de equipe federada" | |
| 872 | + breadcrumb: "Convites" | |
| 873 | + heading: "Convites entre instâncias" | |
| 874 | + subtitle: "Convide uma identidade ActivityPub para se juntar à equipe:" | |
| 875 | + add_legend: "Convidar por handle" | |
| 876 | + handle_label: "Handle do usuário" | |
| 877 | + handle_placeholder: "@user@instancia.tld" | |
| 878 | + role_label: "Função" | |
| 879 | + role_member: "Membro" | |
| 880 | + role_admin: "Administrador" | |
| 881 | + role_owner: "Proprietário" | |
| 882 | + add_button: "Enviar o convite" | |
| 883 | + table_handle: "Handle convidado" | |
| 884 | + table_role: "Função" | |
| 885 | + table_status: "Status" | |
| 886 | + table_created_at: "Enviado em" | |
| 887 | + table_actions: "Ações" | |
| 888 | + revoke_button: "Revogar" | |
| 889 | + revoke_confirm_title: "Confirmar a revogação" | |
| 890 | + revoke_confirm_body: "O convite será cancelado. Se já foi aceito, a identidade será removida da equipe pelo sinal Revoke{Membership} (RFC 0010)." | |
| 891 | + modal_cancel: "Cancelar" | |
| 892 | + modal_confirm: "Confirmar" | |
| 893 | + empty_title: "Sem convites" | |
| 894 | + empty_body: "Nenhum convite foi enviado ainda para esta equipe." | |
| 895 | + saved_toast: "Ação realizada." | |
| 896 | + | |
| 897 | +# Apps federadas conectadas (RFC 0024 Slice C iter. 5). | |
| 898 | +fediverse_connected_apps: | |
| 899 | + page_title: "Apps federadas conectadas" | |
| 900 | + heading: "Minhas apps federadas conectadas" | |
| 901 | + subtitle: "Instâncias ActivityPub onde você se autenticou usando sua identidade deste servidor." | |
| 902 | + table_domain: "Instância" | |
| 903 | + table_count: "Sessões" | |
| 904 | + table_last_seen: "Última atividade" | |
| 905 | + empty_title: "Sem apps federadas" | |
| 906 | + empty_body: "Você ainda não aprovou nenhuma autenticação federada para outra instância." | |
| 907 | + revoke_note: "Nota: a revogação manual de uma sessão remota por esta página estará disponível com a RFC 0006 Fase 2 (lista negra de JWT remoto). Enquanto isso, peça ao administrador via /admin/fediverse/sessions_remote ou aguarde a expiração natural do JWT." | |
| 908 | + back_to_profile: "Voltar ao perfil" | |
| 909 | + | |
| 910 | +# Aceitação de convite de equipe entre instâncias (RFC 0024 Slice C iter. 6). | |
| 911 | +fediverse_invite_landing: | |
| 912 | + page_title: "Convite de equipe" | |
| 913 | + heading: "Você recebeu um convite" | |
| 914 | + explainer: "Uma equipe de outra instância federada está convidando você a entrar. Verifique os detalhes abaixo antes de responder." | |
| 915 | + team_label: "Equipe" | |
| 916 | + role_label: "Função proposta" | |
| 917 | + status_label: "Status" | |
| 918 | + accept_button: "Aceitar" | |
| 919 | + reject_button: "Recusar" | |
| 920 | + already_decided: "Este convite já foi processado." | |
| 921 | + cannot_accept_note: "Para aceitar, você deve estar conectado por meio de uma identidade federada (login da sua instância de origem). Pode recusar desta conta local, mas a aceitação passa pelo fluxo AP-nativo." |
M
crates/rustwarden-ui/src/handlers/admin_fediverse.rs
+18
-0
@@ -685,6 +685,24 @@ pub async fn add_trust_entry(
| 685 | 685 | trust |
| 686 | 686 | .add_blocked_domain(domain, severity, note_opt, added_by) |
| 687 | 687 | .await?; |
| 688 | + // F9 (iter 4 / S4) — auto-kill: blocking a domain must | |
| 689 | + // immediately disable any active `remote_identities` for | |
| 690 | + // that domain. Without this, the admin has to perform two | |
| 691 | + // separate actions for a single intent (« cut this hostile | |
| 692 | + // instance »). The middleware F10 check then rejects all | |
| 693 | + // future protected requests carrying a JWT bound to the | |
| 694 | + // disabled identity. | |
| 695 | + use rustwarden_core::services::fediverse_trust_service::normalize_domain; | |
| 696 | + use sea_orm::sea_query::Expr; | |
| 697 | + let now = chrono::Utc::now(); | |
| 698 | + let normalized = normalize_domain(domain); | |
| 699 | + remote_identity::Entity::update_many() | |
| 700 | + .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) | |
| 701 | + .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) | |
| 702 | + .filter(remote_identity::Column::Domain.eq(normalized)) | |
| 703 | + .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 704 | + .exec(&db) | |
| 705 | + .await?; | |
| 688 | 706 | } |
| 689 | 707 | } |
| 690 | 708 |
M
crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs
+90
-19
@@ -28,11 +28,17 @@ use rustwarden_core::i18n::extractor::Locale;
| 28 | 28 | use rustwarden_core::i18n::service::I18nService; |
| 29 | 29 | use rustwarden_core::auth::jwt::Claims; |
| 30 | 30 | use rustwarden_core::services::fediverse_auth_service::{ |
| 31 | - ChallengeDecision, ChallengeStatus, FediverseAuthService, InitiateRequest, | |
| 31 | + build_auth_decision_payload, ChallengeDecision, ChallengeStatus, FediverseAuthService, | |
| 32 | + InitiateRequest, | |
| 32 | 33 | }; |
| 33 | 34 | use rustwarden_core::services::fediverse_invite_service::FediverseInviteService; |
| 34 | 35 | use rustwarden_core::models::fediverse_pending_invite; |
| 35 | 36 | use rustwarden_core::services::fediverse_trust_service::FediverseTrustService; |
| 37 | +use rustwarden_core::services::fediverse_outbound_service::{ | |
| 38 | + EnqueueRequest, FediverseOutboundService, | |
| 39 | +}; | |
| 40 | +use rustwarden_core::config::FediverseConfig; | |
| 41 | +use rustwarden_core::models::remote_identity; | |
| 36 | 42 | |
| 37 | 43 | use rustwarden_core::models::fediverse_auth_challenge; |
| 38 | 44 | use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; |
@@ -576,7 +582,7 @@ pub async fn consent_form(
| 576 | 582 | /// inject any `remote_identity_id` from the form. The follow-up RFC |
| 577 | 583 | /// 0006 Phase 2 will close the binding. |
| 578 | 584 | pub async fn consent_submit( |
| 579 | - Extension(_claims): Extension<Claims>, | |
| 585 | + Extension(claims): Extension<Claims>, | |
| 580 | 586 | State(db): State<DatabaseConnection>, |
| 581 | 587 | session: Session, |
| 582 | 588 | Path(challenge_id): Path<Uuid>, |
@@ -589,31 +595,96 @@ pub async fn consent_submit(
| 589 | 595 | return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); |
| 590 | 596 | } |
| 591 | 597 | |
| 592 | - let decision = match form.decision.as_str() { | |
| 593 | - "approve" => { | |
| 594 | - // Re-read the challenge to source the bound remote_identity_id. | |
| 595 | - let model = FediverseAuthService::status(&db, challenge_id).await?; | |
| 596 | - let remote_id = model.requested_remote_id.ok_or_else(|| { | |
| 597 | - AppError::Validation( | |
| 598 | - "auth challenge has no remote_identity_id binding — \ | |
| 599 | - inbox enrichment (RFC 0006 Phase 2) not yet wired" | |
| 600 | - .to_string(), | |
| 601 | - ) | |
| 602 | - })?; | |
| 603 | - ChallengeDecision::Approve { | |
| 604 | - remote_identity_id: remote_id, | |
| 605 | - } | |
| 598 | + // Pre-fetch challenge: we need `nonce` + `requested_remote_id` both | |
| 599 | + // to feed `record_decision` and to enqueue the outbound back-channel | |
| 600 | + // (RFC 0025). Doing this once avoids a second roundtrip on approve. | |
| 601 | + let challenge = FediverseAuthService::status(&db, challenge_id).await?; | |
| 602 | + let remote_id = challenge.requested_remote_id.ok_or_else(|| { | |
| 603 | + AppError::Validation( | |
| 604 | + "auth challenge has no remote_identity_id binding — inbox \ | |
| 605 | + enrichment (RFC 0006 Phase 2) not yet wired" | |
| 606 | + .to_string(), | |
| 607 | + ) | |
| 608 | + })?; | |
| 609 | + | |
| 610 | + let approve_flag = form.decision.as_str() == "approve"; | |
| 611 | + let decision = if approve_flag { | |
| 612 | + ChallengeDecision::Approve { | |
| 613 | + remote_identity_id: remote_id, | |
| 606 | 614 | } |
| 607 | - // Anything other than `approve` is treated as a rejection (the | |
| 608 | - // client must explicitly opt in to approval). | |
| 609 | - _ => ChallengeDecision::Reject, | |
| 615 | + } else { | |
| 616 | + ChallengeDecision::Reject | |
| 610 | 617 | }; |
| 611 | 618 | |
| 612 | 619 | FediverseAuthService::record_decision(&db, challenge_id, decision).await?; |
| 613 | 620 | |
| 621 | + // RFC 0025 — push the decision to B's inbox as Accept(AuthRequest) | |
| 622 | + // / Reject(AuthRequest). Failure to enqueue must not roll back the | |
| 623 | + // local decision (the row already moved past Pending), but we | |
| 624 | + // surface it so the operator sees the dead-letter / retry need. | |
| 625 | + if let Err(e) = enqueue_auth_decision(&db, &claims, &challenge, approve_flag).await { | |
| 626 | + tracing::error!( | |
| 627 | + challenge_id = %challenge_id, | |
| 628 | + decision = if approve_flag { "approve" } else { "reject" }, | |
| 629 | + error = %e, | |
| 630 | + "consent_submit: failed to enqueue back-channel activity", | |
| 631 | + ); | |
| 632 | + } | |
| 633 | + | |
| 614 | 634 | Ok(Redirect::to("/home").into_response()) |
| 615 | 635 | } |
| 616 | 636 | |
| 637 | +/// Build and enqueue the `Accept(AuthRequest)` or `Reject(AuthRequest)` | |
| 638 | +/// activity (RFC 0025) addressed to the requester's inbox. | |
| 639 | +/// | |
| 640 | +/// Resolves the requester via `challenge.requested_remote_id`. The | |
| 641 | +/// approver actor URL is derived from the IdP's `FediverseConfig` base | |
| 642 | +/// + the approver's username (in `Claims`). | |
| 643 | +async fn enqueue_auth_decision( | |
| 644 | + db: &DatabaseConnection, | |
| 645 | + approver: &Claims, | |
| 646 | + challenge: &fediverse_auth_challenge::Model, | |
| 647 | + approve: bool, | |
| 648 | +) -> Result<(), AppError> { | |
| 649 | + let remote_id = challenge.requested_remote_id.ok_or_else(|| { | |
| 650 | + AppError::Internal("challenge approved without requested_remote_id".into()) | |
| 651 | + })?; | |
| 652 | + | |
| 653 | + let requester = remote_identity::Entity::find_by_id(remote_id) | |
| 654 | + .one(db) | |
| 655 | + .await? | |
| 656 | + .ok_or_else(|| { | |
| 657 | + AppError::Internal(format!("remote_identity {remote_id} disappeared between checks")) | |
| 658 | + })?; | |
| 659 | + | |
| 660 | + let config = FediverseConfig::from_env() | |
| 661 | + .map_err(|e| AppError::Config(format!("FediverseConfig::from_env: {e}")))?; | |
| 662 | + let approver_actor_url = format!("{}users/{}", config.base_url(), approver.username); | |
| 663 | + | |
| 664 | + let payload = build_auth_decision_payload( | |
| 665 | + config.base_url(), | |
| 666 | + &approver_actor_url, | |
| 667 | + &requester.inbox_url, | |
| 668 | + &challenge.nonce, | |
| 669 | + approve, | |
| 670 | + ); | |
| 671 | + let activity_id = payload["id"].as_str().unwrap_or("").to_string(); | |
| 672 | + | |
| 673 | + FediverseOutboundService::enqueue( | |
| 674 | + db, | |
| 675 | + EnqueueRequest { | |
| 676 | + activity_id, | |
| 677 | + payload_json: serde_json::to_string(&payload) | |
| 678 | + .map_err(|e| AppError::Internal(format!("payload serialize: {e}")))?, | |
| 679 | + target_inbox: requester.inbox_url.clone(), | |
| 680 | + target_domain: requester.domain.clone(), | |
| 681 | + actor_user_id: Some(approver.sub), | |
| 682 | + }, | |
| 683 | + ) | |
| 684 | + .await?; | |
| 685 | + Ok(()) | |
| 686 | +} | |
| 687 | + | |
| 617 | 688 | // ───────────────────────────────────────────────────────────────────── |
| 618 | 689 | // /profile/connected_apps (RFC 0024 Slice C iter. 5) |
| 619 | 690 | // ───────────────────────────────────────────────────────────────────── |
A
docs/design/ui-refonte-prompt.md
+198
-0
@@ -0,0 +1,198 @@
| 1 | +# Prompt — Refonte UI Rustwarden (designer agent) | |
| 2 | + | |
| 3 | +> Fichier de brief destiné à être passé à un agent `designer` (ou | |
| 4 | +> `oh-my-claudecode:designer`) pour piloter la refonte de | |
| 5 | +> [crates/rustwarden-ui/](../../crates/rustwarden-ui/). | |
| 6 | +> | |
| 7 | +> Statut : brouillon initial — 2026-05-11. | |
| 8 | + | |
| 9 | +## Contexte projet | |
| 10 | + | |
| 11 | +Tu interviens sur **Rustwarden**, un framework backend Rust de gestion | |
| 12 | +d'utilisateurs (auth JWT, RBAC, OAuth2/OIDC, 2FA TOTP, équipes, sessions, | |
| 13 | +refresh tokens, rate-limit, panneau d'admin). **Ce n'est PAS un gestionnaire | |
| 14 | +de mots de passe** — aucun rapport avec Bitwarden/Vaultwarden malgré le nom. | |
| 15 | + | |
| 16 | +Le crate [rustwarden-ui](../../crates/rustwarden-ui/) fournit l'UI de | |
| 17 | +référence, **optionnelle**, qu'un consommateur peut activer ou remplacer par | |
| 18 | +sa propre SPA. | |
| 19 | + | |
| 20 | +## Stack UI imposée (ne pas changer) | |
| 21 | + | |
| 22 | +- Templates [Askama 0.15](../../crates/rustwarden-ui/Cargo.toml) côté serveur | |
| 23 | + (pas de SPA, pas de bundler JS). | |
| 24 | +- [DaisyUI](https://daisyui.com/) sur Tailwind CSS (thèmes clair/sombre via | |
| 25 | + `data-theme`). | |
| 26 | +- HTMX pour les interactions partielles (fragments dans | |
| 27 | + [templates/fragments/](../../crates/rustwarden-ui/templates/fragments/)). | |
| 28 | +- Alpine.js pour l'interactivité locale (toggles, dropdowns, modals). | |
| 29 | +- i18n via fichiers YAML dans | |
| 30 | + [crates/rustwarden-ui/locales/](../../crates/rustwarden-ui/locales/) — | |
| 31 | + **fr, en, de, es, it, pt** : tout libellé visible doit passer par la | |
| 32 | + fonction `t(...)`, jamais en dur dans le HTML. | |
| 33 | +- Aucun framework front lourd (pas de React/Vue/Svelte), aucun build Node. | |
| 34 | + | |
| 35 | +## Périmètre de la refonte | |
| 36 | + | |
| 37 | +Surfaces à reprendre (lis-les avant de proposer) : | |
| 38 | + | |
| 39 | +### 1. Layout global | |
| 40 | +- [templates/base.html](../../crates/rustwarden-ui/templates/base.html) (224 lignes) | |
| 41 | +- [base_with_theme.html](../../crates/rustwarden-ui/templates/base_with_theme.html) | |
| 42 | +- Composants : [navbar.html](../../crates/rustwarden-ui/templates/components/navbar.html), | |
| 43 | + [sidebar.html](../../crates/rustwarden-ui/templates/components/sidebar.html), | |
| 44 | + [header.html](../../crates/rustwarden-ui/templates/components/header.html), | |
| 45 | + [user_menu.html](../../crates/rustwarden-ui/templates/components/user_menu.html), | |
| 46 | + [breadcrumb.html](../../crates/rustwarden-ui/templates/components/breadcrumb.html), | |
| 47 | + [navigation.html](../../crates/rustwarden-ui/templates/components/navigation.html) | |
| 48 | + | |
| 49 | +### 2. Auth public | |
| 50 | +- [login.html](../../crates/rustwarden-ui/templates/login.html) | |
| 51 | + (**641 lignes — clairement trop long, à éclater**) | |
| 52 | +- [login_2fa.html](../../crates/rustwarden-ui/templates/login_2fa.html) | |
| 53 | +- [reset_password.html](../../crates/rustwarden-ui/templates/reset_password.html) | |
| 54 | +- [resend_verification.html](../../crates/rustwarden-ui/templates/resend_verification.html) | |
| 55 | +- [email_verified.html](../../crates/rustwarden-ui/templates/email_verified.html) | |
| 56 | + | |
| 57 | +### 3. Espace utilisateur | |
| 58 | +- [home.html](../../crates/rustwarden-ui/templates/home.html) | |
| 59 | +- [profile.html](../../crates/rustwarden-ui/templates/profile.html) | |
| 60 | +- [profile/](../../crates/rustwarden-ui/templates/profile/) | |
| 61 | +- [settings/](../../crates/rustwarden-ui/templates/settings/) | |
| 62 | +- [sharing/](../../crates/rustwarden-ui/templates/sharing/) | |
| 63 | + | |
| 64 | +### 4. Admin | |
| 65 | +- [admin/dashboard.html](../../crates/rustwarden-ui/templates/admin/dashboard.html) | |
| 66 | +- [users_list.html](../../crates/rustwarden-ui/templates/admin/users_list.html) | |
| 67 | +- [user_form.html](../../crates/rustwarden-ui/templates/admin/user_form.html) | |
| 68 | +- [roles_list.html](../../crates/rustwarden-ui/templates/admin/roles_list.html) | |
| 69 | +- [role_form.html](../../crates/rustwarden-ui/templates/admin/role_form.html) | |
| 70 | +- [settings.html](../../crates/rustwarden-ui/templates/admin/settings.html) | |
| 71 | +- [errors.html](../../crates/rustwarden-ui/templates/admin/errors.html) | |
| 72 | + | |
| 73 | +### 5. Fediverse (feature flag `experimental-fediverse`) | |
| 74 | +Surfaces nouvelles, encore non polies : | |
| 75 | + | |
| 76 | +- **Public** : [fediverse/login_form.html](../../crates/rustwarden-ui/templates/fediverse/login_form.html), | |
| 77 | + [login_waiting.html](../../crates/rustwarden-ui/templates/fediverse/login_waiting.html), | |
| 78 | + [consent_screen.html](../../crates/rustwarden-ui/templates/fediverse/consent_screen.html), | |
| 79 | + [invite_landing.html](../../crates/rustwarden-ui/templates/fediverse/invite_landing.html), | |
| 80 | + [error_unauthorized.html](../../crates/rustwarden-ui/templates/fediverse/error_unauthorized.html) | |
| 81 | +- **Profil** : [profile/fediverse_connected_apps.html](../../crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html) | |
| 82 | +- **Admin** : [admin/fediverse/](../../crates/rustwarden-ui/templates/admin/fediverse/) | |
| 83 | + (settings, trust_list, queue, sessions_remote, nodeinfo, team_invites) | |
| 84 | +- **Composants partagés** : [components/fediverse/](../../crates/rustwarden-ui/templates/components/fediverse/) | |
| 85 | + | |
| 86 | +**Routes correspondantes** : [crates/rustwarden-ui/src/routes.rs](../../crates/rustwarden-ui/src/routes.rs) | |
| 87 | +— sert de carte de navigation autoritative. | |
| 88 | + | |
| 89 | +## Objectifs de la refonte | |
| 90 | + | |
| 91 | +1. **Cohérence visuelle** — un seul système de design unifié (palette, typo, | |
| 92 | + espacements, radius, ombres, états focus/hover/disabled). Aujourd'hui | |
| 93 | + chaque page improvise. | |
| 94 | +2. **Hiérarchie d'information** — les pages admin | |
| 95 | + (users/roles/settings/fediverse) doivent supporter une densité élevée sans | |
| 96 | + devenir illisibles. Pattern table + filtres + actions en masse + | |
| 97 | + pagination cohérent partout. | |
| 98 | +3. **Accessibilité WCAG 2.2 AA** — contrastes, ordre de tabulation, `aria-*` | |
| 99 | + sur HTMX, focus visibles, navigation clavier complète. Préciser les | |
| 100 | + patterns ARIA pour modals, toasts, tabs, comboboxes. | |
| 101 | +4. **Responsive mobile-first** — la sidebar admin doit se replier proprement, | |
| 102 | + les tableaux doivent dégrader (cards empilées sur mobile). | |
| 103 | +5. **Thèmes clair/sombre** via DaisyUI `data-theme`, persistance dans les | |
| 104 | + préférences utilisateur (déjà existant côté backend dans | |
| 105 | + `settings/preferences`). | |
| 106 | +6. **Réduction du bruit** — `login.html` à 641 lignes est un symptôme : | |
| 107 | + extraire en partials Askama, factoriser via `components/`. | |
| 108 | +7. **Patterns HTMX standardisés** — feedback de chargement (`hx-indicator`), | |
| 109 | + gestion d'erreur (toast), validation inline, swap targets nommés. | |
| 110 | + Documenter le pattern « formulaire qui répond en fragment » une fois pour | |
| 111 | + toutes. | |
| 112 | +8. **Onboarding Fediverse** — les écrans `login_waiting` (SSE), | |
| 113 | + `consent_screen` (style OAuth-consent), `invite_landing` doivent être | |
| 114 | + *immédiatement compréhensibles* pour un utilisateur qui ne connaît pas | |
| 115 | + ActivityPub. Vulgarisation, pas jargon. | |
| 116 | + | |
| 117 | +## Contraintes non-négociables | |
| 118 | + | |
| 119 | +- **Aucune dépendance JS additionnelle** au-delà d'HTMX + Alpine + DaisyUI. | |
| 120 | + Pas de bundler, pas de NPM, pas de build step. | |
| 121 | +- **Tout texte visible passe par i18n** (`{{ t("clé.dotted") }}`). Si tu | |
| 122 | + ajoutes une clé, ajoute-la dans **les 6 fichiers de locales** (commencer | |
| 123 | + par `fr.yml` et `en.yml`, marquer `# TODO i18n` ailleurs si nécessaire). | |
| 124 | +- **Feature flag respecté** — tout ce qui touche au fediverse doit rester | |
| 125 | + sous `#[cfg(feature = "experimental-fediverse")]` côté handlers et | |
| 126 | + conditionné côté templates (le menu admin par exemple ne doit pas afficher | |
| 127 | + d'entrées Fediverse si la feature est off). | |
| 128 | +- **Pas de raw SQL, pas de logique métier dans les templates** — uniquement | |
| 129 | + présentation. La logique reste dans les handlers Rust. | |
| 130 | +- **CSP** — l'app applique aujourd'hui `unsafe-inline`/`unsafe-eval` (pour | |
| 131 | + Alpine/HTMX inlines). Si ta refonte permet de durcir la CSP en supprimant | |
| 132 | + les `<script>` inlines et `style="..."` inlines, c'est un bonus prioritaire | |
| 133 | + à signaler. | |
| 134 | +- **Pas de raccourcis ANSSI** : `unwrap`/`expect` interdits hors tests, `///` | |
| 135 | + rustdoc en anglais sur toute API publique ajoutée. | |
| 136 | + | |
| 137 | +## Livrables attendus | |
| 138 | + | |
| 139 | +1. **Document `docs/design/ui-refonte-2026.md`** (français) : | |
| 140 | + - Inventaire de l'existant avec captures de problèmes constatés. | |
| 141 | + - Système de design proposé (tokens DaisyUI, échelle d'espacement, | |
| 142 | + typographie, composants). | |
| 143 | + - Bibliothèque de composants partials Askama à créer/refactorer (liste | |
| 144 | + exhaustive avec props attendues). | |
| 145 | + - Wireframes ASCII ou descriptions textuelles écran par écran (pas besoin | |
| 146 | + de Figma). | |
| 147 | + - Plan de migration en *slices* livrables indépendamment (auth → user → | |
| 148 | + admin → fediverse), avec critères d'acceptation par slice. | |
| 149 | + - Checklist accessibilité par type de composant. | |
| 150 | +2. **Patches templates** appliqués par slices (commencer par la slice 1 | |
| 151 | + *layout + auth*), chaque slice doit : | |
| 152 | + - Builder (`cargo build -p rustwarden-ui` et | |
| 153 | + `cargo build -p rustwarden-ui --features experimental-fediverse`). | |
| 154 | + - Passer `cargo clippy` sans nouveau warning. | |
| 155 | + - Préserver toutes les routes existantes de | |
| 156 | + [routes.rs](../../crates/rustwarden-ui/src/routes.rs) (aucune URL | |
| 157 | + cassée). | |
| 158 | +3. **Mise à jour des locales** pour toute clé ajoutée/renommée. | |
| 159 | +4. **Note de migration** pour les apps consommatrices : breaking changes dans | |
| 160 | + les blocs Askama overridables, nouveaux composants disponibles, impact | |
| 161 | + CSP. | |
| 162 | + | |
| 163 | +## Méthode | |
| 164 | + | |
| 165 | +- **Ne touche pas au code Rust des handlers** dans cette première passe — | |
| 166 | + uniquement templates, locales, et éventuellement assets statiques sous | |
| 167 | + `static/`. Si un changement de handler s'impose (ex. nouveau contexte | |
| 168 | + template), liste-le dans le doc et demande validation avant. | |
| 169 | +- **Pas de commit, pas de git push** — l'utilisateur gère le versioning | |
| 170 | + manuellement (cf. [CLAUDE.md](../../CLAUDE.md) projet). Laisse les | |
| 171 | + modifications en working tree. | |
| 172 | +- **Demande validation avant chaque slice** plutôt que de tout pondre d'un | |
| 173 | + coup. | |
| 174 | +- **Une slice = un PR mental** : le doc design en premier (slice 0), puis | |
| 175 | + livraison incrémentale. | |
| 176 | + | |
| 177 | +## Non-objectifs (ne fais PAS) | |
| 178 | + | |
| 179 | +- Pas de réécriture en SPA React/Vue/Svelte. | |
| 180 | +- Pas d'introduction de Storybook, Vite, Webpack, ni d'aucun build front. | |
| 181 | +- Pas de redesign de l'API REST (`/api/v1/*`) ni de la structure des routes. | |
| 182 | +- Pas de modification du schéma DB ni des modèles SeaORM. | |
| 183 | +- Pas de logo/branding/marketing — purement UX/UI fonctionnelle. | |
| 184 | + | |
| 185 | +## Première action attendue | |
| 186 | + | |
| 187 | +Lis : | |
| 188 | + | |
| 189 | +- [base.html](../../crates/rustwarden-ui/templates/base.html) | |
| 190 | +- [components/navbar.html](../../crates/rustwarden-ui/templates/components/navbar.html) | |
| 191 | +- [components/sidebar.html](../../crates/rustwarden-ui/templates/components/sidebar.html) | |
| 192 | +- [admin/dashboard.html](../../crates/rustwarden-ui/templates/admin/dashboard.html) | |
| 193 | +- [admin/users_list.html](../../crates/rustwarden-ui/templates/admin/users_list.html) | |
| 194 | +- [login.html](../../crates/rustwarden-ui/templates/login.html) | |
| 195 | +- [fediverse/consent_screen.html](../../crates/rustwarden-ui/templates/fediverse/consent_screen.html) | |
| 196 | + | |
| 197 | +Renvoie un **diagnostic en 10 points** (ce qui marche / ce qui cloche) avant | |
| 198 | +de proposer le système de design — l'utilisateur valide avant slice 0. |
A
docs/fediverse-operations.md
+321
-0
@@ -0,0 +1,321 @@
| 1 | +# Guide opérateur — Fédération `experimental-fediverse` | |
| 2 | + | |
| 3 | +**Audience** : DevOps / SRE / mainteneur déployant une instance Rustwarden avec la feature `experimental-fediverse` activée. | |
| 4 | + | |
| 5 | +**Prérequis lecture** : avoir déployé une instance Rustwarden classique au moins une fois (PostgreSQL, JWT, env vars). Ce guide ne reprend pas les bases — il se concentre sur ce que la fédération AJOUTE. | |
| 6 | + | |
| 7 | +> **Mode Diátaxis** : ce document est une combinaison **How-to** + **Reference**. Pour comprendre **pourquoi** ces choix architecturaux ont été faits, lire les RFCs 0001-0025 (`docs/rfcs/`) ou le plan parent `docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md`. | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 1. Activer la feature | |
| 12 | + | |
| 13 | +### 1.1 Compilation | |
| 14 | + | |
| 15 | +Dans le `Cargo.toml` de l'app consommatrice : | |
| 16 | + | |
| 17 | +```toml | |
| 18 | +[dependencies] | |
| 19 | +rustwarden-core = { path = "...", features = ["experimental-fediverse"] } | |
| 20 | +rustwarden-ui = { path = "...", features = ["experimental-fediverse"] } | |
| 21 | +``` | |
| 22 | + | |
| 23 | +Et dans le binaire : | |
| 24 | + | |
| 25 | +```rust | |
| 26 | +let builder = RustwardenApp::builder() | |
| 27 | + .from_env() | |
| 28 | + .with_ui(rustwarden_ui::default_routes) | |
| 29 | + .with_fediverse()?; // ← appel obligatoire pour monter les routes AP | |
| 30 | +builder.build().await?.run().await | |
| 31 | +``` | |
| 32 | + | |
| 33 | +Sans `.with_fediverse()`, **les routes sont compilées mais pas montées** — `/users/{handle}`, `/.well-known/webfinger`, `/inbox`, `/fediverse/auth/inbox` retournent 404. | |
| 34 | + | |
| 35 | +### 1.2 Variables d'environnement | |
| 36 | + | |
| 37 | +| Var | Obligatoire | Default | Rôle | | |
| 38 | +|---|---|---|---| | |
| 39 | +| `FEDIVERSE_DOMAIN` | ✅ | — | Domaine canonique de l'instance (`example.org` ou `http://example.org` pour dev). Refusé en HTTP en prod sauf si `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` | | |
| 40 | +| `FEDIVERSE_DEBUG_LOCAL_FEDERATION` | ❌ | `false` | **Dev/test uniquement.** Autorise HTTP plain + IP privées dans le SSRF guard. NE JAMAIS activer en prod | | |
| 41 | +| `FEDIVERSE_AUTHORIZED_FETCH` | ❌ | `false` | Mastodon « secure mode » — exiger signatures sur les GET d'actor | | |
| 42 | +| `FEDIVERSE_OUTBOUND_WORKERS` | ❌ | `4` | Nombre de workers parallèles de la file outbound | | |
| 43 | +| `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` | ❌ | `8` | Retry count avant dead-letter | | |
| 44 | +| `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` | ❌ | `30` | Timeout par requête sortante | | |
| 45 | +| `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` | ❌ | `86400` | TTL du cache `remote_identities` (24h) | | |
| 46 | +| `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` | ❌ | `300` | TTL des challenges auth (5 min) | | |
| 47 | +| `ENCRYPTION_KEY` | ✅ | — | 64 hex chars (32 bytes) — déchiffre les clés RSA privées. Si rotation, ALL actor keys must be re-encrypted offline | | |
| 48 | + | |
| 49 | +### 1.3 Premier démarrage | |
| 50 | + | |
| 51 | +```bash | |
| 52 | +# 1. Migrations (incluent les 7 tables fediverse) | |
| 53 | +cargo run --bin migrate up | |
| 54 | + | |
| 55 | +# 2. Seed (optionnel mais recommandé pour test) | |
| 56 | +cargo run --bin seed | |
| 57 | + | |
| 58 | +# 3. Lancer l'app | |
| 59 | +cargo run -p rustwarden-app --release --features experimental-fediverse | |
| 60 | +``` | |
| 61 | + | |
| 62 | +Vérification : `curl http://localhost:3000/.well-known/webfinger?resource=acct:alice@example.org` doit répondre 200 (si alice existe). | |
| 63 | + | |
| 64 | +--- | |
| 65 | + | |
| 66 | +## 2. Configuration en base : `app_settings` | |
| 67 | + | |
| 68 | +Tous ces réglages sont stockés dans la table `app_settings` (clé/valeur). Modifiables à chaud via SQL ou via l'UI `/admin/fediverse/settings`. | |
| 69 | + | |
| 70 | +| Clé | Valeurs | Default | Effet | | |
| 71 | +|---|---|---|---| | |
| 72 | +| `fediverse_enabled` | `true` / `false` | `false` | Master switch. Tant que `false`, l'UI fediverse cache toutes les pages et les routes admin retournent 404. **Routes protocole (`/inbox`, `/.well-known/*`) restent actives quand même** | | |
| 73 | +| `fediverse_trust_mode` | `allowlist` / `open_with_blocklist` | `allowlist` | Zéro-trust par défaut. `allowlist` : seul un domaine de `fediverse_trusted_domains` est accepté. `open_with_blocklist` : tout passe sauf si dans `fediverse_blocked_domains` | | |
| 74 | +| `fediverse_interop_scope` | `rustwarden_only` / `rustwarden_plus_universal_read` / `open` | `rustwarden_only` | Scope d'interop : Rustwarden uniquement ou ouvrir aux Mastodon-like | | |
| 75 | +| `fediverse_authorized_fetch` | `true` / `false` | `false` | Exiger les signatures HTTP sur les GET d'actor (Mastodon « secure mode ») | | |
| 76 | +| `fediverse_node_name` | string | `"Rustwarden"` | Nom de l'instance exposé en NodeInfo | | |
| 77 | +| `fediverse_node_description` | string | `""` | Description NodeInfo | | |
| 78 | +| `fediverse_observability_enabled` | `true` / `false` | `false` | Active les `tracing::info!` structurés (RFC 0019) sur la file outbound. Activer quand un agent (Vector / Loki / Promtail) consomme les logs. Sans agent : laisser `false` pour éviter le bruit | | |
| 79 | + | |
| 80 | +Exemple SQL pour activer la fédération en mode allowlist standard : | |
| 81 | + | |
| 82 | +```sql | |
| 83 | +INSERT INTO app_settings (id, key, value, description, updated_at) | |
| 84 | +VALUES | |
| 85 | + (gen_random_uuid(), 'fediverse_enabled', 'true', NULL, NOW()), | |
| 86 | + (gen_random_uuid(), 'fediverse_trust_mode', 'allowlist', NULL, NOW()) | |
| 87 | +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW(); | |
| 88 | +``` | |
| 89 | + | |
| 90 | +--- | |
| 91 | + | |
| 92 | +## 3. Trust list / Block list | |
| 93 | + | |
| 94 | +Deux tables, deux modes (cf. §2). | |
| 95 | + | |
| 96 | +### 3.1 Ajouter un domaine de confiance (mode `allowlist`) | |
| 97 | + | |
| 98 | +```sql | |
| 99 | +INSERT INTO fediverse_trusted_domains (id, domain, note, added_by_user_id, created_at) | |
| 100 | +VALUES (gen_random_uuid(), 'partner.example.com', 'partenariat 2026', NULL, NOW()) | |
| 101 | +ON CONFLICT (domain) DO NOTHING; | |
| 102 | +``` | |
| 103 | + | |
| 104 | +Effet : signatures venant de `partner.example.com` passent la gate F8 dans `auth_inbox` et `/inbox`. | |
| 105 | + | |
| 106 | +### 3.2 Bloquer un domaine hostile (mode `open_with_blocklist`) | |
| 107 | + | |
| 108 | +Préférer l'UI `/admin/fediverse/trust_list` qui combine **automatiquement** : | |
| 109 | +1. INSERT dans `fediverse_blocked_domains` | |
| 110 | +2. UPDATE `remote_identities.disabled_at = NOW()` pour le domaine (F9) | |
| 111 | + | |
| 112 | +Combiné avec la F10 (middleware `AuthService` vérifie `disabled_at`), les JWT remote existants de ce domaine sont **rejetés au prochain request** (redirect /login). Aucune nouvelle action AP n'est acceptée. | |
| 113 | + | |
| 114 | +SQL direct (si pas d'UI accessible) : | |
| 115 | + | |
| 116 | +```sql | |
| 117 | +BEGIN; | |
| 118 | +INSERT INTO fediverse_blocked_domains | |
| 119 | + (id, domain, severity, reason, added_by_user_id, created_at) | |
| 120 | +VALUES (gen_random_uuid(), 'evil.tld', 'suspend', 'spam mass-invite 2026-05-11', NULL, NOW()) | |
| 121 | +ON CONFLICT (domain) DO NOTHING; | |
| 122 | +UPDATE remote_identities | |
| 123 | + SET disabled_at = NOW(), updated_at = NOW() | |
| 124 | + WHERE domain = 'evil.tld' AND disabled_at IS NULL; | |
| 125 | +COMMIT; | |
| 126 | +``` | |
| 127 | + | |
| 128 | +### 3.3 Révocation ciblée d'une session | |
| 129 | + | |
| 130 | +Cas : un user remote spécifique abuse, sans couper tout son domaine. | |
| 131 | + | |
| 132 | +```sql | |
| 133 | +UPDATE remote_identities | |
| 134 | + SET disabled_at = NOW(), updated_at = NOW() | |
| 135 | + WHERE ap_id = 'http://other.tld/users/badbot'; | |
| 136 | +``` | |
| 137 | + | |
| 138 | +Effet identique : son JWT remote sera invalidé au prochain request (F10). | |
| 139 | + | |
| 140 | +--- | |
| 141 | + | |
| 142 | +## 4. Incident playbook | |
| 143 | + | |
| 144 | +### 4.1 « User `@alice@partner.com` ne peut pas se logger » | |
| 145 | + | |
| 146 | +1. **Trust list ?** — `SELECT * FROM fediverse_trusted_domains WHERE domain='partner.com';` | |
| 147 | + Si vide en mode `allowlist`, c'est ça. Ajouter (§3.1). | |
| 148 | +2. **Block list ?** — `SELECT * FROM fediverse_blocked_domains WHERE domain='partner.com';` | |
| 149 | + Si présent, retirer ou changer de mode. | |
| 150 | +3. **`fediverse_enabled` ?** — `SELECT value FROM app_settings WHERE key='fediverse_enabled';` | |
| 151 | +4. **WebFinger résoluble ?** — `curl 'https://partner.com/.well-known/webfinger?resource=acct:alice@partner.com'`. Si 404 : l'IdP partenaire n'expose pas alice. Hors de notre contrôle. | |
| 152 | +5. **Actor fetchable ?** — `curl -H 'Accept: application/activity+json' https://partner.com/users/alice`. Doit renvoyer un Person AP avec `publicKey`. | |
| 153 | +6. **Signature errors ?** — `docker compose logs app | grep 'signature'` ou consultation des logs structurés. Causes fréquentes : clock skew, body modifié en proxy, clé fetched depuis cache stale (vider via `DELETE FROM remote_identities WHERE ap_id='...';`). | |
| 154 | + | |
| 155 | +### 4.2 « File outbound qui s'accumule » | |
| 156 | + | |
| 157 | +```sql | |
| 158 | +SELECT status, COUNT(*) FROM fediverse_outbound_queue GROUP BY status; | |
| 159 | +-- pending : à délivrer (peut être grand normalement) | |
| 160 | +-- delivered : ok, ignore | |
| 161 | +-- dead : ⚠️ retry épuisé. Inspecter `last_error` | |
| 162 | +``` | |
| 163 | + | |
| 164 | +Si gros volume `pending` : worker bloqué ou cible inaccessible. Inspecter via : | |
| 165 | +```sql | |
| 166 | +SELECT target_domain, COUNT(*), MAX(last_error) | |
| 167 | + FROM fediverse_outbound_queue | |
| 168 | + WHERE status='pending' | |
| 169 | + GROUP BY target_domain | |
| 170 | + ORDER BY COUNT(*) DESC LIMIT 10; | |
| 171 | +``` | |
| 172 | + | |
| 173 | +Action : domaine spammé → blocklist. Domaine plante (5xx) → laisser le retry exponentiel finir. | |
| 174 | + | |
| 175 | +### 4.3 « Sessions remote ne se déconnectent pas après kill_domain » | |
| 176 | + | |
| 177 | +→ F10 active : le middleware `AuthService` vérifie `remote_identities.disabled_at` à chaque request. Si la session continue, vérifier : | |
| 178 | +1. `SELECT disabled_at FROM remote_identities WHERE ap_id='<actor>';` — doit être NOT NULL. | |
| 179 | +2. Le JWT n'est révoqué qu'au **prochain request** (lazy). Pas de session active réelle qui s'invalide en push. | |
| 180 | +3. Si bug suspecté : grep logs pour `"AuthService: remote_identity X is disabled, rejecting JWT"` qui prouve que F10 fire. | |
| 181 | + | |
| 182 | +--- | |
| 183 | + | |
| 184 | +## 5. Observabilité | |
| 185 | + | |
| 186 | +### 5.1 Tracing structuré (default-off) | |
| 187 | + | |
| 188 | +Le service `FediverseOutboundService::drain_one` peut émettre 3 events JSON par tentative de delivery : | |
| 189 | + | |
| 190 | +| Event | Niveau | Quand | | |
| 191 | +|---|---|---| | |
| 192 | +| `delivered` | INFO | Succès | | |
| 193 | +| `dead` | ERROR | Retry épuisé (`attempts >= max_attempts`) | | |
| 194 | +| `deferred` | WARN | Échec retryable, `next_retry_at` programmé | | |
| 195 | + | |
| 196 | +**Activation** : | |
| 197 | +```sql | |
| 198 | +UPDATE app_settings SET value='true', updated_at=NOW() | |
| 199 | + WHERE key='fediverse_observability_enabled'; | |
| 200 | +-- ou INSERT-ON-CONFLICT si la row n'existe pas (cf. §2) | |
| 201 | +``` | |
| 202 | + | |
| 203 | +Sans agent qui consomme, laisser **off** pour éviter le bruit logs. | |
| 204 | + | |
| 205 | +### 5.2 Consommation des logs structurés | |
| 206 | + | |
| 207 | +Recommandé : `Vector` ou `Promtail` configuré pour matcher `target=rustwarden::fediverse::outbound` et router vers Loki / Prometheus. Conversion logs → métriques se fait dans l'agent, pas dans Rustwarden — ça évite de coupler `rustwarden-core` à `metrics-rs`. | |
| 208 | + | |
| 209 | +Exemple Vector source : | |
| 210 | +```toml | |
| 211 | +[sources.rustwarden_logs] | |
| 212 | +type = "file" | |
| 213 | +include = ["/var/log/rustwarden/*.log"] | |
| 214 | + | |
| 215 | +[transforms.fediverse_metrics] | |
| 216 | +type = "log_to_metric" | |
| 217 | +inputs = ["rustwarden_logs"] | |
| 218 | +metrics = [ | |
| 219 | + { type = "counter", field = "event", name = "fediverse_outbound_total", tags = { domain = "{{target_domain}}", event = "{{event}}" } }, | |
| 220 | +] | |
| 221 | +``` | |
| 222 | + | |
| 223 | +### 5.3 Métriques métier consultables en SQL | |
| 224 | + | |
| 225 | +Pas de Prometheus ? Les `SELECT COUNT(*)` font le job pour le quotidien : | |
| 226 | + | |
| 227 | +```sql | |
| 228 | +-- Volume outbound par status | |
| 229 | +SELECT status, COUNT(*) FROM fediverse_outbound_queue GROUP BY status; | |
| 230 | + | |
| 231 | +-- Volume challenges par status | |
| 232 | +SELECT status, COUNT(*) FROM fediverse_auth_challenges GROUP BY status; | |
| 233 | + | |
| 234 | +-- Identités actives par domaine | |
| 235 | +SELECT domain, COUNT(*) FROM remote_identities | |
| 236 | + WHERE disabled_at IS NULL GROUP BY domain ORDER BY COUNT(*) DESC; | |
| 237 | + | |
| 238 | +-- Volume invites par status | |
| 239 | +SELECT status, COUNT(*) FROM fediverse_pending_invites GROUP BY status; | |
| 240 | +``` | |
| 241 | + | |
| 242 | +--- | |
| 243 | + | |
| 244 | +## 6. Maintenance / GC | |
| 245 | + | |
| 246 | +Aucun cleanup automatique n'existe à ce jour (dette identifiée plan iter1-findings §6). Sans intervention, plusieurs tables grossissent linéairement : | |
| 247 | + | |
| 248 | +| Table | Critère de nettoyage | Fréquence suggérée | | |
| 249 | +|---|---|---| | |
| 250 | +| `fediverse_auth_challenges` | `status IN ('expired','consumed','rejected') AND created_at < NOW() - INTERVAL '7 days'` | Quotidien | | |
| 251 | +| `fediverse_pending_invites` | `status IN ('expired','rejected','revoked','accepted') AND created_at < NOW() - INTERVAL '30 days'` | Hebdo | | |
| 252 | +| `fediverse_outbound_queue` | `status IN ('delivered','dead') AND created_at < NOW() - INTERVAL '7 days'` | Quotidien | | |
| 253 | +| `remote_identities` | `disabled_at < NOW() - INTERVAL '90 days'` | Mensuel (purge totale) | | |
| 254 | + | |
| 255 | +Script bash cron à adapter : | |
| 256 | +```sql | |
| 257 | +DELETE FROM fediverse_outbound_queue | |
| 258 | + WHERE status IN ('delivered','dead') AND created_at < NOW() - INTERVAL '7 days'; | |
| 259 | +DELETE FROM fediverse_auth_challenges | |
| 260 | + WHERE status IN ('expired','consumed','rejected') AND created_at < NOW() - INTERVAL '7 days'; | |
| 261 | +``` | |
| 262 | + | |
| 263 | +--- | |
| 264 | + | |
| 265 | +## 7. Promotion `experimental-fediverse` → `fediverse` (stable) | |
| 266 | + | |
| 267 | +Critères acquis (cf. plan parent §3) : | |
| 268 | + | |
| 269 | +- [x] 6/6 scénarios E2E S1-S6 automatisés (32 tests verts). | |
| 270 | +- [x] F1-F11 corrigés ou explicitement différés UX (F4-F7, F11 non-bloquant). | |
| 271 | +- [x] Build clean, clippy `-D warnings` zero, 348 unit tests verts. | |
| 272 | +- [x] Trust enforcement protocole (F8) + auto-kill block (F9) + JWT revocation (F10). | |
| 273 | +- [x] Audit privacy manuel passé (cf. doc iter2-3 findings). | |
| 274 | +- [ ] **Documentation opérateur** — ce document. | |
| 275 | +- [ ] Bench perf p95 < 2s (post-promotion possible). | |
| 276 | +- [ ] Pen test léger formel (post-promotion possible). | |
| 277 | + | |
| 278 | +Quand tous les ✅ sont pris : | |
| 279 | +```toml | |
| 280 | +# Cargo.toml — renommer la feature | |
| 281 | +fediverse = [] # au lieu de experimental-fediverse = [] | |
| 282 | +``` | |
| 283 | + | |
| 284 | +Bump version majeure recommandé (breaking pour quiconque utilisait `experimental-fediverse`). | |
| 285 | + | |
| 286 | +--- | |
| 287 | + | |
| 288 | +## 8. Référence rapide — URLs exposées | |
| 289 | + | |
| 290 | +| Route | Méthode | Protégée ? | Rôle | | |
| 291 | +|---|---|---|---| | |
| 292 | +| `/.well-known/webfinger?resource=acct:user@domain` | GET | Public | Discovery | | |
| 293 | +| `/.well-known/nodeinfo` | GET | Public | Discovery NodeInfo | | |
| 294 | +| `/nodeinfo/2.1` | GET | Public | NodeInfo détail | | |
| 295 | +| `/users/{handle}` | GET | Public (AP-only) | Person actor JSON | | |
| 296 | +| `/inbox` | POST | Signature obligatoire + trust gate | Lifecycle signals (Update / Delete / Revoke) | | |
| 297 | +| `/fediverse/auth/inbox` | POST | Signature obligatoire + trust gate | Auth flow (AuthRequest / Accept / Reject) | | |
| 298 | +| `/api/v1/auth/login/fediverse/initiate` | POST | Public | SP-side: créer un challenge local | | |
| 299 | +| `/api/v1/auth/login/fediverse/consume/{id}` | POST | Public | SP-side: récupérer le JWT remote après approval | | |
| 300 | +| `/api/v1/teams/{team_id}/members/invite-fediverse` | POST | Admin team | Créer invite cross-instance | | |
| 301 | +| `/admin/fediverse/settings` | GET | Admin | UI réglages | | |
| 302 | +| `/admin/fediverse/trust_list` | GET / POST | Admin | UI trust/block | | |
| 303 | +| `/admin/fediverse/sessions_remote` | GET | Admin | UI sessions actives | | |
| 304 | +| `/admin/fediverse/sessions_remote/kill_domain` | POST | Admin | Bulk disable | | |
| 305 | + | |
| 306 | +--- | |
| 307 | + | |
| 308 | +## 9. Limites connues | |
| 309 | + | |
| 310 | +- **F4-F7 — back-channel AP `Invite` / `Accept(Invite)` pas implémenté** : les invites cross-instance fonctionnent **sans** notification poussée vers l'instance distante. L'admin et l'invité se coordonnent via le partage manuel du lien. Différé UX, voir RFC future. | |
| 311 | +- **F11 — clock skew non-enforced en mode debug** : `AlwaysAcceptVerifier::with_resolver` n'applique pas la fenêtre Date ±30s. Le real `HttpSigVerifier` le fait. Non-bloquant car le mode debug est dev-only. | |
| 312 | +- **GC automatique absent** (§6). Faire un cron applicatif. | |
| 313 | +- **`fediverse_outbound_queue.actor_user_id`** est nullable. La voie canonique prod (UI `/admin/fediverse/team_invites`) appelle `FediverseInviteService::invite_remote` avec `claims.sub` — donc l'inviter réel est tracé. La route API `/api/v1/teams/{team_id}/members/invite-fediverse` accepte un JWT optionnel (`Option<Extension<Claims>>`) et, si présent, utilise `claims.sub` ; sinon fallback sur le placeholder `system_actor_user_id()` (random UUID). Cette route API n'est pas protégée par le middleware actuel — une RFC future câblera un auth-extractor per-route. | |
| 314 | + | |
| 315 | +## 10. Pour aller plus loin | |
| 316 | + | |
| 317 | +- **Plan d'évaluation campagne** : [`docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md`](plans/2026-05-10-fediverse-promotion-stable-evaluation.md) | |
| 318 | +- **Iter 1 findings (S1)** : [`docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md`](plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md) | |
| 319 | +- **Iter 2-3 findings (S2/S3 + F8)** : [`docs/plans/2026-05-11-fediverse-evaluation-iter2-3-findings.md`](plans/2026-05-11-fediverse-evaluation-iter2-3-findings.md) | |
| 320 | +- **Testbed local** : [`tests/fediverse-testbed/README.md`](../tests/fediverse-testbed/README.md) | |
| 321 | +- **RFCs 0001-0025** : [`docs/rfcs/`](rfcs/) — index dans `docs/rfcs/README.md` |
A
docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md
+520
-0
@@ -0,0 +1,520 @@
| 1 | +# Évaluation pour sortir `experimental-fediverse` — plan pédagogique | |
| 2 | + | |
| 3 | +**Statut** : draft, 2026-05-10 | |
| 4 | +**Auteur** : EBO + Claude | |
| 5 | +**Périmètre** : protocole d'**identité fédérée** Rustwarden (SSO + invitations cross-instance d'équipe). **Pas** de fonctionnalité sociale (followers, Notes, timelines). | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. Pourquoi ce plan | |
| 10 | + | |
| 11 | +Le feature flag `experimental-fediverse` signale aux intégrateurs que l'API peut casser sans préavis. Pour le promouvoir vers `fediverse` (stable), on doit prouver — pas affirmer — que : | |
| 12 | + | |
| 13 | +1. Le protocole fonctionne **entre vraies instances Rustwarden** (pas seulement contre wiremock). | |
| 14 | +2. Les chemins d'erreur (réseau, replay, expiry, instance hostile) sont gérés sans corruption d'état ni fuite de données. | |
| 15 | +3. La perf et l'observabilité permettent à un opérateur de **piloter** la fédération en prod. | |
| 16 | +4. La doc permet à un opérateur **autre que l'auteur** de configurer et opérer une instance fédérée. | |
| 17 | + | |
| 18 | +Sortir d'expérimental = **figer l'API publique sous garantie SemVer**. On s'engage donc à ne pas casser les intégrateurs sans bump majeur. Cet engagement coûte cher si on le prend trop tôt. | |
| 19 | + | |
| 20 | +## 2. Critères de sortie (résumé) | |
| 21 | + | |
| 22 | +| Catégorie | Critère | Mesure | | |
| 23 | +|---|---|---| | |
| 24 | +| Fonctionnel | 6 scénarios E2E passent automatisés | Suite `tests/e2e_fediverse/` | | |
| 25 | +| Sécurité | Replay, expiry, rate-limit, lockout testés | Tests négatifs verts | | |
| 26 | +| Privacy | Aucune fuite donnée hors du minimum déclaré | Audit payloads AP | | |
| 27 | +| Performance | p95 auth round-trip < 2s, fanout invite < 5s | Bench scriptable | | |
| 28 | +| Observabilité | 6 métriques clés émises | Endpoint `/metrics` ou logs structurés | | |
| 29 | +| Documentation | Guide opérateur + troubleshooting | `docs/fediverse-operations.md` | | |
| 30 | +| Interop | 2 instances Rustwarden distinctes en accord | Topologie de test reproductible | | |
| 31 | + | |
| 32 | +Détails dans la suite. | |
| 33 | + | |
| 34 | +## 3. Topologie de test | |
| 35 | + | |
| 36 | +On instancie **trois** Rustwarden locaux avec domaines distincts. Trois (et pas deux) parce que certains scénarios — notamment l'absence de trust transitif — ne peuvent se valider qu'à partir de trois. | |
| 37 | + | |
| 38 | +```mermaid | |
| 39 | +graph TB | |
| 40 | + subgraph Host["Machine de test"] | |
| 41 | + subgraph A["Instance A — alpha.local:3001"] | |
| 42 | + A_DB[(rustwarden_a)] | |
| 43 | + A_APP[rustwarden-app A<br/>FEDIVERSE_DOMAIN=alpha.local] | |
| 44 | + A_APP --> A_DB | |
| 45 | + end | |
| 46 | + subgraph B["Instance B — beta.local:3002"] | |
| 47 | + B_DB[(rustwarden_b)] | |
| 48 | + B_APP[rustwarden-app B<br/>FEDIVERSE_DOMAIN=beta.local] | |
| 49 | + B_APP --> B_DB | |
| 50 | + end | |
| 51 | + subgraph C["Instance C — gamma.local:3003"] | |
| 52 | + C_DB[(rustwarden_c)] | |
| 53 | + C_APP[rustwarden-app C<br/>FEDIVERSE_DOMAIN=gamma.local] | |
| 54 | + C_APP --> C_DB | |
| 55 | + end | |
| 56 | + A_APP -.HTTP+Sig.-> B_APP | |
| 57 | + B_APP -.HTTP+Sig.-> A_APP | |
| 58 | + A_APP -.HTTP+Sig.-> C_APP | |
| 59 | + C_APP -.HTTP+Sig.-> A_APP | |
| 60 | + B_APP -.HTTP+Sig.-> C_APP | |
| 61 | + C_APP -.HTTP+Sig.-> B_APP | |
| 62 | + end | |
| 63 | + Reverse[Reverse proxy local<br/>nginx/caddy<br/>resolves *.local → 127.0.0.1] --> A_APP | |
| 64 | + Reverse --> B_APP | |
| 65 | + Reverse --> C_APP | |
| 66 | +``` | |
| 67 | + | |
| 68 | +### 3.1 Mise en place concrète | |
| 69 | + | |
| 70 | +Trois options, par ordre de préférence : | |
| 71 | + | |
| 72 | +**Option 1 — `docker-compose.fediverse.yml`** (recommandée, reproductible) | |
| 73 | +- 3 services `rustwarden-app`, 3 PostgreSQL, 1 caddy frontal. | |
| 74 | +- Caddy fait TLS auto-signé + résolution `alpha.local`, `beta.local`, `gamma.local` (la signature HTTP nécessite un Host cohérent). | |
| 75 | +- Cible : tout démarre en `docker compose up`, tests lancent depuis l'hôte. | |
| 76 | + | |
| 77 | +**Option 2 — Script bash + `/etc/hosts`** (rapide à itérer) | |
| 78 | +- 3 binaires lancés en background avec ports + DB différents. | |
| 79 | +- `/etc/hosts` : `127.0.0.1 alpha.local beta.local gamma.local`. | |
| 80 | +- Pas de TLS : signatures HTTP fonctionnent en clair sur la draft-cavage. | |
| 81 | + | |
| 82 | +**Option 3 — `axum-test` + processus en mémoire** (le plus rapide en CI, le moins réaliste) | |
| 83 | +- 3 routers Axum dans le même process, branchés via `tower::ServiceExt::oneshot`. | |
| 84 | +- Convient aux tests unitaires E2E mais ne valide **pas** la couche HTTP réelle (sérialisation TCP, headers, timeouts). | |
| 85 | + | |
| 86 | +**Recommandation** : Option 1 en local + CI nightly, Option 3 en CI standard pour rapidité. | |
| 87 | + | |
| 88 | +## 4. Scénarios fonctionnels | |
| 89 | + | |
| 90 | +Chaque scénario suit le même pattern : **but pédagogique → pré-conditions → flux (mermaid) → critère de succès → critère d'échec attendu**. | |
| 91 | + | |
| 92 | +### S1 — SSO bilatéral A ↔ B | |
| 93 | + | |
| 94 | +**But** : prouver qu'un utilisateur d'A peut s'authentifier sur B sans recréer de compte. | |
| 95 | + | |
| 96 | +**Pré-conditions** : | |
| 97 | +- `alice` existe sur A (seed). | |
| 98 | +- B a A dans sa **trust list** (ou trust manuel via prompt consent). | |
| 99 | +- Aucune session existante de `@alice@alpha.local` sur B. | |
| 100 | + | |
| 101 | +**Flux** : | |
| 102 | + | |
| 103 | +```mermaid | |
| 104 | +sequenceDiagram | |
| 105 | + actor Alice | |
| 106 | + participant B as B (beta.local) | |
| 107 | + participant A as A (alpha.local) | |
| 108 | + | |
| 109 | + Alice->>B: GET /fediverse/login?handle=@alice@alpha.local | |
| 110 | + B->>A: GET /.well-known/webfinger?resource=acct:alice@alpha.local | |
| 111 | + A-->>B: 200 { actor: https://alpha.local/users/alice } | |
| 112 | + B->>A: GET /users/alice (Accept: application/activity+json) | |
| 113 | + A-->>B: 200 Person { publicKey, inbox, ... } | |
| 114 | + B->>A: POST /fediverse/auth/inbox<br/>AuthRequest{nonce, target=beta.local}<br/>HTTP-Signature | |
| 115 | + A-->>B: 202 Accepted | |
| 116 | + A-->>Alice: notification "B veut savoir qui vous êtes" (UI consent) | |
| 117 | + Alice->>A: POST /fediverse/consent { challenge_id, decision=approve } | |
| 118 | + A->>B: POST /fediverse/auth/inbox<br/>AuthApproval{nonce, subject=alice}<br/>HTTP-Signature | |
| 119 | + B-->>A: 202 Accepted | |
| 120 | + B->>B: création remote_identity (alice@alpha.local)<br/>+ génération JWT remote (IdentityKind::Remote) | |
| 121 | + B-->>Alice: redirect /home avec session JWT remote | |
| 122 | +``` | |
| 123 | + | |
| 124 | +**Critère de succès** : | |
| 125 | +- Alice atterrit sur `/home` de B avec un JWT contenant `identity_kind=remote` et `remote_id` pointant sur la `remote_identity` créée. | |
| 126 | +- Une ligne `audit_log` côté A et côté B horodate l'événement. | |
| 127 | + | |
| 128 | +**Critères d'échec attendus** (négatifs) : | |
| 129 | +- Si Alice rejette le consent → B reçoit `AuthDenial`, aucune session créée, message UX clair. | |
| 130 | +- Si A est dans la blocklist de B → 403 dès l'étape WebFinger, message UX clair. | |
| 131 | +- Si la signature HTTP est invalide → A et B retournent 401 sans créer de challenge. | |
| 132 | + | |
| 133 | +### S2 — Invitation cross-instance dans une équipe | |
| 134 | + | |
| 135 | +**But** : prouver qu'un admin de B peut ajouter `@alice@alpha.local` à une équipe sans qu'Alice ait jamais ouvert de compte sur B (et avant même qu'elle se connecte). | |
| 136 | + | |
| 137 | +**Pré-conditions** : | |
| 138 | +- `team_marketing` existe sur B avec admin `bob`. | |
| 139 | +- `alice` existe sur A. | |
| 140 | +- Trust mutuel A ↔ B configuré. | |
| 141 | + | |
| 142 | +**Flux** : | |
| 143 | + | |
| 144 | +```mermaid | |
| 145 | +sequenceDiagram | |
| 146 | + actor Bob as Bob (admin B) | |
| 147 | + participant B as B (beta.local) | |
| 148 | + participant A as A (alpha.local) | |
| 149 | + actor Alice | |
| 150 | + | |
| 151 | + Bob->>B: POST /admin/fediverse/invites<br/>{team_id, handle=@alice@alpha.local} | |
| 152 | + B->>B: création fediverse_pending_invite (state=Pending, ttl=7d) | |
| 153 | + B->>A: POST /fediverse/inbox<br/>Invite{team, target=alice, invite_url}<br/>HTTP-Signature | |
| 154 | + A-->>B: 202 Accepted | |
| 155 | + A->>A: notification persistée pour alice | |
| 156 | + Note over Alice: plus tard, Alice se connecte à A | |
| 157 | + Alice->>A: GET /home → voit notification | |
| 158 | + Alice->>A: clic lien d'invitation → redirect /fediverse/invite/{token} vers B | |
| 159 | + Alice->>B: GET /fediverse/invite/{token} (non authentifié sur B) | |
| 160 | + B-->>Alice: page landing "Acceptez-vous l'invitation à team_marketing ?" | |
| 161 | + Alice->>B: POST /fediverse/invite/{token}/accept | |
| 162 | + Note over B,A: déclenche flux S1 si pas de session B | |
| 163 | + B-->>Alice: redirect /home, alice membre de team_marketing | |
| 164 | + B->>A: POST /fediverse/inbox<br/>Accept{invite}<br/>HTTP-Signature | |
| 165 | + A->>A: marque l'invitation acceptée côté A (UI Bob mise à jour) | |
| 166 | +``` | |
| 167 | + | |
| 168 | +**Critère de succès** : | |
| 169 | +- `team_membership` créée sur B avec `remote_identity_id` d'Alice. | |
| 170 | +- Alice voit team_marketing dans `/profile` côté B. | |
| 171 | +- Bob voit dans `/admin/fediverse/invites` que l'invite est acceptée. | |
| 172 | +- Audit log côté B : "remote user accepted invite". | |
| 173 | + | |
| 174 | +**Critères d'échec attendus** : | |
| 175 | +- Token invite expiré → page "lien expiré" + bouton "demander un nouveau lien". | |
| 176 | +- Alice rejette → invite passe à `Rejected`, message à Bob. | |
| 177 | +- Bob révoque avant acceptation → token invalide, page UX claire. | |
| 178 | + | |
| 179 | +### S3 — Trois instances, pas de trust transitif | |
| 180 | + | |
| 181 | +**But** : prouver que si A fait confiance à B et B fait confiance à C, **A ne fait pas automatiquement confiance à C**. | |
| 182 | + | |
| 183 | +C'est un point de sécurité critique : sans cette garantie, un admin de B malveillant pourrait blanchir une identité C douteuse en l'invitant via lui-même. | |
| 184 | + | |
| 185 | +**Pré-conditions** : | |
| 186 | +- A trust B uniquement. | |
| 187 | +- B trust A et C. | |
| 188 | +- C trust B uniquement. | |
| 189 | +- A **ne trust pas** C, et C n'est pas dans la blocklist de A non plus (état "inconnu"). | |
| 190 | + | |
| 191 | +**Flux** : | |
| 192 | + | |
| 193 | +```mermaid | |
| 194 | +sequenceDiagram | |
| 195 | + participant A as A (alpha.local) | |
| 196 | + participant B as B (beta.local) | |
| 197 | + participant C as C (gamma.local) | |
| 198 | + actor Charlie as Charlie@C | |
| 199 | + actor AdminA as Admin A | |
| 200 | + | |
| 201 | + AdminA->>A: POST /admin/fediverse/invites<br/>{team_x, handle=@charlie@gamma.local} | |
| 202 | + A->>C: POST /fediverse/inbox Invite (signé par A) | |
| 203 | + Note over A,C: A et C n'ont jamais dialogué avant | |
| 204 | + C->>A: GET /users/admin (résolution actor pour vérif signature) | |
| 205 | + C->>C: vérifie signature → OK | |
| 206 | + C->>C: vérifie politique trust : A inconnu<br/>policy = "prompt user explicit consent" | |
| 207 | + C-->>A: 202 Accepted (Invite stockée pending) | |
| 208 | + Note over Charlie: Charlie se connecte à C | |
| 209 | + Charlie->>C: GET /home, voit notification | |
| 210 | + C-->>Charlie: page "A veut vous inviter dans team_x.<br/>⚠️ A n'est pas dans votre liste de confiance.<br/>Continuer ?" | |
| 211 | + Note over Charlie: Charlie clique Refuser | |
| 212 | + Charlie->>C: POST /fediverse/invite/{token}/reject | |
| 213 | + C->>A: POST /fediverse/inbox Reject | |
| 214 | +``` | |
| 215 | + | |
| 216 | +**Critère de succès** : | |
| 217 | +- C demande un consent **explicite et étiqueté "domaine inconnu"** à Charlie. | |
| 218 | +- Si Charlie refuse, A reçoit le Reject et n'a aucune autre voie. | |
| 219 | +- Aucun trust automatique inscrit en base. | |
| 220 | + | |
| 221 | +**Test négatif clé** : vérifier qu'aucune table ne stocke "A trusté par C" suite à ce flux. Le trust reste manuel. | |
| 222 | + | |
| 223 | +### S4 — Block list / révocation en masse | |
| 224 | + | |
| 225 | +**But** : prouver qu'un admin peut couper une instance hostile et que les sessions actives sont invalidées immédiatement. | |
| 226 | + | |
| 227 | +**Flux** : | |
| 228 | + | |
| 229 | +```mermaid | |
| 230 | +sequenceDiagram | |
| 231 | + actor AdminA as Admin A | |
| 232 | + participant A as A | |
| 233 | + participant B as B | |
| 234 | + actor Bob as Bob@B (session active sur A) | |
| 235 | + | |
| 236 | + Note over Bob: Bob a une session JWT remote sur A | |
| 237 | + AdminA->>A: POST /admin/fediverse/blocks { domain=beta.local } | |
| 238 | + A->>A: insertion fediverse_blocked_domain | |
| 239 | + A->>A: kill_domain → UPDATE remote_identities SET disabled_at=now()<br/>WHERE domain='beta.local' | |
| 240 | + A->>A: invalide JWT remote correspondants (blacklist + claim check) | |
| 241 | + Bob->>A: GET /home (avec ancien JWT) | |
| 242 | + A-->>Bob: 401 + redirect /login (message "session révoquée") | |
| 243 | + B->>A: POST /fediverse/inbox AuthRequest pour Bob | |
| 244 | + A-->>B: 403 Domain blocked | |
| 245 | +``` | |
| 246 | + | |
| 247 | +**Critère de succès** : | |
| 248 | +- Délai entre clic admin et invalidation effective : < 5s (mesuré). | |
| 249 | +- Aucune nouvelle requête de B ne passe. | |
| 250 | +- Audit log enregistre l'événement avec opérateur, raison, timestamp. | |
| 251 | + | |
| 252 | +### S5 — Modes de panne | |
| 253 | + | |
| 254 | +**But** : prouver que les chemins d'erreur ne corrompent pas l'état et donnent des messages exploitables. | |
| 255 | + | |
| 256 | +| Cas | Provoqué par | Comportement attendu | | |
| 257 | +|---|---|---| | |
| 258 | +| **Réseau down B→A pendant invite** | Couper le port B en cours de flux | Retry exponentiel via `OutboundWorker` (jusqu'à N fois), invite reste `Pending`, alerte si dead-letter | | |
| 259 | +| **Replay AuthRequest** | Rejouer même nonce/signature | 1er passe (202), 2e rejeté (409 Conflict ou 401 Replay) | | |
| 260 | +| **Challenge expiré** | Attendre TTL+1 (5min) avant d'approuver | Approve renvoie "challenge expired", aucune session créée | | |
| 261 | +| **Clock skew > 30s** | Modifier horloge instance | Signature rejetée (401), message log explicite | | |
| 262 | +| **WebFinger 404** | Handle inexistant | 404 propre côté B, pas de challenge créé, message UX clair | | |
| 263 | +| **Instance distante OOM** | Killer B pendant flux | A retry sur sa file outbound, dead-letter après N échecs, métrique incrémentée | | |
| 264 | + | |
| 265 | +Chaque cas devient **un test négatif** en CI. | |
| 266 | + | |
| 267 | +### S6 — Cycle de vie : Update / Delete Person | |
| 268 | + | |
| 269 | +**But** : valider la propagation des changements d'identité. | |
| 270 | + | |
| 271 | +```mermaid | |
| 272 | +sequenceDiagram | |
| 273 | + actor Alice | |
| 274 | + participant A as A | |
| 275 | + participant B as B | |
| 276 | + Note over Alice,B: Alice a session active sur B (S1 déjà passé) | |
| 277 | + Alice->>A: PUT /profile { display_name="Alice Dupont" } | |
| 278 | + A->>A: UPDATE users SET display_name=... | |
| 279 | + A->>B: POST /fediverse/inbox<br/>Update Person { actor: alice } | |
| 280 | + B->>A: GET /users/alice (re-fetch actor) | |
| 281 | + A-->>B: 200 Person { display_name: "Alice Dupont", ... } | |
| 282 | + B->>B: UPDATE remote_identity SET display_name='Alice Dupont'<br/>WHERE ap_id='https://alpha.local/users/alice' | |
| 283 | + Note over Alice,B: prochaine page B affiche le nouveau nom | |
| 284 | + | |
| 285 | + Alice->>A: DELETE /account | |
| 286 | + A->>A: soft-delete users.alice | |
| 287 | + A->>B: POST /fediverse/inbox<br/>Delete Person { actor: alice } | |
| 288 | + B->>B: kill_remote_identity → disabled_at=now(),<br/>retrait des team_memberships,<br/>invalidation JWT remote | |
| 289 | +``` | |
| 290 | + | |
| 291 | +**Critère de succès** : | |
| 292 | +- Le `display_name` se met à jour sur B sous 1 minute (ou immédiat si Update reçu). | |
| 293 | +- Après Delete, Alice ne peut plus se reconnecter sur B (401), retirée de toutes les équipes, audit log enregistré. | |
| 294 | + | |
| 295 | +## 5. Critères qualité non-fonctionnels | |
| 296 | + | |
| 297 | +### 5.1 Sécurité | |
| 298 | + | |
| 299 | +| Item | Comment vérifier | | |
| 300 | +|---|---| | |
| 301 | +| Signature HTTP draft-cavage RSA-SHA256 vérifiée systématiquement | Test négatif : retirer header Signature → 401 | | |
| 302 | +| Fenêtre `Date` ±30s appliquée | Test négatif : forger Date+1h → 401 | | |
| 303 | +| Replay protection | Cache nonce/signature TTL ; rejouer même requête → 401 | | |
| 304 | +| Rate-limit `/fediverse/inbox` par domaine source | Bench : 1000 req/s d'un même domaine → 429 après seuil | | |
| 305 | +| Lockout `(local_user, idp_domain)` | 10 challenges échec en 5min → cooldown 15min | | |
| 306 | +| Pas d'info user sensible dans payload AP | Audit manuel des structs sérialisées : `User.password_hash`, `User.email` ne doivent jamais apparaître hors champ déclaré | | |
| 307 | + | |
| 308 | +### 5.2 Privacy / minimisation | |
| 309 | + | |
| 310 | +Le payload AP `Person` exposé via `/users/{handle}` doit contenir **uniquement** : | |
| 311 | +- `id`, `type=Person`, `preferredUsername`, `inbox`, `outbox`, `publicKey` | |
| 312 | +- `name` (display_name optionnel) | |
| 313 | +- **Pas** : email, hash, rôles internes, équipes, dernière IP, derniers logins. | |
| 314 | + | |
| 315 | +Audit : `grep -r "serialize" crates/rustwarden-core/src/fediverse/` + revue visuelle des structs. | |
| 316 | + | |
| 317 | +### 5.3 Performance | |
| 318 | + | |
| 319 | +| Métrique | Cible | Méthode | | |
| 320 | +|---|---|---| | |
| 321 | +| p95 auth round-trip A↔B (S1 complet) | < 2s | Script `wrk` ou bench Rust | | |
| 322 | +| p95 invite delivery A→B | < 5s | Idem | | |
| 323 | +| Throughput inbox B (signatures concurrentes) | > 100 req/s sur 1 worker | Bench | | |
| 324 | +| Taille file outbound stable sous charge | < 1000 rows pending | Métriques + load test | | |
| 325 | + | |
| 326 | +### 5.4 Observabilité | |
| 327 | + | |
| 328 | +Six métriques **minimum** à exposer (Prometheus ou logs structurés) : | |
| 329 | + | |
| 330 | +1. `fediverse_inbox_requests_total{domain, kind, result}` — compteur signatures vérifiées/rejetées. | |
| 331 | +2. `fediverse_outbound_queue_depth{state}` — gauge pending/done/dead-letter. | |
| 332 | +3. `fediverse_auth_challenge_total{result}` — succès/refus/expirés. | |
| 333 | +4. `fediverse_invite_total{result}` — créés/acceptés/refusés/expirés/révoqués. | |
| 334 | +5. `fediverse_webfinger_duration_seconds{domain}` — histogramme latence discovery. | |
| 335 | +6. `fediverse_signature_failures_total{domain, reason}` — détection abuse. | |
| 336 | + | |
| 337 | +### 5.5 Observabilité — état des challenges | |
| 338 | + | |
| 339 | +```mermaid | |
| 340 | +stateDiagram-v2 | |
| 341 | + [*] --> Pending : initiate | |
| 342 | + Pending --> Approved : user consent OK | |
| 343 | + Pending --> Rejected : user consent KO | |
| 344 | + Pending --> Expired : TTL 5min | |
| 345 | + Pending --> Cancelled : revoke admin | |
| 346 | + Approved --> [*] | |
| 347 | + Rejected --> [*] | |
| 348 | + Expired --> [*] : GC quotidien | |
| 349 | + Cancelled --> [*] : GC quotidien | |
| 350 | +``` | |
| 351 | + | |
| 352 | +```mermaid | |
| 353 | +stateDiagram-v2 | |
| 354 | + [*] --> Pending : admin invite | |
| 355 | + Pending --> Accepted : remote user accept | |
| 356 | + Pending --> Rejected : remote user reject | |
| 357 | + Pending --> Expired : TTL 7j | |
| 358 | + Pending --> Revoked : admin revoke | |
| 359 | + Accepted --> [*] | |
| 360 | + Rejected --> [*] | |
| 361 | + Expired --> [*] : GC quotidien | |
| 362 | + Revoked --> [*] : GC quotidien | |
| 363 | +``` | |
| 364 | + | |
| 365 | +Chaque transition doit incrémenter une métrique et logger un événement audit. | |
| 366 | + | |
| 367 | +## 6. Plan d'exécution | |
| 368 | + | |
| 369 | +### Itération 0 — Mise en place environnement (1j) — ✅ LIVRÉ (2026-05-10) | |
| 370 | +- ✅ `tests/fediverse-testbed/` au lieu de `docker-compose.fediverse.yml` à la racine ; pas de caddy (HTTP plain via `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`). | |
| 371 | +- ✅ 4 scripts cycle de vie : `setup.sh`, `start.sh`, `stop.sh`, `teardown.sh` ; seed Happy Days + trust list configurée par psql direct. | |
| 372 | +- ✅ Plan détaillé : [2026-05-10-iter0-fediverse-docker-compose.md](2026-05-10-iter0-fediverse-docker-compose.md). | |
| 373 | + | |
| 374 | +### Itération 1 — S1 SSO bilatéral automatisé (2j) — ✅ LIVRÉ (2026-05-11) | |
| 375 | +- ✅ 14 tests E2E dans `crates/rustwarden-core/tests/e2e_fediverse.rs` (1a/1b/1c/1d). | |
| 376 | +- ✅ Findings F1, F2, F3 trouvés et corrigés ; RFC 0025 (back-channel `Accept(AuthRequest)`) écrite et acceptée. | |
| 377 | +- ✅ Round-trip signé bout-en-bout via crypto réelle (RSA-2048 + draft-cavage). | |
| 378 | +- Plan détaillé : [2026-05-11-fediverse-stable-promotion-iter1-findings.md](2026-05-11-fediverse-stable-promotion-iter1-findings.md). | |
| 379 | + | |
| 380 | +### Itération 2 — S2 Invite cross-instance (2j) — ✅ LIVRÉ flux B-only (2026-05-11) | |
| 381 | +- ✅ 4 tests E2E (admin invite → team_member binding, reject, revoke, IdP listing). | |
| 382 | +- 🟡 F4–F7 différés (back-channel AP `Invite`/`Accept(Invite)` — UX non-bloquant). | |
| 383 | +- Plan détaillé : [2026-05-11-fediverse-evaluation-iter2-3-findings.md](2026-05-11-fediverse-evaluation-iter2-3-findings.md). | |
| 384 | + | |
| 385 | +### Itération 3 — S3 Trois instances pas de transitivité (1j) — ✅ LIVRÉ (2026-05-11) | |
| 386 | +- ✅ 4 tests E2E (non-transitivité écriture, permanence, untrusted rejected 403, trusted passe). | |
| 387 | +- ✅ **Finding F8 corrigé** (la trust list était décorative au niveau protocole) — bloquant promotion stable levé. | |
| 388 | +- ✅ `FediverseError::Forbidden` ajouté + `FediverseTrustService::assert_domain_allowed` câblé dans `auth_inbox` et `/inbox`. | |
| 389 | +- Plan détaillé : [2026-05-11-fediverse-evaluation-iter2-3-findings.md](2026-05-11-fediverse-evaluation-iter2-3-findings.md). | |
| 390 | + | |
| 391 | +### Itération 4 — S4 Block / révocation (1j) — ✅ LIVRÉ (2026-05-11) | |
| 392 | +- ✅ 4 tests E2E (`s4_blocked_domain_payload_is_rejected_with_403`, `s4_kill_domain_marks_remote_identities_disabled`, `s4_add_blocked_domain_auto_kills_remote_identities`, `s4_kill_domain_invalidates_active_remote_jwt`). | |
| 393 | +- ✅ **Finding F9** corrigé : `add_blocked_domain` UI handler effectue désormais atomiquement INSERT blocklist + UPDATE `disabled_at` sur `remote_identities`. | |
| 394 | +- ✅ **Finding F10** corrigé : middleware `AuthService` vérifie `remote_identities.disabled_at` au validation JWT — un JWT remote dont l'identité backing a été désactivée est immédiatement rejeté (redirect /login). Pas besoin d'entrée `jwt_blacklist` séparée. | |
| 395 | +- Plan détaillé : [2026-05-11-fediverse-evaluation-iter2-3-findings.md](2026-05-11-fediverse-evaluation-iter2-3-findings.md). | |
| 396 | + | |
| 397 | +### Itération 5 — S5 Modes de panne (2j) — ✅ LIVRÉ (2026-05-11) | |
| 398 | +- ✅ 4 tests négatifs (`s5_replay_authrequest_with_same_nonce_rejected`, `s5_webfinger_returns_404_for_unknown_user`, `s5_initiate_rejects_malformed_handle`, `s5_finding_f11_clock_skew_not_enforced_with_debug_verifier`). | |
| 399 | +- ✅ Replay protection : contrainte UNIQUE sur `fediverse_auth_challenges.nonce` empêche le rejeu. | |
| 400 | +- 🟡 **Finding F11** documenté : clock skew non-enforced avec `AlwaysAcceptVerifier` (debug). Le real `HttpSigVerifier` (prod) le fait. Non-bloquant car testbed est dev-only. | |
| 401 | +- Cases non couverts E2E : réseau down (nécessiterait proxy iptables ou middleware tower simulant timeout), Instance OOM. Test via observation manuelle des métriques outbound queue. | |
| 402 | + | |
| 403 | +### Itération 6 — S6 Lifecycle Update/Delete (1j) — ✅ LIVRÉ (2026-05-11) | |
| 404 | +- ✅ 2 tests E2E (`s6_update_person_refreshes_cached_display_name`, `s6_delete_person_disables_remote_identity_and_kills_active_jwt`). | |
| 405 | +- ✅ Confirme que `dispatch_update` / `dispatch_delete` côté `/inbox` (RFC 0010) tournent correctement contre les payloads AP `Update(Person)` et `Delete(Person)`. | |
| 406 | +- ✅ Validation end-to-end de la chaîne **Delete → disabled_at → F10 JWT rejection** : un Delete(Person) signé d'alpha invalide instantanément les sessions actives sur beta. | |
| 407 | + | |
| 408 | +### Itération 7 — Bench performance (1j) — 🟡 DIFFÉRÉ post-promotion | |
| 409 | +- Non-bloquant pour promotion stable v0.x ; pertinent pour SLA prod. | |
| 410 | +- Action future : script `tests/bench_fediverse/` + capture p95. | |
| 411 | + | |
| 412 | +### Itération 8 — Audit privacy + sécurité (1j) — ✅ LIVRÉ partiel (2026-05-11) | |
| 413 | +- ✅ Audit privacy manuel : Person AP / WebFinger / NodeInfo / UserSummary / logs — aucune fuite (email, password_hash, rôles, IPs, clé privée). Finding F12 latent (`summary_html` non sanitized, mais jamais rendu UI). | |
| 414 | +- ✅ Replay protection validée par S5 (`s5_replay_authrequest_with_same_nonce_rejected`). | |
| 415 | +- 🟡 Rate-limit + lockout — non implémentés. Action future : ajouter `tower::limit` + table `auth_attempt_lockout`. ~1 jour. | |
| 416 | +- 🟡 Pen test léger formel — partiellement couvert par négatifs S1/S3/S5. Action future : 2-3h dédiées. | |
| 417 | + | |
| 418 | +### Itération 9 — Documentation opérateur (1j) — ✅ LIVRÉ (2026-05-11) | |
| 419 | +- ✅ [`docs/fediverse-operations.md`](../fediverse-operations.md) écrit (10 sections Diátaxis how-to + reference) : activation, env vars, `app_settings`, trust/block list, incident playbook, observabilité, GC, critères promotion, URLs exposées, limites connues. | |
| 420 | +- 🟡 Revue par un tiers à programmer. | |
| 421 | + | |
| 422 | +### Itération 10 — Promotion (0.5j) | |
| 423 | +- Renommer `experimental-fediverse` → `fediverse` dans tous les `Cargo.toml`. | |
| 424 | +- Entrée CHANGELOG : "BREAKING: feature renamed". | |
| 425 | +- Bump de version selon politique projet. | |
| 426 | +- Annonce dans README. | |
| 427 | + | |
| 428 | +**Total estimé** : ~12 jours-ouvrés (itérations séquentielles, certaines parallélisables). | |
| 429 | + | |
| 430 | +## 7. Quality gates de promotion | |
| 431 | + | |
| 432 | +Avant le merge final qui renomme le flag, **tous** ces points doivent être verts : | |
| 433 | + | |
| 434 | +```mermaid | |
| 435 | +flowchart LR | |
| 436 | + subgraph Tests["Tests"] | |
| 437 | + T1[S1 SSO ✓] | |
| 438 | + T2[S2 Invite ✓] | |
| 439 | + T3[S3 No transitive ✓] | |
| 440 | + T4[S4 Block ✓] | |
| 441 | + T5[S5 Failures ✓] | |
| 442 | + T6[S6 Lifecycle ✓] | |
| 443 | + end | |
| 444 | + subgraph Quality["Qualité"] | |
| 445 | + Q1[Build clean] | |
| 446 | + Q2[Clippy -D warnings] | |
| 447 | + Q3[Tests unit + E2E green] | |
| 448 | + Q4[Doc clean] | |
| 449 | + Q5[cargo audit clean] | |
| 450 | + end | |
| 451 | + subgraph NF["Non-fonctionnel"] | |
| 452 | + N1[Perf p95 OK] | |
| 453 | + N2[6 métriques émises] | |
| 454 | + N3[Audit privacy passé] | |
| 455 | + N4[Pen test léger OK] | |
| 456 | + end | |
| 457 | + subgraph Doc["Documentation"] | |
| 458 | + D1[Guide opérateur] | |
| 459 | + D2[Troubleshooting] | |
| 460 | + D3[Revue tierce passée] | |
| 461 | + end | |
| 462 | + Tests --> Promotion[Promotion fediverse stable] | |
| 463 | + Quality --> Promotion | |
| 464 | + NF --> Promotion | |
| 465 | + Doc --> Promotion | |
| 466 | +``` | |
| 467 | + | |
| 468 | +Si **un seul** item est rouge → on reporte la promotion. Pas de "on verra ça après". | |
| 469 | + | |
| 470 | +## 8. Risques identifiés | |
| 471 | + | |
| 472 | +| Risque | Impact | Mitigation | | |
| 473 | +|---|---|---| | |
| 474 | +| Tests E2E flaky (timing réseau) | CI rouge intermittent | Retries explicites + timeouts généreux + `MockClock` pour TTL | | |
| 475 | +| Bench non reproductible (charge machine) | Faux négatifs perf | Bench tagué nightly, pas blocant CI standard | | |
| 476 | +| Bugs découverts tard dans l'itération 9 | Glissement | Si bug **critique** trouvé après itération 7, retour itération concernée — pas de raccourci | | |
| 477 | +| Spec ActivityPub interprétée différemment de Mastodon | Interop limitée | Documenter explicitement les choix d'implémentation, pas de promesse Mastodon-compat | | |
| 478 | + | |
| 479 | +## 9. Hors périmètre (explicite) | |
| 480 | + | |
| 481 | +Ne **pas** chercher à valider dans cette campagne : | |
| 482 | +- Interop Mastodon réel (autre campagne, autre coût, autre cible). | |
| 483 | +- Notes / timelines / followers / fanout social (jamais dans Rustwarden). | |
| 484 | +- Move activity (rare, marginal pour SSO). | |
| 485 | +- Rotation de clés RSA actor (RFC future, pas bloquant pour stable v1). | |
| 486 | + | |
| 487 | +## 10. Annexe — commandes utiles | |
| 488 | + | |
| 489 | +```bash | |
| 490 | +# Lancer la topologie 3 instances | |
| 491 | +docker compose -f docker-compose.fediverse.yml up -d | |
| 492 | + | |
| 493 | +# Lancer un seul scénario | |
| 494 | +cargo test --test e2e_fediverse s1_sso_bilateral --features experimental-fediverse | |
| 495 | + | |
| 496 | +# Lancer toute la suite E2E | |
| 497 | +cargo test --test e2e_fediverse --features experimental-fediverse -- --test-threads=1 | |
| 498 | + | |
| 499 | +# Bench perf | |
| 500 | +cargo bench --bench fediverse_perf --features experimental-fediverse | |
| 501 | + | |
| 502 | +# Audit privacy (grep des champs sensibles) | |
| 503 | +rg --type rust 'serialize|to_value' crates/rustwarden-core/src/fediverse/ | |
| 504 | + | |
| 505 | +# Vérifier les 6 métriques | |
| 506 | +curl -s http://alpha.local:3001/metrics | grep fediverse_ | |
| 507 | + | |
| 508 | +# Lecture des logs structurés en cas d'incident | |
| 509 | +docker compose logs rustwarden-app-a | jq 'select(.target | startswith("rustwarden::fediverse"))' | |
| 510 | +``` | |
| 511 | + | |
| 512 | +## 11. Critère d'arrêt anticipé | |
| 513 | + | |
| 514 | +Si pendant l'itération 8 (audit sécurité/privacy) on découvre une faille **fondamentale** dans le protocole tel qu'implémenté (ex. : minimisation impossible sans casser un flux, ou trust transitif accidentel non corrigeable sans refonte), **on n'avance pas vers la promotion**. On retourne sur le RFC concerné, on accepte de rester en `experimental-*` quelques mois de plus, et on documente la décision dans le CHANGELOG. | |
| 515 | + | |
| 516 | +L'objectif de cette campagne n'est pas "promouvoir coûte que coûte" mais "obtenir des preuves objectives qu'on peut promouvoir sans regret". | |
| 517 | + | |
| 518 | +--- | |
| 519 | + | |
| 520 | +**Prochaine action concrète** : valider le plan, choisir l'option de topologie (1, 2 ou 3), ouvrir l'itération 0. |
A
docs/plans/2026-05-10-iter0-fediverse-docker-compose.md
+157
-0
@@ -0,0 +1,157 @@
| 1 | +# Itération 0 — docker-compose.fediverse.yml : topologie 3 instances locale, 100 % automatisable | |
| 2 | + | |
| 3 | +## Contexte | |
| 4 | + | |
| 5 | +Pour évaluer la promotion `experimental-fediverse` → `fediverse` stable, le plan parent ([docs/plans/2026-05-09-fediverse-identity-federation.md](docs/plans/2026-05-09-fediverse-identity-federation.md) et [docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md](docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md)) requiert une topologie de 3 instances Rustwarden distinctes pour valider l'identité fédérée Rustwarden↔Rustwarden. L'utilisateur veut savoir si l'**option 1** (docker-compose) est totalement automatisable et exploitable en local. | |
| 6 | + | |
| 7 | +**Réponse : oui**, et avec une simplification importante par rapport à ma première proposition. | |
| 8 | + | |
| 9 | +### Découvertes qui changent le design | |
| 10 | + | |
| 11 | +| Découverte | Source | Conséquence | | |
| 12 | +|---|---|---| | |
| 13 | +| Flag `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` accepte HTTP plain | `crates/rustwarden-core/src/config/fediverse.rs:120` (et l.427 pour le bypass HTTPS) | **Caddy/TLS supprimés** — gain énorme en simplicité | | |
| 14 | +| `APP_DEBUG=true` + `COOKIE_SECURE=false` permettent cookies HTTP plain | `crates/rustwarden-core/src/config/auth.rs:171-188` | Le frontend UI fonctionne sans HTTPS | | |
| 15 | +| Aucun Dockerfile pour `rustwarden-app` n'existe | exploration | À créer (50 lignes, multi-stage) | | |
| 16 | +| `/health` existe déjà | `crates/rustwarden-ui/src/handlers/health.rs` | healthchecks compose triviaux | | |
| 17 | +| Migrations via `run_migrations(db)` au démarrage de l'app (pas via binaire `migrate` séparé) | `crates/rustwarden-core/migrations/src/lib.rs:83` | Pas besoin d'un service `migrate` séparé en compose | | |
| 18 | +| Clés RSA AP générées à la demande (`actor_key_service::ensure_keys`) | `crates/rustwarden-core/src/services/actor_key_service.rs:95` | Le seed n'a rien à pré-générer | | |
| 19 | + | |
| 20 | +## Architecture cible | |
| 21 | + | |
| 22 | +``` | |
| 23 | +docker-compose.fediverse.yml (à créer à la racine) | |
| 24 | +├── postgres-alpha : PostgreSQL 18, DB rustwarden_alpha, port 5433 | |
| 25 | +├── postgres-beta : idem, DB rustwarden_beta, port 5434 | |
| 26 | +├── postgres-gamma : idem, DB rustwarden_gamma, port 5435 | |
| 27 | +├── app-alpha : rustwarden-app, FEDIVERSE_DOMAIN=alpha.local, port 3001 | |
| 28 | +├── app-beta : idem, beta.local, port 3002 | |
| 29 | +├── app-gamma : idem, gamma.local, port 3003 | |
| 30 | +└── seed-alpha/beta/gamma : job one-shot qui appelle le binaire seed une fois la DB ready | |
| 31 | +``` | |
| 32 | + | |
| 33 | +Réseau docker unique `fediverse-net`. Chaque service `app-*` a un `network_alias` correspondant à son `FEDIVERSE_DOMAIN`, ce qui permet aux 3 apps de se parler **en interne** via leur nom de domaine logique (`http://alpha.local:3000`) sans `/etc/hosts` ni reverse proxy. | |
| 34 | + | |
| 35 | +Pour l'**hôte** (développeur ou tests CLI depuis la machine), exposition via ports : `localhost:3001` → app-alpha, etc. Si l'utilisateur veut taper `http://alpha.local:3001` dans son navigateur, **une seule ligne à ajouter à `/etc/hosts`** (alternative facultative). | |
| 36 | + | |
| 37 | +## Fichiers à produire | |
| 38 | + | |
| 39 | +**Tout vit dans un seul dossier dédié** : `tests/fediverse-testbed/`. Aucun fichier infra à la racine du repo, aucune pollution du workspace. | |
| 40 | + | |
| 41 | +``` | |
| 42 | +tests/fediverse-testbed/ ← dossier unique, isolé | |
| 43 | +├── Dockerfile # multi-stage build de rustwarden-app | |
| 44 | +├── docker-compose.yml # 3 PG + 3 app + 3 seed-jobs | |
| 45 | +├── env/ | |
| 46 | +│ ├── alpha.env # env vars instance A | |
| 47 | +│ ├── beta.env # env vars instance B | |
| 48 | +│ └── gamma.env # env vars instance C | |
| 49 | +├── scripts/ | |
| 50 | +│ ├── setup.sh # up --wait + post-seed trust lists | |
| 51 | +│ ├── teardown.sh # down -v (purge volumes) | |
| 52 | +│ └── configure-trust.sh # script curl appelé par setup.sh | |
| 53 | +├── .gitignore # ignore data/, *.log, .env.local | |
| 54 | +└── README.md # mode d'emploi opérateur | |
| 55 | +``` | |
| 56 | + | |
| 57 | +| Fichier | Rôle | LoC approx | | |
| 58 | +|---|---|---| | |
| 59 | +| `tests/fediverse-testbed/Dockerfile` | Multi-stage Rust build → image runtime mince (debian-slim ou distroless), feature `experimental-fediverse` activée | ~40 | | |
| 60 | +| `tests/fediverse-testbed/docker-compose.yml` | 6 services (3 PG + 3 app) + 3 jobs seed + healthchecks + dépendances | ~150 | | |
| 61 | +| `tests/fediverse-testbed/env/{alpha,beta,gamma}.env` | env vars par instance (DB, JWT, ENCRYPTION_KEY, FEDIVERSE_DOMAIN) | ~15 chacun | | |
| 62 | +| `tests/fediverse-testbed/scripts/setup.sh` | `docker compose up -d --wait` + attente healthchecks + appel à configure-trust.sh | ~50 | | |
| 63 | +| `tests/fediverse-testbed/scripts/teardown.sh` | `docker compose down -v` (purge volumes pour reproductibilité) | ~10 | | |
| 64 | +| `tests/fediverse-testbed/scripts/configure-trust.sh` | `curl` sur les API admin des 3 instances pour cross-trust mutuel | ~40 | | |
| 65 | +| `tests/fediverse-testbed/README.md` | Mode d'emploi : `cd tests/fediverse-testbed && ./scripts/setup.sh` | ~50 | | |
| 66 | +| `tests/fediverse-testbed/.gitignore` | Ignore `data/`, `*.log`, fichiers locaux | ~5 | | |
| 67 | + | |
| 68 | +**Aucune modification du code Rust n'est nécessaire pour cette itération.** Tout est de l'infra autour du binaire existant. | |
| 69 | + | |
| 70 | +Les tests E2E Rust eux-mêmes (`cargo test`) viendront dans les itérations 1+ et iront dans `crates/rustwarden-core/tests/e2e_fediverse/` (intégration cargo standard), pas dans ce dossier infra. **Séparation nette : `tests/fediverse-testbed/` = infra docker, `crates/*/tests/` = code Rust de test.** | |
| 71 | + | |
| 72 | +## Détail des variables d'env par instance (factuel, dérivé de l'exploration) | |
| 73 | + | |
| 74 | +Toutes obligatoires sauf marqué défaut. Communes aux 3 instances avec valeurs distinctes : | |
| 75 | + | |
| 76 | +``` | |
| 77 | +DATABASE_URL=postgres://rustwarden:rustwarden@postgres-<alpha|beta|gamma>:5432/rustwarden_<alpha|beta|gamma> | |
| 78 | +JWT_SECRET=<32+ bytes générés par seed, distincts par instance> | |
| 79 | +ENCRYPTION_KEY=<64 hex chars distincts par instance> | |
| 80 | +SERVER_HOST=0.0.0.0 | |
| 81 | +SERVER_PORT=3000 # même port en interne, mappé différemment en externe | |
| 82 | +FEDIVERSE_DOMAIN=<alpha|beta|gamma>.local | |
| 83 | +FEDIVERSE_DEBUG_LOCAL_FEDERATION=true # clé du dispositif : autorise HTTP | |
| 84 | +APP_DEBUG=true # cookies non Secure | |
| 85 | +COOKIE_SECURE=false | |
| 86 | +SESSION_BACKEND=seaorm | |
| 87 | +JWT_ISSUER=rustwarden-<alpha|beta|gamma> | |
| 88 | +``` | |
| 89 | + | |
| 90 | +`FEDIVERSE_DOMAIN=alpha.local` couplé à `network_aliases: [alpha.local]` sur le service `app-alpha` permet aux deux autres apps de l'atteindre via `http://alpha.local:3000` sans configuration DNS supplémentaire. | |
| 91 | + | |
| 92 | +## Pourquoi c'est 100 % automatisable | |
| 93 | + | |
| 94 | +1. **Aucune intervention manuelle entre `docker compose up` et `cargo test`.** | |
| 95 | + - DB créée par image Postgres officielle via variables `POSTGRES_DB/USER/PASSWORD`. | |
| 96 | + - Migrations exécutées par l'app au démarrage (`run_migrations` dans le bootstrap). | |
| 97 | + - Seed admin lancé en job `depends_on` avec `condition: service_healthy`. | |
| 98 | + - Trust list configurée par script `curl` post-démarrage frappant `/admin/fediverse/trust` via JWT admin. | |
| 99 | + | |
| 100 | +2. **Aucune dépendance hôte hors Docker.** Pas de `/etc/hosts`, pas de TLS, pas de mkcert, pas de DNS local. Seul prérequis : Docker installé. La résolution `alpha.local` se fait dans le réseau Docker via `network_aliases`. | |
| 101 | + | |
| 102 | +3. **Reproductible.** `teardown.sh` purge les volumes ; `setup.sh` repart à zéro. Pas de fuite d'état entre runs. | |
| 103 | + | |
| 104 | +4. **Exploitable en CI.** Le même `docker compose up --wait` fonctionne en GitHub Actions / GitLab CI (services Docker disponibles partout). Les seuls coûts : ~30 s de démarrage (compile Rust en image cachée) + ~10 s de démarrage runtime des 6 services. | |
| 105 | + | |
| 106 | +5. **Pas besoin de Mastodon ni d'instance externe.** L'objectif est Rustwarden ↔ Rustwarden ; aucune dépendance réseau externe. | |
| 107 | + | |
| 108 | +## Limites connues à documenter dans le README | |
| 109 | + | |
| 110 | +- L'accès humain au navigateur depuis l'hôte requiert soit `localhost:3001` (mais alors les URLs AP retournées par l'API ne sont pas joignables côté humain car contiennent `alpha.local`), soit ajout `/etc/hosts` ponctuel. Pour les tests automatisés, sans navigateur, **non bloquant**. | |
| 111 | +- HTTP plain : à n'utiliser qu'en local. Le flag `FEDIVERSE_DEBUG_LOCAL_FEDERATION` impose un check qui refuse `debug_local_federation=true` en production (à vérifier dans `config/fediverse.rs:120-135` — si pas déjà fait, ajout à prévoir hors périmètre de cette itération). | |
| 112 | +- L'image Docker à compiler à chaque modif source. Mitigation : Dockerfile multi-stage avec layer cache sur `cargo chef` ou équivalent — gain ~80 % sur recompil incrémentale. | |
| 113 | + | |
| 114 | +## Vérification end-to-end | |
| 115 | + | |
| 116 | +Une fois les fichiers produits, l'exécution attendue est : | |
| 117 | + | |
| 118 | +```bash | |
| 119 | +cd tests/fediverse-testbed | |
| 120 | + | |
| 121 | +# Démarrage complet, attente readiness | |
| 122 | +./scripts/setup.sh | |
| 123 | + | |
| 124 | +# Vérifications manuelles minimales (depuis l'hôte, ports mappés) | |
| 125 | +curl http://localhost:3001/health # 200 OK | |
| 126 | +curl http://localhost:3002/health # 200 OK | |
| 127 | +curl http://localhost:3003/health # 200 OK | |
| 128 | + | |
| 129 | +# WebFinger croisé | |
| 130 | +curl 'http://localhost:3001/.well-known/webfinger?resource=acct:admin@alpha.local' | |
| 131 | + | |
| 132 | +# Inspection trust list configurée par configure-trust.sh | |
| 133 | +curl -H "Authorization: Bearer <admin_jwt_alpha>" \ | |
| 134 | + http://localhost:3001/admin/fediverse/trust | |
| 135 | +# → doit contenir beta.local et gamma.local | |
| 136 | + | |
| 137 | +# Lancement de la suite E2E (itérations 1+, ailleurs dans crates/*/tests/) | |
| 138 | +cd ../.. | |
| 139 | +cargo test --test e2e_fediverse --features experimental-fediverse -- --test-threads=1 | |
| 140 | + | |
| 141 | +# Nettoyage (depuis tests/fediverse-testbed/) | |
| 142 | +./scripts/teardown.sh | |
| 143 | +``` | |
| 144 | + | |
| 145 | +**Critère de succès de l'itération 0** : | |
| 146 | +1. `setup.sh` retourne 0 sans intervention manuelle, en moins de 60 s à froid (build inclus) ou 15 s à chaud (image cachée). | |
| 147 | +2. Les 3 endpoints `/health` répondent 200. | |
| 148 | +3. Le WebFinger d'admin sur chaque instance répond avec un payload AP valide. | |
| 149 | +4. La trust list de chaque instance contient bien les 2 autres après `setup.sh`. | |
| 150 | +5. `teardown.sh` laisse zéro container et zéro volume. | |
| 151 | + | |
| 152 | +## Post-actions obligatoires (CLAUDE.md du projet) | |
| 153 | + | |
| 154 | +Une fois ce plan validé via ExitPlanMode : | |
| 155 | +1. Copier ce fichier vers `docs/plans/2026-05-10-iter0-fediverse-docker-compose.md` (nom descriptif). | |
| 156 | +2. Supprimer `~/.claude/plans/logical-zooming-twilight.md`. | |
| 157 | +3. Travailler ensuite uniquement depuis la version `docs/plans/`. |
A
docs/plans/2026-05-11-extract-rustwarden-fediverse-crate.md
+371
-0
@@ -0,0 +1,371 @@
| 1 | +# Extraction d'un crate `rustwarden-fediverse` | |
| 2 | + | |
| 3 | +## Contexte | |
| 4 | + | |
| 5 | +Le module fediverse de `rustwarden-core` a atteint ~3815 LoC (`fediverse/*` + services + models + config). En face, `rustwarden-ui` héberge **2747 lignes de handlers fediverse** (`admin_fediverse.rs`: 1694, `fediverse_user_auth.rs`: 1053), **12 templates Askama**, **5 namespaces i18n** (~250+ clés), un endpoint **SSE** stateful, des migrations dédiées, 25 RFCs et des tests e2e + interop. Phases 1–5 marquées complètes, tests passent. | |
| 6 | + | |
| 7 | +Coût actuel du couplage in-crate : | |
| 8 | +- Feature flag `experimental-fediverse` pollue ~29 sites de `rustwarden-core` + `rustwarden-ui` | |
| 9 | +- Compile time : `activitypub_federation`, `rsa`, `reqwest`, `ammonia`, `url` lient même quand le code est désactivé | |
| 10 | +- Surface mentale : contributeur travaillant sur l'auth standard lit du code fediverse dans `core` | |
| 11 | +- Pas d'API pour qu'un consommateur tiers étende la whitelist d'auth (cf. Q4) | |
| 12 | + | |
| 13 | +Analyse antérieure (RFC 0002, Alt. C) avait rejeté l'extraction sur 3 critères (~1500 LoC, monoproducteur, migration future facile) — les 3 sont retournés : volume × 2.5, multi-consommateurs (ui+app+template+cli), API stabilisée, plusieurs findings (F8) qui imposent un point d'attention à l'import des routes protocole. | |
| 14 | + | |
| 15 | +Objectif : | |
| 16 | +1. supprimer le feature flag `experimental-fediverse` de `rustwarden-core` (le crate optionnel = la feature) | |
| 17 | +2. permettre des releases découplées | |
| 18 | +3. isoler les dépendances lourdes | |
| 19 | +4. corriger au passage l'anti-pattern de la whitelist d'auth hardcodée | |
| 20 | +5. ouvrir la voie à des intégrateurs tiers qui veulent juste l'auth+RBAC | |
| 21 | + | |
| 22 | +## Décisions arbitrées | |
| 23 | + | |
| 24 | +| # | Question | Décision | | |
| 25 | +|---|---|---| | |
| 26 | +| Q1 | `actor_key_service` | Déplacer dans `rustwarden-fediverse` | | |
| 27 | +| Q2 | `remote_identity` (modèle + table) | Déplacer dans `rustwarden-fediverse` + trait `RemoteIdentityRevocationChecker` côté core | | |
| 28 | +| Q3 | Agrégation migrations | Migrator par crate, composés par le binaire `migrate` | | |
| 29 | +| Q4 | Whitelist routes protégées | Registry public extensible dans `core` (corrige aussi le défaut UI) | | |
| 30 | + | |
| 31 | +UI fediverse → **reste dans `rustwarden-ui`** (pas de crate `rustwarden-fediverse-ui` séparé). Les handlers consomment `rustwarden-fediverse` via dépendance optionnelle gated par feature `fediverse`. | |
| 32 | + | |
| 33 | +## Inventaire des modules déplacés | |
| 34 | + | |
| 35 | +### Vers `crates/rustwarden-fediverse/src/` | |
| 36 | + | |
| 37 | +**Module fediverse** (14 fichiers) : | |
| 38 | +- `mod.rs`, `routes.rs`, `actor.rs`, `invite_routes.rs`, `state.rs`, `nodeinfo.rs`, `webfinger.rs`, `http_signatures.rs`, `auth_routes.rs`, `types.rs`, `error.rs`, `resolver.rs`, `fetcher.rs`, `inbox.rs` | |
| 39 | + | |
| 40 | +**Services** (6 fichiers) : | |
| 41 | +- `fediverse_auth_service.rs`, `fediverse_invite_service.rs`, `fediverse_outbound_service.rs`, `fediverse_signal_service.rs`, `fediverse_trust_service.rs` | |
| 42 | +- `actor_key_service.rs` (Q1) | |
| 43 | + | |
| 44 | +**Modèles SeaORM** (6 fichiers) : | |
| 45 | +- `fediverse_outbound_queue.rs`, `fediverse_auth_challenge.rs`, `fediverse_trusted_domain.rs`, `fediverse_blocked_domain.rs`, `fediverse_pending_invite.rs` | |
| 46 | +- `remote_identity.rs` (Q2) | |
| 47 | + | |
| 48 | +**Config** : `config/fediverse.rs` → `src/config.rs` | |
| 49 | + | |
| 50 | +**Migrations** (4 fichiers) → `crates/rustwarden-fediverse/migrations/` : | |
| 51 | +- `m20260510_000010_fediverse_trust_and_blocks.rs` | |
| 52 | +- `m20260510_000012_fediverse_outbound_queue.rs` | |
| 53 | +- `m20260510_000013_fediverse_auth_challenges.rs` | |
| 54 | +- `m20260510_000015_fediverse_pending_invites.rs` | |
| 55 | + | |
| 56 | +**Tests** → `crates/rustwarden-fediverse/tests/` : | |
| 57 | +- `e2e_fediverse.rs` | |
| 58 | +- `fediverse_interop.rs` | |
| 59 | + | |
| 60 | +### Reste dans `rustwarden-core` | |
| 61 | + | |
| 62 | +- Modèles partagés : `user`, `team`, `team_member`, `app_settings`, etc. | |
| 63 | +- Services partagés : `team_service`, `app_settings_service`, `jwt_blacklist_service` | |
| 64 | +- `middleware/auth_service.rs` — refactor pour registry + trait `RemoteIdentityRevocationChecker` | |
| 65 | +- `config/auth.rs` (`AuthConfig`), `error::AppError`, traits `Identity`/`IdentityResolver`, JWT claims, `i18n` | |
| 66 | + | |
| 67 | +### Reste dans `rustwarden-ui` (mais dépend désormais de `rustwarden-fediverse`) | |
| 68 | + | |
| 69 | +- `handlers/admin_fediverse.rs` (1694 lignes) | |
| 70 | +- `handlers/fediverse_user_auth.rs` (1053 lignes) | |
| 71 | +- 12 templates Askama (cf. section UI ci-dessous) | |
| 72 | +- 5 namespaces i18n dans `crates/rustwarden-ui/locales/{fr,en,…}.yml` | |
| 73 | +- Routes gated dans `crates/rustwarden-ui/src/routes.rs` | |
| 74 | + | |
| 75 | +## Direction de dépendance | |
| 76 | + | |
| 77 | +``` | |
| 78 | +rustwarden-fediverse ──depends on──> rustwarden-core | |
| 79 | + ▲ ▲ | |
| 80 | + │ │ | |
| 81 | + └──── rustwarden-ui (feature "fediverse") ─┘ | |
| 82 | + ▲ | |
| 83 | + │ | |
| 84 | + app/main.rs | |
| 85 | +``` | |
| 86 | + | |
| 87 | +Acyclique. `rustwarden-fediverse` importe `User`, `Team`, `TeamMember`, `AuthConfig`, `AppError`, traits `Identity`/`IdentityResolver`, `JwtBlacklistService`, `csrf`, `i18n`. `rustwarden-ui` ajoute une dep `rustwarden-fediverse` derrière sa feature. | |
| 88 | + | |
| 89 | +## Surface UI fediverse à migrer (rustwarden-ui) | |
| 90 | + | |
| 91 | +### Handlers admin (RFC 0021 + Slice C — 13 fonctions) | |
| 92 | +[crates/rustwarden-ui/src/handlers/admin_fediverse.rs](crates/rustwarden-ui/src/handlers/admin_fediverse.rs) | |
| 93 | + | |
| 94 | +| Fonction | Route | Verbe | RFC | | |
| 95 | +|---|---|---|---| | |
| 96 | +| `get_settings` | `/admin/fediverse/settings` | GET | 0021 | | |
| 97 | +| `update_settings` | `/admin/fediverse/settings` | POST | 0021 | | |
| 98 | +| `get_trust_list` | `/admin/fediverse/trust_list` | GET | 0021 | | |
| 99 | +| `add_trust_entry` | `/admin/fediverse/trust_list/add` | POST | 0021 | | |
| 100 | +| `delete_trust_entry` | `/admin/fediverse/trust_list/delete` | POST | 0021 | | |
| 101 | +| `get_queue` | `/admin/fediverse/queue` | GET | 0024 | | |
| 102 | +| `get_sessions_remote` | `/admin/fediverse/sessions_remote` | GET | 0024 | | |
| 103 | +| `revoke_remote_session` | `/admin/fediverse/sessions_remote/revoke` | POST | 0024 | | |
| 104 | +| `kill_domain` | `/admin/fediverse/sessions_remote/kill_domain` | POST | 0024 | | |
| 105 | +| `get_nodeinfo` | `/admin/fediverse/nodeinfo` | GET | 0024 | | |
| 106 | +| `get_team_invites` | `/admin/teams/{team_id}/invites` | GET | 0024 | | |
| 107 | +| `create_team_invite` | `/admin/teams/{team_id}/invites/add` | POST | 0024 | | |
| 108 | +| `revoke_team_invite` | `/admin/teams/{team_id}/invites/revoke` | POST | 0024 | | |
| 109 | + | |
| 110 | +### Handlers user-auth (RFC 0023 + Slice C — 10 fonctions) | |
| 111 | +[crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) | |
| 112 | + | |
| 113 | +| Fonction | Route | Verbe | RFC | | |
| 114 | +|---|---|---|---| | |
| 115 | +| `error` | `/login/fediverse/error` | GET | 0023 | | |
| 116 | +| `login_form` | `/login/fediverse` | GET | 0023 | | |
| 117 | +| `login_initiate` | `/login/fediverse` | POST | 0023 | | |
| 118 | +| `waiting` | `/login/fediverse/waiting/{id}` | GET | 0023 | | |
| 119 | +| `sse_status` | `/login/fediverse/sse/{id}` | GET (SSE) | 0023 | | |
| 120 | +| `consent_form` | `/fediverse/consent/{id}` | GET | 0023 | | |
| 121 | +| `consent_submit` | `/fediverse/consent/{id}` | POST | 0023 | | |
| 122 | +| `connected_apps` | `/profile/connected_apps` | GET | 0024 | | |
| 123 | +| `invite_landing_form` | `/fediverse/invite/{id}` | GET | 0024 | | |
| 124 | +| `invite_landing_submit` | `/fediverse/invite/{id}` | POST | 0024 | | |
| 125 | + | |
| 126 | +### Templates Askama (12 fichiers) | |
| 127 | +[crates/rustwarden-ui/templates/admin/fediverse/](crates/rustwarden-ui/templates/admin/fediverse/) — `settings.html`, `trust_list.html`, `queue.html`, `sessions_remote.html`, `nodeinfo.html`, `team_invites.html` | |
| 128 | +[crates/rustwarden-ui/templates/fediverse/](crates/rustwarden-ui/templates/fediverse/) — `login_form.html`, `login_waiting.html` (contient le client EventSource SSE), `consent_screen.html`, `error_unauthorized.html`, `invite_landing.html` | |
| 129 | +[crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html](crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html) | |
| 130 | + | |
| 131 | +Aucun template ne nécessite de déplacement de fichier (ils restent dans `rustwarden-ui`), mais leurs structs `#[derive(Template)]` doivent être réimportées si elles référencent des types `rustwarden_core::fediverse::*`. | |
| 132 | + | |
| 133 | +### i18n | |
| 134 | +[crates/rustwarden-ui/locales/](crates/rustwarden-ui/locales/) — 5 namespaces fediverse dans `fr.yml` + `en.yml` (à confirmer : `de.yml`, `es.yml`, `it.yml`, `pt.yml` semblent ne pas avoir ces clés — **pré-requis à vérifier avant extraction**) : | |
| 135 | +- `fediverse:` (settings/trust/auth/queue/sessions_remote — ~138 clés) | |
| 136 | +- `fediverse_nodeinfo:` (~21 clés) | |
| 137 | +- `fediverse_invites:` (~26 clés) | |
| 138 | +- `fediverse_connected_apps:` | |
| 139 | +- `fediverse_invite_landing:` | |
| 140 | + | |
| 141 | +**Décision i18n** : les clés restent dans `rustwarden-ui/locales/` (l'UI les consomme, le crate fediverse est headless). Pas de duplication. | |
| 142 | + | |
| 143 | +### SSE — endpoint stateful | |
| 144 | +[fediverse_user_auth.rs](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) `sse_status` (~L402-448) : `axum::response::sse::{Event, KeepAlive, Sse}`, boucle DB poll sur `fediverse_auth_challenge`, émet `event: status` avec JSON `{state: pending|approved|rejected|expired|consumed}`. Client EventSource natif JS dans `login_waiting.html:21-76` (pas Alpine, pas HTMX). | |
| 145 | + | |
| 146 | +→ Le handler reste dans `rustwarden-ui` ; sa dep `rustwarden_fediverse::services::FediverseAuthService` + `rustwarden_fediverse::models::fediverse_auth_challenge` doit être rebindée Phase 3. | |
| 147 | + | |
| 148 | +### Imports à rebinder dans les 2 handlers UI | |
| 149 | + | |
| 150 | +**admin_fediverse.rs** (12 imports à changer) : | |
| 151 | +- `rustwarden_core::services::fediverse_trust_service` → `rustwarden_fediverse::services::trust` | |
| 152 | +- `rustwarden_core::services::fediverse_invite_service` → `rustwarden_fediverse::services::invite` | |
| 153 | +- `rustwarden_core::services::fediverse_outbound_service` → `rustwarden_fediverse::services::outbound` | |
| 154 | +- `rustwarden_core::models::fediverse_outbound_queue` → `rustwarden_fediverse::models::outbound_queue` | |
| 155 | +- `rustwarden_core::models::remote_identity` → `rustwarden_fediverse::models::remote_identity` | |
| 156 | +- `rustwarden_core::models::fediverse_pending_invite` → `rustwarden_fediverse::models::pending_invite` | |
| 157 | +- inchangés : `auth::jwt::Claims`, `error::AppError`, `i18n::*`, `middleware::csrf`, `services::app_settings_service`, `services::team_service::TeamRole`, `models::{user, team}` | |
| 158 | + | |
| 159 | +**fediverse_user_auth.rs** (8 imports à changer) : | |
| 160 | +- `rustwarden_core::services::fediverse_auth_service` → `rustwarden_fediverse::services::auth` | |
| 161 | +- `rustwarden_core::services::fediverse_invite_service` → `rustwarden_fediverse::services::invite` | |
| 162 | +- `rustwarden_core::services::fediverse_trust_service` → `rustwarden_fediverse::services::trust` | |
| 163 | +- `rustwarden_core::services::fediverse_outbound_service` → `rustwarden_fediverse::services::outbound` | |
| 164 | +- `rustwarden_core::config::FediverseConfig` → `rustwarden_fediverse::FediverseConfig` | |
| 165 | +- `rustwarden_core::models::{fediverse_pending_invite, remote_identity, fediverse_auth_challenge}` → `rustwarden_fediverse::models::{pending_invite, remote_identity, auth_challenge}` | |
| 166 | +- inchangés : `auth::jwt::Claims`, `error::AppError`, `i18n::*`, `middleware::csrf` | |
| 167 | + | |
| 168 | +### Tests UI existants à préserver | |
| 169 | +6 unit tests dans [fediverse_user_auth.rs:987-1052](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) : | |
| 170 | +- `error_reason_parses_known_values`, `error_reason_falls_back_to_unknown`, `error_reason_trims_and_lowercases`, `error_reason_round_trips_through_str` | |
| 171 | +- `extract_handle_domain_accepts_canonical`, `extract_handle_domain_rejects_malformed` | |
| 172 | + | |
| 173 | +`admin_fediverse.rs` : aucun test. Acceptable pour cette PR — gap à combler dans une RFC future, pas dans le scope de l'extraction. | |
| 174 | + | |
| 175 | +### Mode dégradé (P9 du Plan UI HTML) | |
| 176 | +Quand `fediverse_enabled=false` dans `AppSettings`, toutes les routes `/admin/fediverse/*` retournent 404 **sauf** `/admin/fediverse/settings` (l'écran de toggle). Les routes user-facing `/login/fediverse*` retournent aussi 404. Cette logique vit dans les handlers eux-mêmes (early return). → Logique conservée verbatim au déplacement. | |
| 177 | + | |
| 178 | +### CSP | |
| 179 | +Alpine attribute-based + EventSource SSE natif → CSP existante reste compatible. Aucun nouveau `<script>` inline introduit. Vérification automatique : `cargo run -p rustwarden-app --features fediverse`, ouvrir `/login/fediverse`, vérifier 0 violation CSP dans la console. | |
| 180 | + | |
| 181 | +## Findings/dette préalable à traiter AVANT l'extraction | |
| 182 | + | |
| 183 | +Source : [docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md](docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md) et [iter2-3-findings.md](docs/plans/2026-05-11-fediverse-evaluation-iter2-3-findings.md). | |
| 184 | + | |
| 185 | +| ID | Gravité | État | Action requise avant extraction | | |
| 186 | +|---|---|---|---| | |
| 187 | +| **F8** | Bloquant | ✅ Corrigé | Garder l'appel `FediverseTrustService::assert_domain_allowed()` AVANT la vérification de signature dans les routes `/inbox`, `auth_routes`, `invite_routes`. À ne PAS perdre au déplacement. | | |
| 188 | +| **Race `FEDIVERSE_DOMAIN`** | Bloquant tests | ⚠️ À corriger | Tests `e2e_fediverse.rs` mutent `std::env::set_var("FEDIVERSE_DOMAIN", …)` → non-thread-safe. Avant extraction : refactorer pour passer `FediverseConfig` par argument. Sinon les tests deviennent flaky une fois isolés dans un crate dédié. | | |
| 189 | +| **F4** | UX différé | ⏸️ Documenter | `create_team_invite` ne queue pas le payload AP `Invite`. À tracker dans une RFC future. Ne bloque pas l'extraction. | | |
| 190 | +| **F5** | UX différé | ⏸️ Documenter | `/inbox` ne dispatch pas le type `Invite`. Idem F4. | | |
| 191 | +| **F6** | UX différé | ⏸️ Documenter | `record_inbound_invite` manquant. | | |
| 192 | +| **F7** | UX différé | ⏸️ Documenter | `Accept(Invite)` back-channel absent. | | |
| 193 | + | |
| 194 | +Étape 0 du plan d'exécution : appliquer le fix race `FEDIVERSE_DOMAIN` AVANT Phase 1. | |
| 195 | + | |
| 196 | +## Q4 — Registry de routes protégées (refactor middleware) | |
| 197 | + | |
| 198 | +[auth_service.rs:67-75](crates/rustwarden-core/src/middleware/auth_service.rs#L67-L75) hardcode 7 préfixes (`/home`, `/profile`, `/test-base`, `/settings`, `/admin`, `/fediverse/consent`, `/fediverse/invite`). Non extensible. | |
| 199 | + | |
| 200 | +Refactor : | |
| 201 | +- `AuthService::new(inner, db, protected_prefixes: Arc<Vec<String>>)` au lieu de `AuthService::new(inner, db)` | |
| 202 | +- `RustwardenBuilder::register_protected_prefix(&mut self, prefix: &str) -> &mut Self` accumule | |
| 203 | +- `RustwardenBuilder::build()` passe la liste accumulée à `AuthService` | |
| 204 | +- `rustwarden-ui::default_routes()` enregistre `/home`, `/profile`, `/test-base`, `/settings`, `/admin` (lignes accumulées dans `default_routes`) | |
| 205 | +- `rustwarden_fediverse::mount()` enregistre `/fediverse/consent`, `/fediverse/invite` | |
| 206 | + | |
| 207 | +Effet de bord positif : une appli tierce headless peut maintenant écrire `builder.register_protected_prefix("/api/v1/secure")` au lieu de forker. | |
| 208 | + | |
| 209 | +## Q3 — Migrator agrégé | |
| 210 | + | |
| 211 | +- `rustwarden_fediverse::migrations::Migrator` (impl `MigratorTrait`) expose ses 4 migrations | |
| 212 | +- Binaire `migrate` dans `rustwarden-core/bin/migrate.rs` compose : | |
| 213 | + ```rust | |
| 214 | + let mut migrators: Vec<Box<dyn MigratorTrait>> = vec![Box::new(CoreMigrator)]; | |
| 215 | + #[cfg(feature = "fediverse")] | |
| 216 | + migrators.push(Box::new(rustwarden_fediverse::migrations::Migrator)); | |
| 217 | + for m in migrators { m.up(&db, None).await?; } | |
| 218 | + ``` | |
| 219 | +- Ordre déterministe par numéro chronologique. Les FK fediverse → users/teams marchent car users/teams sont créés en 0000xx (avant 000010). | |
| 220 | +- `rustwarden-core` doit aussi exposer une feature `fediverse` (ou similaire) côté binaire `migrate` pour conditionnellement linker `rustwarden_fediverse`. Cette feature n'allume PAS de code dans le crate `rustwarden-core` lib lui-même. | |
| 221 | + | |
| 222 | +## Q2 — Conséquences du déplacement de `remote_identity` | |
| 223 | + | |
| 224 | +Le middleware `AuthService` contient un check gated [auth_service.rs:168-196](crates/rustwarden-core/src/middleware/auth_service.rs#L168-L196) qui révoque les JWT remote dont `remote_identities.disabled_at` est non-null (F10 / RFC 0018). Ce code dépend du modèle qui bouge. | |
| 225 | + | |
| 226 | +Solution : exposer dans `core` un trait | |
| 227 | + | |
| 228 | +```rust | |
| 229 | +#[async_trait] | |
| 230 | +pub trait RemoteIdentityRevocationChecker: Send + Sync { | |
| 231 | + async fn is_revoked(&self, remote_identity_id: i32) -> bool; | |
| 232 | +} | |
| 233 | +``` | |
| 234 | + | |
| 235 | +et l'injecter via `Extension<Arc<dyn RemoteIdentityRevocationChecker>>`. `rustwarden_fediverse` fournit l'impl par défaut au moment du `mount()`. Si l'extension est absente, le check est sauté (= comportement actuel quand la feature est off). | |
| 236 | + | |
| 237 | +## Feature flag : avant / après | |
| 238 | + | |
| 239 | +| Crate | Avant | Après | | |
| 240 | +|---|---|---| | |
| 241 | +| `rustwarden-core` | `experimental-fediverse = [dep:activitypub_federation, dep:ammonia, dep:url, dep:rsa, dep:reqwest]` | (supprimé) | | |
| 242 | +| `rustwarden-fediverse` | n/a | (toujours linké quand le crate est dans `Cargo.toml`) | | |
| 243 | +| `rustwarden-ui` | `experimental-fediverse = ["rustwarden-core/experimental-fediverse"]` | `fediverse = ["dep:rustwarden-fediverse"]` | | |
| 244 | +| `app` | `experimental-fediverse = ["rustwarden-core/experimental-fediverse", "rustwarden-ui/experimental-fediverse"]` | `fediverse = ["dep:rustwarden-fediverse", "rustwarden-ui/fediverse"]` | | |
| 245 | +| `template` | absent | (à aligner sur `app`) | | |
| 246 | + | |
| 247 | +`app/src/main.rs` : `with_fediverse()` disparaît du builder ; remplacé par | |
| 248 | +```rust | |
| 249 | +#[cfg(feature = "fediverse")] | |
| 250 | +rustwarden_fediverse::mount(&mut builder, FediverseConfig::from_env()?)?; | |
| 251 | +``` | |
| 252 | + | |
| 253 | +## Plan d'exécution | |
| 254 | + | |
| 255 | +### Phase 0 — Pré-requis (avant tout déplacement) | |
| 256 | +- [ ] Corriger la race `FEDIVERSE_DOMAIN` dans `e2e_fediverse.rs` et `fediverse_interop.rs` : passer `FediverseConfig` en argument, ne plus muter `std::env`. | |
| 257 | +- [ ] Vérifier l'état des 5 namespaces i18n fediverse dans les 6 fichiers locales (`fr/en/de/es/it/pt`). Si `de/es/it/pt` n'ont pas les clés, décider : (a) compléter avant extraction, (b) accepter le fallback `en`. Recommandation : (b), documenter dans une note de release. | |
| 258 | +- [ ] `cargo test --workspace --all-features` green sur HEAD actuel (baseline). | |
| 259 | +- [ ] Snapshot de la surface publique : `cargo public-api -p rustwarden-core > /tmp/core-before.txt`. | |
| 260 | + | |
| 261 | +### Phase 1 — Scaffolding (vide, build OK) | |
| 262 | +- [ ] `crates/rustwarden-fediverse/Cargo.toml` avec deps : `rustwarden-core` (path), `activitypub_federation`, `rsa`, `reqwest`, `ammonia`, `url`, `sea-orm`, `sea-orm-migration`, `axum`, `tokio`, `tracing`, `serde`, `serde_json`, `chrono`, `uuid`, `thiserror`, `async-trait`. | |
| 263 | +- [ ] `crates/rustwarden-fediverse/src/lib.rs` minimal (`pub mod models; pub mod services; pub mod config;`) | |
| 264 | +- [ ] `crates/rustwarden-fediverse/migrations/lib.rs` avec `pub struct Migrator;` impl `MigratorTrait` retournant `vec![]`. | |
| 265 | +- [ ] Ajouter membre au workspace racine [Cargo.toml](Cargo.toml). | |
| 266 | +- [ ] ✅ `cargo build -p rustwarden-fediverse` | |
| 267 | + | |
| 268 | +### Phase 2 — Modèles + migrations + actor_key_service | |
| 269 | +- [ ] Déplacer 4 migrations → `crates/rustwarden-fediverse/migrations/` | |
| 270 | +- [ ] Déplacer 6 modèles (5 fediverse_* + remote_identity) → `src/models/` | |
| 271 | +- [ ] Déplacer `actor_key_service` → `src/services/actor_key.rs` | |
| 272 | +- [ ] Adapter imports : `use crate::models::{user, team, …}` → `use rustwarden_core::models::…` | |
| 273 | +- [ ] Adapter le binaire `migrate` pour composer les Migrators (Q3) | |
| 274 | +- [ ] Le check F10 dans `auth_service.rs` est temporairement gardé sous `cfg(feature = "experimental-fediverse")` via une feature mirror dans `rustwarden-core` qui ne fait que `dep:rustwarden-fediverse` — sera supprimée Phase 4 | |
| 275 | +- [ ] ✅ `cargo build --workspace --all-features` ET `cargo test --workspace --all-features` | |
| 276 | + | |
| 277 | +### Phase 3 — Services + config | |
| 278 | +- [ ] Déplacer 5 services fediverse → `src/services/{auth, invite, outbound, signal, trust}.rs` | |
| 279 | +- [ ] Déplacer `config/fediverse.rs` → `src/config.rs` | |
| 280 | +- [ ] Re-export top-level dans `lib.rs` : `pub use config::FediverseConfig;` | |
| 281 | +- [ ] **Rebind imports dans [crates/rustwarden-ui/src/handlers/admin_fediverse.rs](crates/rustwarden-ui/src/handlers/admin_fediverse.rs)** (12 occurrences cf. inventaire ci-dessus) | |
| 282 | +- [ ] **Rebind imports dans [crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs)** (8 occurrences) | |
| 283 | +- [ ] [crates/rustwarden-ui/Cargo.toml](crates/rustwarden-ui/Cargo.toml) : ajouter `rustwarden-fediverse = { path = "...", optional = true }`, renommer feature `experimental-fediverse` → `fediverse` | |
| 284 | +- [ ] [crates/rustwarden-ui/src/routes.rs](crates/rustwarden-ui/src/routes.rs) : renommer toutes les `#[cfg(feature = "experimental-fediverse")]` → `#[cfg(feature = "fediverse")]` | |
| 285 | +- [ ] ✅ `cargo build --workspace --all-features` | |
| 286 | + | |
| 287 | +### Phase 4 — Routes protocole + builder + refactors transverses | |
| 288 | +- [ ] Déplacer les 14 fichiers `fediverse/*.rs` → `src/*.rs` | |
| 289 | +- [ ] Vérifier que l'appel `FediverseTrustService::assert_domain_allowed()` est conservé dans `inbox.rs`, `auth_routes.rs`, `invite_routes.rs` (F8 régression-proof) | |
| 290 | +- [ ] **Refactor Q4** : modifier `AuthService::new` pour accepter `protected_prefixes: Arc<Vec<String>>`. Ajouter `RustwardenBuilder::register_protected_prefix`. `rustwarden-ui::default_routes` s'enregistre via le builder. `rustwarden_fediverse::mount` s'enregistre. Retirer les 7 préfixes hardcodés. | |
| 291 | +- [ ] **Refactor Q2** : introduire `trait RemoteIdentityRevocationChecker` dans `rustwarden-core::middleware`. Impl par défaut dans `rustwarden-fediverse`. Injection via `Extension` au mount. Supprimer le `#[cfg(feature = "experimental-fediverse")]` du middleware. | |
| 292 | +- [ ] Supprimer `RustwardenBuilder::with_fediverse()` ; exposer `rustwarden_fediverse::mount(&mut builder, config) -> Result<(), Error>` qui : | |
| 293 | + 1. injecte `Extension<FediverseConfig>` | |
| 294 | + 2. injecte `Extension<Arc<dyn RemoteIdentityRevocationChecker>>` | |
| 295 | + 3. enregistre les préfixes protégés | |
| 296 | + 4. merge le router fediverse | |
| 297 | +- [ ] Mettre à jour [app/src/main.rs](app/src/main.rs) et [app/Cargo.toml](app/Cargo.toml) | |
| 298 | +- [ ] Mettre à jour [template/Cargo.toml](template/Cargo.toml) pour aligner sur app | |
| 299 | +- [ ] Retirer toutes les `#[cfg(feature = "experimental-fediverse")]` de `rustwarden-core` (29 sites) | |
| 300 | +- [ ] Retirer la feature `experimental-fediverse` de [rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml) ainsi que les 5 deps optionnelles | |
| 301 | +- [ ] ✅ `cargo build --workspace --all-features` ET `cargo build --workspace` (default features, `rustwarden-core` doit construire sans aucune trace fediverse) | |
| 302 | + | |
| 303 | +### Phase 5 — Tests, runtime, validation | |
| 304 | +- [ ] Déplacer `crates/rustwarden-core/tests/{e2e_fediverse,fediverse_interop}.rs` → `crates/rustwarden-fediverse/tests/` | |
| 305 | +- [ ] Adapter les imports des tests (`use rustwarden_core::fediverse::…` → `use rustwarden_fediverse::…`) | |
| 306 | +- [ ] Vérifier que les 6 unit tests in-source de `fediverse_user_auth.rs` passent toujours | |
| 307 | +- [ ] `cargo test --workspace --all-features` → tout green | |
| 308 | +- [ ] `cargo clippy --workspace --all-features -- -D warnings` | |
| 309 | +- [ ] **Smoke runtime** : `cargo run -p rustwarden-app --features fediverse`, hit : | |
| 310 | + - `/.well-known/webfinger?resource=acct:admin@<domain>` → 200 | |
| 311 | + - `/login/fediverse` → 200, formulaire rendu, i18n correct | |
| 312 | + - `/admin/fediverse/settings` → 200 (auth admin) | |
| 313 | + - Console DevTools : 0 violation CSP | |
| 314 | +- [ ] **Smoke testbed Docker** : [tests/fediverse-testbed/scripts/start.sh](tests/fediverse-testbed/scripts/start.sh), puis vérifier qu'un signal lifecycle traverse alpha → beta → gamma | |
| 315 | +- [ ] **Smoke SSE** : ouvrir `/login/fediverse`, soumettre `@user@example.com`, vérifier que la page waiting reçoit bien les events SSE et fait la transition d'état | |
| 316 | +- [ ] `cargo run --bin migrate` sur DB vide → 15+ tables créées dans l'ordre, FK respectées | |
| 317 | +- [ ] Diff de surface : `cargo public-api -p rustwarden-core > /tmp/core-after.txt && diff /tmp/core-before.txt /tmp/core-after.txt` → confirmer disparition de tous les items `fediverse_*` / `FediverseConfig` / `remote_identity` de `rustwarden-core` | |
| 318 | + | |
| 319 | +## Fichiers critiques par responsabilité | |
| 320 | + | |
| 321 | +### rustwarden-core | |
| 322 | +- [crates/rustwarden-core/src/lib.rs](crates/rustwarden-core/src/lib.rs) — retirer `pub mod fediverse` + re-exports | |
| 323 | +- [crates/rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs) — retirer `with_fediverse()`, ajouter `register_protected_prefix`, retirer le merge de routes fediverse | |
| 324 | +- [crates/rustwarden-core/src/middleware/auth_service.rs](crates/rustwarden-core/src/middleware/auth_service.rs) — registry + trait `RemoteIdentityRevocationChecker` | |
| 325 | +- [crates/rustwarden-core/src/models/mod.rs](crates/rustwarden-core/src/models/mod.rs) — retirer 6 re-exports | |
| 326 | +- [crates/rustwarden-core/src/services/mod.rs](crates/rustwarden-core/src/services/mod.rs) — retirer 6 re-exports (5 fediverse_* + actor_key) | |
| 327 | +- [crates/rustwarden-core/src/config/mod.rs](crates/rustwarden-core/src/config/mod.rs) — retirer `FediverseConfig` | |
| 328 | +- [crates/rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml) — retirer feature `experimental-fediverse` + 5 deps opt | |
| 329 | +- `crates/rustwarden-core/src/bin/migrate.rs` — composer Migrators | |
| 330 | + | |
| 331 | +### rustwarden-fediverse (nouveau) | |
| 332 | +- `crates/rustwarden-fediverse/Cargo.toml` | |
| 333 | +- `crates/rustwarden-fediverse/src/lib.rs` (root, `mount()`, re-exports) | |
| 334 | +- `crates/rustwarden-fediverse/src/{config,models,services}/*` | |
| 335 | +- `crates/rustwarden-fediverse/src/{routes,inbox,auth_routes,invite_routes,actor,webfinger,nodeinfo,resolver,fetcher,http_signatures,types,error,state}.rs` | |
| 336 | +- `crates/rustwarden-fediverse/migrations/*` | |
| 337 | +- `crates/rustwarden-fediverse/tests/{e2e_fediverse,fediverse_interop}.rs` | |
| 338 | + | |
| 339 | +### rustwarden-ui | |
| 340 | +- [crates/rustwarden-ui/Cargo.toml](crates/rustwarden-ui/Cargo.toml) — feature `fediverse`, dep optionnelle | |
| 341 | +- [crates/rustwarden-ui/src/handlers/admin_fediverse.rs](crates/rustwarden-ui/src/handlers/admin_fediverse.rs) — 12 imports à rebinder | |
| 342 | +- [crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) — 8 imports à rebinder | |
| 343 | +- [crates/rustwarden-ui/src/routes.rs](crates/rustwarden-ui/src/routes.rs) — feature gates renommées + appel `register_protected_prefix` | |
| 344 | +- Templates (12) : pas de déplacement, mais vérifier que les structs Askama compilent après rebinding | |
| 345 | +- Locales `fr.yml` + `en.yml` : pas de changement de contenu — vérifier au runtime que les clés sont toujours résolues | |
| 346 | + | |
| 347 | +### app + template | |
| 348 | +- [app/src/main.rs](app/src/main.rs) — appel mount explicite | |
| 349 | +- [app/Cargo.toml](app/Cargo.toml) — feature `fediverse` | |
| 350 | +- [template/Cargo.toml](template/Cargo.toml) — aligner | |
| 351 | + | |
| 352 | +## Risques et pièges identifiés | |
| 353 | + | |
| 354 | +1. **Régression F8** : si l'appel `assert_domain_allowed()` saute au déplacement, on réintroduit la faille. Mitigation : test e2e dédié dans `fediverse_interop.rs` (à vérifier qu'il existe — sinon l'ajouter). | |
| 355 | +2. **SSE downstream** : le client `EventSource` JS dans `login_waiting.html` parse `event: status`. Si le service emit changes au déplacement (impossible si on copie verbatim mais à vérifier), le polling casse silencieusement. | |
| 356 | +3. **Migrations ordre** : si quelqu'un ajoute une migration core 0000XX avec FK vers une table fediverse, l'ordre se brise. Mitigation : documenter la règle "core ne dépend jamais de fediverse" dans un README de migrations. | |
| 357 | +4. **i18n partielle** : si `de/es/it/pt` n'ont pas les clés fediverse, le runtime fallback sur `en`. Acceptable mais à documenter. | |
| 358 | +5. **`rustwarden-fediverse-cli` ?** : le wizard `tools/rustwarden-cli` configure-t-il quoi que ce soit de fediverse ? À vérifier en Phase 0 — sinon documenter qu'aucun changement n'est requis. | |
| 359 | +6. **Public API breakage** : tout consommateur externe de `rustwarden_core::fediverse::*` doit migrer. Pas de bridge de compat — c'est un breaking change majeur. À annoncer dans CHANGELOG.md + bump major. | |
| 360 | + | |
| 361 | +## Vérification finale | |
| 362 | + | |
| 363 | +1. `cargo build --workspace` (sans features) → `rustwarden-core` construit sans aucune des 5 deps fediverse, sans aucun `#[cfg]` fediverse | |
| 364 | +2. `cargo build --workspace --all-features` → OK | |
| 365 | +3. `cargo test --workspace --all-features` → tous les tests passent dont `e2e_fediverse`, `fediverse_interop` (relocalisés), et les 6 unit tests UI préservés | |
| 366 | +4. `cargo clippy --workspace --all-features -- -D warnings` → zéro warning | |
| 367 | +5. Smoke runtime : `/login/fediverse` rendu OK, SSE fonctionne, `/admin/fediverse/settings` OK, 0 violation CSP | |
| 368 | +6. Smoke testbed Docker tri-instance : signal lifecycle alpha → beta → gamma OK | |
| 369 | +7. `cargo run --bin migrate` sur base vide → toutes tables créées, FK respectées | |
| 370 | +8. Diff de surface API : `cargo public-api -p rustwarden-core` → tous les items `fediverse_*`, `FediverseConfig`, `remote_identity` ont disparu de core | |
| 371 | +9. CHANGELOG.md mis à jour avec note de breaking change + guide de migration pour consommateurs externes |
A
docs/plans/2026-05-11-fediverse-evaluation-iter2-3-findings.md
+180
-0
@@ -0,0 +1,180 @@
| 1 | +# Itérations 2 + 3 — Findings de la campagne `experimental-fediverse` | |
| 2 | + | |
| 3 | +**Statut** : ✅ **iter 2a + iter 3 (F8) + iter 4 (F9 + F10) clos** — F4-F7 différés (UX non-bloquant), 2026-05-11 | |
| 4 | +**Plan parent** : [2026-05-10-fediverse-promotion-stable-evaluation.md](2026-05-10-fediverse-promotion-stable-evaluation.md) | |
| 5 | +**Plan précédent** : [2026-05-11-fediverse-stable-promotion-iter1-findings.md](2026-05-11-fediverse-stable-promotion-iter1-findings.md) | |
| 6 | +**Testbed** : [tests/fediverse-testbed/](../../tests/fediverse-testbed/) | |
| 7 | +**Tests E2E** : [crates/rustwarden-core/tests/e2e_fediverse.rs](../../crates/rustwarden-core/tests/e2e_fediverse.rs) | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 1. Contexte | |
| 12 | + | |
| 13 | +Suite de la campagne après l'itération 1 (S1 SSO bilatéral). Cette itération couvre les scénarios : | |
| 14 | + | |
| 15 | +- **S2** — Invitation cross-instance dans une équipe (RFC 0014 + RFC 0009) | |
| 16 | +- **S3** — Trois instances, non-transitivité du trust (RFC 0004) | |
| 17 | + | |
| 18 | +Méthodologie inchangée : audit code → tests E2E sur testbed → identifier les manques → corriger ou différer selon gravité → re-valider. | |
| 19 | + | |
| 20 | +## 2. Ce qui a été livré dans cette itération | |
| 21 | + | |
| 22 | +### 2.1 Tests E2E nouveaux | |
| 23 | + | |
| 24 | +Sur la suite [e2e_fediverse.rs](../../crates/rustwarden-core/tests/e2e_fediverse.rs), 8 tests ajoutés : | |
| 25 | + | |
| 26 | +**S2 (4 tests) — flux invite B-only** : | |
| 27 | + | |
| 28 | +| Test | Couvre | | |
| 29 | +|---|---| | |
| 30 | +| `s2_admin_invite_to_remote_joanie_creates_team_membership` | Happy path : admin crée invite → accept lie `team_member` à `remote_identity` | | |
| 31 | +| `s2_invite_reject_flips_status_to_rejected_no_member` | Reject ne crée pas de membre | | |
| 32 | +| `s2_invite_revoke_blocks_subsequent_accept` | Revoke bloque les accept ultérieurs | | |
| 33 | +| `s2_idp_list_returns_pending_invite_for_handle` | Listing IdP-side fonctionne | | |
| 34 | + | |
| 35 | +**S3 (4 tests) — trust model** : | |
| 36 | + | |
| 37 | +| Test | Couvre | | |
| 38 | +|---|---| | |
| 39 | +| `s3_signed_protocol_flow_never_writes_trust_list` | Non-transitivité : aucune écriture auto dans `fediverse_trusted_domains` | | |
| 40 | +| `s3_trust_list_entries_persist_independent_of_protocol_activity` | Permanence des entries explicites | | |
| 41 | +| `s3_untrusted_domain_is_rejected_with_403` | **F8 fix** : un domaine retiré du trust list reçoit 403 | | |
| 42 | +| `s3_trusted_domain_still_passes_protocol_inbox` | Régression positive : domaine trusté passe toujours | | |
| 43 | + | |
| 44 | +**Total suite E2E** : 22 tests (14 iter 1 + 8 iter 2+3) en 7.83s. | |
| 45 | + | |
| 46 | +### 2.2 Code livré (F8) | |
| 47 | + | |
| 48 | +Trust enforcement au niveau protocole : | |
| 49 | + | |
| 50 | +- [crates/rustwarden-core/src/services/fediverse_trust_service.rs](../../crates/rustwarden-core/src/services/fediverse_trust_service.rs) : nouvelle méthode `assert_domain_allowed(domain) -> Result<()>` qui retourne `AppError::Validation` si refusé. | |
| 51 | +- [crates/rustwarden-core/src/fediverse/error.rs](../../crates/rustwarden-core/src/fediverse/error.rs) : nouvelle variante `FediverseError::Forbidden(String)` → 403. | |
| 52 | +- [crates/rustwarden-core/src/fediverse/auth_routes.rs](../../crates/rustwarden-core/src/fediverse/auth_routes.rs) : appel `assert_domain_allowed` dans `auth_inbox`, **avant** la vérification de signature. | |
| 53 | +- [crates/rustwarden-core/src/fediverse/inbox.rs](../../crates/rustwarden-core/src/fediverse/inbox.rs) : idem dans `/inbox`. | |
| 54 | + | |
| 55 | +### 2.3 Tests unit fixés | |
| 56 | + | |
| 57 | +7 tests unit ont régressé après l'ajout de F8 (le mode allowlist par défaut rejetait tout dans un `MockDatabase` vide). Fix : helper `permissive_trust_mock(MockDatabase)` ajouté dans `fediverse/inbox.rs::tests` et `fediverse/auth_routes.rs::tests`. Pré-pend 3 query results : | |
| 58 | + | |
| 59 | +1. `app_settings.fediverse_enabled = true` | |
| 60 | +2. `app_settings.fediverse_trust_mode = open_with_blocklist` | |
| 61 | +3. `fediverse_blocked_domains` empty | |
| 62 | + | |
| 63 | +Effet de bord trouvé en cours de fix : race env var `FEDIVERSE_DOMAIN` entre `test_fediverse_config` et `auth_inbox_returns_400_when_requester_actor_unknown`. F8 a élargi la fenêtre de mutation. Corrigé en **supprimant** la mutation env du test (l'assertion BadRequest tient quelle que soit la valeur de l'env). | |
| 64 | + | |
| 65 | +--- | |
| 66 | + | |
| 67 | +## 3. Findings | |
| 68 | + | |
| 69 | +### F4–F7 — Back-channel AP invite (DIFFÉRÉS, non-bloquant) | |
| 70 | + | |
| 71 | +**Sévérité** : 🟡 UX — non-bloquant pour la promotion stable. | |
| 72 | + | |
| 73 | +**Constat de l'audit S2** : | |
| 74 | + | |
| 75 | +| # | Endroit | Description | | |
| 76 | +|---|---|---| | |
| 77 | +| F4 | [crates/rustwarden-ui/src/handlers/admin_fediverse.rs:1617](../../crates/rustwarden-ui/src/handlers/admin_fediverse.rs#L1617) `create_team_invite` | Crée la `fediverse_pending_invite` row côté B mais **n'enqueue pas** le payload AP `Invite{TeamMembership}` vers l'inbox de A | | |
| 78 | +| F5 | [crates/rustwarden-core/src/fediverse/inbox.rs:86](../../crates/rustwarden-core/src/fediverse/inbox.rs#L86) `match activity.kind` | Dispatch sur `Update/Delete/Revoke` uniquement — **pas de cas `Invite`** | | |
| 79 | +| F6 | `FediverseAuthService` (par analogie F3) | Pas de `record_inbound_invite(db, body)` service pour persister la notification côté A | | |
| 80 | +| F7 | [crates/rustwarden-core/src/fediverse/invite_routes.rs:170](../../crates/rustwarden-core/src/fediverse/invite_routes.rs#L170) `idp_accept` | Flippe le status local côté B mais **n'enqueue pas** l'`Accept(Invite)` back vers A pour mise à jour admin | | |
| 81 | + | |
| 82 | +**Pourquoi différé** : le flux S2 fonctionne **déjà bout-en-bout** sans ces back-channels (testé par S2a) — l'admin de B et l'invité d'A se coordonnent via le partage manuel du lien d'invitation (similaire à un lien GitHub team invite). Le back-channel AP est une **commodité UX** (notification poussée + status admin), pas une condition fonctionnelle. | |
| 83 | + | |
| 84 | +**À implémenter quand** : une priorité produit l'exige, ou lors de la rédaction d'une suite RFC 0014 Phase 3. | |
| 85 | + | |
| 86 | +**Effort estimé** : ~400 LoC, similaire à F3 dans sa forme (réutiliser le dispatch type-based de F8, ajouter Invite/Accept(Invite)/Reject(Invite) au match). Pattern bien connu maintenant. | |
| 87 | + | |
| 88 | +### F8 — Trust list décorative au niveau protocole (CORRIGÉ) | |
| 89 | + | |
| 90 | +**Sévérité** : 🔴 **bloquant pour promotion stable**. | |
| 91 | + | |
| 92 | +**Constat** : `FediverseTrustService::is_domain_allowed` existait avec docstring « applied **before** any signature verification or fetch towards a remote domain », mais **n'était appelé que depuis les handlers UI**. Les 3 routes protocole (`auth_inbox`, `/inbox`, `invite_routes`) ne consultaient jamais la trust list. | |
| 93 | + | |
| 94 | +**Conséquence** : un domaine `evil.tld` avec une signature valide pouvait POST des activités à n'importe quelle instance Rustwarden, **indépendamment** de la trust list. Le mode `Allowlist` (zéro-trust par défaut) n'avait aucun effet runtime. | |
| 95 | + | |
| 96 | +**Reproductibilité** : test `s3_finding_f8_untrusted_domain_payload_accepted` (initial), qui validait empiriquement que beta acceptait un POST signé d'alpha même après retrait d'alpha de la trust list. | |
| 97 | + | |
| 98 | +**Fix appliqué** : | |
| 99 | + | |
| 100 | +1. Ajout `FediverseError::Forbidden(String)` → 403. | |
| 101 | +2. Ajout `FediverseTrustService::assert_domain_allowed(domain) -> Result<()>`. | |
| 102 | +3. Appel `assert_domain_allowed` au début de `auth_inbox` et `/inbox`, **avant** la vérification de signature. | |
| 103 | +4. Test flipped : `s3_untrusted_domain_is_rejected_with_403` attend désormais 403. | |
| 104 | +5. Test positif : `s3_trusted_domain_still_passes_protocol_inbox` confirme l'absence de régression. | |
| 105 | + | |
| 106 | +**Critère d'acceptation** : ✅ Test `s3_untrusted_domain_is_rejected_with_403` vert, 348 unit tests OK. | |
| 107 | + | |
| 108 | +### Effet collatéral de F8 — Race env var dans tests (CORRIGÉ) | |
| 109 | + | |
| 110 | +**Sévérité** : 🟡 dette technique de la base de tests. | |
| 111 | + | |
| 112 | +**Constat** : `test_fediverse_config` dans `fediverse_auth_service.rs` mute `FEDIVERSE_DOMAIN=test.local` ; `auth_inbox_returns_400_when_requester_actor_unknown` dans `auth_routes.rs` mute `FEDIVERSE_DOMAIN=a.tld` puis `remove_var`. Avec F8 ajoutant 3 queries DB entre set et remove, la fenêtre s'est élargie suffisamment pour qu'un test concurrent `build_*_payload` (sync) lise une valeur incohérente et rate son assertion. | |
| 113 | + | |
| 114 | +**Fix appliqué** : suppression complète de la mutation env dans `auth_inbox_returns_400_when_requester_actor_unknown`. L'assertion BadRequest est robuste à toute valeur de `FEDIVERSE_DOMAIN` (target mismatch dans tous les cas). | |
| 115 | + | |
| 116 | +**Dette restante** : la mutation env globale dans `test_fediverse_config` reste un anti-pattern. Une RFC dette technique pourrait introduire un mutex `ENV_LOCK` cross-file ou refactorer pour passer `FediverseConfig` en argument plutôt que via env. **Hors-scope cette itération.** | |
| 117 | + | |
| 118 | +--- | |
| 119 | + | |
| 120 | +## 4. État technique final iter 2+3 | |
| 121 | + | |
| 122 | +``` | |
| 123 | +Tests E2E : 22 verts (iter 1a + 1b + 1c + 1d + 2a + 3) | |
| 124 | + ├── 7 smoke (WebFinger, NodeInfo, Actor, inbox 401) | |
| 125 | + ├── 1 AuthRequest signed B→A (iter 1b) | |
| 126 | + ├── 1 consume B → JWT (iter 1c) | |
| 127 | + ├── 5 round-trip Accept/Reject A→B (iter 1d / F3) | |
| 128 | + ├── 4 invite B-only (iter 2a / S2) | |
| 129 | + └── 4 trust model (iter 3 / S3 + F8) | |
| 130 | +Tests unit rustwarden-core --lib : 348 verts | |
| 131 | +Bugs ouverts (bloquants) : 0 | |
| 132 | +Bugs ouverts (UX différés) : F4, F5, F6, F7 | |
| 133 | +RFC nouvelles : (rien dans cette itération — F8 utilise l'API trust existante) | |
| 134 | +Quality gates : build ✅, clippy -D warnings ✅ | |
| 135 | +Run time E2E suite : 7.83s | |
| 136 | +``` | |
| 137 | + | |
| 138 | +--- | |
| 139 | + | |
| 140 | +## 5. Position promotion stable | |
| 141 | + | |
| 142 | +| Scénario plan parent | Statut | | |
| 143 | +|---|---| | |
| 144 | +| **S1** SSO bilatéral A ↔ B | ✅ (iter 1) | | |
| 145 | +| **S2** Invite cross-instance | ✅ B-only ; back-channel A↔B en F4-F7 différés (UX) | | |
| 146 | +| **S3** 3 instances, non-transitivité trust | ✅ (iter 3 + F8 corrigé) | | |
| 147 | +| S4 Block / révocation en masse | À auditer (`fediverse_blocked_domains` existe, vérifier que F8 honore le blocklist) | | |
| 148 | +| S5 Modes de panne (replay, expiry, network) | Partiel (négatifs S1/S2/S3 couvrent une part) | | |
| 149 | +| S6 Lifecycle Update / Delete Person | À automatiser | | |
| 150 | +| Non-fonctionnels (perf, observabilité, doc opérateur) | Non commencé | | |
| 151 | + | |
| 152 | +**3 scénarios sur 6 validés, 0 bloquant restant pour le protocole identité fédérée.** La promotion `experimental-fediverse` → `fediverse` reste suspendue aux scénarios S4–S6 mais aucun finding restant ne **bloque** : tous sont soit (a) UX différée, soit (b) non encore audités. | |
| 153 | + | |
| 154 | +--- | |
| 155 | + | |
| 156 | +## 6. Patterns récurrents repérés (méta-finding) | |
| 157 | + | |
| 158 | +Trois itérations ont révélé un pattern systémique dans l'architecture fédiverse Rustwarden : | |
| 159 | + | |
| 160 | +> **Les helpers existent (payloads, services), mais leur appel depuis les routes HTTP est régulièrement absent.** | |
| 161 | + | |
| 162 | +| Itération | Helper présent | Route qui devait l'appeler | Statut | | |
| 163 | +|---|---|---|---| | |
| 164 | +| F3 | `record_inbound_decision` à ajouter, mais `Accept`/`Reject` types AP standard | `auth_inbox` ne dispatchait pas sur `type` | ✅ Fixé | | |
| 165 | +| F4 | `build_invite_payload` | `create_team_invite` UI n'enqueuait pas | ⏳ Différé | | |
| 166 | +| F5 | (Invite dispatch attendu) | `/inbox` ne matchait pas `Invite` | ⏳ Différé | | |
| 167 | +| F8 | `FediverseTrustService::is_domain_allowed` | Routes protocole n'appelaient jamais | ✅ Fixé | | |
| 168 | + | |
| 169 | +**Recommandation pour la stabilité long terme** : avant la promotion stable finale, faire une revue systématique « pour chaque helper public dans `services/fediverse_*`, qui l'appelle en prod ? » et combler les trous. Sans ça, les helpers restent du code mort et la surface fonctionnelle est plus petite que ce que l'API suggère. | |
| 170 | + | |
| 171 | +--- | |
| 172 | + | |
| 173 | +## 7. Prochaine itération suggérée | |
| 174 | + | |
| 175 | +**S4 — Block list / révocation en masse**, qui : | |
| 176 | +- Audite que F8 honore aussi `fediverse_blocked_domains` (mode `OpenWithBlocklist`), pas que `fediverse_trusted_domains` (déjà couvert). | |
| 177 | +- Teste la révocation en masse : admin ajoute un domaine au blocklist → toutes les sessions JWT remote de ce domaine doivent être invalidées (RFC 0010 lifecycle signals). | |
| 178 | +- Surface attendue de tests : ~4-5 E2E. | |
| 179 | + | |
| 180 | +Effort estimé : ~3-4h, similaire à iter 2. |
A
docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md
+301
-0
@@ -0,0 +1,301 @@
| 1 | +# Itération 1 — Findings de la campagne d'évaluation `experimental-fediverse` | |
| 2 | + | |
| 3 | +**Statut** : ✅ **CLOS — itération 1 complète, 2026-05-11** | |
| 4 | +**Mise à jour** : 2026-05-11 (F2 + F3 livrés, suite E2E 14 verts) | |
| 5 | +**Auteur** : EBO + Claude | |
| 6 | +**Plan parent** : [2026-05-10-fediverse-promotion-stable-evaluation.md](2026-05-10-fediverse-promotion-stable-evaluation.md) | |
| 7 | +**Testbed** : [tests/fediverse-testbed/](../../tests/fediverse-testbed/) | |
| 8 | +**Tests E2E** : [crates/rustwarden-core/tests/e2e_fediverse.rs](../../crates/rustwarden-core/tests/e2e_fediverse.rs) | |
| 9 | + | |
| 10 | +--- | |
| 11 | + | |
| 12 | +## 1. Contexte | |
| 13 | + | |
| 14 | +La campagne d'évaluation pour la promotion `experimental-fediverse` → `fediverse` (stable) suit un cycle : | |
| 15 | + | |
| 16 | +``` | |
| 17 | + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ | |
| 18 | + │ Itération │ ────► │ Findings │ ────► │ Corrections │ | |
| 19 | + │ E2E testbed │ │ documentés │ │ ciblées │ | |
| 20 | + └──────────────┘ └──────────────┘ └──────────────┘ | |
| 21 | + ▲ │ | |
| 22 | + │ │ | |
| 23 | + └──────────────────────────────────────────────────┘ | |
| 24 | + Re-évaluation après chaque cycle | |
| 25 | +``` | |
| 26 | + | |
| 27 | +L'itération 1 (iter 0 setup + iter 1a/1b/1c tests) a livré **9 tests E2E verts** contre une topologie 3-instances Docker. Le présent document : | |
| 28 | + | |
| 29 | +1. Synthétise ce qui a été livré. | |
| 30 | +2. Liste les **manques** que les tests ont révélés dans le code source de Rustwarden. | |
| 31 | +3. Définit le plan de correction par finding (sévérité, effort, critère d'acceptation). | |
| 32 | +4. Spécifie la re-évaluation à effectuer après corrections. | |
| 33 | + | |
| 34 | +--- | |
| 35 | + | |
| 36 | +## 2. Ce que la campagne a livré jusqu'ici | |
| 37 | + | |
| 38 | +### 2.1 Infrastructure (iter 0) | |
| 39 | + | |
| 40 | +- [tests/fediverse-testbed/](../../tests/fediverse-testbed/) — topologie 3 instances locales, 100 % automatisable | |
| 41 | +- 4 scripts cycle de vie : `setup.sh`, `stop.sh`, `start.sh`, `teardown.sh` | |
| 42 | +- Image Docker `rustwarden-fediverse-testbed:latest` (rustc 1.91, multi-stage) | |
| 43 | +- Compose : 12 services (3 PG + 3 migrate + 3 app + 3 seed) | |
| 44 | +- Réseau Docker avec `network_aliases` (`alpha.local`, `beta.local`, `gamma.local`) | |
| 45 | +- Pas de TLS, pas de `/etc/hosts` hôte requis (via `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`) | |
| 46 | + | |
| 47 | +### 2.2 Tests E2E (iter 1a + 1b + 1c) | |
| 48 | + | |
| 49 | +Total : **9 tests verts** en 1.42 s à chaud. | |
| 50 | + | |
| 51 | +| Test | But | | |
| 52 | +|---|---| | |
| 53 | +| `webfinger_resolves_local_user_on_each_instance` | WebFinger JRD sur les 3 instances | | |
| 54 | +| `webfinger_returns_404_for_foreign_domain` | A refuse `acct:user@beta.local` | | |
| 55 | +| `webfinger_returns_4xx_for_malformed_resource` | Resource mal formée → 4xx | | |
| 56 | +| `actor_fetch_returns_valid_person_jsonld` | `/users/joanie` retourne Person AP avec publicKey | | |
| 57 | +| `nodeinfo_discovery_returns_links` | `.well-known/nodeinfo` répond | | |
| 58 | +| `auth_inbox_rejects_unsigned_post` | Inbox refuse POST sans Signature | | |
| 59 | +| `auth_inbox_rejects_invalid_signature` | Inbox refuse Signature mal formée | | |
| 60 | +| `s1_signed_authrequest_yields_pending_challenge_on_alpha` (1b) | **Round-trip signé draft-cavage avec vraie crypto** — B → A AuthRequest signé → A crée challenge Pending | | |
| 61 | +| `s1_consume_on_beta_mints_jwt_when_challenge_approved` (1c) | Consume B émet JWT remote depuis challenge Approved | | |
| 62 | + | |
| 63 | +### 2.3 Bugs corrigés en cours de campagne | |
| 64 | + | |
| 65 | +| # | Endroit | Description | Statut | | |
| 66 | +|---|---|---|---| | |
| 67 | +| F1 | [crates/rustwarden-core/src/fediverse/auth_routes.rs:196](../../crates/rustwarden-core/src/fediverse/auth_routes.rs#L196) | `FEDIVERSE_DOMAIN` lu brut sans normalisation, échoue quand on passe la forme URL (`http://alpha.local`) | ✅ Fix 2-LoC appliqué | | |
| 68 | +| F2 | [crates/rustwarden-core/src/services/fediverse_signal_service.rs](../../crates/rustwarden-core/src/services/fediverse_signal_service.rs) | `AlwaysAcceptVerifier` ne populait pas `remote_identities`, brisant le contrat de `persist_inbound_request` | ✅ Fix livré + durci (timeout, body cap, SSRF guard) + 9 tests unit | | |
| 69 | +| F3 | [crates/rustwarden-core/src/services/fediverse_auth_service.rs](../../crates/rustwarden-core/src/services/fediverse_auth_service.rs) + [auth_routes.rs](../../crates/rustwarden-core/src/fediverse/auth_routes.rs) + [fediverse_user_auth.rs](../../crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) | Pas de back-channel A→B → SSO bilatéral impossible à compléter sans DB-direct | ✅ RFC 0025 implémentée (dispatch type-based, `record_inbound_decision`, payload builder, enqueue UI) + 5 tests E2E | | |
| 70 | + | |
| 71 | +--- | |
| 72 | + | |
| 73 | +## 3. Findings — analyse des manques (historique, tous corrigés) | |
| 74 | + | |
| 75 | +### Finding F2 — `AlwaysAcceptVerifier` ne respecte pas son contrat | |
| 76 | + | |
| 77 | +**Sévérité** : 🟠 Sérieux. Non bloquant pour la promotion (le verifier en prod fait le job), mais empoisonne tout test E2E qui dépend de la création implicite de `remote_identities`. | |
| 78 | + | |
| 79 | +**Origine** : `crates/rustwarden-core/src/services/fediverse_signal_service.rs` (à vérifier — nom exact à confirmer). | |
| 80 | + | |
| 81 | +**Symptôme observé en iter 1b** : POST signé arrive sur `auth_inbox` d'alpha. La signature est acceptée (logs warn `AlwaysAcceptVerifier in use`), mais `persist_inbound_request` plante en 400 avec « Requester actor not in remote_identities cache » parce que le verifier-debug n'a pas peuplé la table comme un vrai verifier le fait. | |
| 82 | + | |
| 83 | +**Contrat attendu** (cf. commentaire du `persist_inbound_request`) : | |
| 84 | +> *"the signature verifier ran upstream and already required this row"* | |
| 85 | + | |
| 86 | +Le vrai verifier (`HttpSigVerifier::with_resolver`) : | |
| 87 | +1. Extrait `keyId` de l'en-tête `Signature`. | |
| 88 | +2. Fetch l'actor JSON via HTTP GET. | |
| 89 | +3. Stocke `publicKey + inbox + ...` dans `remote_identities` (UPSERT). | |
| 90 | +4. Vérifie la signature RSA-SHA256 avec la clé fetchée. | |
| 91 | + | |
| 92 | +`AlwaysAcceptVerifier` saute toutes ces étapes et retourne `Ok(())`. | |
| 93 | + | |
| 94 | +**Impact concret** : | |
| 95 | +- Tout test E2E utilisant le mode debug doit pré-seeder `remote_identities` manuellement (workaround actuel dans `e2e_fediverse.rs`). | |
| 96 | +- Pire : en production, si quelqu'un active accidentellement `AlwaysAcceptVerifier`, la chaîne casse au runtime — pas seulement la sécurité. | |
| 97 | + | |
| 98 | +**Plan de correction F2** : | |
| 99 | + | |
| 100 | +Deux options, par ordre de préférence : | |
| 101 | + | |
| 102 | +**Option F2.A** (recommandée) — Faire honorer le contrat même en mode debug. | |
| 103 | + | |
| 104 | +Modifier `AlwaysAcceptVerifier` pour qu'il : | |
| 105 | +1. Continue d'accepter toutes les signatures (sa raison d'être). | |
| 106 | +2. **Aussi** : fetch l'actor URL extrait du keyId, peuple `remote_identities`. | |
| 107 | + | |
| 108 | +Avantages : | |
| 109 | +- Tests E2E sans pré-seeding manuel. | |
| 110 | +- Cohérence : `AlwaysAcceptVerifier` devient « tolérant en signature, strict en data ». | |
| 111 | +- Aucun impact prod (mode debug uniquement). | |
| 112 | + | |
| 113 | +Effort : ~80 LoC + 1 test unitaire. ~1 h. | |
| 114 | + | |
| 115 | +**Option F2.B** — Supprimer `AlwaysAcceptVerifier` et toujours utiliser le vrai verifier. | |
| 116 | + | |
| 117 | +Avantages : code plus simple, pas de divergence debug/prod. | |
| 118 | +Inconvénients : impossible de tester sans HTTPS fonctionnel entre instances. Casse le mode `FEDIVERSE_DEBUG_LOCAL_FEDERATION` actuel. | |
| 119 | + | |
| 120 | +**Critère d'acceptation F2** : le test `s1_signed_authrequest_yields_pending_challenge_on_alpha` passe **sans** appeler `upsert_remote_identity` dans son setup. | |
| 121 | + | |
| 122 | +--- | |
| 123 | + | |
| 124 | +### Finding F3 — Pas de back-channel A → B (AuthApproval) | |
| 125 | + | |
| 126 | +**Sévérité** : 🔴 **Bloquant pour la promotion stable.** | |
| 127 | + | |
| 128 | +**Origine** : Conception RFC 0006. Le protocole AP-natif tel qu'implémenté ne définit que la moitié A→B du flux d'authentification fédérée. | |
| 129 | + | |
| 130 | +**Symptôme observé en iter 1c** : `grep -rni "AuthApproval"` dans `crates/rustwarden-core/src` retourne **zéro occurrence**. L'`auth_inbox` ne dispatche pas sur le champ `type` du body — il assume `AuthRequest`. La fonction `consent_submit` (UI) flippe le challenge à `Approved` côté A et **rien d'autre** : aucune enqueue outbound, aucun appel HTTP vers B. | |
| 131 | + | |
| 132 | +**Mécanique manquante** : | |
| 133 | + | |
| 134 | +Quand l'utilisateur clique « Approve » sur l'IdP (A), il faut **propager l'approbation vers le SP (B)**. Trois designs possibles : | |
| 135 | + | |
| 136 | +```mermaid | |
| 137 | +sequenceDiagram | |
| 138 | + participant A as A (IdP) | |
| 139 | + participant B as B (SP) | |
| 140 | + | |
| 141 | + Note over A,B: Design 1 — Push AP signé (recommandé) | |
| 142 | + A->>B: POST /fediverse/auth/inbox<br/>{ type: "AuthApproval", inResponseTo: nonce } (signé) | |
| 143 | + B->>B: dispatch sur type → flip challenge local à Approved | |
| 144 | + | |
| 145 | + Note over A,B: Design 2 — Pull / polling | |
| 146 | + B->>A: POST /fediverse/auth/status/{nonce} | |
| 147 | + A-->>B: { status: "approved" | "pending" | ... } | |
| 148 | + B->>B: si approved → flip local challenge | |
| 149 | + | |
| 150 | + Note over A,B: Design 3 — Long-polling SSE | |
| 151 | + B-->>A: GET /fediverse/auth/wait/{nonce} (keep-alive) | |
| 152 | + A-->>B: stream { status: approved } quand changé | |
| 153 | +``` | |
| 154 | + | |
| 155 | +Recommandation : **Design 1** (push AP signé). Avantages : | |
| 156 | +- Aligné avec la philosophie AP (activities push). | |
| 157 | +- Permet l'écosystème fédéré (n'importe quelle instance peut être A ou B). | |
| 158 | +- Pas de polling B → A (charge réseau prévisible). | |
| 159 | +- L'`auth_inbox` existe déjà, il suffit de dispatch sur `type`. | |
| 160 | + | |
| 161 | +**Plan de correction F3** (RFC 0006 Phase 3) : | |
| 162 | + | |
| 163 | +Cette correction mérite sa propre RFC. Périmètre minimal viable : | |
| 164 | + | |
| 165 | +#### F3.1 — Dispatch type-based dans `auth_inbox` | |
| 166 | + | |
| 167 | +[crates/rustwarden-core/src/fediverse/auth_routes.rs:156](../../crates/rustwarden-core/src/fediverse/auth_routes.rs#L156) | |
| 168 | + | |
| 169 | +Avant traitement actuel (qui assume AuthRequest), ajouter : | |
| 170 | + | |
| 171 | +```rust | |
| 172 | +let activity_type = body_json | |
| 173 | + .get("type") | |
| 174 | + .and_then(|v| v.as_str()) | |
| 175 | + .unwrap_or(""); | |
| 176 | +match activity_type { | |
| 177 | + "AuthRequest" => { /* code actuel */ }, | |
| 178 | + "AuthApproval" => FediverseAuthService::record_inbound_approval(&db, &body_json).await?, | |
| 179 | + "AuthDenial" => FediverseAuthService::record_inbound_denial(&db, &body_json).await?, | |
| 180 | + other => return Err(FediverseError::BadRequest(format!("unknown activity type `{other}`"))), | |
| 181 | +} | |
| 182 | +``` | |
| 183 | + | |
| 184 | +#### F3.2 — Service `record_inbound_approval` / `record_inbound_denial` | |
| 185 | + | |
| 186 | +Nouveau dans `services/fediverse_auth_service.rs` : | |
| 187 | +- Récupère le challenge local de B par `nonce` (champ `inResponseTo` du payload). | |
| 188 | +- Vérifie expiry et état (doit être Pending). | |
| 189 | +- Flippe status à Approved/Rejected et lie `requested_remote_id` à l'actor approver. | |
| 190 | + | |
| 191 | +#### F3.3 — Enqueue outbound après decision côté A | |
| 192 | + | |
| 193 | +[crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs:578](../../crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs#L578) (`consent_submit`) après `record_decision` : | |
| 194 | +- Construire payload AP `AuthApproval` (id, type, actor=IdP user, inResponseTo=nonce, ...). | |
| 195 | +- Récupérer l'inbox du requester via la `remote_identity` liée au challenge. | |
| 196 | +- `FediverseOutboundService::enqueue` un job délivré par `HttpSignedDelivery`. | |
| 197 | + | |
| 198 | +#### F3.4 — Payload AP `AuthApproval` (spec interne) | |
| 199 | + | |
| 200 | +```json | |
| 201 | +{ | |
| 202 | + "@context": ["https://www.w3.org/ns/activitystreams", | |
| 203 | + "https://schema.rustwarden.org/auth-approval/v1"], | |
| 204 | + "type": "AuthApproval", | |
| 205 | + "id": "http://alpha.local/auth-approvals/<uuid>", | |
| 206 | + "actor": "http://alpha.local/users/joanie", | |
| 207 | + "inResponseTo": "<nonce>", | |
| 208 | + "decision": "approve" | "reject" | |
| 209 | +} | |
| 210 | +``` | |
| 211 | + | |
| 212 | +#### F3.5 — Tests E2E nouveaux (à ajouter à `e2e_fediverse.rs`) | |
| 213 | + | |
| 214 | +| Test | But | | |
| 215 | +|---|---| | |
| 216 | +| `s1_full_round_trip_signed_e2e` | B initiate → POST AuthRequest signé vers A → A flip via consent JSON API → A enqueue AuthApproval → polling B's consume jusqu'à 200 avec JWT | | |
| 217 | +| `s1_reject_propagates_to_b` | Idem, mais decision=reject → B's consume retourne 4xx | | |
| 218 | +| `s1_unknown_inResponseTo_rejected` | B reçoit AuthApproval pour un nonce inconnu → 400 | | |
| 219 | +| `s1_expired_challenge_no_longer_consumable` | Approval arrive après TTL → 4xx | | |
| 220 | + | |
| 221 | +**Effort estimé F3** : ~400 LoC code prod + 4 tests E2E + RFC formelle. **~2 jours.** | |
| 222 | + | |
| 223 | +**Critère d'acceptation F3** : le test `s1_full_round_trip_signed_e2e` passe sans aucun DB-direct sur B. | |
| 224 | + | |
| 225 | +--- | |
| 226 | + | |
| 227 | +## 4. Plan d'exécution | |
| 228 | + | |
| 229 | +### 4.1 Phase de correction (≈ 1 jour + 2 jours) | |
| 230 | + | |
| 231 | +**Sprint 1 (½ journée)** — Finding F2 corrigé. | |
| 232 | + | |
| 233 | +- Implémenter Option F2.A (peupler `remote_identities` même en mode debug). | |
| 234 | +- Adapter `e2e_fediverse.rs` pour retirer `upsert_remote_identity` du test 1b. | |
| 235 | +- Re-lancer la suite → 9 tests verts. | |
| 236 | + | |
| 237 | +**Sprint 2 (2 jours)** — Finding F3 corrigé (RFC 0006 Phase 3). | |
| 238 | + | |
| 239 | +- Rédiger `docs/rfcs/0006-phase3-auth-approval-back-channel.md`. | |
| 240 | +- Implémenter F3.1 à F3.4. | |
| 241 | +- Écrire les 4 tests E2E F3.5. | |
| 242 | +- Re-lancer la suite → 13 tests verts. | |
| 243 | + | |
| 244 | +### 4.2 Phase de re-évaluation (½ journée) | |
| 245 | + | |
| 246 | +Après chaque sprint : | |
| 247 | + | |
| 248 | +1. `tests/fediverse-testbed/scripts/teardown.sh && setup.sh` — état propre. | |
| 249 | +2. `cargo test -p rustwarden-core --test e2e_fediverse --features experimental-fediverse -- --test-threads=1`. | |
| 250 | +3. Mettre à jour le tableau « Tests E2E » de ce plan. | |
| 251 | +4. Si tout est vert, attaquer le scénario suivant du plan parent (S2 invite cross-instance, S3 trois instances no transitive trust, etc.). | |
| 252 | + | |
| 253 | +### 4.3 Critères pour clore l'itération 1 | |
| 254 | + | |
| 255 | +- [x] **F2 corrigé** ; test 1b passe sans pré-seeding (`upsert_remote_identity` | |
| 256 | + retiré). Helper `fetch_and_cache_remote_actor` durci (timeout / body | |
| 257 | + cap / SSRF guard) + 9 tests unit verts. | |
| 258 | +- [x] **F3 corrigé** ; round-trip S1 complet automatisé sans DB-direct via | |
| 259 | + `Accept(AuthRequest)` standard AP. 5 tests E2E neufs verts. | |
| 260 | +- [x] **CHANGELOG.md** mis à jour avec entrées « bug F1, F2, F3 résolus ». | |
| 261 | +- [x] **Plan parent** ([2026-05-10-fediverse-promotion-stable-evaluation.md](2026-05-10-fediverse-promotion-stable-evaluation.md)) | |
| 262 | + : Itération 1 ✅ (à propager dans le tableau §6 du plan parent). | |
| 263 | + | |
| 264 | +--- | |
| 265 | + | |
| 266 | +## 5. Décisions et trade-offs explicites | |
| 267 | + | |
| 268 | +| Décision | Pourquoi | Conséquence | | |
| 269 | +|---|---|---| | |
| 270 | +| Choisir Design 1 (push AP) pour F3 | Aligné avec écosystème AP, scalable, pas de polling | Demande un endpoint de dispatch type-based côté inbox | | |
| 271 | +| Faire F2 avant F3 | Sans F2, le test de F3 reste polluable par le bug de cache | Ordre strict de mise en œuvre | | |
| 272 | +| Pas de RFC séparée pour F1 | Bug local, 2 LoC, déjà fixé en cours d'iter 1b | TODO de refactor noté en commentaire de code | | |
| 273 | +| Conserver le testbed en HTTP plain (pas de TLS) | TLS dev = friction inutile pour la fédération identité | Le testbed restera dev-only | | |
| 274 | + | |
| 275 | +--- | |
| 276 | + | |
| 277 | +## 6. Annexe — État technique final (itération 1 close) | |
| 278 | + | |
| 279 | +``` | |
| 280 | +Tests E2E : 14 verts (iter 1a + 1b + 1c + 1d) | |
| 281 | + ├── 7 smoke (WebFinger, NodeInfo, Actor, inbox 401) | |
| 282 | + ├── 1 AuthRequest signed B→A (iter 1b) | |
| 283 | + ├── 1 consume B → JWT (iter 1c) | |
| 284 | + └── 5 round-trip Accept/Reject A→B (iter 1d / F3) | |
| 285 | +Tests unit rustwarden-core --lib : 348 verts (incluant +9 pour F2) | |
| 286 | +Bugs ouverts : 0 sur le périmètre de l'itération 1 | |
| 287 | +RFC nouvelles : 0025 (Phase 3 RFC 0006) | |
| 288 | +Quality gates : build ✅, clippy -D warnings ✅, doc ✅ | |
| 289 | +Run time E2E suite : 2.93s | |
| 290 | +``` | |
| 291 | + | |
| 292 | +**État final :** RFC `experimental-fediverse` désormais **techniquement | |
| 293 | +prêt** pour promotion stable côté flux S1 SSO bilatéral. Restent les | |
| 294 | +scénarios S2 (invite cross-instance), S3 (3 instances, non-transitivité du | |
| 295 | +trust), S4 (block / révocation), S5 (modes de panne), S6 (lifecycle Update / | |
| 296 | +Delete Person) du plan parent à automatiser. Aucune dette technique sur le | |
| 297 | +S1. | |
| 298 | + | |
| 299 | +**Suite : voir [2026-05-11-fediverse-evaluation-iter2-3-findings.md](2026-05-11-fediverse-evaluation-iter2-3-findings.md)** | |
| 300 | +qui couvre S2 (✅ B-only, F4–F7 différés UX), S3 (✅) et F8 — trust | |
| 301 | +enforcement protocole (✅ corrigé, bloquant qui était caché). |
A
docs/rfcs/0025-fediverse-auth-back-channel.md
+413
-0
@@ -0,0 +1,413 @@
| 1 | +# RFC 0025 — Fediverse: back-channel `Accept(AuthRequest)` (RFC 0006 Phase 3) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (rustwarden) | |
| 4 | +- **Date** : 2026-05-11 | |
| 5 | +- **Statut** : **Accepted** — implémentation livrée 2026-05-11 (5 tests E2E + 348 unit verts, suite E2E complète à 14) | |
| 6 | +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui souhaite réellement compléter un round-trip d'authentification fédérée Rustwarden ↔ Rustwarden. | |
| 7 | +- **Plan associé** : [`docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md`](../plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md) (Finding F3). | |
| 8 | +- **Dépendances** : RFC 0006 (Phase 1 & 2), RFC 0011 (outbound worker), RFC 0015 (HTTP signatures), RFC 0016 (signed delivery). | |
| 9 | +- **Supersedes** : aucun (complète RFC 0006). | |
| 10 | + | |
| 11 | +## Summary | |
| 12 | + | |
| 13 | +Implémenter le back-channel manquant du flow d'authentification AP-native : quand l'IdP (instance A) approuve ou rejette une `AuthRequest`, propager la décision vers le SP (instance B) via une activité **AP standard `Accept` / `Reject`** dont le champ `object` pointe l'`AuthRequest` originale. Côté B, l'inbox dispatche sur `type` et bascule le challenge local en `Approved` / `Rejected`, ce qui débloque `consume()` qui peut alors émettre le JWT remote. | |
| 14 | + | |
| 15 | +## Motivation | |
| 16 | + | |
| 17 | +L'évaluation E2E (testbed `tests/fediverse-testbed/`, itération 1c) a démontré que **deux instances Rustwarden ne peuvent pas effectivement compléter un SSO bilatéral** avec le code actuel : | |
| 18 | + | |
| 19 | +- ✅ `B → A` : l'`AuthRequest` signé est reçu et stocké en challenge Pending côté A. — RFC 0006 Phase 1 + 2. | |
| 20 | +- ✅ `A → A` : l'utilisateur clique « Approve » dans l'UI A, le challenge passe à Approved côté A. — RFC 0023 Slice B. | |
| 21 | +- ❌ **`A → B` : aucun mécanisme.** L'`auth_inbox` ne dispatche pas sur `type`, `consent_submit` n'enqueue rien dans `fediverse_outbound_queue`. Le challenge sur B reste éternellement Pending jusqu'au TTL 5 min. | |
| 22 | + | |
| 23 | +Conséquence : **bloquant pour la promotion `experimental-fediverse` → `fediverse` stable.** Sans round-trip, le protocole n'est pas opérationnel et aucune deuxième instance Rustwarden ne peut être un partenaire fonctionnel. | |
| 24 | + | |
| 25 | +La RFC 0006 originale référence explicitement `Accept{AuthRequest}` dans son Summary mais n'a jamais livré l'implémentation côté inbox B. Cette RFC clôt cette dette. | |
| 26 | + | |
| 27 | +## Non-goals | |
| 28 | + | |
| 29 | +- **Pas de polling / long-polling / SSE** côté `consume()` — le client polling au niveau de l'app consommatrice reste la responsabilité de l'UI (RFC 0023 Slice B garde son SSE). | |
| 30 | +- **Pas de modification du flow `B → A`** initial — `AuthRequest` reste un type custom dans le namespace Rustwarden, traité par le chemin actuel. | |
| 31 | +- **Pas de nouveau type d'activité custom** — explicitement, on **n'invente pas** `AuthApproval` / `AuthDenial` ; on utilise les types AP standards `Accept` / `Reject`. | |
| 32 | +- **Pas d'interop Mastodon** — un Mastodon recevant un `Accept(AuthRequest)` l'ignorera silencieusement (pas de side-effect connu). C'est le comportement souhaité : l'isolation du protocole Rustwarden ↔ Rustwarden reste intacte. | |
| 33 | +- **Pas de re-signature des activités existantes** dans le code (aucun `AuthApproval` n'a été émis en prod jusqu'à présent). | |
| 34 | +- **Pas de migration `cavage-draft` → RFC 9421** des HTTP Signatures (sera traité dans une RFC séparée, cf. § Alternatives). | |
| 35 | + | |
| 36 | +## API proposée | |
| 37 | + | |
| 38 | +### 1. Dispatch type-based dans `auth_inbox` | |
| 39 | + | |
| 40 | +[crates/rustwarden-core/src/fediverse/auth_routes.rs:156](../../crates/rustwarden-core/src/fediverse/auth_routes.rs#L156) | |
| 41 | + | |
| 42 | +Le handler actuel assume `AuthRequest`. Le nouveau code dispatche : | |
| 43 | + | |
| 44 | +```rust | |
| 45 | +async fn auth_inbox( | |
| 46 | + Extension(state): Extension<AuthFlowState>, | |
| 47 | + Extension(db): Extension<DatabaseConnection>, | |
| 48 | + headers: HeaderMap, | |
| 49 | + body: axum::body::Bytes, | |
| 50 | +) -> Result<StatusCode, FediverseError> { | |
| 51 | + // ... vérification signature (inchangé) ... | |
| 52 | + | |
| 53 | + let body_json = serde_json::from_slice::<serde_json::Value>(&body) | |
| 54 | + .map_err(|e| FediverseError::BadRequest(format!("malformed JSON: {e}")))?; | |
| 55 | + let activity_type = body_json | |
| 56 | + .get("type") | |
| 57 | + .and_then(|v| v.as_str()) | |
| 58 | + .unwrap_or(""); | |
| 59 | + | |
| 60 | + let idp_domain = normalised_idp_domain(); | |
| 61 | + | |
| 62 | + match activity_type { | |
| 63 | + "AuthRequest" => { | |
| 64 | + FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain) | |
| 65 | + .await | |
| 66 | + .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 67 | + } | |
| 68 | + "Accept" | "Reject" => { | |
| 69 | + FediverseAuthService::record_inbound_decision(&db, &body_json) | |
| 70 | + .await | |
| 71 | + .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 72 | + } | |
| 73 | + other => { | |
| 74 | + // AP spec §7 : types non reconnus sont ignorés silencieusement. | |
| 75 | + tracing::debug!(activity_type = other, "auth_inbox: ignoring unknown type"); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + Ok(StatusCode::ACCEPTED) | |
| 79 | +} | |
| 80 | +``` | |
| 81 | + | |
| 82 | +### 2. Nouveau service `record_inbound_decision` | |
| 83 | + | |
| 84 | +[crates/rustwarden-core/src/services/fediverse_auth_service.rs](../../crates/rustwarden-core/src/services/fediverse_auth_service.rs) : | |
| 85 | + | |
| 86 | +```rust | |
| 87 | +impl FediverseAuthService { | |
| 88 | + /// Phase 3 — IdP (A) → SP (B) back-channel. | |
| 89 | + /// | |
| 90 | + /// Called by B's `/fediverse/auth/inbox` when A delivers an | |
| 91 | + /// `Accept(AuthRequest)` or `Reject(AuthRequest)`. The activity's | |
| 92 | + /// `object` IRI references B's original AuthRequest, and we use the | |
| 93 | + /// embedded nonce (last URL segment) to look up B's local challenge. | |
| 94 | + /// | |
| 95 | + /// Idempotent: replaying the same decision returns Ok without DB write. | |
| 96 | + pub async fn record_inbound_decision( | |
| 97 | + db: &DatabaseConnection, | |
| 98 | + body: &serde_json::Value, | |
| 99 | + ) -> Result<(), AppError> { | |
| 100 | + let activity_type = body | |
| 101 | + .get("type") | |
| 102 | + .and_then(|v| v.as_str()) | |
| 103 | + .ok_or_else(|| AppError::Validation("activity missing `type`".into()))?; | |
| 104 | + | |
| 105 | + let nonce = extract_nonce_from_object(body)?; | |
| 106 | + let approver_actor_url = body | |
| 107 | + .get("actor") | |
| 108 | + .and_then(|v| v.as_str()) | |
| 109 | + .ok_or_else(|| AppError::Validation("activity missing `actor`".into()))? | |
| 110 | + .to_string(); | |
| 111 | + | |
| 112 | + let challenge = fediverse_auth_challenge::Entity::find() | |
| 113 | + .filter(fediverse_auth_challenge::Column::Nonce.eq(&nonce)) | |
| 114 | + .one(db) | |
| 115 | + .await? | |
| 116 | + .ok_or_else(|| AppError::NotFound(format!("unknown nonce {nonce}")))?; | |
| 117 | + | |
| 118 | + if challenge.status != ChallengeStatus::Pending.as_str() { | |
| 119 | + // Idempotence : replay sur challenge déjà décidé → ok silencieux. | |
| 120 | + return Ok(()); | |
| 121 | + } | |
| 122 | + if challenge.expires_at < Utc::now() { | |
| 123 | + return Err(AppError::Validation("challenge expired".into())); | |
| 124 | + } | |
| 125 | + | |
| 126 | + // Résoudre l'approver côté B en `remote_identities`. Le verifier | |
| 127 | + // upstream a déjà créé/mis à jour la ligne (cf. F2). | |
| 128 | + let approver = remote_identity::Entity::find() | |
| 129 | + .filter(remote_identity::Column::ApId.eq(&approver_actor_url)) | |
| 130 | + .one(db) | |
| 131 | + .await? | |
| 132 | + .ok_or_else(|| AppError::Validation(format!( | |
| 133 | + "approver {approver_actor_url} not in remote_identities cache" | |
| 134 | + )))?; | |
| 135 | + | |
| 136 | + let new_status = match activity_type { | |
| 137 | + "Accept" => ChallengeStatus::Approved, | |
| 138 | + "Reject" => ChallengeStatus::Rejected, | |
| 139 | + other => return Err(AppError::Validation(format!("unexpected type {other}"))), | |
| 140 | + }; | |
| 141 | + | |
| 142 | + let mut active: fediverse_auth_challenge::ActiveModel = challenge.into(); | |
| 143 | + active.status = Set(new_status.as_str().to_string()); | |
| 144 | + active.requested_remote_id = Set(Some(approver.id)); | |
| 145 | + active.update(db).await?; | |
| 146 | + Ok(()) | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +/// Extract the nonce from the `object` field. Supports both string-IRI | |
| 151 | +/// and inline-object shapes (`object: "https://b/auth/req/<nonce>"` vs | |
| 152 | +/// `object: { "id": "...", "type": "AuthRequest", "nonce": "..." }`). | |
| 153 | +fn extract_nonce_from_object(body: &serde_json::Value) -> Result<String, AppError> { | |
| 154 | + let object = body.get("object").ok_or_else(|| { | |
| 155 | + AppError::Validation("Accept/Reject activity missing `object`".into()) | |
| 156 | + })?; | |
| 157 | + | |
| 158 | + if let Some(s) = object.as_str() { | |
| 159 | + // IRI form: last segment is the nonce. | |
| 160 | + return Ok(s.rsplit('/').next().unwrap_or("").to_string()); | |
| 161 | + } | |
| 162 | + if let Some(inline) = object.as_object() | |
| 163 | + && let Some(nonce) = inline.get("nonce").and_then(|v| v.as_str()) | |
| 164 | + { | |
| 165 | + return Ok(nonce.to_string()); | |
| 166 | + } | |
| 167 | + Err(AppError::Validation( | |
| 168 | + "cannot extract nonce from Accept/Reject object".into(), | |
| 169 | + )) | |
| 170 | +} | |
| 171 | +``` | |
| 172 | + | |
| 173 | +### 3. Enqueue outbound dans `consent_submit` | |
| 174 | + | |
| 175 | +[crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs:578](../../crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs#L578) — après `record_decision`, enqueue le `Accept` ou `Reject` sortant. | |
| 176 | + | |
| 177 | +```rust | |
| 178 | +// Après FediverseAuthService::record_decision(&db, challenge_id, decision).await?; | |
| 179 | + | |
| 180 | +// Charger le challenge pour récupérer le requester remote_identity. | |
| 181 | +let model = FediverseAuthService::status(&db, challenge_id).await?; | |
| 182 | +let requester_id = model | |
| 183 | + .requested_remote_id | |
| 184 | + .ok_or_else(|| AppError::Internal("challenge has no requester binding".into()))?; | |
| 185 | +let requester = RemoteIdentityService::find_by_id(&db, requester_id).await?; | |
| 186 | + | |
| 187 | +let approver_user = UserService::find_by_id(&db, claims.user_id()).await?; | |
| 188 | +let approver_actor_url = format!("{}users/{}", config.base_url(), approver_user.username); | |
| 189 | +let original_auth_request_iri = format!("{}auth-requests/{}", | |
| 190 | + requester.base_url_or_homepage(), model.nonce); | |
| 191 | + | |
| 192 | +let payload = json!({ | |
| 193 | + "@context": [ | |
| 194 | + "https://www.w3.org/ns/activitystreams", | |
| 195 | + { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } } | |
| 196 | + ], | |
| 197 | + "type": if matches!(decision, ChallengeDecision::Approve { .. }) { "Accept" } else { "Reject" }, | |
| 198 | + "id": format!("{}auth-decisions/{}", config.base_url(), uuid::Uuid::new_v4()), | |
| 199 | + "actor": approver_actor_url, | |
| 200 | + "object": original_auth_request_iri, | |
| 201 | + "to": [ requester.inbox_url.clone() ], | |
| 202 | +}); | |
| 203 | + | |
| 204 | +FediverseOutboundService::enqueue( | |
| 205 | + &db, | |
| 206 | + OutboundEnqueueRequest { | |
| 207 | + target_inbox: requester.inbox_url.clone(), | |
| 208 | + actor_user_id: Some(approver_user.id), | |
| 209 | + payload_json: serde_json::to_string(&payload)?, | |
| 210 | + activity_kind: ActivityKind::AuthDecision, | |
| 211 | + }, | |
| 212 | +).await?; | |
| 213 | +``` | |
| 214 | + | |
| 215 | +### 4. Payload exact | |
| 216 | + | |
| 217 | +#### `Accept(AuthRequest)` — approbation | |
| 218 | + | |
| 219 | +```json | |
| 220 | +{ | |
| 221 | + "@context": [ | |
| 222 | + "https://www.w3.org/ns/activitystreams", | |
| 223 | + { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } } | |
| 224 | + ], | |
| 225 | + "type": "Accept", | |
| 226 | + "id": "http://alpha.local/auth-decisions/0d23-...", | |
| 227 | + "actor": "http://alpha.local/users/joanie", | |
| 228 | + "object": "http://beta.local/auth-requests/<nonce>", | |
| 229 | + "to": ["http://beta.local/users/joanie/inbox"] | |
| 230 | +} | |
| 231 | +``` | |
| 232 | + | |
| 233 | +#### `Reject(AuthRequest)` — refus | |
| 234 | + | |
| 235 | +```json | |
| 236 | +{ | |
| 237 | + "type": "Reject", | |
| 238 | + "id": "http://alpha.local/auth-decisions/...", | |
| 239 | + "actor": "http://alpha.local/users/joanie", | |
| 240 | + "object": "http://beta.local/auth-requests/<nonce>", | |
| 241 | + "to": ["http://beta.local/users/joanie/inbox"] | |
| 242 | +} | |
| 243 | +``` | |
| 244 | + | |
| 245 | +Le champ `object` est une **IRI string** (forme légère), pas un objet inline. Le nonce de B est extrait du dernier segment de l'URL — c'est ce que la fonction `extract_nonce_from_object` fait avec un fallback objet inline pour robustesse. | |
| 246 | + | |
| 247 | +### 5. Tests E2E à ajouter | |
| 248 | + | |
| 249 | +À ajouter à [crates/rustwarden-core/tests/e2e_fediverse.rs](../../crates/rustwarden-core/tests/e2e_fediverse.rs) : | |
| 250 | + | |
| 251 | +| Test | Couvre | | |
| 252 | +|---|---| | |
| 253 | +| `s1_full_round_trip_signed_e2e` | Happy path : B initiate → POST signé vers A → consent API JSON → A enqueue Accept → polling B's consume → 200 + JWT remote | | |
| 254 | +| `s1_reject_propagates_to_b` | Idem mais decision=reject → B's consume retourne 4xx | | |
| 255 | +| `s1_unknown_nonce_rejected_silently` | A poste Accept avec nonce inconnu → B retourne 202 (silent ignore) ou 4xx (cf. § Décisions) | | |
| 256 | +| `s1_expired_challenge_rejects_late_accept` | Accept arrive après TTL → erreur Validation | | |
| 257 | +| `s1_replay_accept_is_idempotent` | Rejouer le même Accept → 202, pas de double-write DB | | |
| 258 | + | |
| 259 | +## Analyse de compatibilité | |
| 260 | + | |
| 261 | +- [x] API existante **non modifiée** — `auth_inbox` signature publique inchangée, ajouts derrière la couche service. | |
| 262 | +- [x] Pas de changement de schéma DB — `fediverse_auth_challenges` et `remote_identities` sont déjà en place. | |
| 263 | +- [x] Pas de breaking change feature-flag — toujours sous `experimental-fediverse`. | |
| 264 | +- [x] Pas d'activité AP custom émise jusque-là, donc aucune compat ascendante à maintenir. | |
| 265 | +- [x] Migration sérialisée : aucune. | |
| 266 | + | |
| 267 | +→ **Non-breaking. Minor version bump possible.** | |
| 268 | + | |
| 269 | +## Alternatives considérées | |
| 270 | + | |
| 271 | +### Alternative A (recommandée) : `Accept` / `Reject` AP standards avec `object` = IRI de l'`AuthRequest` | |
| 272 | + | |
| 273 | +**Pros** : | |
| 274 | +- Convention AP universelle (Mastodon utilise `Accept(Follow)` / `Reject(Follow)` exactement comme ça pour les demandes d'amitié, cf. [Mastodon AP spec § Follow](https://docs.joinmastodon.org/spec/activitypub/)). | |
| 275 | +- Spec W3C explicite (`Accept` / `Reject` polymorphes selon `object.type`, [ActivityPub §7.6-7.7](https://www.w3.org/TR/activitypub/#accept-activity-inbox)). | |
| 276 | +- Pas de nouveau type custom à documenter ni à protéger contre la sérialisation parasite par AP-strict servers. | |
| 277 | +- Mastodon ignore silencieusement → isolation propre du protocole Rustwarden. | |
| 278 | +- Dispatch côté B trivial : un seul `match` sur `type`, déjà familier au reste de l'écosystème. | |
| 279 | + | |
| 280 | +**Cons** : | |
| 281 | +- Lecture du payload nécessite un fallback IRI/inline pour le `object` (légèrement plus de code que `body.nonce` direct). | |
| 282 | +- Sémantique « Accept » peut sembler bizarre pour de l'auth (vs. Follow) — atténué par le namespace clair de l'`object`. | |
| 283 | + | |
| 284 | +### Alternative B : Activités custom `AuthApproval` / `AuthDenial` | |
| 285 | + | |
| 286 | +**Pros** : | |
| 287 | +- Sémantique plus directement lisible côté code. | |
| 288 | +- Champ `nonce` au niveau racine sans extraction depuis `object`. | |
| 289 | + | |
| 290 | +**Cons** : | |
| 291 | +- Invente un type AP non-standard alors qu'un type standard existe et correspond. | |
| 292 | +- Toute implémentation tierce stricte (validateurs JSON-LD) requiert le `@context` complet documenté, sinon le terme est silencieusement perdu — coût de maintenance. | |
| 293 | +- Mastodon le rejette comme inconnu → log noise sur tous les serveurs tiers même si interop n'est pas un goal. | |
| 294 | +- Pas dans la lignée de ce que la RFC 0006 originale a écrit dans son Summary (`Accept{AuthRequest}` est explicitement mentionné). | |
| 295 | + | |
| 296 | +**Verdict** : rejetée. | |
| 297 | + | |
| 298 | +### Alternative C : Polling B → A (`GET /fediverse/auth/status/{nonce}`) | |
| 299 | + | |
| 300 | +**Pros** : | |
| 301 | +- Pas de signature à émettre côté A. | |
| 302 | +- B contrôle son propre rythme de polling. | |
| 303 | +- Pas de side-effect inbox. | |
| 304 | + | |
| 305 | +**Cons** : | |
| 306 | +- Charge réseau prévisible mais O(N) par challenge ouvert (B doit poller chaque challenge non résolu). | |
| 307 | +- Latence de notification = intervalle de poll. | |
| 308 | +- Casse le modèle AP-push qui structure le reste de Rustwarden Fediverse. | |
| 309 | +- N'aide pas pour l'extension future (`Update Person`, `Delete Person`, RFC 0010) qui nécessite le push de toute façon. | |
| 310 | + | |
| 311 | +**Verdict** : rejetée (moins propre que A, et n'aide pas pour la suite). | |
| 312 | + | |
| 313 | +### Alternative D : Long-polling / SSE B → A | |
| 314 | + | |
| 315 | +**Pros** : | |
| 316 | +- Latence quasi-nulle. | |
| 317 | + | |
| 318 | +**Cons** : | |
| 319 | +- Connexions persistantes coûteuses (potentiellement N par utilisateur sur l'IdP). | |
| 320 | +- Ajout d'infrastructure (timeouts, gestion déconnexions) pour une fonctionnalité que push AP gère gratuitement. | |
| 321 | +- Non-aligned avec le reste du protocole. | |
| 322 | + | |
| 323 | +**Verdict** : rejetée. | |
| 324 | + | |
| 325 | +### Alternative E : Ne rien faire (statu quo) | |
| 326 | + | |
| 327 | +**Pros** : pas de code à écrire. | |
| 328 | + | |
| 329 | +**Cons** : | |
| 330 | +- `experimental-fediverse` ne peut **pas** être promu stable. Le SSO bilatéral est l'objectif #1 du master plan, et il ne fonctionne pas. | |
| 331 | +- Tout futur consommateur découvrira le trou en testant et le signalera comme bug critique. | |
| 332 | + | |
| 333 | +**Verdict** : rejetée. | |
| 334 | + | |
| 335 | +## Sécurité | |
| 336 | + | |
| 337 | +### Replay protection | |
| 338 | + | |
| 339 | +Une activité `Accept` interceptée et rejouée : | |
| 340 | +- Hors fenêtre Date (>30s, cf. RFC 0015) → rejetée par le verifier de signature. | |
| 341 | +- Dans la fenêtre, sur un challenge déjà `Approved` → idempotent (cf. § record_inbound_decision), aucun side-effect. | |
| 342 | +- Dans la fenêtre, sur un challenge déjà `Consumed` → idempotent. | |
| 343 | +- Sur un nonce qui n'existe pas → 4xx (cf. test `s1_unknown_nonce_rejected_silently`). | |
| 344 | + | |
| 345 | +Pour la sécurité accrue (challenge nonce single-use + stockage `seen_accept_ids`), une suite F3-bis pourrait ajouter une table `fediverse_auth_decisions_seen` indexée par `Accept.id`. **Hors scope cette RFC.** | |
| 346 | + | |
| 347 | +### Authentification de l'approver | |
| 348 | + | |
| 349 | +`record_inbound_decision` se contente de retrouver le `remote_identities` row du `actor` de l'`Accept` — le verifier de signature en amont garantit que la clé qui a signé correspond au domaine de cet actor. Donc : | |
| 350 | + | |
| 351 | +- Un attaquant sur `evil.tld` ne peut **pas** émettre un `Accept(http://beta.local/auth-requests/<nonce>)` valide pour B parce que sa signature serait signée par `evil.tld`, mais l'IdP autorisé est `alpha.local` — mismatch détecté. | |
| 352 | +- Plus précisément : B doit vérifier que l'`actor` du `Accept` est sur le **même domaine** que l'IdP qui a reçu l'`AuthRequest`. Cette vérification est ajoutée à `record_inbound_decision` (extension simple : recharger le challenge, comparer `challenge.domain` au domaine de l'actor). | |
| 353 | + | |
| 354 | +### Note sur RFC 9421 | |
| 355 | + | |
| 356 | +Mastodon est passé de draft-cavage à RFC 9421 (`Signature-Input` + `Signature`) en v4.5.0 ([Mastodon Security spec](https://docs.joinmastodon.org/spec/security/)). Cette RFC continue d'utiliser draft-cavage comme partout dans le projet ; **une RFC séparée traitera la migration vers RFC 9421** pour l'ensemble du fediverse Rustwarden. | |
| 357 | + | |
| 358 | +## Décisions explicites à noter | |
| 359 | + | |
| 360 | +1. **Le `object` est une IRI string par défaut** (pas un inline object) — réduit le verbosité et matche ce que Mastodon fait. Fallback inline pour robustesse défensive. | |
| 361 | +2. **Nonce extrait du dernier segment de l'URL** — convention `http://b.tld/auth-requests/<nonce>`. Documenter et exposer cette URL dans `build_auth_request_payload` (RFC 0006 ligne 354, déjà compatible : le champ `id` y est déjà construit comme `{base_url}auth-requests/{challenge.id}`, à modifier pour utiliser le nonce). | |
| 362 | +3. **Replay sur challenge décidé → 202 silent** (idempotence) plutôt que 4xx. AP-friendly : tout outbound worker qui retry n'a pas à savoir si c'est la 1re ou la 3e tentative. | |
| 363 | +4. **Replay sur challenge expiré → 4xx** plutôt que 202. Décision claire : l'expiry est une condition métier, pas une erreur transitoire. | |
| 364 | +5. **Nonce inconnu → 4xx Validation** plutôt que ignore silent. Aide au debug (logs explicites) et ne permet pas d'utiliser l'inbox comme oracle pour des nonces existants (le timing reste constant : query DB unique dans les deux cas). | |
| 365 | + | |
| 366 | +## Plan d'implémentation | |
| 367 | + | |
| 368 | +### Étape 1 — Service `record_inbound_decision` | |
| 369 | +- Implémentation + tests unitaires `MockDatabase` (5 tests : happy path, idempotence approved/consumed, unknown nonce, expired, type mismatch). | |
| 370 | +- ~120 LoC code + 80 LoC tests. | |
| 371 | + | |
| 372 | +### Étape 2 — Dispatch type-based dans `auth_inbox` | |
| 373 | +- Refactor du handler avec `match`. | |
| 374 | +- Adapter le test existant `auth_inbox_returns_400_when_requester_actor_unknown` qui ne touche que la branche AuthRequest. | |
| 375 | +- ~50 LoC. | |
| 376 | + | |
| 377 | +### Étape 3 — Build payload helper | |
| 378 | +- `build_auth_decision_payload(config, challenge, approver_actor, decision)` retournant le JSON. | |
| 379 | +- Tests unitaires : structure JSON-LD, contexte, object IRI bien formé. | |
| 380 | +- ~80 LoC. | |
| 381 | + | |
| 382 | +### Étape 4 — Enqueue dans `consent_submit` UI | |
| 383 | +- Wire l'enqueue après `record_decision`. | |
| 384 | +- Test unitaire (UI handler) avec MockDatabase qui vérifie l'enqueue. | |
| 385 | +- ~60 LoC. | |
| 386 | + | |
| 387 | +### Étape 5 — 5 tests E2E nouveaux dans `e2e_fediverse.rs` | |
| 388 | +- ~300 LoC. | |
| 389 | + | |
| 390 | +**Total estimé** : ~700 LoC. ~1.5 jour de développement + tests. | |
| 391 | + | |
| 392 | +## Critères d'acceptation | |
| 393 | + | |
| 394 | +- [x] Test `s1_full_round_trip_accept_marks_b_challenge_approved` vert sans aucun DB-direct côté B. | |
| 395 | +- [x] Test `s1_reject_propagates_and_blocks_consume` vert. | |
| 396 | +- [x] Test `s1_replay_accept_is_idempotent` vert (assertion via `xmin` PostgreSQL). | |
| 397 | +- [x] Test `s1_expired_challenge_rejects_late_accept` vert. | |
| 398 | +- [x] Test bonus `s1_unknown_nonce_is_rejected` vert. | |
| 399 | +- [x] Quality gates standards : build clean, clippy `-D warnings`, doc clean, tests unitaires verts (348/348). | |
| 400 | +- [x] Mise à jour CHANGELOG.md. | |
| 401 | +- [x] Plan parent [iter1-findings.md](../plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md) marque F3 = ✅. | |
| 402 | +- [ ] Doc opérateur (futur RFC) mentionne que `Accept(AuthRequest)` est le format de décision attendu. *(reporté hors-scope cette RFC)* | |
| 403 | + | |
| 404 | +## Références | |
| 405 | + | |
| 406 | +- [W3C ActivityPub Recommendation, §7.6 Accept](https://www.w3.org/TR/activitypub/#accept-activity-inbox), [§7.7 Reject](https://www.w3.org/TR/activitypub/#reject-activity-inbox) | |
| 407 | +- [Mastodon ActivityPub specification](https://docs.joinmastodon.org/spec/activitypub/) — Follow approval flow | |
| 408 | +- [Mastodon Security specification](https://docs.joinmastodon.org/spec/security/) — HTTP Signatures, fenêtre Date | |
| 409 | +- [SWICG HTTP Signatures guide](https://swicg.github.io/activitypub-http-signature/) — pratiques 2025 | |
| 410 | +- [SocialHub: Single Sign-On for Fediverse thread](https://socialhub.activitypub.rocks/t/single-sign-on-for-fediverse/712) — état du non-consensus | |
| 411 | +- RFC 0006 — flow d'authentification AP-native (couche service, Phase 1 + 2) | |
| 412 | +- RFC 0011 — outbound queue | |
| 413 | +- RFC 0015 — HTTP signatures |
M
docs/rfcs/README.md
+1
-0
@@ -83,6 +83,7 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
| 83 | 83 | | [0022](0022-smoke-tests-regression.md) | Smoke tests anti-régression (middleware AuthConfig + pagination admin) | **Accepted** (26/35) | EBO | 2026-05-10 | |
| 84 | 84 | | [0023](0023-fediverse-ui-user-auth.md) | Fediverse UI — Parcours utilisateur d'authentification fédérée (Slice B) | **Accepted** (26/35) | EBO | 2026-05-10 | |
| 85 | 85 | | [0024](0024-fediverse-ui-ops-slice-c.md) | Fediverse UI — Invitations & ops admin (Slice C, 6 écrans) | **Accepted** (26/35) | EBO | 2026-05-10 | |
| 86 | +| [0025](0025-fediverse-auth-back-channel.md) | Fediverse — Back-channel `Accept(AuthRequest)` (RFC 0006 Phase 3) | **Accepted** (livré + 5 tests E2E verts) | EBO | 2026-05-11 | | |
| 86 | 87 | |
| 87 | 88 | À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant. |
| 88 | 89 |
A
tests/fediverse-testbed/.gitignore
+3
-0
@@ -0,0 +1,3 @@
| 1 | +data/ | |
| 2 | +*.log | |
| 3 | +.env.local |
A
tests/fediverse-testbed/Dockerfile
+43
-0
@@ -0,0 +1,43 @@
| 1 | +# Build context must be the workspace root: `docker build -f tests/fediverse-testbed/Dockerfile .` | |
| 2 | +# The compose file already passes context: ../.. accordingly. | |
| 3 | + | |
| 4 | +FROM rust:1.91-slim-bookworm AS builder | |
| 5 | +WORKDIR /build | |
| 6 | + | |
| 7 | +RUN apt-get update \ | |
| 8 | + && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \ | |
| 9 | + && rm -rf /var/lib/apt/lists/* | |
| 10 | + | |
| 11 | +COPY Cargo.toml Cargo.lock ./ | |
| 12 | +COPY app ./app | |
| 13 | +COPY crates ./crates | |
| 14 | +COPY tools ./tools | |
| 15 | + | |
| 16 | +# rustwarden-app + migrate (SeaORM CLI) + seed helper. | |
| 17 | +RUN cargo build --release \ | |
| 18 | + -p rustwarden-app --features experimental-fediverse \ | |
| 19 | + && cargo build --release \ | |
| 20 | + -p rustwarden-core --bin seed --bin migrate | |
| 21 | + | |
| 22 | +FROM debian:bookworm-slim AS runtime | |
| 23 | +RUN apt-get update \ | |
| 24 | + && apt-get install -y --no-install-recommends ca-certificates libssl3 curl postgresql-client \ | |
| 25 | + && rm -rf /var/lib/apt/lists/* | |
| 26 | + | |
| 27 | +WORKDIR /app | |
| 28 | + | |
| 29 | +# Locales are read at runtime from these *relative* paths (see init_i18n in | |
| 30 | +# crates/rustwarden-core/src/app.rs). The directory layout must mirror the | |
| 31 | +# workspace tree for the i18n loader to find them. | |
| 32 | +COPY --from=builder /build/crates/rustwarden-core/locales ./crates/rustwarden-core/locales | |
| 33 | +COPY --from=builder /build/crates/rustwarden-ui/locales ./crates/rustwarden-ui/locales | |
| 34 | + | |
| 35 | +# Static assets served by ServeDir (path overridable via RUSTWARDEN_STATIC_DIR). | |
| 36 | +COPY static ./static | |
| 37 | + | |
| 38 | +COPY --from=builder /build/target/release/rustwarden /usr/local/bin/rustwarden | |
| 39 | +COPY --from=builder /build/target/release/seed /usr/local/bin/seed | |
| 40 | +COPY --from=builder /build/target/release/migrate /usr/local/bin/migrate | |
| 41 | + | |
| 42 | +EXPOSE 80 | |
| 43 | +CMD ["rustwarden"] |
A
tests/fediverse-testbed/README.md
+123
-0
@@ -0,0 +1,123 @@
| 1 | +# Fediverse testbed — three-instance Rustwarden topology | |
| 2 | + | |
| 3 | +Banc d'essai isolé pour valider la fédération identitaire Rustwarden ↔ Rustwarden en local. Trois instances complètes (`alpha`, `beta`, `gamma`) tournent dans Docker, chacune avec sa propre PostgreSQL et son `FEDIVERSE_DOMAIN`, communiquant via le réseau Docker sans TLS ni reverse proxy. | |
| 4 | + | |
| 5 | +Cet environnement sert d'**itération 0** de la campagne décrite dans [docs/plans/2026-05-10-iter0-fediverse-docker-compose.md](../../docs/plans/2026-05-10-iter0-fediverse-docker-compose.md) et [docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md](../../docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md). | |
| 6 | + | |
| 7 | +## Prérequis | |
| 8 | + | |
| 9 | +- Docker Engine + Compose plugin v2 (`docker compose version` doit fonctionner). | |
| 10 | +- ~3 Go de RAM libre. | |
| 11 | +- Ports `3001`, `3002`, `3003` libres sur l'hôte. | |
| 12 | + | |
| 13 | +Aucun ajout dans `/etc/hosts` n'est nécessaire pour les tests automatisés. Pour piloter le testbed depuis un navigateur sur l'hôte, voir « Accès humain » plus bas. | |
| 14 | + | |
| 15 | +## Démarrage | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +cd tests/fediverse-testbed | |
| 19 | +./scripts/setup.sh | |
| 20 | +``` | |
| 21 | + | |
| 22 | +Le script : | |
| 23 | + | |
| 24 | +1. Build l'image multi-stage (long la première fois, ~5–10 min ; secondes ensuite grâce au cache). | |
| 25 | +2. Démarre les 3 PostgreSQL et attend qu'elles soient `healthy`. | |
| 26 | +3. Lance les 3 jobs `migrate-*` one-shot (SeaORM CLI : `migrate up`). L'app **ne fait pas** les migrations au démarrage, ce service le fait à sa place. | |
| 27 | +4. Démarre les 3 `app-*` une fois leur migrate-* sorti en code 0. | |
| 28 | +5. Lance les 3 jobs `seed-*` qui chargent les fixtures Happy Days. | |
| 29 | +6. Insère en SQL la trust list croisée (`alpha ↔ beta ↔ gamma`) et active `fediverse_enabled`. | |
| 30 | +7. Smoke check final depuis l'hôte (`curl /health` × 3). | |
| 31 | + | |
| 32 | +## Topologie | |
| 33 | + | |
| 34 | +``` | |
| 35 | + ┌──────────────┐ | |
| 36 | + │ fediverse- │ | |
| 37 | + │ net │ | |
| 38 | + │ (bridge) │ | |
| 39 | + └──────┬───────┘ | |
| 40 | + ┌────────────────────┼─────────────────────┐ | |
| 41 | + │ │ │ | |
| 42 | + ┌─────────▼────────┐ ┌─────────▼────────┐ ┌─────────▼────────┐ | |
| 43 | + │ app-alpha │ │ app-beta │ │ app-gamma │ | |
| 44 | + │ alias alpha.local│ │ alias beta.local │ │ alias gamma.local│ | |
| 45 | + │ host: :3001 │ │ host: :3002 │ │ host: :3003 │ | |
| 46 | + └─────────┬────────┘ └─────────┬────────┘ └─────────┬────────┘ | |
| 47 | + │ │ │ | |
| 48 | + ┌─────────▼────────┐ ┌─────────▼────────┐ ┌─────────▼────────┐ | |
| 49 | + │ postgres-alpha │ │ postgres-beta │ │ postgres-gamma │ | |
| 50 | + └──────────────────┘ └──────────────────┘ └──────────────────┘ | |
| 51 | +``` | |
| 52 | + | |
| 53 | +À l'intérieur du réseau Docker, chaque app résout `alpha.local`, `beta.local`, `gamma.local` vers les containers des autres apps grâce aux `network_aliases`. Aucune dépendance DNS hôte. | |
| 54 | + | |
| 55 | +## Identifiants seed | |
| 56 | + | |
| 57 | +Chaque instance a les mêmes 5 utilisateurs Happy Days (cf. `crates/rustwarden-core/src/bin/seed.rs`) : | |
| 58 | + | |
| 59 | +| Login | Email | Rôle | Mot de passe | | |
| 60 | +|---|---|---|---| | |
| 61 | +| joanie | joanie@rustwarden.local | admin | `happydays123` | | |
| 62 | +| richie | richie@rustwarden.local | user | `happydays123` | | |
| 63 | +| fonzie | fonzie@rustwarden.local | user | `happydays123` | | |
| 64 | +| potsie | potsie@rustwarden.local | user | `happydays123` | | |
| 65 | +| ralph | ralph@rustwarden.local | user | `happydays123` | | |
| 66 | + | |
| 67 | +## Vérifications manuelles utiles | |
| 68 | + | |
| 69 | +```bash | |
| 70 | +# Santé | |
| 71 | +curl http://localhost:3001/health | |
| 72 | +curl http://localhost:3002/health | |
| 73 | +curl http://localhost:3003/health | |
| 74 | + | |
| 75 | +# WebFinger (un user existant sur alpha) | |
| 76 | +curl 'http://localhost:3001/.well-known/webfinger?resource=acct:joanie@alpha.local' | |
| 77 | + | |
| 78 | +# Trust list configurée | |
| 79 | +docker compose exec -T postgres-alpha psql -U rustwarden -d rustwarden_alpha \ | |
| 80 | + -c 'SELECT domain FROM fediverse_trusted_domains;' | |
| 81 | + | |
| 82 | +# Logs en direct d'une instance | |
| 83 | +docker compose logs -f app-alpha | |
| 84 | +``` | |
| 85 | + | |
| 86 | +## Accès humain (optionnel) | |
| 87 | + | |
| 88 | +Les URLs ActivityPub renvoyées par chaque instance contiennent `alpha.local` / `beta.local` / `gamma.local`. Pour suivre ces liens depuis un navigateur sur l'hôte, ajouter une fois pour toutes : | |
| 89 | + | |
| 90 | +``` | |
| 91 | +# /etc/hosts | |
| 92 | +127.0.0.1 alpha.local beta.local gamma.local | |
| 93 | +``` | |
| 94 | + | |
| 95 | +Puis pour accéder à chaque instance, utiliser les ports : `http://alpha.local:3001`, `http://beta.local:3002`, `http://gamma.local:3003`. Les tests automatisés (`cargo test`) n'en ont pas besoin — ils s'adressent à `localhost:3001/3002/3003`. | |
| 96 | + | |
| 97 | +## Cycle de vie | |
| 98 | + | |
| 99 | +| Commande | Effet | Quand l'utiliser | | |
| 100 | +|---|---|---| | |
| 101 | +| `./scripts/setup.sh` | Build + démarre + migre + seed + trust + smoke | Premier boot, ou après un teardown | | |
| 102 | +| `./scripts/stop.sh` | `docker compose stop` — pause sans rien perdre | Tu veux libérer CPU/RAM mais reprendre vite avec l'état actuel (DB préservée) | | |
| 103 | +| `./scripts/start.sh` | Reprend les containers stoppés, attend `/health` | Après un `stop.sh`. Pas de re-seed, pas de re-trust | | |
| 104 | +| `./scripts/teardown.sh` | `docker compose down -v` — purge containers + volumes | Tu veux repartir d'un état totalement vierge | | |
| 105 | + | |
| 106 | +Cycle typique pour itérer sur des tests sans tout reconstruire : | |
| 107 | +```bash | |
| 108 | +./scripts/setup.sh # une fois | |
| 109 | +cargo test --features ... # itère | |
| 110 | +./scripts/stop.sh # pause quand tu fermes ta session | |
| 111 | +./scripts/start.sh # reprend plus tard | |
| 112 | +``` | |
| 113 | + | |
| 114 | +## Limites connues | |
| 115 | + | |
| 116 | +- HTTP plain only. Le flag `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` autorise le bypass HTTPS en local — ne **jamais** activer en production. | |
| 117 | +- Build à froid long. Mitigation v2 : intégrer [`cargo-chef`](https://github.com/LukeMathWalker/cargo-chef) au Dockerfile pour cacher les couches de dépendances. | |
| 118 | +- Les `seed-*` se déclenchent après `app-*` healthy, donc après que l'app a déjà démarré. Conséquence : les rôles `admin`/`user` et les premiers users sont insérés **après** que le serveur HTTP écoute déjà — bref creux de quelques secondes où l'app tourne sans user. Pas bloquant pour les tests E2E qui attendent `setup.sh` complet. | |
| 119 | +- La trust list est écrite via SQL direct (`configure-trust.sh`) et non via l'UI admin (CSRF). Si la table `fediverse_trusted_domains` change de schéma, mettre à jour le script. | |
| 120 | + | |
| 121 | +## Plan d'évolution | |
| 122 | + | |
| 123 | +Itération 1+ : tests E2E Rust (`cargo test --test e2e_fediverse`) qui pilotent le testbed via les ports `localhost:300X` exposés. Code dans `crates/rustwarden-core/tests/e2e_fediverse/` (à créer, hors périmètre de cette itération). |
A
tests/fediverse-testbed/docker-compose.yml
+182
-0
@@ -0,0 +1,182 @@
| 1 | +# Three-instance Rustwarden topology for fediverse end-to-end tests. | |
| 2 | +# Each app talks to the others via the docker network using FEDIVERSE_DOMAIN | |
| 3 | +# as a network alias, so signed HTTP requests can resolve the peer hostnames | |
| 4 | +# without any /etc/hosts trickery on the host machine. | |
| 5 | +# | |
| 6 | +# Usage: | |
| 7 | +# cd tests/fediverse-testbed | |
| 8 | +# ./scripts/setup.sh | |
| 9 | +# ./scripts/teardown.sh | |
| 10 | + | |
| 11 | +name: rustwarden-fediverse-testbed | |
| 12 | + | |
| 13 | +x-app-common: &app-common | |
| 14 | + build: | |
| 15 | + context: ../.. | |
| 16 | + dockerfile: tests/fediverse-testbed/Dockerfile | |
| 17 | + image: rustwarden-fediverse-testbed:latest | |
| 18 | + restart: unless-stopped | |
| 19 | + healthcheck: | |
| 20 | + test: ["CMD", "curl", "-fsS", "http://localhost/health"] | |
| 21 | + interval: 5s | |
| 22 | + timeout: 3s | |
| 23 | + retries: 30 | |
| 24 | + start_period: 10s | |
| 25 | + | |
| 26 | +services: | |
| 27 | + postgres-alpha: | |
| 28 | + image: postgres:18-trixie | |
| 29 | + environment: | |
| 30 | + POSTGRES_DB: rustwarden_alpha | |
| 31 | + POSTGRES_USER: rustwarden | |
| 32 | + POSTGRES_PASSWORD: rustwarden | |
| 33 | + healthcheck: | |
| 34 | + test: ["CMD-SHELL", "pg_isready -U rustwarden -d rustwarden_alpha"] | |
| 35 | + interval: 3s | |
| 36 | + timeout: 2s | |
| 37 | + retries: 20 | |
| 38 | + networks: [fediverse-net] | |
| 39 | + | |
| 40 | + postgres-beta: | |
| 41 | + image: postgres:18-trixie | |
| 42 | + environment: | |
| 43 | + POSTGRES_DB: rustwarden_beta | |
| 44 | + POSTGRES_USER: rustwarden | |
| 45 | + POSTGRES_PASSWORD: rustwarden | |
| 46 | + healthcheck: | |
| 47 | + test: ["CMD-SHELL", "pg_isready -U rustwarden -d rustwarden_beta"] | |
| 48 | + interval: 3s | |
| 49 | + timeout: 2s | |
| 50 | + retries: 20 | |
| 51 | + networks: [fediverse-net] | |
| 52 | + | |
| 53 | + postgres-gamma: | |
| 54 | + image: postgres:18-trixie | |
| 55 | + environment: | |
| 56 | + POSTGRES_DB: rustwarden_gamma | |
| 57 | + POSTGRES_USER: rustwarden | |
| 58 | + POSTGRES_PASSWORD: rustwarden | |
| 59 | + healthcheck: | |
| 60 | + test: ["CMD-SHELL", "pg_isready -U rustwarden -d rustwarden_gamma"] | |
| 61 | + interval: 3s | |
| 62 | + timeout: 2s | |
| 63 | + retries: 20 | |
| 64 | + networks: [fediverse-net] | |
| 65 | + | |
| 66 | + # One-shot migration jobs. The rustwarden-app binary does NOT auto-run | |
| 67 | + # migrations at startup — the dedicated `migrate` CLI (SeaORM) must run | |
| 68 | + # first and exit 0 before the apps boot. | |
| 69 | + migrate-alpha: | |
| 70 | + <<: *app-common | |
| 71 | + restart: "no" | |
| 72 | + healthcheck: | |
| 73 | + disable: true | |
| 74 | + env_file: env/alpha.env | |
| 75 | + command: ["migrate", "up"] | |
| 76 | + depends_on: | |
| 77 | + postgres-alpha: | |
| 78 | + condition: service_healthy | |
| 79 | + networks: [fediverse-net] | |
| 80 | + | |
| 81 | + migrate-beta: | |
| 82 | + <<: *app-common | |
| 83 | + restart: "no" | |
| 84 | + healthcheck: | |
| 85 | + disable: true | |
| 86 | + env_file: env/beta.env | |
| 87 | + command: ["migrate", "up"] | |
| 88 | + depends_on: | |
| 89 | + postgres-beta: | |
| 90 | + condition: service_healthy | |
| 91 | + networks: [fediverse-net] | |
| 92 | + | |
| 93 | + migrate-gamma: | |
| 94 | + <<: *app-common | |
| 95 | + restart: "no" | |
| 96 | + healthcheck: | |
| 97 | + disable: true | |
| 98 | + env_file: env/gamma.env | |
| 99 | + command: ["migrate", "up"] | |
| 100 | + depends_on: | |
| 101 | + postgres-gamma: | |
| 102 | + condition: service_healthy | |
| 103 | + networks: [fediverse-net] | |
| 104 | + | |
| 105 | + app-alpha: | |
| 106 | + <<: *app-common | |
| 107 | + env_file: env/alpha.env | |
| 108 | + depends_on: | |
| 109 | + migrate-alpha: | |
| 110 | + condition: service_completed_successfully | |
| 111 | + ports: | |
| 112 | + - "3001:80" | |
| 113 | + networks: | |
| 114 | + fediverse-net: | |
| 115 | + aliases: [alpha.local] | |
| 116 | + | |
| 117 | + app-beta: | |
| 118 | + <<: *app-common | |
| 119 | + env_file: env/beta.env | |
| 120 | + depends_on: | |
| 121 | + migrate-beta: | |
| 122 | + condition: service_completed_successfully | |
| 123 | + ports: | |
| 124 | + - "3002:80" | |
| 125 | + networks: | |
| 126 | + fediverse-net: | |
| 127 | + aliases: [beta.local] | |
| 128 | + | |
| 129 | + app-gamma: | |
| 130 | + <<: *app-common | |
| 131 | + env_file: env/gamma.env | |
| 132 | + depends_on: | |
| 133 | + migrate-gamma: | |
| 134 | + condition: service_completed_successfully | |
| 135 | + ports: | |
| 136 | + - "3003:80" | |
| 137 | + networks: | |
| 138 | + fediverse-net: | |
| 139 | + aliases: [gamma.local] | |
| 140 | + | |
| 141 | + # One-shot seed jobs: each waits for its app to be healthy (which means | |
| 142 | + # migrations have run) then loads the Happy Days fixtures with | |
| 143 | + # joanie/happydays123 as the admin user. | |
| 144 | + seed-alpha: | |
| 145 | + <<: *app-common | |
| 146 | + restart: "no" | |
| 147 | + healthcheck: | |
| 148 | + disable: true | |
| 149 | + env_file: env/alpha.env | |
| 150 | + command: ["seed", "--force"] | |
| 151 | + depends_on: | |
| 152 | + app-alpha: | |
| 153 | + condition: service_healthy | |
| 154 | + networks: [fediverse-net] | |
| 155 | + | |
| 156 | + seed-beta: | |
| 157 | + <<: *app-common | |
| 158 | + restart: "no" | |
| 159 | + healthcheck: | |
| 160 | + disable: true | |
| 161 | + env_file: env/beta.env | |
| 162 | + command: ["seed", "--force"] | |
| 163 | + depends_on: | |
| 164 | + app-beta: | |
| 165 | + condition: service_healthy | |
| 166 | + networks: [fediverse-net] | |
| 167 | + | |
| 168 | + seed-gamma: | |
| 169 | + <<: *app-common | |
| 170 | + restart: "no" | |
| 171 | + healthcheck: | |
| 172 | + disable: true | |
| 173 | + env_file: env/gamma.env | |
| 174 | + command: ["seed", "--force"] | |
| 175 | + depends_on: | |
| 176 | + app-gamma: | |
| 177 | + condition: service_healthy | |
| 178 | + networks: [fediverse-net] | |
| 179 | + | |
| 180 | +networks: | |
| 181 | + fediverse-net: | |
| 182 | + driver: bridge |
A
tests/fediverse-testbed/env/alpha.env
+14
-0
@@ -0,0 +1,14 @@
| 1 | +# Instance ALPHA — testbed only, no real secrets. | |
| 2 | +DATABASE_URL=postgres://rustwarden:rustwarden@postgres-alpha:5432/rustwarden_alpha | |
| 3 | +JWT_SECRET=testbed-alpha-jwt-secret-32-bytes-minimum-padding-1234567890 | |
| 4 | +JWT_ISSUER=rustwarden-alpha | |
| 5 | +ENCRYPTION_KEY=a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1 | |
| 6 | +SERVER_HOST=0.0.0.0 | |
| 7 | +SERVER_PORT=80 | |
| 8 | +FEDIVERSE_DOMAIN=http://alpha.local | |
| 9 | +FEDIVERSE_DEBUG_LOCAL_FEDERATION=true | |
| 10 | +APP_DEBUG=true | |
| 11 | +COOKIE_SECURE=false | |
| 12 | +SESSION_BACKEND=seaorm | |
| 13 | +ENVIRONMENT=development | |
| 14 | +RUST_LOG=info,rustwarden=debug |
A
tests/fediverse-testbed/env/beta.env
+14
-0
@@ -0,0 +1,14 @@
| 1 | +# Instance BETA — testbed only, no real secrets. | |
| 2 | +DATABASE_URL=postgres://rustwarden:rustwarden@postgres-beta:5432/rustwarden_beta | |
| 3 | +JWT_SECRET=testbed-beta-jwt-secret-32-bytes-minimum-padding-1234567890aa | |
| 4 | +JWT_ISSUER=rustwarden-beta | |
| 5 | +ENCRYPTION_KEY=b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2 | |
| 6 | +SERVER_HOST=0.0.0.0 | |
| 7 | +SERVER_PORT=80 | |
| 8 | +FEDIVERSE_DOMAIN=http://beta.local | |
| 9 | +FEDIVERSE_DEBUG_LOCAL_FEDERATION=true | |
| 10 | +APP_DEBUG=true | |
| 11 | +COOKIE_SECURE=false | |
| 12 | +SESSION_BACKEND=seaorm | |
| 13 | +ENVIRONMENT=development | |
| 14 | +RUST_LOG=info,rustwarden=debug |
A
tests/fediverse-testbed/env/gamma.env
+14
-0
@@ -0,0 +1,14 @@
| 1 | +# Instance GAMMA — testbed only, no real secrets. | |
| 2 | +DATABASE_URL=postgres://rustwarden:rustwarden@postgres-gamma:5432/rustwarden_gamma | |
| 3 | +JWT_SECRET=testbed-gamma-jwt-secret-32-bytes-minimum-padding-12345678901 | |
| 4 | +JWT_ISSUER=rustwarden-gamma | |
| 5 | +ENCRYPTION_KEY=c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3 | |
| 6 | +SERVER_HOST=0.0.0.0 | |
| 7 | +SERVER_PORT=80 | |
| 8 | +FEDIVERSE_DOMAIN=http://gamma.local | |
| 9 | +FEDIVERSE_DEBUG_LOCAL_FEDERATION=true | |
| 10 | +APP_DEBUG=true | |
| 11 | +COOKIE_SECURE=false | |
| 12 | +SESSION_BACKEND=seaorm | |
| 13 | +ENVIRONMENT=development | |
| 14 | +RUST_LOG=info,rustwarden=debug |
A
tests/fediverse-testbed/scripts/configure-trust.sh
+45
-0
@@ -0,0 +1,45 @@
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Configure cross-instance trust by writing directly into each instance's | |
| 3 | +# fediverse_trusted_domains table via psql. | |
| 4 | +# | |
| 5 | +# Rationale: the admin trust list UI is a CSRF-protected form POST that | |
| 6 | +# would require a full login + token-extraction dance for automation. | |
| 7 | +# Direct SQL insertion is simpler, safer (no auth bypass), and only | |
| 8 | +# reasonable in this testbed context where we own the database. | |
| 9 | + | |
| 10 | +set -euo pipefail | |
| 11 | + | |
| 12 | +cd "$(dirname "$0")/.." | |
| 13 | + | |
| 14 | +# Bidirectional trust: each instance trusts the two others. | |
| 15 | +declare -A TRUSTS=( | |
| 16 | + [alpha]="beta.local gamma.local" | |
| 17 | + [beta]="alpha.local gamma.local" | |
| 18 | + [gamma]="alpha.local beta.local" | |
| 19 | +) | |
| 20 | + | |
| 21 | +# Flip the fediverse_enabled app setting to true, so admin UI shows the | |
| 22 | +# fediverse pages. Protocol endpoints (inbox/webfinger) do not depend on | |
| 23 | +# this flag, but the admin UX does. | |
| 24 | +ENABLE_SQL=" | |
| 25 | +UPDATE app_settings SET value = 'true' WHERE key = 'fediverse_enabled'; | |
| 26 | +" | |
| 27 | + | |
| 28 | +for instance in alpha beta gamma; do | |
| 29 | + echo "==> Configuring trust list on $instance" | |
| 30 | + for peer in ${TRUSTS[$instance]}; do | |
| 31 | + SQL=" | |
| 32 | + INSERT INTO fediverse_trusted_domains (id, domain, note, added_by_user_id, created_at) | |
| 33 | + VALUES (gen_random_uuid(), '$peer', 'testbed bootstrap', NULL, NOW()) | |
| 34 | + ON CONFLICT (domain) DO NOTHING; | |
| 35 | + " | |
| 36 | + docker compose exec -T "postgres-$instance" psql -U rustwarden -d "rustwarden_$instance" \ | |
| 37 | + -c "$SQL" >/dev/null | |
| 38 | + echo " trusts $peer" | |
| 39 | + done | |
| 40 | + docker compose exec -T "postgres-$instance" psql -U rustwarden -d "rustwarden_$instance" \ | |
| 41 | + -c "$ENABLE_SQL" >/dev/null | |
| 42 | + echo " fediverse_enabled=true" | |
| 43 | +done | |
| 44 | + | |
| 45 | +echo "==> Trust list configured for all three instances." |
A
tests/fediverse-testbed/scripts/setup.sh
+62
-0
@@ -0,0 +1,62 @@
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Start the three-instance Rustwarden fediverse testbed. | |
| 3 | +# | |
| 4 | +# Steps: | |
| 5 | +# 1. docker compose up -d --wait -> postgres + apps healthy | |
| 6 | +# 2. wait for seed-* jobs to exit 0 -> Happy Days users loaded | |
| 7 | +# 3. configure-trust.sh -> cross-instance allowlist | |
| 8 | +# 4. smoke-check /health on the 3 apps -> visible from the host | |
| 9 | + | |
| 10 | +set -euo pipefail | |
| 11 | + | |
| 12 | +cd "$(dirname "$0")/.." | |
| 13 | + | |
| 14 | +echo "==> docker compose up (build + start, wait for health)" | |
| 15 | +# `--wait` waits for: services with healthcheck to become healthy, AND | |
| 16 | +# one-shot services (restart:no, no healthcheck) to exit 0. So when this | |
| 17 | +# returns, migrate-* and seed-* are guaranteed to have completed | |
| 18 | +# successfully — no separate wait loop needed. | |
| 19 | +docker compose up -d --wait --wait-timeout 300 | |
| 20 | + | |
| 21 | +echo "==> Verifying one-shot jobs exited 0" | |
| 22 | +for job in migrate-alpha migrate-beta migrate-gamma seed-alpha seed-beta seed-gamma; do | |
| 23 | + code=$(docker inspect -f '{{.State.ExitCode}}' "rustwarden-fediverse-testbed-${job}-1") | |
| 24 | + if [ "$code" != "0" ]; then | |
| 25 | + echo " $job FAILED (exit $code)" | |
| 26 | + docker compose logs "$job" | tail -40 | |
| 27 | + exit 1 | |
| 28 | + fi | |
| 29 | + echo " $job ok" | |
| 30 | +done | |
| 31 | + | |
| 32 | +echo "==> Configuring cross-instance trust" | |
| 33 | +./scripts/configure-trust.sh | |
| 34 | + | |
| 35 | +echo "==> Host-side smoke checks" | |
| 36 | +for port in 3001 3002 3003; do | |
| 37 | + echo -n " http://localhost:$port/health ... " | |
| 38 | + if curl -fsS "http://localhost:$port/health" >/dev/null; then | |
| 39 | + echo "ok" | |
| 40 | + else | |
| 41 | + echo "FAILED" | |
| 42 | + exit 1 | |
| 43 | + fi | |
| 44 | +done | |
| 45 | + | |
| 46 | +cat <<'EOF' | |
| 47 | + | |
| 48 | +==> Testbed ready. | |
| 49 | + | |
| 50 | + Instances: | |
| 51 | + alpha.local -> http://localhost:3001 (DB rustwarden_alpha) | |
| 52 | + beta.local -> http://localhost:3002 (DB rustwarden_beta) | |
| 53 | + gamma.local -> http://localhost:3003 (DB rustwarden_gamma) | |
| 54 | + | |
| 55 | + Seeded admin (per instance): | |
| 56 | + email joanie@rustwarden.local | |
| 57 | + password happydays123 | |
| 58 | + | |
| 59 | + To stop and purge everything: | |
| 60 | + ./scripts/teardown.sh | |
| 61 | + | |
| 62 | +EOF |
A
tests/fediverse-testbed/scripts/start.sh
+28
-0
@@ -0,0 +1,28 @@
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Resume a previously-stopped testbed. Skips build, seed, and trust config — | |
| 3 | +# state from the last setup is preserved. | |
| 4 | +# | |
| 5 | +# Use ./scripts/setup.sh for the first boot or after a teardown. | |
| 6 | + | |
| 7 | +set -euo pipefail | |
| 8 | + | |
| 9 | +cd "$(dirname "$0")/.." | |
| 10 | + | |
| 11 | +echo "==> docker compose start (resume paused containers)" | |
| 12 | +docker compose start | |
| 13 | + | |
| 14 | +echo "==> Waiting for /health on the three apps" | |
| 15 | +for port in 3001 3002 3003; do | |
| 16 | + echo -n " http://localhost:$port/health ... " | |
| 17 | + # Up to 30 s of polling — typical resume is < 5 s but cold disk caches | |
| 18 | + # on slow hosts may need longer. | |
| 19 | + for _ in $(seq 1 30); do | |
| 20 | + if curl -fsS "http://localhost:$port/health" >/dev/null 2>&1; then | |
| 21 | + echo "ok" | |
| 22 | + break | |
| 23 | + fi | |
| 24 | + sleep 1 | |
| 25 | + done | |
| 26 | +done | |
| 27 | + | |
| 28 | +echo "==> Testbed resumed." |
A
tests/fediverse-testbed/scripts/stop.sh
+14
-0
@@ -0,0 +1,14 @@
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Pause the testbed without losing state. Containers and volumes stay, | |
| 3 | +# DB data preserved. Resume later with ./scripts/start.sh. | |
| 4 | +# | |
| 5 | +# For a full purge (fresh state on next setup), use ./scripts/teardown.sh. | |
| 6 | + | |
| 7 | +set -euo pipefail | |
| 8 | + | |
| 9 | +cd "$(dirname "$0")/.." | |
| 10 | + | |
| 11 | +echo "==> docker compose stop (containers paused, volumes preserved)" | |
| 12 | +docker compose stop | |
| 13 | + | |
| 14 | +echo "==> Stopped. Resume with ./scripts/start.sh, or purge with ./scripts/teardown.sh." |
A
tests/fediverse-testbed/scripts/teardown.sh
+12
-0
@@ -0,0 +1,12 @@
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Stop the testbed and purge all volumes so the next setup starts from a | |
| 3 | +# clean slate. | |
| 4 | + | |
| 5 | +set -euo pipefail | |
| 6 | + | |
| 7 | +cd "$(dirname "$0")/.." | |
| 8 | + | |
| 9 | +echo "==> docker compose down -v" | |
| 10 | +docker compose down -v --remove-orphans | |
| 11 | + | |
| 12 | +echo "==> Done. No containers, no volumes left." |
GitRust