UI WIP
EBO <eric.bouhana@softalys.com> committé le 2026-05-10 10:55
f7f5981b367654324a35dacabeae0143312a0ebd
1 parent(s)
43 fichiers modifiés
+3454
-121
A
.cargo/audit.toml
+33
-0
@@ -0,0 +1,33 @@
| 1 | +# cargo-audit configuration (Phase 5 — production gate). | |
| 2 | +# | |
| 3 | +# Each entry below is a transitive advisory we have audited and accepted | |
| 4 | +# pending an upstream fix. Every ignore line MUST link to the section in | |
| 5 | +# `docs/SECURITY_AUDIT_2026_05.md` that documents the rationale. | |
| 6 | +# | |
| 7 | +# Run locally: `cargo audit` | |
| 8 | +# CI: configure the gate to fail on `error: 1 vulnerability found!` and | |
| 9 | +# exit 0 when only `warning:` lines remain. | |
| 10 | + | |
| 11 | +[advisories] | |
| 12 | +ignore = [ | |
| 13 | + # rsa 0.9.x — Marvin Attack (timing side-channel, RUSTSEC-2023-0071). | |
| 14 | + # Pulled transitively by sqlx-mysql (sea-orm-migration), openidconnect, | |
| 15 | + # jsonwebtoken, activitypub_federation. We only use RSA for ActivityPub | |
| 16 | + # actor keys (RFC 0005) — JWT signs HS256, no RSA. No upstream fix. | |
| 17 | + # Tracked in docs/SECURITY_AUDIT_2026_05.md §H3. | |
| 18 | + "RUSTSEC-2023-0071", | |
| 19 | + | |
| 20 | + # lexical-core 0.7.6 — multiple soundness issues (RUSTSEC-2023-0086). | |
| 21 | + # Reached via imap-proto < imap (used by EmailService for bounce | |
| 22 | + # detection). Not on the request hot path; bounded input from a | |
| 23 | + # configured IMAP server. No upstream fix planned in imap 2.x. | |
| 24 | + # Tracked in docs/SECURITY_AUDIT_2026_05.md §H3. | |
| 25 | + "RUSTSEC-2023-0086", | |
| 26 | + | |
| 27 | + # rand 0.8.5 / 0.9.2 — unsound when a custom logger is configured | |
| 28 | + # globally (RUSTSEC-2026-0097). Rustwarden does not install such a | |
| 29 | + # logger; only `tracing-subscriber` is used. Pulled transitively by | |
| 30 | + # tower-sessions, sqlx-postgres, totp-rs, quinn-proto. | |
| 31 | + # Tracked in docs/SECURITY_AUDIT_2026_05.md §H3. | |
| 32 | + "RUSTSEC-2026-0097", | |
| 33 | +] |
M
CHANGELOG.md
+163
-0
@@ -11,6 +11,169 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
| 11 | 11 | |
| 12 | 12 | - **Workspace** : edition Rust **2024** + `rust-version = "1.85"` au niveau |
| 13 | 13 | `[workspace.package]`. Toutes les crates héritent (`edition.workspace = true`). |
| 14 | +- **Fix régression `AuthService` (commit `d4d6828`)** — quand | |
| 15 | + `Extension(Arc<AuthConfig>)` n'est pas appliqué *avant* le layer | |
| 16 | + `AuthService` dans le `ServiceBuilder` du consommateur, toute | |
| 17 | + requête sur une route protégée renvoyait `500 Internal Server Error` | |
| 18 | + silencieusement (sans log ERROR). `AuthService::call` retombe | |
| 19 | + désormais sur `AuthConfig::from_env()` en fallback, comportement | |
| 20 | + identique à l'avant `d4d6828`. Un `tracing::error!` est levé seulement | |
| 21 | + si `from_env` échoue aussi. | |
| 22 | +- **RFC 0021 (Slice A iter. 1)** — Fediverse: page admin | |
| 23 | + `/admin/fediverse/settings`. Premier écran HTML de la couche UI | |
| 24 | + fédération. | |
| 25 | + - Nouveau handler | |
| 26 | + [`crates/rustwarden-ui/src/handlers/admin_fediverse.rs`](crates/rustwarden-ui/src/handlers/admin_fediverse.rs) | |
| 27 | + (GET + POST), gated par `experimental-fediverse`. | |
| 28 | + - Nouveau template | |
| 29 | + [`crates/rustwarden-ui/templates/admin/fediverse/settings.html`](crates/rustwarden-ui/templates/admin/fediverse/settings.html) | |
| 30 | + avec 4 cards (Activation / Confiance / Interopérabilité / Sécurité | |
| 31 | + avancée), CSRF, breadcrumb, toast Alpine `?saved=1`. | |
| 32 | + - Persistence SeaORM via `AppSettingsService::set_setting` sur 4 | |
| 33 | + clés : `fediverse_enabled`, `fediverse_trust_mode`, | |
| 34 | + `fediverse_interop_scope`, `fediverse_authorized_fetch`. | |
| 35 | + Validation des sélecteurs côté serveur (whitelist). | |
| 36 | + - i18n complète FR + EN (24 clés `fediverse.settings.*`) ; les autres | |
| 37 | + locales (de/es/it/pt) tombent automatiquement en fallback FR via | |
| 38 | + `I18nService` (cf. `service.rs:159`). | |
| 39 | + - Conformité Plan UI HTML §3 : P1 (CTA primaire unique), P2 | |
| 40 | + (confirmation modale — pour révocations dans Slice C), P3 (toast | |
| 41 | + succès auto-dismiss 4 s), P7 (`<label for>`, `aria-current`, | |
| 42 | + `aria-live="polite"`), P10 (HTMX + Alpine seuls). | |
| 43 | + - **5 quality gates verts** : build clean, 395 tests pass / 0 fail / | |
| 44 | + 19 ignored, clippy `-D warnings` zéro, doc clean (2 warnings | |
| 45 | + préexistants `admin_dto.rs:134` hors scope), pas de migration. | |
| 46 | + - **Reste pour Slice A** (iter. 2) : page `/admin/fediverse/trust_list`, | |
| 47 | + partials `_modal_confirm.html` + `_toast.html` + `_empty_state.html`, | |
| 48 | + tests handlers, couverture des 5 états (empty / 5xx / 403 / | |
| 49 | + loading / happy). | |
| 50 | +- **Phase 5 — quality gate `cargo audit`** : nouvelle config | |
| 51 | + [`.cargo/audit.toml`](.cargo/audit.toml) qui figue les advisories | |
| 52 | + transitives sans fix upstream avec justification par entrée | |
| 53 | + (`RUSTSEC-2023-0071` rsa Marvin Attack ; `RUSTSEC-2023-0086` | |
| 54 | + lexical-core via imap-proto ; `RUSTSEC-2026-0097` rand custom-logger | |
| 55 | + unsoundness). `cargo audit` passe désormais **clean** (0 | |
| 56 | + vulnerability) en local et en CI. Toute nouvelle ignore exige une | |
| 57 | + ligne traçant le pourquoi vers | |
| 58 | + [`docs/SECURITY_AUDIT_2026_05.md` §H3](docs/SECURITY_AUDIT_2026_05.md). | |
| 59 | +- **RFC 0020** — Fediverse: tests d'interopérabilité wiremock | |
| 60 | + (signed delivery round-trip). | |
| 61 | + - Nouvelle dev-dependency `wiremock = "0.6"` sur `rustwarden-core` | |
| 62 | + (test only, non livrée aux apps consommatrices). | |
| 63 | + - Nouveau binaire de tests d'intégration | |
| 64 | + [`crates/rustwarden-core/tests/fediverse_interop.rs`](crates/rustwarden-core/tests/fediverse_interop.rs) | |
| 65 | + sous flag `experimental-fediverse` : | |
| 66 | + 1. `signed_request_round_trip_through_wiremock` — POST signé | |
| 67 | + capturé par wiremock puis re-vérifié via | |
| 68 | + `parse_and_verify_cavage` contre la même clé publique. | |
| 69 | + 2. `outbound_delivers_when_inbox_returns_202` — `drain_one` flippe | |
| 70 | + la row à `Delivered` quand l'inbox répond `202`. | |
| 71 | + 3. `outbound_defers_when_inbox_returns_503` — `drain_one` laisse | |
| 72 | + la row `Pending` avec `next_retry_at` calé sur le ladder | |
| 73 | + RFC 0010 (5 min pour la 1ʳᵉ tentative) quand l'inbox répond | |
| 74 | + `503`. | |
| 75 | + - Pas d'API publique modifiée, pas de migration. Les tests | |
| 76 | + n'utilisent ni Docker ni vrai Mastodon : ils exercent la chaîne | |
| 77 | + `rsa_sha256_sign` → `reqwest::Client` → `wiremock` → | |
| 78 | + `parse_and_verify_cavage` pour détecter tout mismatch silencieux | |
| 79 | + de format de signature avant la prod. | |
| 80 | +- **RFC 0019** — Fediverse: observabilité du worker outbound (tracing | |
| 81 | + counters). Quatre points d'instrumentation `tracing` sous le target | |
| 82 | + dédié `rustwarden::fediverse::outbound` : | |
| 83 | + - `event = "delivered"` (level `info`) — succès, avec `attempts`, | |
| 84 | + `target_inbox`, `target_domain`, `activity_id`. | |
| 85 | + - `event = "deferred"` (level `warn`) — échec retryable, avec | |
| 86 | + `next_retry_at`, `last_error`, `attempts`, `target_domain`. | |
| 87 | + - `event = "dead"` (level `error`) — `attempts >= max_attempts` ; la | |
| 88 | + row reste persistée pour audit. | |
| 89 | + - `event = "drain_failed"` (level `error`) — erreur DB en bouclant | |
| 90 | + le worker, ne casse pas la boucle. | |
| 91 | + - Aucune nouvelle dépendance, aucun schéma DB. Les apps consommatrices | |
| 92 | + branchent l'exporter de leur choix au niveau | |
| 93 | + `tracing-subscriber` (Prometheus, Loki, OTEL). | |
| 94 | + - Aucun nouveau test ajouté — la sémantique des transitions est | |
| 95 | + déjà couverte par les tests RFC 0010 / 0011 (les events sont | |
| 96 | + best-effort et ne changent pas les valeurs de retour). | |
| 97 | +- **RFC 0018** — Fediverse: clôture de la dette technique (PK relax | |
| 98 | + `team_members` + JWT blacklist par remote identity). | |
| 99 | + - Nouvelle migration SeaORM `m20260510_000016_relax_team_members_pk` : | |
| 100 | + drop de la PK composite `(team_id, user_id)`, `user_id` devient | |
| 101 | + nullable, ajout de `id UUID PRIMARY KEY` (surrogate), | |
| 102 | + CHECK XOR `chk_team_members_principal`, et index unique partiel | |
| 103 | + `team_members_local_unique` sur `(team_id, user_id) WHERE | |
| 104 | + user_id IS NOT NULL` qui préserve l'unicité des rows locales. | |
| 105 | + - **Breaking** sur `team_member::Model` : | |
| 106 | + - Nouveau champ `id: Uuid` (PK SeaORM). | |
| 107 | + - `user_id` passe de `Uuid` à `Option<Uuid>`. | |
| 108 | + - Les call-sites `Entity::find_by_id((team_id, user_id))` ont été | |
| 109 | + migrés vers `Entity::find().filter(...)` (deux call-sites internes | |
| 110 | + mis à jour). | |
| 111 | + - `team_service::add_member_identity(Identity::Remote(_))` insère | |
| 112 | + désormais avec `user_id = NULL` (au lieu du sentinel `Uuid::nil()`). | |
| 113 | + - `FediverseInviteService::accept` également mis à jour. | |
| 114 | + - **Levée de l'`#[ignore]`** sur le test | |
| 115 | + `rfc0009_add_member_identity_remote_writes_remote_identity_id_column` | |
| 116 | + qui passe désormais en CI. | |
| 117 | + - Nouveaux items publics gated `experimental-fediverse` : | |
| 118 | + - `JwtBlacklistService::blacklist_by_remote_identity_id(db, id, iter)`. | |
| 119 | + - Trait `ActiveTokenIterator` + impl `NoopActiveTokenIterator`. | |
| 120 | + - `FediverseSignalService::apply_delete_actor_with_iterator(db, ap_id, iter)`. | |
| 121 | + - `apply_delete_actor` legacy continue de retourner | |
| 122 | + `jwts_blacklisted = 0` (rétrocompatible) ; les apps qui veulent | |
| 123 | + l'invalidation effective utilisent la variante `_with_iterator`. | |
| 124 | + - 2 tests RFC 0018 ajoutés | |
| 125 | + (`apply_delete_actor_with_noop_iterator_keeps_jwts_blacklisted_zero`, | |
| 126 | + `noop_active_token_iterator_returns_empty_list`). | |
| 127 | +- **RFC 0017** — Fediverse: `HttpSigVerifier` auto-résout le PEM | |
| 128 | + public via `RemoteIdentityService` (RFC 0003) quand | |
| 129 | + `ctx.public_key_pem` est vide. Élimine le contrat caller-side | |
| 130 | + laissé ouvert par RFC 0016. | |
| 131 | + - Nouveau constructeur `HttpSigVerifier::with_resolver(db)`. | |
| 132 | + `HttpSigVerifier::new()` reste disponible pour les apps qui | |
| 133 | + veulent gérer la résolution elles-mêmes. | |
| 134 | + - Lookup direct `remote_identities.ap_id == ctx.actor_ap_id`, | |
| 135 | + sans nouveau cache (le service de RFC 0003 a déjà son TTL). | |
| 136 | + - 2 tests unitaires : | |
| 137 | + `http_sig_verifier_resolves_pem_from_remote_identities_when_empty` | |
| 138 | + (round-trip RSA via cache), | |
| 139 | + `http_sig_verifier_rejects_when_actor_not_in_cache`. | |
| 140 | +- **RFC 0016** — Fediverse: élargissement du trait `SignatureVerifier` + | |
| 141 | + `HttpSignedDelivery` complet. Branche cryptographique end-to-end de | |
| 142 | + rustwarden ↔ Mastodon. | |
| 143 | + - Nouvelle struct publique `VerifyCtx<'a>` portant la full request | |
| 144 | + context (method, path, `HeaderMap`, body, public_key_pem). | |
| 145 | + - **Breaking** sur `SignatureVerifier::verify` (gated | |
| 146 | + `experimental-fediverse`) : ancienne signature | |
| 147 | + `verify(actor, sig, body, pem)` remplacée par | |
| 148 | + `verify(&VerifyCtx<'_>)`. Les apps consommatrices qui implémentaient | |
| 149 | + leur propre verifier doivent migrer. | |
| 150 | + - Nouveaux items publics : | |
| 151 | + `rustwarden_core::fediverse::http_signatures::HttpSigVerifier`, | |
| 152 | + `rustwarden_core::fediverse::http_signatures::HttpSignedDelivery`, | |
| 153 | + `rustwarden_core::services::fediverse_signal_service::VerifyCtx`. | |
| 154 | + - `inbox::handle` et `auth_routes::auth_inbox` reconstituent | |
| 155 | + désormais le `VerifyCtx` complet à partir du `Request` Axum, ce | |
| 156 | + qui permet à `HttpSigVerifier` d'effectuer la vérification | |
| 157 | + réelle draft-cavage sans plus aucun TODO côté handler. | |
| 158 | + - `HttpSignedDelivery` : | |
| 159 | + - Charge la paire RSA via `ActorKeyService::ensure_keys`. | |
| 160 | + - Construit `(request-target) host date`, signe RSA-SHA256. | |
| 161 | + - POST l'activité avec `reqwest::Client` + headers | |
| 162 | + `Date` / `Host` / `Signature` / `Content-Type`. | |
| 163 | + - Maps les statuts HTTP : `2xx → Ok(())`, le reste → `Err(Internal)`, | |
| 164 | + ce qui réinjecte la row dans le ladder retry de RFC 0010. | |
| 165 | + - `reqwest` ajouté à `experimental-fediverse` deps (réutilise la | |
| 166 | + version 0.12 déjà tirée par `oauth`). | |
| 167 | + - 4 tests RFC 0016 : | |
| 168 | + `http_sig_verifier_rejects_when_signature_header_missing`, | |
| 169 | + `http_sig_verifier_rejects_when_public_key_pem_empty`, | |
| 170 | + `http_sig_verifier_accepts_valid_signature` (round-trip RSA), | |
| 171 | + `http_signed_delivery_rejects_when_actor_user_id_none`. | |
| 172 | + - Limitations : la résolution automatique du PEM public via | |
| 173 | + `RemoteIdentityService` reste à câbler (le verifier exige | |
| 174 | + pour l'instant que le caller passe `public_key_pem`). | |
| 175 | + Tests d'intégration HTTP réel via `wiremock` ou `mockito` | |
| 176 | + différés à un follow-up `tests/interop/`. | |
| 14 | 177 | - **RFC 0015** — Fediverse: signatures HTTP draft-cavage (RSA-SHA256). |
| 15 | 178 | Premier déblocage cryptographique réel pour interop Mastodon. |
| 16 | 179 | - Nouveau module public gated `experimental-fediverse` : |
M
Cargo.lock
+68
-0
@@ -465,6 +465,16 @@ dependencies = [
| 465 | 465 | ] |
| 466 | 466 | |
| 467 | 467 | [[package]] |
| 468 | +name = "assert-json-diff" | |
| 469 | +version = "2.0.2" | |
| 470 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 471 | +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" | |
| 472 | +dependencies = [ | |
| 473 | + "serde", | |
| 474 | + "serde_json", | |
| 475 | +] | |
| 476 | + | |
| 477 | +[[package]] | |
| 468 | 478 | name = "async-lock" |
| 469 | 479 | version = "3.4.2" |
| 470 | 480 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -1254,6 +1264,24 @@ dependencies = [
| 1254 | 1264 | ] |
| 1255 | 1265 | |
| 1256 | 1266 | [[package]] |
| 1267 | +name = "deadpool" | |
| 1268 | +version = "0.12.3" | |
| 1269 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1270 | +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" | |
| 1271 | +dependencies = [ | |
| 1272 | + "deadpool-runtime", | |
| 1273 | + "lazy_static", | |
| 1274 | + "num_cpus", | |
| 1275 | + "tokio", | |
| 1276 | +] | |
| 1277 | + | |
| 1278 | +[[package]] | |
| 1279 | +name = "deadpool-runtime" | |
| 1280 | +version = "0.1.4" | |
| 1281 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1282 | +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" | |
| 1283 | + | |
| 1284 | +[[package]] | |
| 1257 | 1285 | name = "der" |
| 1258 | 1286 | version = "0.7.10" |
| 1259 | 1287 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -2012,6 +2040,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 2012 | 2040 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" |
| 2013 | 2041 | |
| 2014 | 2042 | [[package]] |
| 2043 | +name = "hermit-abi" | |
| 2044 | +version = "0.5.2" | |
| 2045 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2046 | +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" | |
| 2047 | + | |
| 2048 | +[[package]] | |
| 2015 | 2049 | name = "hex" |
| 2016 | 2050 | version = "0.4.3" |
| 2017 | 2051 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -3027,6 +3061,16 @@ dependencies = [
| 3027 | 3061 | ] |
| 3028 | 3062 | |
| 3029 | 3063 | [[package]] |
| 3064 | +name = "num_cpus" | |
| 3065 | +version = "1.17.0" | |
| 3066 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3067 | +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" | |
| 3068 | +dependencies = [ | |
| 3069 | + "hermit-abi", | |
| 3070 | + "libc", | |
| 3071 | +] | |
| 3072 | + | |
| 3073 | +[[package]] | |
| 3030 | 3074 | name = "oauth2" |
| 3031 | 3075 | version = "5.0.0" |
| 3032 | 3076 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -4156,6 +4200,7 @@ dependencies = [
| 4156 | 4200 | "urlencoding", |
| 4157 | 4201 | "uuid", |
| 4158 | 4202 | "validator", |
| 4203 | + "wiremock", | |
| 4159 | 4204 | "yaml-rust2", |
| 4160 | 4205 | ] |
| 4161 | 4206 |
@@ -6177,6 +6222,29 @@ dependencies = [
| 6177 | 6222 | ] |
| 6178 | 6223 | |
| 6179 | 6224 | [[package]] |
| 6225 | +name = "wiremock" | |
| 6226 | +version = "0.6.5" | |
| 6227 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6228 | +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" | |
| 6229 | +dependencies = [ | |
| 6230 | + "assert-json-diff", | |
| 6231 | + "base64 0.22.1", | |
| 6232 | + "deadpool", | |
| 6233 | + "futures", | |
| 6234 | + "http", | |
| 6235 | + "http-body-util", | |
| 6236 | + "hyper", | |
| 6237 | + "hyper-util", | |
| 6238 | + "log", | |
| 6239 | + "once_cell", | |
| 6240 | + "regex", | |
| 6241 | + "serde", | |
| 6242 | + "serde_json", | |
| 6243 | + "tokio", | |
| 6244 | + "url", | |
| 6245 | +] | |
| 6246 | + | |
| 6247 | +[[package]] | |
| 6180 | 6248 | name = "wit-bindgen" |
| 6181 | 6249 | version = "0.51.0" |
| 6182 | 6250 | source = "registry+https://github.com/rust-lang/crates.io-index" |
M
app/Cargo.toml
+11
-0
@@ -9,6 +9,17 @@ description = "Reference application using the Rustwarden framework"
| 9 | 9 | name = "rustwarden" |
| 10 | 10 | path = "src/main.rs" |
| 11 | 11 | |
| 12 | +[features] | |
| 13 | +default = [] | |
| 14 | +# Active la fédération ActivityPub expérimentale (RFC 0001+). | |
| 15 | +# Compile la couche backend (rustwarden-core) ET les pages UI HTML | |
| 16 | +# d'administration (rustwarden-ui), notamment `/admin/fediverse/settings` | |
| 17 | +# (RFC 0021 Slice A). | |
| 18 | +experimental-fediverse = [ | |
| 19 | + "rustwarden-core/experimental-fediverse", | |
| 20 | + "rustwarden-ui/experimental-fediverse", | |
| 21 | +] | |
| 22 | + | |
| 12 | 23 | [dependencies] |
| 13 | 24 | rustwarden-core = { path = "../crates/rustwarden-core", features = ["oauth"] } |
| 14 | 25 | rustwarden-ui = { path = "../crates/rustwarden-ui" } |
M
crates/rustwarden-core/Cargo.toml
+5
-2
@@ -81,11 +81,11 @@ rsa = { version = "0.9", features = ["pem", "getrandom"], optional = true }
| 81 | 81 | [features] |
| 82 | 82 | default = [] |
| 83 | 83 | redis-session = ["tower-sessions-redis-store"] |
| 84 | -oauth = ["openidconnect", "reqwest"] | |
| 84 | +oauth = ["dep:openidconnect", "dep:reqwest"] | |
| 85 | 85 | # RFC 0001: experimental ActivityPub identity federation. Promoted to |
| 86 | 86 | # `fediverse` when stabilised (>= 2 adopting apps or 30 days without |
| 87 | 87 | # critical bugs, cf. RUSTWARDEN_EVOLUTION_PROCESS.md §5.1). |
| 88 | -experimental-fediverse = ["dep:activitypub_federation", "dep:ammonia", "dep:url", "dep:rsa"] | |
| 88 | +experimental-fediverse = ["dep:activitypub_federation", "dep:ammonia", "dep:url", "dep:rsa", "dep:reqwest"] | |
| 89 | 89 | |
| 90 | 90 | [lints] |
| 91 | 91 | workspace = true |
@@ -112,3 +112,6 @@ hyper-util = { version = "0.1", features = ["full"] }
| 112 | 112 | tower = "0.5" |
| 113 | 113 | # Enable SeaORM mock backend for unit tests (RFC 0003+). |
| 114 | 114 | sea-orm = { version = "2.0.0-rc.20", features = ["mock"] } |
| 115 | +# Fediverse interop tests (RFC 0020): fake-Mastodon HTTP server for | |
| 116 | +# round-trip verification of HTTP signatures and outbound delivery. | |
| 117 | +wiremock = "0.6" |
M
crates/rustwarden-core/migrations/src/lib.rs
+2
-0
@@ -15,6 +15,7 @@ pub mod m20260510_000012_fediverse_outbound_queue;
| 15 | 15 | pub mod m20260510_000013_fediverse_auth_challenges; |
| 16 | 16 | pub mod m20260510_000014_identity_sibling_fks; |
| 17 | 17 | pub mod m20260510_000015_fediverse_pending_invites; |
| 18 | +pub mod m20260510_000016_relax_team_members_pk; | |
| 18 | 19 | |
| 19 | 20 | pub struct Migrator; |
| 20 | 21 |
@@ -37,6 +38,7 @@ impl MigratorTrait for Migrator {
| 37 | 38 | Box::new(m20260510_000013_fediverse_auth_challenges::Migration), |
| 38 | 39 | Box::new(m20260510_000014_identity_sibling_fks::Migration), |
| 39 | 40 | Box::new(m20260510_000015_fediverse_pending_invites::Migration), |
| 41 | + Box::new(m20260510_000016_relax_team_members_pk::Migration), | |
| 40 | 42 | ] |
| 41 | 43 | } |
| 42 | 44 | } |
A
crates/rustwarden-core/migrations/src/m20260510_000016_relax_team_members_pk.rs
+89
-0
@@ -0,0 +1,89 @@
| 1 | +//! Migration `m20260510_000016_relax_team_members_pk` (RFC 0018). | |
| 2 | +//! | |
| 3 | +//! Drops the composite PK `(team_id, user_id)`, makes `user_id` | |
| 4 | +//! nullable, adds a CHECK XOR with `remote_identity_id`, and switches | |
| 5 | +//! to a synthetic `id` PK so SeaORM can address the row through a | |
| 6 | +//! single column lookup. A partial UNIQUE index preserves the | |
| 7 | +//! original `(team_id, user_id)` uniqueness for local rows. | |
| 8 | + | |
| 9 | +use sea_orm_migration::prelude::*; | |
| 10 | +use sea_orm_migration::sea_orm::ConnectionTrait; | |
| 11 | + | |
| 12 | +#[derive(DeriveMigrationName)] | |
| 13 | +pub struct Migration; | |
| 14 | + | |
| 15 | +#[async_trait::async_trait] | |
| 16 | +impl MigrationTrait for Migration { | |
| 17 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 18 | + let conn = manager.get_connection(); | |
| 19 | + | |
| 20 | + // 1. Drop composite PK. | |
| 21 | + conn.execute_unprepared( | |
| 22 | + "ALTER TABLE team_members DROP CONSTRAINT team_members_pkey", | |
| 23 | + ) | |
| 24 | + .await?; | |
| 25 | + | |
| 26 | + // 2. Make user_id nullable (remote rows have user_id IS NULL). | |
| 27 | + conn.execute_unprepared( | |
| 28 | + "ALTER TABLE team_members ALTER COLUMN user_id DROP NOT NULL", | |
| 29 | + ) | |
| 30 | + .await?; | |
| 31 | + | |
| 32 | + // 3. CHECK XOR. | |
| 33 | + conn.execute_unprepared( | |
| 34 | + "ALTER TABLE team_members ADD CONSTRAINT chk_team_members_principal \ | |
| 35 | + CHECK ((user_id IS NOT NULL)::int + (remote_identity_id IS NOT NULL)::int = 1)", | |
| 36 | + ) | |
| 37 | + .await?; | |
| 38 | + | |
| 39 | + // 4. Surrogate id PK (uses pgcrypto's gen_random_uuid()). | |
| 40 | + conn.execute_unprepared( | |
| 41 | + "CREATE EXTENSION IF NOT EXISTS pgcrypto", | |
| 42 | + ) | |
| 43 | + .await?; | |
| 44 | + conn.execute_unprepared( | |
| 45 | + "ALTER TABLE team_members ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid()", | |
| 46 | + ) | |
| 47 | + .await?; | |
| 48 | + conn.execute_unprepared( | |
| 49 | + "ALTER TABLE team_members ADD PRIMARY KEY (id)", | |
| 50 | + ) | |
| 51 | + .await?; | |
| 52 | + | |
| 53 | + // 5. Preserve uniqueness for local rows. | |
| 54 | + conn.execute_unprepared( | |
| 55 | + "CREATE UNIQUE INDEX team_members_local_unique \ | |
| 56 | + ON team_members (team_id, user_id) \ | |
| 57 | + WHERE user_id IS NOT NULL", | |
| 58 | + ) | |
| 59 | + .await?; | |
| 60 | + | |
| 61 | + Ok(()) | |
| 62 | + } | |
| 63 | + | |
| 64 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 65 | + let conn = manager.get_connection(); | |
| 66 | + | |
| 67 | + // Reverse order. | |
| 68 | + conn.execute_unprepared("DROP INDEX IF EXISTS team_members_local_unique") | |
| 69 | + .await?; | |
| 70 | + conn.execute_unprepared("ALTER TABLE team_members DROP CONSTRAINT IF EXISTS team_members_pkey") | |
| 71 | + .await?; | |
| 72 | + conn.execute_unprepared("ALTER TABLE team_members DROP COLUMN IF EXISTS id") | |
| 73 | + .await?; | |
| 74 | + conn.execute_unprepared( | |
| 75 | + "ALTER TABLE team_members DROP CONSTRAINT IF EXISTS chk_team_members_principal", | |
| 76 | + ) | |
| 77 | + .await?; | |
| 78 | + conn.execute_unprepared( | |
| 79 | + "ALTER TABLE team_members ALTER COLUMN user_id SET NOT NULL", | |
| 80 | + ) | |
| 81 | + .await?; | |
| 82 | + conn.execute_unprepared( | |
| 83 | + "ALTER TABLE team_members ADD PRIMARY KEY (team_id, user_id)", | |
| 84 | + ) | |
| 85 | + .await?; | |
| 86 | + | |
| 87 | + Ok(()) | |
| 88 | + } | |
| 89 | +} |
M
crates/rustwarden-core/src/auth/jwt.rs
+2
-7
@@ -19,22 +19,17 @@ use crate::error::AppError;
| 19 | 19 | /// a federated remote identity (`remote_identities.id` in `sub`) without |
| 20 | 20 | /// touching the database. Persisted on the wire as the snake_case key |
| 21 | 21 | /// `kind`. Tokens minted before RFC 0007 deserialise as `Local`. |
| 22 | -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 22 | +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 23 | 23 | #[serde(rename_all = "snake_case")] |
| 24 | 24 | pub enum IdentityKind { |
| 25 | 25 | /// Subject = `users.id` (local account). |
| 26 | + #[default] | |
| 26 | 27 | Local, |
| 27 | 28 | /// Subject = `remote_identities.id` (federated user authenticated via |
| 28 | 29 | /// the AP-native flow of RFC 0006). |
| 29 | 30 | Remote, |
| 30 | 31 | } |
| 31 | 32 | |
| 32 | -impl Default for IdentityKind { | |
| 33 | - fn default() -> Self { | |
| 34 | - Self::Local | |
| 35 | - } | |
| 36 | -} | |
| 37 | - | |
| 38 | 33 | /// JWT Claims structure |
| 39 | 34 | /// |
| 40 | 35 | /// Contains all information embedded in the JWT token. |
M
crates/rustwarden-core/src/fediverse/auth_routes.rs
+11
-9
@@ -32,7 +32,7 @@ use crate::services::fediverse_auth_service::{
| 32 | 32 | }; |
| 33 | 33 | #[cfg(test)] |
| 34 | 34 | use crate::services::fediverse_auth_service::ChallengeStatus; |
| 35 | -use crate::services::fediverse_signal_service::SignatureVerifier; | |
| 35 | +use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; | |
| 36 | 36 | |
| 37 | 37 | /// Per-router state injected into the auth-flow handlers. |
| 38 | 38 | #[derive(Clone)] |
@@ -171,9 +171,17 @@ async fn auth_inbox(
| 171 | 171 | .and_then(|v| v.get("actor").and_then(|a| a.as_str()).map(str::to_string)) |
| 172 | 172 | .unwrap_or_default(); |
| 173 | 173 | |
| 174 | + let _ = signature; // presence already enforced above | |
| 174 | 175 | state |
| 175 | 176 | .verifier |
| 176 | - .verify(&actor, signature, &body, "") | |
| 177 | + .verify(&VerifyCtx { | |
| 178 | + actor_ap_id: &actor, | |
| 179 | + method: "post", | |
| 180 | + path: "/fediverse/auth/inbox", | |
| 181 | + headers: &headers, | |
| 182 | + body: &body, | |
| 183 | + public_key_pem: "", | |
| 184 | + }) | |
| 177 | 185 | .await |
| 178 | 186 | .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; |
| 179 | 187 |
@@ -267,13 +275,7 @@ mod tests {
| 267 | 275 | |
| 268 | 276 | #[async_trait] |
| 269 | 277 | impl SignatureVerifier for AlwaysAccept { |
| 270 | - async fn verify( | |
| 271 | - &self, | |
| 272 | - _: &str, | |
| 273 | - _: &str, | |
| 274 | - _: &[u8], | |
| 275 | - _: &str, | |
| 276 | - ) -> Result<(), AppError> { | |
| 278 | + async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 277 | 279 | Ok(()) |
| 278 | 280 | } |
| 279 | 281 | } |
M
crates/rustwarden-core/src/fediverse/http_signatures.rs
+379
-0
@@ -22,7 +22,14 @@ use rsa::signature::{SignatureEncoding, Signer, Verifier};
| 22 | 22 | use rsa::{RsaPrivateKey, RsaPublicKey}; |
| 23 | 23 | use sha2::Sha256; |
| 24 | 24 | |
| 25 | +use async_trait::async_trait; | |
| 26 | +use sea_orm::DatabaseConnection; | |
| 27 | + | |
| 25 | 28 | use crate::error::AppError; |
| 29 | +use crate::models::fediverse_outbound_queue; | |
| 30 | +use crate::services::actor_key_service::ActorKeyService; | |
| 31 | +use crate::services::fediverse_outbound_service::OutboundDelivery; | |
| 32 | +use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; | |
| 26 | 33 | |
| 27 | 34 | /// Canonical fields extracted from a draft-cavage `Signature` header. |
| 28 | 35 | #[derive(Debug, Clone, PartialEq, Eq)] |
@@ -164,6 +171,172 @@ pub fn parse_and_verify_cavage(
| 164 | 171 | rsa_sha256_verify(public_pem, input.as_bytes(), &parsed.signature) |
| 165 | 172 | } |
| 166 | 173 | |
| 174 | +/// Production-grade implementation of [`SignatureVerifier`] (RFC 0016 | |
| 175 | +/// + RFC 0017). | |
| 176 | +/// | |
| 177 | +/// Reads the `Signature` header, builds the canonical input string, | |
| 178 | +/// and validates the RSA-SHA256 signature. When `ctx.public_key_pem` | |
| 179 | +/// is empty: | |
| 180 | +/// | |
| 181 | +/// - if the verifier was built with [`Self::with_resolver`], it looks | |
| 182 | +/// up the actor in `remote_identities` (RFC 0003) and uses the | |
| 183 | +/// cached PEM ; | |
| 184 | +/// - otherwise it rejects with `Validation`. | |
| 185 | +pub struct HttpSigVerifier { | |
| 186 | + resolver_db: Option<DatabaseConnection>, | |
| 187 | +} | |
| 188 | + | |
| 189 | +impl HttpSigVerifier { | |
| 190 | + /// Build a verifier that does **not** auto-resolve the public | |
| 191 | + /// key. Callers must populate `ctx.public_key_pem` themselves. | |
| 192 | + pub fn new() -> Self { | |
| 193 | + Self { resolver_db: None } | |
| 194 | + } | |
| 195 | + | |
| 196 | + /// Build a verifier that auto-resolves the public key from the | |
| 197 | + /// `remote_identities` cache (RFC 0003) when `ctx.public_key_pem` | |
| 198 | + /// is empty (RFC 0017). | |
| 199 | + pub fn with_resolver(db: DatabaseConnection) -> Self { | |
| 200 | + Self { | |
| 201 | + resolver_db: Some(db), | |
| 202 | + } | |
| 203 | + } | |
| 204 | +} | |
| 205 | + | |
| 206 | +impl Default for HttpSigVerifier { | |
| 207 | + fn default() -> Self { | |
| 208 | + Self::new() | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +#[async_trait] | |
| 213 | +impl SignatureVerifier for HttpSigVerifier { | |
| 214 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 215 | + let signature_header = ctx | |
| 216 | + .headers | |
| 217 | + .get("signature") | |
| 218 | + .and_then(|v| v.to_str().ok()) | |
| 219 | + .ok_or_else(|| AppError::Validation("missing Signature header".into()))?; | |
| 220 | + | |
| 221 | + let resolved_pem; | |
| 222 | + let public_key_pem: &str = if !ctx.public_key_pem.is_empty() { | |
| 223 | + ctx.public_key_pem | |
| 224 | + } else if let Some(db) = self.resolver_db.as_ref() { | |
| 225 | + use crate::models::remote_identity; | |
| 226 | + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 227 | + let row = remote_identity::Entity::find() | |
| 228 | + .filter(remote_identity::Column::ApId.eq(ctx.actor_ap_id)) | |
| 229 | + .one(db) | |
| 230 | + .await? | |
| 231 | + .ok_or_else(|| { | |
| 232 | + AppError::Validation(format!( | |
| 233 | + "actor not in cache: {}", | |
| 234 | + ctx.actor_ap_id, | |
| 235 | + )) | |
| 236 | + })?; | |
| 237 | + resolved_pem = row.public_key_pem; | |
| 238 | + &resolved_pem | |
| 239 | + } else { | |
| 240 | + return Err(AppError::Validation( | |
| 241 | + "public key not resolved for actor".into(), | |
| 242 | + )); | |
| 243 | + }; | |
| 244 | + | |
| 245 | + let request_target = format!("{} {}", ctx.method, ctx.path); | |
| 246 | + parse_and_verify_cavage(signature_header, &request_target, ctx.body, public_key_pem, |h| { | |
| 247 | + ctx.headers.get(h).and_then(|v| v.to_str().ok().map(str::to_string)) | |
| 248 | + }) | |
| 249 | + } | |
| 250 | +} | |
| 251 | + | |
| 252 | +/// Production-grade implementation of [`OutboundDelivery`] (RFC 0016). | |
| 253 | +/// | |
| 254 | +/// Loads the local actor's RSA key (RFC 0005), signs the | |
| 255 | +/// `(request-target) host date` input string, and POSTs the activity | |
| 256 | +/// body to the remote inbox. | |
| 257 | +pub struct HttpSignedDelivery { | |
| 258 | + db: DatabaseConnection, | |
| 259 | + http: reqwest::Client, | |
| 260 | +} | |
| 261 | + | |
| 262 | +impl HttpSignedDelivery { | |
| 263 | + /// Build a delivery handler around an existing SeaORM connection. | |
| 264 | + /// Uses a default `reqwest::Client` (TLS via `native-tls`). | |
| 265 | + pub fn new(db: DatabaseConnection) -> Self { | |
| 266 | + Self { | |
| 267 | + db, | |
| 268 | + http: reqwest::Client::new(), | |
| 269 | + } | |
| 270 | + } | |
| 271 | +} | |
| 272 | + | |
| 273 | +#[async_trait] | |
| 274 | +impl OutboundDelivery for HttpSignedDelivery { | |
| 275 | + async fn deliver( | |
| 276 | + &self, | |
| 277 | + item: &fediverse_outbound_queue::Model, | |
| 278 | + ) -> Result<(), AppError> { | |
| 279 | + let actor_user_id = item.actor_user_id.ok_or_else(|| { | |
| 280 | + AppError::Config("HttpSignedDelivery requires actor_user_id".into()) | |
| 281 | + })?; | |
| 282 | + | |
| 283 | + let material = ActorKeyService::ensure_keys(&self.db, actor_user_id).await?; | |
| 284 | + | |
| 285 | + let url = url::Url::parse(&item.target_inbox).map_err(|e| { | |
| 286 | + AppError::Internal(format!("invalid target_inbox URL: {e}")) | |
| 287 | + })?; | |
| 288 | + let host = url | |
| 289 | + .host_str() | |
| 290 | + .ok_or_else(|| { | |
| 291 | + AppError::Internal("target_inbox URL has no host".to_string()) | |
| 292 | + })? | |
| 293 | + .to_string(); | |
| 294 | + let path = url.path().to_string(); | |
| 295 | + | |
| 296 | + let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 297 | + let signed_headers = vec![ | |
| 298 | + "(request-target)".to_string(), | |
| 299 | + "host".to_string(), | |
| 300 | + "date".to_string(), | |
| 301 | + ]; | |
| 302 | + let request_target = format!("post {path}"); | |
| 303 | + let input = build_signature_input(&signed_headers, &request_target, |h| match h { | |
| 304 | + "host" => Some(host.clone()), | |
| 305 | + "date" => Some(date.clone()), | |
| 306 | + _ => None, | |
| 307 | + })?; | |
| 308 | + | |
| 309 | + let signature_bytes = rsa_sha256_sign(&material.private_pem, input.as_bytes())?; | |
| 310 | + let signature_b64 = | |
| 311 | + base64::engine::general_purpose::STANDARD.encode(&signature_bytes); | |
| 312 | + let signature_header = format!( | |
| 313 | + r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{signature_b64}""#, | |
| 314 | + item.target_inbox.trim_end_matches('/'), | |
| 315 | + ); | |
| 316 | + | |
| 317 | + let response = self | |
| 318 | + .http | |
| 319 | + .post(&item.target_inbox) | |
| 320 | + .header("Date", date) | |
| 321 | + .header("Host", host) | |
| 322 | + .header("Signature", signature_header) | |
| 323 | + .header("Content-Type", "application/activity+json") | |
| 324 | + .body(item.payload_json.clone()) | |
| 325 | + .send() | |
| 326 | + .await | |
| 327 | + .map_err(|e| AppError::Internal(format!("HTTP delivery failed: {e}")))?; | |
| 328 | + | |
| 329 | + let status = response.status(); | |
| 330 | + if status.is_success() { | |
| 331 | + Ok(()) | |
| 332 | + } else { | |
| 333 | + Err(AppError::Internal(format!( | |
| 334 | + "remote inbox responded {status}", | |
| 335 | + ))) | |
| 336 | + } | |
| 337 | + } | |
| 338 | +} | |
| 339 | + | |
| 167 | 340 | #[cfg(test)] |
| 168 | 341 | mod tests { |
| 169 | 342 | use super::*; |
@@ -294,6 +467,212 @@ mod tests {
| 294 | 467 | assert!(matches!(err, AppError::Validation(_))); |
| 295 | 468 | } |
| 296 | 469 | |
| 470 | + // ─── RFC 0016 — HttpSigVerifier + HttpSignedDelivery ─────────── | |
| 471 | + | |
| 472 | + #[tokio::test] | |
| 473 | + async fn http_sig_verifier_rejects_when_signature_header_missing() { | |
| 474 | + use axum::http::HeaderMap; | |
| 475 | + let headers = HeaderMap::new(); | |
| 476 | + let ctx = VerifyCtx { | |
| 477 | + actor_ap_id: "x", | |
| 478 | + method: "post", | |
| 479 | + path: "/inbox", | |
| 480 | + headers: &headers, | |
| 481 | + body: b"", | |
| 482 | + public_key_pem: "anything", | |
| 483 | + }; | |
| 484 | + let err = HttpSigVerifier::new().verify(&ctx).await.expect_err(""); | |
| 485 | + assert!(matches!(err, AppError::Validation(_))); | |
| 486 | + } | |
| 487 | + | |
| 488 | + #[tokio::test] | |
| 489 | + async fn http_sig_verifier_rejects_when_public_key_pem_empty() { | |
| 490 | + use axum::http::HeaderMap; | |
| 491 | + let mut headers = HeaderMap::new(); | |
| 492 | + headers.insert("signature", "keyId=\"x\"".parse().unwrap()); | |
| 493 | + let ctx = VerifyCtx { | |
| 494 | + actor_ap_id: "x", | |
| 495 | + method: "post", | |
| 496 | + path: "/inbox", | |
| 497 | + headers: &headers, | |
| 498 | + body: b"", | |
| 499 | + public_key_pem: "", | |
| 500 | + }; | |
| 501 | + let err = HttpSigVerifier::new().verify(&ctx).await.expect_err(""); | |
| 502 | + assert!(matches!(err, AppError::Validation(_))); | |
| 503 | + } | |
| 504 | + | |
| 505 | + #[tokio::test] | |
| 506 | + async fn http_sig_verifier_accepts_valid_signature() { | |
| 507 | + use axum::http::{HeaderMap, HeaderValue}; | |
| 508 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 509 | + let signed_headers = vec![ | |
| 510 | + "(request-target)".to_string(), | |
| 511 | + "host".to_string(), | |
| 512 | + "date".to_string(), | |
| 513 | + ]; | |
| 514 | + let input = | |
| 515 | + build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 516 | + "host" => Some("b.tld".into()), | |
| 517 | + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 518 | + _ => None, | |
| 519 | + }) | |
| 520 | + .unwrap(); | |
| 521 | + let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 522 | + let header_value = format!( | |
| 523 | + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 524 | + b64(&sig_bytes), | |
| 525 | + ); | |
| 526 | + let mut headers = HeaderMap::new(); | |
| 527 | + headers.insert("signature", HeaderValue::from_str(&header_value).unwrap()); | |
| 528 | + headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 529 | + headers.insert( | |
| 530 | + "date", | |
| 531 | + HeaderValue::from_static("Mon, 10 May 2026 12:00:00 GMT"), | |
| 532 | + ); | |
| 533 | + let body = b""; | |
| 534 | + let ctx = VerifyCtx { | |
| 535 | + actor_ap_id: "x", | |
| 536 | + method: "post", | |
| 537 | + path: "/inbox", | |
| 538 | + headers: &headers, | |
| 539 | + body, | |
| 540 | + public_key_pem: &pub_pem, | |
| 541 | + }; | |
| 542 | + HttpSigVerifier::new() | |
| 543 | + .verify(&ctx) | |
| 544 | + .await | |
| 545 | + .expect("valid signature must pass"); | |
| 546 | + } | |
| 547 | + | |
| 548 | + #[tokio::test] | |
| 549 | + async fn http_sig_verifier_resolves_pem_from_remote_identities_when_empty() { | |
| 550 | + use axum::http::{HeaderMap, HeaderValue}; | |
| 551 | + use chrono::Utc; | |
| 552 | + use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 553 | + use uuid::Uuid; | |
| 554 | + | |
| 555 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 556 | + let signed_headers = vec![ | |
| 557 | + "(request-target)".to_string(), | |
| 558 | + "host".to_string(), | |
| 559 | + "date".to_string(), | |
| 560 | + ]; | |
| 561 | + let input = | |
| 562 | + build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 563 | + "host" => Some("b.tld".into()), | |
| 564 | + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 565 | + _ => None, | |
| 566 | + }) | |
| 567 | + .unwrap(); | |
| 568 | + let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 569 | + let header_value = format!( | |
| 570 | + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 571 | + b64(&sig_bytes), | |
| 572 | + ); | |
| 573 | + let mut headers = HeaderMap::new(); | |
| 574 | + headers.insert("signature", HeaderValue::from_str(&header_value).unwrap()); | |
| 575 | + headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 576 | + headers.insert( | |
| 577 | + "date", | |
| 578 | + HeaderValue::from_static("Mon, 10 May 2026 12:00:00 GMT"), | |
| 579 | + ); | |
| 580 | + | |
| 581 | + let now = Utc::now(); | |
| 582 | + let cached = crate::models::remote_identity::Model { | |
| 583 | + id: Uuid::new_v4(), | |
| 584 | + ap_id: "https://a.tld/users/alice".into(), | |
| 585 | + username: "alice".into(), | |
| 586 | + domain: "a.tld".into(), | |
| 587 | + display_name: None, | |
| 588 | + summary_html: None, | |
| 589 | + inbox_url: "https://a.tld/users/alice/inbox".into(), | |
| 590 | + shared_inbox_url: None, | |
| 591 | + public_key_pem: pub_pem, | |
| 592 | + icon_url: None, | |
| 593 | + actor_type: "Person".into(), | |
| 594 | + raw_json: None, | |
| 595 | + last_fetched_at: now, | |
| 596 | + disabled_at: None, | |
| 597 | + created_at: now, | |
| 598 | + updated_at: now, | |
| 599 | + }; | |
| 600 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 601 | + .append_query_results([vec![cached]]) | |
| 602 | + .into_connection(); | |
| 603 | + | |
| 604 | + let ctx = VerifyCtx { | |
| 605 | + actor_ap_id: "https://a.tld/users/alice", | |
| 606 | + method: "post", | |
| 607 | + path: "/inbox", | |
| 608 | + headers: &headers, | |
| 609 | + body: b"", | |
| 610 | + public_key_pem: "", // empty → triggers resolver path | |
| 611 | + }; | |
| 612 | + HttpSigVerifier::with_resolver(db) | |
| 613 | + .verify(&ctx) | |
| 614 | + .await | |
| 615 | + .expect("resolver path must accept valid signature"); | |
| 616 | + } | |
| 617 | + | |
| 618 | + #[tokio::test] | |
| 619 | + async fn http_sig_verifier_rejects_when_actor_not_in_cache() { | |
| 620 | + use axum::http::{HeaderMap, HeaderValue}; | |
| 621 | + use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 622 | + | |
| 623 | + let mut headers = HeaderMap::new(); | |
| 624 | + headers.insert( | |
| 625 | + "signature", | |
| 626 | + HeaderValue::from_static(r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="dGVzdA==""#), | |
| 627 | + ); | |
| 628 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 629 | + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 630 | + .into_connection(); | |
| 631 | + | |
| 632 | + let ctx = VerifyCtx { | |
| 633 | + actor_ap_id: "https://ghost.tld/users/nobody", | |
| 634 | + method: "post", | |
| 635 | + path: "/inbox", | |
| 636 | + headers: &headers, | |
| 637 | + body: b"", | |
| 638 | + public_key_pem: "", | |
| 639 | + }; | |
| 640 | + let err = HttpSigVerifier::with_resolver(db) | |
| 641 | + .verify(&ctx) | |
| 642 | + .await | |
| 643 | + .expect_err("must reject unknown actor"); | |
| 644 | + assert!(matches!(err, AppError::Validation(_))); | |
| 645 | + } | |
| 646 | + | |
| 647 | + #[tokio::test] | |
| 648 | + async fn http_signed_delivery_rejects_when_actor_user_id_none() { | |
| 649 | + use crate::models::fediverse_outbound_queue::Model; | |
| 650 | + use chrono::Utc; | |
| 651 | + use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 652 | + use uuid::Uuid; | |
| 653 | + let db: DatabaseConnection = | |
| 654 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 655 | + let now = Utc::now(); | |
| 656 | + let item = Model { | |
| 657 | + id: Uuid::new_v4(), | |
| 658 | + activity_id: "act".into(), | |
| 659 | + payload_json: "{}".into(), | |
| 660 | + target_inbox: "https://b.tld/inbox".into(), | |
| 661 | + target_domain: "b.tld".into(), | |
| 662 | + status: "pending".into(), | |
| 663 | + attempts: 0, | |
| 664 | + max_attempts: 8, | |
| 665 | + last_error: None, | |
| 666 | + next_retry_at: None, | |
| 667 | + created_at: now, | |
| 668 | + delivered_at: None, | |
| 669 | + actor_user_id: None, | |
| 670 | + }; | |
| 671 | + let delivery = HttpSignedDelivery::new(db); | |
| 672 | + let err = delivery.deliver(&item).await.expect_err("must reject"); | |
| 673 | + assert!(matches!(err, AppError::Config(_))); | |
| 674 | + } | |
| 675 | + | |
| 297 | 676 | #[test] |
| 298 | 677 | fn parse_and_verify_cavage_rejects_tampered_input() { |
| 299 | 678 | let (pub_pem, priv_pem) = fresh_keypair(); |
M
crates/rustwarden-core/src/fediverse/inbox.rs
+15
-16
@@ -18,7 +18,7 @@ use uuid::Uuid;
| 18 | 18 | |
| 19 | 19 | use super::error::FediverseError; |
| 20 | 20 | use crate::services::fediverse_signal_service::{ |
| 21 | - FediverseSignalService, SignatureVerifier, UpdatedActor, | |
| 21 | + FediverseSignalService, SignatureVerifier, UpdatedActor, VerifyCtx, | |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | 24 | /// Per-router state injected into the inbox handler. |
@@ -65,9 +65,20 @@ pub async fn handle(
| 65 | 65 | .map_err(|e| FediverseError::BadRequest(format!("invalid JSON: {e}")))?; |
| 66 | 66 | |
| 67 | 67 | let actor_ap_id = activity.actor.as_deref().unwrap_or(""); |
| 68 | + // Touch `signature_header` to keep the early validation in place | |
| 69 | + // (its absence already errors above) — the value is also reachable | |
| 70 | + // via `headers["signature"]` for impls. | |
| 71 | + let _ = signature_header; | |
| 68 | 72 | state |
| 69 | 73 | .verifier |
| 70 | - .verify(actor_ap_id, signature_header, &body, "") | |
| 74 | + .verify(&VerifyCtx { | |
| 75 | + actor_ap_id, | |
| 76 | + method: "post", | |
| 77 | + path: "/inbox", | |
| 78 | + headers: &headers, | |
| 79 | + body: &body, | |
| 80 | + public_key_pem: "", | |
| 81 | + }) | |
| 71 | 82 | .await |
| 72 | 83 | .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; |
| 73 | 84 |
@@ -209,13 +220,7 @@ mod tests {
| 209 | 220 | |
| 210 | 221 | #[async_trait] |
| 211 | 222 | impl SignatureVerifier for AlwaysAccept { |
| 212 | - async fn verify( | |
| 213 | - &self, | |
| 214 | - _actor: &str, | |
| 215 | - _sig: &str, | |
| 216 | - _body: &[u8], | |
| 217 | - _pem: &str, | |
| 218 | - ) -> Result<(), AppError> { | |
| 223 | + async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 219 | 224 | Ok(()) |
| 220 | 225 | } |
| 221 | 226 | } |
@@ -224,13 +229,7 @@ mod tests {
| 224 | 229 | |
| 225 | 230 | #[async_trait] |
| 226 | 231 | impl SignatureVerifier for AlwaysReject { |
| 227 | - async fn verify( | |
| 228 | - &self, | |
| 229 | - _: &str, | |
| 230 | - _: &str, | |
| 231 | - _: &[u8], | |
| 232 | - _: &str, | |
| 233 | - ) -> Result<(), AppError> { | |
| 232 | + async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 234 | 233 | Err(AppError::Internal("nope".into())) |
| 235 | 234 | } |
| 236 | 235 | } |
M
crates/rustwarden-core/src/fediverse/invite_routes.rs
+2
-1
@@ -411,8 +411,9 @@ mod tests {
| 411 | 411 | after_invite.invited_remote_identity_id = Some(remote_id); |
| 412 | 412 | let now = Utc::now(); |
| 413 | 413 | let new_member = crate::models::team_member::Model { |
| 414 | + id: Uuid::new_v4(), | |
| 414 | 415 | team_id, |
| 415 | - user_id: Uuid::nil(), | |
| 416 | + user_id: None, | |
| 416 | 417 | role: "member".into(), |
| 417 | 418 | joined_at: now, |
| 418 | 419 | remote_identity_id: Some(remote_id), |
M
crates/rustwarden-core/src/identity.rs
+4
-3
@@ -147,7 +147,7 @@ mod tests {
| 147 | 147 | fn identity_serialises_with_kind_and_id() { |
| 148 | 148 | let id = Uuid::new_v4(); |
| 149 | 149 | let local = Identity::Local(id); |
| 150 | - let value = serde_json::to_value(&local).unwrap(); | |
| 150 | + let value = serde_json::to_value(local).unwrap(); | |
| 151 | 151 | assert_eq!(value["kind"], serde_json::json!("local")); |
| 152 | 152 | assert_eq!(value["id"], serde_json::json!(id)); |
| 153 | 153 | let round_trip: Identity = serde_json::from_value(value).unwrap(); |
@@ -197,8 +197,9 @@ mod tests {
| 197 | 197 | let id = Uuid::new_v4(); |
| 198 | 198 | let original = Identity::Remote(id); |
| 199 | 199 | let copied = original; |
| 200 | - let cloned = original.clone(); | |
| 200 | + // Copy semantics: a fresh copy via plain assignment. | |
| 201 | + let copied_again = original; | |
| 201 | 202 | assert_eq!(original, copied); |
| 202 | - assert_eq!(original, cloned); | |
| 203 | + assert_eq!(original, copied_again); | |
| 203 | 204 | } |
| 204 | 205 | } |
M
crates/rustwarden-core/src/middleware/admin_helper.rs
+21
-5
@@ -47,11 +47,27 @@ pub async fn extract_admin_claims_from_parts(
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | // Validate JWT token — use the shared AuthConfig from request extensions |
| 50 | - let auth_config = parts.extensions.get::<std::sync::Arc<AuthConfig>>().cloned() | |
| 51 | - .ok_or_else(|| { | |
| 52 | - tracing::error!("AuthConfig extension missing from request parts"); | |
| 53 | - AppError::Config("Auth configuration not available".to_string()) | |
| 54 | - })?; | |
| 50 | + // when present, otherwise fall back to `AuthConfig::from_env()` (same | |
| 51 | + // regression mitigation as `AuthService`/`AdminService` since commit | |
| 52 | + // `d4d6828`). | |
| 53 | + let auth_config = match parts | |
| 54 | + .extensions | |
| 55 | + .get::<std::sync::Arc<AuthConfig>>() | |
| 56 | + .cloned() | |
| 57 | + { | |
| 58 | + Some(cfg) => cfg, | |
| 59 | + None => match AuthConfig::from_env() { | |
| 60 | + Ok(cfg) => std::sync::Arc::new(cfg), | |
| 61 | + Err(err) => { | |
| 62 | + tracing::error!( | |
| 63 | + "admin_helper: AuthConfig unavailable (no extension and from_env failed): {err}" | |
| 64 | + ); | |
| 65 | + return Err(AppError::Config( | |
| 66 | + "Auth configuration not available".to_string(), | |
| 67 | + )); | |
| 68 | + } | |
| 69 | + }, | |
| 70 | + }; | |
| 55 | 71 | |
| 56 | 72 | let claims = JwtService::validate_token(&auth_config, &jwt_token).map_err(|_| { |
| 57 | 73 | tracing::warn!("Invalid JWT token for admin route"); |
M
crates/rustwarden-core/src/middleware/admin_service.rs
+22
-9
@@ -89,16 +89,29 @@ where
| 89 | 89 | return Ok(axum::response::Redirect::to(&redirect_url).into_response()); |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | - // Load auth config from request extensions (injected at startup) | |
| 93 | - let auth_config = match parts.extensions.get::<std::sync::Arc<AuthConfig>>().cloned() { | |
| 92 | + // Load auth config from request extensions; fall back to | |
| 93 | + // `AuthConfig::from_env()` when the layer order puts the | |
| 94 | + // Extension layer further inside the stack (same regression | |
| 95 | + // as `AuthService` since commit `d4d6828`). | |
| 96 | + let auth_config = match parts | |
| 97 | + .extensions | |
| 98 | + .get::<std::sync::Arc<AuthConfig>>() | |
| 99 | + .cloned() | |
| 100 | + { | |
| 94 | 101 | Some(config) => config, |
| 95 | - None => { | |
| 96 | - return Ok(( | |
| 97 | - axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 98 | - "Internal server error", | |
| 99 | - ) | |
| 100 | - .into_response()); | |
| 101 | - } | |
| 102 | + None => match AuthConfig::from_env() { | |
| 103 | + Ok(cfg) => std::sync::Arc::new(cfg), | |
| 104 | + Err(err) => { | |
| 105 | + tracing::error!( | |
| 106 | + "AdminService: AuthConfig unavailable (no extension and from_env failed): {err}" | |
| 107 | + ); | |
| 108 | + return Ok(( | |
| 109 | + axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 110 | + "Internal server error", | |
| 111 | + ) | |
| 112 | + .into_response()); | |
| 113 | + } | |
| 114 | + }, | |
| 102 | 115 | }; |
| 103 | 116 | |
| 104 | 117 | let claims = match JwtService::validate_token(&auth_config, &jwt_token) { |
M
crates/rustwarden-core/src/middleware/auth_service.rs
+25
-8
@@ -96,15 +96,32 @@ where
| 96 | 96 | return Ok(axum::response::Redirect::to(&redirect_url).into_response()); |
| 97 | 97 | } |
| 98 | 98 | |
| 99 | - let auth_config = match parts.extensions.get::<std::sync::Arc<AuthConfig>>().cloned() { | |
| 99 | + // Read AuthConfig from request extensions when the | |
| 100 | + // outer router injects it (`.layer(Extension(auth_config))`), | |
| 101 | + // otherwise fall back to `AuthConfig::from_env()` so this | |
| 102 | + // middleware keeps working when the layer order puts the | |
| 103 | + // Extension layer further inside the stack — the regression | |
| 104 | + // commit `d4d6828` flipped this path to a hard 500 by | |
| 105 | + // mistake. | |
| 106 | + let auth_config = match parts | |
| 107 | + .extensions | |
| 108 | + .get::<std::sync::Arc<AuthConfig>>() | |
| 109 | + .cloned() | |
| 110 | + { | |
| 100 | 111 | Some(config) => config, |
| 101 | - None => { | |
| 102 | - return Ok(( | |
| 103 | - axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 104 | - "Internal server error", | |
| 105 | - ) | |
| 106 | - .into_response()); | |
| 107 | - } | |
| 112 | + None => match AuthConfig::from_env() { | |
| 113 | + Ok(cfg) => std::sync::Arc::new(cfg), | |
| 114 | + Err(err) => { | |
| 115 | + tracing::error!( | |
| 116 | + "AuthService: AuthConfig unavailable (no extension and from_env failed): {err}" | |
| 117 | + ); | |
| 118 | + return Ok(( | |
| 119 | + axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 120 | + "Internal server error", | |
| 121 | + ) | |
| 122 | + .into_response()); | |
| 123 | + } | |
| 124 | + }, | |
| 108 | 125 | }; |
| 109 | 126 | |
| 110 | 127 | let claims = match JwtService::validate_token(&auth_config, &jwt_token) { |
M
crates/rustwarden-core/src/models/team_member.rs
+16
-9
@@ -3,24 +3,31 @@ use sea_orm::entity::prelude::*;
| 3 | 3 | use serde::{Deserialize, Serialize}; |
| 4 | 4 | use uuid::Uuid; |
| 5 | 5 | |
| 6 | -/// Membership edge between a user and a team. Composite primary key | |
| 7 | -/// `(team_id, user_id)` prevents duplicates at the schema level. | |
| 6 | +/// Membership edge between a (local user OR remote identity) and a team. | |
| 7 | +/// | |
| 8 | +/// RFC 0018: the composite PK `(team_id, user_id)` was relaxed to a | |
| 9 | +/// surrogate `id` PK so that remote-only rows (where `user_id` is | |
| 10 | +/// `NULL`) are addressable. Uniqueness for local rows is preserved by | |
| 11 | +/// the partial unique index `team_members_local_unique`. Mutual | |
| 12 | +/// exclusion between `user_id` and `remote_identity_id` is enforced at | |
| 13 | +/// the DB level by the CHECK XOR `chk_team_members_principal`. | |
| 8 | 14 | #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] |
| 9 | 15 | #[sea_orm(table_name = "team_members")] |
| 10 | 16 | pub struct Model { |
| 11 | - /// FK → teams.id (part of composite primary key). | |
| 17 | + /// Surrogate primary key (RFC 0018). | |
| 12 | 18 | #[sea_orm(primary_key, auto_increment = false)] |
| 19 | + pub id: Uuid, | |
| 20 | + /// FK → teams.id. | |
| 13 | 21 | pub team_id: Uuid, |
| 14 | - /// FK → users.id (part of composite primary key). | |
| 15 | - #[sea_orm(primary_key, auto_increment = false)] | |
| 16 | - pub user_id: Uuid, | |
| 22 | + /// FK → users.id (`None` for cross-instance memberships). | |
| 23 | + pub user_id: Option<Uuid>, | |
| 17 | 24 | /// `"owner" | "admin" | "member"` (validated at the service layer). |
| 18 | 25 | pub role: String, |
| 19 | 26 | /// Timestamp when the user joined (or was added to) the team. |
| 20 | 27 | pub joined_at: DateTime<Utc>, |
| 21 | - /// RFC 0008: optional FK to `remote_identities.id` for cross-instance | |
| 22 | - /// memberships. Mutually exclusive with `user_id` at the row level | |
| 23 | - /// (enforced once RFC 0009 lands the CHECK + relaxed PK). | |
| 28 | + /// RFC 0008/0018: optional FK to `remote_identities.id` for | |
| 29 | + /// cross-instance memberships. Mutually exclusive with `user_id` | |
| 30 | + /// (enforced by `chk_team_members_principal`). | |
| 24 | 31 | pub remote_identity_id: Option<Uuid>, |
| 25 | 32 | } |
| 26 | 33 |
M
crates/rustwarden-core/src/services/actor_key_service.rs
+5
-0
@@ -130,6 +130,11 @@ impl ActorKeyService {
| 130 | 130 | } |
| 131 | 131 | |
| 132 | 132 | #[cfg(test)] |
| 133 | +// `ENV_LOCK` is intentionally held across `.await` so that two | |
| 134 | +// async tests cannot trample each other's `ENCRYPTION_KEY` env var | |
| 135 | +// while a SeaORM mock query is in flight. Tests on a `MockDatabase` | |
| 136 | +// never block; the lock is in practice released within microseconds. | |
| 137 | +#[allow(clippy::await_holding_lock)] | |
| 133 | 138 | mod tests { |
| 134 | 139 | use super::*; |
| 135 | 140 | use chrono::Utc; |
M
crates/rustwarden-core/src/services/fediverse_invite_service.rs
+7
-5
@@ -129,12 +129,13 @@ impl FediverseInviteService {
| 129 | 129 | active.update(db).await?; |
| 130 | 130 | |
| 131 | 131 | let member = team_member::ActiveModel { |
| 132 | + id: Set(Uuid::new_v4()), | |
| 132 | 133 | team_id: Set(team_id), |
| 133 | - // For remote rows, the existing PK still requires a non-null | |
| 134 | - // `user_id`; we use a sentinel zero UUID until RFC 0010+ | |
| 135 | - // relaxes the PK. The unique partial index on | |
| 134 | + // RFC 0018: PK relaxed; remote rows leave `user_id` NULL. | |
| 135 | + // The CHECK XOR guarantees exactly one principal column is | |
| 136 | + // populated; the unique partial index on | |
| 136 | 137 | // (team_id, remote_identity_id) prevents duplicates. |
| 137 | - user_id: Set(Uuid::nil()), | |
| 138 | + user_id: Set(None), | |
| 138 | 139 | role: Set(role), |
| 139 | 140 | joined_at: Set(Utc::now()), |
| 140 | 141 | remote_identity_id: Set(Some(remote_identity_id)), |
@@ -426,8 +427,9 @@ mod tests {
| 426 | 427 | |
| 427 | 428 | let now = Utc::now(); |
| 428 | 429 | let new_member = team_member::Model { |
| 430 | + id: Uuid::new_v4(), | |
| 429 | 431 | team_id, |
| 430 | - user_id: Uuid::nil(), | |
| 432 | + user_id: None, | |
| 431 | 433 | role: "member".into(), |
| 432 | 434 | joined_at: now, |
| 433 | 435 | remote_identity_id: Some(remote_id), |
M
crates/rustwarden-core/src/services/fediverse_outbound_service.rs
+45
-2
@@ -186,7 +186,7 @@ impl FediverseOutboundService {
| 186 | 186 | let model_for_update = model.clone(); |
| 187 | 187 | let mut active: fediverse_outbound_queue::ActiveModel = model_for_update.into(); |
| 188 | 188 | active.attempts = Set(next_attempts); |
| 189 | - match delivery { | |
| 189 | + match &delivery { | |
| 190 | 190 | Ok(()) => { |
| 191 | 191 | active.status = Set(OutboundStatus::Delivered.as_str().to_string()); |
| 192 | 192 | active.delivered_at = Set(Some(Utc::now())); |
@@ -216,6 +216,45 @@ impl FediverseOutboundService {
| 216 | 216 | } |
| 217 | 217 | } |
| 218 | 218 | let updated = active.update(db).await?; |
| 219 | + | |
| 220 | + // RFC 0019: structured tracing events — drive Prometheus / Loki / | |
| 221 | + // OTEL exporters without coupling rustwarden-core to a metrics | |
| 222 | + // crate. | |
| 223 | + match (&delivery, updated.status.as_str()) { | |
| 224 | + (Ok(()), _) => { | |
| 225 | + tracing::info!( | |
| 226 | + target: "rustwarden::fediverse::outbound", | |
| 227 | + activity_id = %updated.activity_id, | |
| 228 | + target_inbox = %updated.target_inbox, | |
| 229 | + target_domain = %updated.target_domain, | |
| 230 | + attempts = updated.attempts, | |
| 231 | + event = "delivered", | |
| 232 | + ); | |
| 233 | + } | |
| 234 | + (Err(err), status) if status == OutboundStatus::Dead.as_str() => { | |
| 235 | + tracing::error!( | |
| 236 | + target: "rustwarden::fediverse::outbound", | |
| 237 | + activity_id = %updated.activity_id, | |
| 238 | + target_domain = %updated.target_domain, | |
| 239 | + last_error = %err, | |
| 240 | + attempts = updated.attempts, | |
| 241 | + max_attempts = updated.max_attempts, | |
| 242 | + event = "dead", | |
| 243 | + ); | |
| 244 | + } | |
| 245 | + (Err(err), _) => { | |
| 246 | + tracing::warn!( | |
| 247 | + target: "rustwarden::fediverse::outbound", | |
| 248 | + activity_id = %updated.activity_id, | |
| 249 | + target_domain = %updated.target_domain, | |
| 250 | + last_error = %err, | |
| 251 | + attempts = updated.attempts, | |
| 252 | + next_retry_at = ?updated.next_retry_at, | |
| 253 | + event = "deferred", | |
| 254 | + ); | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 219 | 258 | Ok(Some(updated)) |
| 220 | 259 | } |
| 221 | 260 | } |
@@ -250,7 +289,11 @@ impl<T: OutboundDelivery> OutboundWorker<T> {
| 250 | 289 | Ok(Some(_)) => continue, // drain greedily while items exist |
| 251 | 290 | Ok(None) => tokio::time::sleep(self.poll_interval).await, |
| 252 | 291 | Err(err) => { |
| 253 | - tracing::error!(error = %err, "outbound worker drain failed"); | |
| 292 | + tracing::error!( | |
| 293 | + target: "rustwarden::fediverse::outbound", | |
| 294 | + error = %err, | |
| 295 | + event = "drain_failed", | |
| 296 | + ); | |
| 254 | 297 | tokio::time::sleep(self.poll_interval).await; |
| 255 | 298 | } |
| 256 | 299 | } |
M
crates/rustwarden-core/src/services/fediverse_signal_service.rs
+121
-20
@@ -147,6 +147,37 @@ impl FediverseSignalService {
| 147 | 147 | }) |
| 148 | 148 | } |
| 149 | 149 | |
| 150 | + /// RFC 0018 extension: same as [`Self::apply_delete_actor`] but | |
| 151 | + /// also blacklists every active JWT issued for the deleted remote | |
| 152 | + /// identity. The `iterator` argument is pluggable | |
| 153 | + /// ([`crate::services::jwt_blacklist_service::ActiveTokenIterator`]) — | |
| 154 | + /// pass `&NoopActiveTokenIterator` to keep the legacy behaviour. | |
| 155 | + pub async fn apply_delete_actor_with_iterator( | |
| 156 | + db: &DatabaseConnection, | |
| 157 | + ap_id: &str, | |
| 158 | + iterator: &dyn crate::services::jwt_blacklist_service::ActiveTokenIterator, | |
| 159 | + ) -> Result<DeleteOutcome, AppError> { | |
| 160 | + let mut outcome = Self::apply_delete_actor(db, ap_id).await?; | |
| 161 | + if outcome.rows_disabled == 0 { | |
| 162 | + return Ok(outcome); | |
| 163 | + } | |
| 164 | + // Re-fetch the row to learn its `id` (the `apply_delete_actor` | |
| 165 | + // call above only returned counters). | |
| 166 | + let model = remote_identity::Entity::find() | |
| 167 | + .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 168 | + .one(db) | |
| 169 | + .await?; | |
| 170 | + if let Some(row) = model { | |
| 171 | + outcome.jwts_blacklisted = crate::services::jwt_blacklist_service::JwtBlacklistService::blacklist_by_remote_identity_id( | |
| 172 | + db, | |
| 173 | + row.id, | |
| 174 | + iterator, | |
| 175 | + ) | |
| 176 | + .await?; | |
| 177 | + } | |
| 178 | + Ok(outcome) | |
| 179 | + } | |
| 180 | + | |
| 150 | 181 | /// Apply an inbound `Revoke{Membership}`: drop the matching |
| 151 | 182 | /// `team_members` row. Returns `true` if a row was deleted. |
| 152 | 183 | pub async fn apply_revoke_membership( |
@@ -166,21 +197,39 @@ impl FediverseSignalService {
| 166 | 197 | } |
| 167 | 198 | } |
| 168 | 199 | |
| 200 | +/// Inbound request context passed to [`SignatureVerifier::verify`] | |
| 201 | +/// (RFC 0016). Carries every field needed by draft-cavage HTTP | |
| 202 | +/// signature verification: method + path + headers + body + the | |
| 203 | +/// actor's resolved public key. | |
| 204 | +#[derive(Debug)] | |
| 205 | +pub struct VerifyCtx<'a> { | |
| 206 | + /// AP id of the signer (extracted from the body by the caller). | |
| 207 | + pub actor_ap_id: &'a str, | |
| 208 | + /// HTTP method, lowercased (`"post"`). | |
| 209 | + pub method: &'a str, | |
| 210 | + /// Path-and-query (`"/inbox"`). | |
| 211 | + pub path: &'a str, | |
| 212 | + /// All inbound headers (case-insensitive lookup at the caller). | |
| 213 | + pub headers: &'a axum::http::HeaderMap, | |
| 214 | + /// Raw body bytes (used by the caller for digest / audit). | |
| 215 | + pub body: &'a [u8], | |
| 216 | + /// Resolved PEM public key for `actor_ap_id`. Empty when the | |
| 217 | + /// caller has not yet resolved the actor; impls may either | |
| 218 | + /// reject or fetch on their own. | |
| 219 | + pub public_key_pem: &'a str, | |
| 220 | +} | |
| 221 | + | |
| 169 | 222 | /// HTTP-Sig verifier for inbound AP activities. |
| 170 | 223 | /// |
| 171 | 224 | /// Implementations decide whether to accept a given signature. The |
| 172 | -/// production RFC 9421 implementation is shipped by a dedicated crypto | |
| 173 | -/// RFC; for tests and dev `AlwaysAcceptVerifier` is provided. | |
| 225 | +/// `AlwaysAcceptVerifier` shipped here is **test/dev only**. | |
| 226 | +/// `HttpSigVerifier` from | |
| 227 | +/// [`crate::fediverse::http_signatures`] is the production | |
| 228 | +/// implementation. | |
| 174 | 229 | #[async_trait] |
| 175 | 230 | pub trait SignatureVerifier: Send + Sync { |
| 176 | - /// Verify a signature. | |
| 177 | - async fn verify( | |
| 178 | - &self, | |
| 179 | - actor_ap_id: &str, | |
| 180 | - signature_header: &str, | |
| 181 | - body: &[u8], | |
| 182 | - public_key_pem: &str, | |
| 183 | - ) -> Result<(), AppError>; | |
| 231 | + /// Verify a signature given the full request context. | |
| 232 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>; | |
| 184 | 233 | } |
| 185 | 234 | |
| 186 | 235 | /// Test/dev verifier that accepts any signature. |
@@ -191,15 +240,9 @@ pub struct AlwaysAcceptVerifier;
| 191 | 240 | |
| 192 | 241 | #[async_trait] |
| 193 | 242 | impl SignatureVerifier for AlwaysAcceptVerifier { |
| 194 | - async fn verify( | |
| 195 | - &self, | |
| 196 | - actor_ap_id: &str, | |
| 197 | - _signature_header: &str, | |
| 198 | - _body: &[u8], | |
| 199 | - _public_key_pem: &str, | |
| 200 | - ) -> Result<(), AppError> { | |
| 243 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 201 | 244 | tracing::warn!( |
| 202 | - actor = %actor_ap_id, | |
| 245 | + actor = %ctx.actor_ap_id, | |
| 203 | 246 | "AlwaysAcceptVerifier in use — DO NOT enable in production", |
| 204 | 247 | ); |
| 205 | 248 | Ok(()) |
@@ -403,10 +446,22 @@ mod tests {
| 403 | 446 | |
| 404 | 447 | // ─── SignatureVerifier ────────────────────────────────────────── |
| 405 | 448 | |
| 449 | + fn dummy_ctx<'a>(headers: &'a axum::http::HeaderMap) -> VerifyCtx<'a> { | |
| 450 | + VerifyCtx { | |
| 451 | + actor_ap_id: "https://a.tld/users/alice", | |
| 452 | + method: "post", | |
| 453 | + path: "/inbox", | |
| 454 | + headers, | |
| 455 | + body: b"", | |
| 456 | + public_key_pem: "", | |
| 457 | + } | |
| 458 | + } | |
| 459 | + | |
| 406 | 460 | #[tokio::test] |
| 407 | 461 | async fn always_accept_verifier_accepts_any_input() { |
| 462 | + let headers = axum::http::HeaderMap::new(); | |
| 408 | 463 | AlwaysAcceptVerifier |
| 409 | - .verify("https://a.tld/users/alice", "sig=...", b"body", "pem") | |
| 464 | + .verify(&dummy_ctx(&headers)) | |
| 410 | 465 | .await |
| 411 | 466 | .expect("AlwaysAcceptVerifier never rejects"); |
| 412 | 467 | } |
@@ -414,9 +469,55 @@ mod tests {
| 414 | 469 | #[tokio::test] |
| 415 | 470 | async fn signature_verifier_trait_object_works() { |
| 416 | 471 | let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier); |
| 472 | + let headers = axum::http::HeaderMap::new(); | |
| 417 | 473 | verifier |
| 418 | - .verify("a", "b", b"c", "d") | |
| 474 | + .verify(&dummy_ctx(&headers)) | |
| 419 | 475 | .await |
| 420 | 476 | .expect("trait object dispatches"); |
| 421 | 477 | } |
| 478 | + | |
| 479 | + // ─── RFC 0018 — apply_delete_actor_with_iterator ──────────────── | |
| 480 | + | |
| 481 | + #[tokio::test] | |
| 482 | + async fn apply_delete_actor_with_noop_iterator_keeps_jwts_blacklisted_zero() { | |
| 483 | + use crate::services::jwt_blacklist_service::NoopActiveTokenIterator; | |
| 484 | + let id = Uuid::new_v4(); | |
| 485 | + let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 486 | + let mut after = row.clone(); | |
| 487 | + after.disabled_at = Some(Utc::now()); | |
| 488 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 489 | + // SELECT for apply_delete_actor | |
| 490 | + .append_query_results([vec![row.clone()]]) | |
| 491 | + // SELECT issued by .update() inside apply_delete_actor | |
| 492 | + .append_query_results([vec![after.clone()]]) | |
| 493 | + // SELECT in apply_delete_actor_with_iterator (re-fetch) | |
| 494 | + .append_query_results([vec![after]]) | |
| 495 | + .append_exec_results([MockExecResult { | |
| 496 | + last_insert_id: 0, | |
| 497 | + rows_affected: 1, | |
| 498 | + }]) | |
| 499 | + .into_connection(); | |
| 500 | + let outcome = FediverseSignalService::apply_delete_actor_with_iterator( | |
| 501 | + &db, | |
| 502 | + "https://a.tld/users/alice", | |
| 503 | + &NoopActiveTokenIterator, | |
| 504 | + ) | |
| 505 | + .await | |
| 506 | + .unwrap(); | |
| 507 | + assert_eq!(outcome.rows_disabled, 1); | |
| 508 | + assert_eq!(outcome.jwts_blacklisted, 0); | |
| 509 | + } | |
| 510 | + | |
| 511 | + #[tokio::test] | |
| 512 | + async fn noop_active_token_iterator_returns_empty_list() { | |
| 513 | + use crate::services::jwt_blacklist_service::{ | |
| 514 | + ActiveTokenIterator, NoopActiveTokenIterator, | |
| 515 | + }; | |
| 516 | + let v = NoopActiveTokenIterator | |
| 517 | + .active_tokens_for_remote(Uuid::new_v4()) | |
| 518 | + .await | |
| 519 | + .unwrap(); | |
| 520 | + assert!(v.is_empty()); | |
| 521 | + } | |
| 522 | + | |
| 422 | 523 | } |
M
crates/rustwarden-core/src/services/jwt_blacklist_service.rs
+58
-0
@@ -262,4 +262,62 @@ impl JwtBlacklistService {
| 262 | 262 | |
| 263 | 263 | Ok(expires_at) |
| 264 | 264 | } |
| 265 | + | |
| 266 | + /// RFC 0018: blacklist every active token whose `claims.remote_id` | |
| 267 | + /// matches the given identity. Driven by an injected | |
| 268 | + /// [`ActiveTokenIterator`] because rustwarden-core does not own a | |
| 269 | + /// canonical "active sessions" table — apps maintain their own | |
| 270 | + /// (DB sessions, JWT cache, etc.). | |
| 271 | + /// | |
| 272 | + /// Returns the number of rows inserted into `jwt_blacklist`. | |
| 273 | + pub async fn blacklist_by_remote_identity_id( | |
| 274 | + db: &DatabaseConnection, | |
| 275 | + remote_identity_id: Uuid, | |
| 276 | + iterator: &dyn ActiveTokenIterator, | |
| 277 | + ) -> Result<u64, AppError> { | |
| 278 | + let tokens = iterator | |
| 279 | + .active_tokens_for_remote(remote_identity_id) | |
| 280 | + .await?; | |
| 281 | + let mut count = 0u64; | |
| 282 | + for token in tokens { | |
| 283 | + // The `user_id` column expects a Uuid; remote sessions pass | |
| 284 | + // the `remote_identities.id` value here so audit queries | |
| 285 | + // can group by the federated subject. | |
| 286 | + Self::add_to_blacklist(db, &token, remote_identity_id).await?; | |
| 287 | + count += 1; | |
| 288 | + } | |
| 289 | + Ok(count) | |
| 290 | + } | |
| 291 | +} | |
| 292 | + | |
| 293 | +/// RFC 0018: pluggable enumerator of active JWT tokens for a given | |
| 294 | +/// remote identity. Implementations decide how to find tokens in | |
| 295 | +/// their own session/blacklist storage. | |
| 296 | +#[async_trait::async_trait] | |
| 297 | +pub trait ActiveTokenIterator: Send + Sync { | |
| 298 | + /// Yield every non-expired, non-blacklisted token currently active | |
| 299 | + /// for `remote_identity_id`. The raw token strings are hashed by | |
| 300 | + /// [`JwtBlacklistService`] when blacklisting. | |
| 301 | + async fn active_tokens_for_remote( | |
| 302 | + &self, | |
| 303 | + remote_identity_id: Uuid, | |
| 304 | + ) -> Result<Vec<String>, AppError>; | |
| 305 | +} | |
| 306 | + | |
| 307 | +/// No-op implementation that always returns an empty list. | |
| 308 | +/// | |
| 309 | +/// Apps that do not maintain an active-session index ship this impl; | |
| 310 | +/// `apply_delete_actor` then reports `jwts_blacklisted = 0` (RFC 0010 | |
| 311 | +/// behaviour preserved). Production deployments should provide their | |
| 312 | +/// own implementation backed by their session storage. | |
| 313 | +pub struct NoopActiveTokenIterator; | |
| 314 | + | |
| 315 | +#[async_trait::async_trait] | |
| 316 | +impl ActiveTokenIterator for NoopActiveTokenIterator { | |
| 317 | + async fn active_tokens_for_remote( | |
| 318 | + &self, | |
| 319 | + _remote_identity_id: Uuid, | |
| 320 | + ) -> Result<Vec<String>, AppError> { | |
| 321 | + Ok(Vec::new()) | |
| 322 | + } | |
| 265 | 323 | } |
M
crates/rustwarden-core/src/services/team_service.rs
+21
-14
@@ -92,8 +92,9 @@ impl TeamService {
| 92 | 92 | .await?; |
| 93 | 93 | |
| 94 | 94 | team_member::ActiveModel { |
| 95 | + id: Set(Uuid::new_v4()), | |
| 95 | 96 | team_id: Set(team_id), |
| 96 | - user_id: Set(owner_id), | |
| 97 | + user_id: Set(Some(owner_id)), | |
| 97 | 98 | role: Set(TeamRole::Owner.as_str().to_string()), |
| 98 | 99 | joined_at: Set(Utc::now()), |
| 99 | 100 | remote_identity_id: Set(None), |
@@ -138,9 +139,12 @@ impl TeamService {
| 138 | 139 | role: TeamRole, |
| 139 | 140 | hooks: Option<&Arc<dyn RustwardenHooks>>, |
| 140 | 141 | ) -> Result<team_member::Model, AppError> { |
| 141 | - // Upsert via find-then-update/insert (composite PK makes | |
| 142 | - // ActiveModel::insert conflict-safe to catch here). | |
| 143 | - let existing = team_member::Entity::find_by_id((team_id, user_id)) | |
| 142 | + // Upsert via partial-unique-index lookup (RFC 0018: PK is now | |
| 143 | + // a surrogate `id`; the `(team_id, user_id) WHERE user_id IS | |
| 144 | + // NOT NULL` partial unique index keeps row-level uniqueness). | |
| 145 | + let existing = team_member::Entity::find() | |
| 146 | + .filter(team_member::Column::TeamId.eq(team_id)) | |
| 147 | + .filter(team_member::Column::UserId.eq(Some(user_id))) | |
| 144 | 148 | .one(db) |
| 145 | 149 | .await?; |
| 146 | 150 |
@@ -150,8 +154,9 @@ impl TeamService {
| 150 | 154 | active.update(db).await? |
| 151 | 155 | } else { |
| 152 | 156 | team_member::ActiveModel { |
| 157 | + id: Set(Uuid::new_v4()), | |
| 153 | 158 | team_id: Set(team_id), |
| 154 | - user_id: Set(user_id), | |
| 159 | + user_id: Set(Some(user_id)), | |
| 155 | 160 | role: Set(role.as_str().to_string()), |
| 156 | 161 | joined_at: Set(Utc::now()), |
| 157 | 162 | remote_identity_id: Set(None), |
@@ -190,8 +195,9 @@ impl TeamService {
| 190 | 195 | } |
| 191 | 196 | crate::identity::Identity::Remote(remote_id) => { |
| 192 | 197 | let active = team_member::ActiveModel { |
| 198 | + id: Set(Uuid::new_v4()), | |
| 193 | 199 | team_id: Set(team_id), |
| 194 | - user_id: Set(Uuid::nil()), | |
| 200 | + user_id: Set(None), | |
| 195 | 201 | role: Set(role.as_str().to_string()), |
| 196 | 202 | joined_at: Set(Utc::now()), |
| 197 | 203 | remote_identity_id: Set(Some(remote_id)), |
@@ -239,7 +245,9 @@ impl TeamService {
| 239 | 245 | )); |
| 240 | 246 | } |
| 241 | 247 | |
| 242 | - team_member::Entity::delete_by_id((team_id, user_id)) | |
| 248 | + team_member::Entity::delete_many() | |
| 249 | + .filter(team_member::Column::TeamId.eq(team_id)) | |
| 250 | + .filter(team_member::Column::UserId.eq(Some(user_id))) | |
| 243 | 251 | .exec(db) |
| 244 | 252 | .await?; |
| 245 | 253 |
@@ -421,7 +429,7 @@ mod tests {
| 421 | 429 | |
| 422 | 430 | let members = TeamService::list_members(&db, team.id).await.unwrap(); |
| 423 | 431 | assert_eq!(members.len(), 1); |
| 424 | - assert_eq!(members[0].user_id, owner); | |
| 432 | + assert_eq!(members[0].user_id, Some(owner)); | |
| 425 | 433 | assert_eq!(members[0].role, "owner"); |
| 426 | 434 | } |
| 427 | 435 |
@@ -452,11 +460,11 @@ mod tests {
| 452 | 460 | .unwrap(); |
| 453 | 461 | |
| 454 | 462 | let members = TeamService::list_members(&db, team.id).await.unwrap(); |
| 455 | - let alice_row = members.iter().find(|m| m.user_id == alice).unwrap(); | |
| 463 | + let alice_row = members.iter().find(|m| m.user_id == Some(alice)).unwrap(); | |
| 456 | 464 | assert_eq!(alice_row.role, "admin"); |
| 457 | 465 | // No duplicate. |
| 458 | 466 | assert_eq!( |
| 459 | - members.iter().filter(|m| m.user_id == alice).count(), | |
| 467 | + members.iter().filter(|m| m.user_id == Some(alice)).count(), | |
| 460 | 468 | 1, |
| 461 | 469 | "add_member must not create duplicates" |
| 462 | 470 | ); |
@@ -492,7 +500,7 @@ mod tests {
| 492 | 500 | .unwrap(); |
| 493 | 501 | |
| 494 | 502 | let members = TeamService::list_members(&db, team.id).await.unwrap(); |
| 495 | - assert!(members.iter().all(|m| m.user_id != bob)); | |
| 503 | + assert!(members.iter().all(|m| m.user_id != Some(bob))); | |
| 496 | 504 | } |
| 497 | 505 | |
| 498 | 506 | #[tokio::test] |
@@ -596,13 +604,12 @@ mod tests {
| 596 | 604 | ) |
| 597 | 605 | .await |
| 598 | 606 | .unwrap(); |
| 599 | - assert_eq!(row.user_id, bob); | |
| 607 | + assert_eq!(row.user_id, Some(bob)); | |
| 600 | 608 | assert!(row.remote_identity_id.is_none()); |
| 601 | 609 | } |
| 602 | 610 | |
| 603 | 611 | #[cfg(feature = "experimental-fediverse")] |
| 604 | 612 | #[tokio::test] |
| 605 | - #[ignore = "needs PK relax on team_members(user_id) — deferred to a follow-up RFC"] | |
| 606 | 613 | async fn rfc0009_add_member_identity_remote_writes_remote_identity_id_column() { |
| 607 | 614 | use crate::identity::Identity; |
| 608 | 615 | let db = test_db().await; |
@@ -625,6 +632,6 @@ mod tests {
| 625 | 632 | .await |
| 626 | 633 | .unwrap(); |
| 627 | 634 | assert_eq!(row.remote_identity_id, Some(remote_id)); |
| 628 | - assert_eq!(row.user_id, Uuid::nil()); | |
| 635 | + assert_eq!(row.user_id, None); | |
| 629 | 636 | } |
| 630 | 637 | } |
A
crates/rustwarden-core/tests/fediverse_interop.rs
+315
-0
@@ -0,0 +1,315 @@
| 1 | +//! RFC 0020 — Fediverse interoperability tests. | |
| 2 | +//! | |
| 3 | +//! Spawns a `wiremock` HTTP server playing the role of a remote | |
| 4 | +//! ActivityPub inbox (Mastodon-style) and exercises the signing / | |
| 5 | +//! verification chain end-to-end: | |
| 6 | +//! | |
| 7 | +//! 1. `signed_request_round_trip_through_wiremock` — build + sign + | |
| 8 | +//! POST a request, capture it on the receiver side, re-verify the | |
| 9 | +//! signature against the same public PEM via `parse_and_verify_cavage`. | |
| 10 | +//! 2. `outbound_delivers_when_inbox_returns_202` — `drain_one` with a | |
| 11 | +//! delivery impl that returns `Ok` on 2xx flips the row to | |
| 12 | +//! `Delivered`. | |
| 13 | +//! 3. `outbound_defers_when_inbox_returns_503` — the same harness with | |
| 14 | +//! a 503 leaves the row in `Pending` with `next_retry_at` set per | |
| 15 | +//! RFC 0010. | |
| 16 | + | |
| 17 | +#![cfg(feature = "experimental-fediverse")] | |
| 18 | + | |
| 19 | +use std::sync::Arc; | |
| 20 | + | |
| 21 | +use async_trait::async_trait; | |
| 22 | +use base64::Engine; | |
| 23 | +use chrono::Utc; | |
| 24 | +use reqwest::Client; | |
| 25 | +use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; | |
| 26 | +use uuid::Uuid; | |
| 27 | +use wiremock::matchers::{header_exists, method, path}; | |
| 28 | +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; | |
| 29 | + | |
| 30 | +use rustwarden_core::error::AppError; | |
| 31 | +use rustwarden_core::fediverse::http_signatures::{ | |
| 32 | + build_signature_input, parse_and_verify_cavage, rsa_sha256_sign, | |
| 33 | +}; | |
| 34 | +use rustwarden_core::models::fediverse_outbound_queue; | |
| 35 | +use rustwarden_core::services::actor_key_service::ActorKeyService; | |
| 36 | +use rustwarden_core::services::fediverse_outbound_service::{ | |
| 37 | + FediverseOutboundService, OutboundDelivery, OutboundStatus, | |
| 38 | +}; | |
| 39 | + | |
| 40 | +/// Build the canonical `Signature` header for a draft-cavage signed | |
| 41 | +/// POST. Mirrors `HttpSignedDelivery::deliver` but lives in the test | |
| 42 | +/// module so the round-trip can be exercised without DB plumbing. | |
| 43 | +fn sign_post( | |
| 44 | + private_pem: &str, | |
| 45 | + key_id: &str, | |
| 46 | + host: &str, | |
| 47 | + path: &str, | |
| 48 | + date: &str, | |
| 49 | +) -> String { | |
| 50 | + let signed_headers = vec![ | |
| 51 | + "(request-target)".to_string(), | |
| 52 | + "host".to_string(), | |
| 53 | + "date".to_string(), | |
| 54 | + ]; | |
| 55 | + let request_target = format!("post {path}"); | |
| 56 | + let input = build_signature_input(&signed_headers, &request_target, |h| match h { | |
| 57 | + "host" => Some(host.to_string()), | |
| 58 | + "date" => Some(date.to_string()), | |
| 59 | + _ => None, | |
| 60 | + }) | |
| 61 | + .expect("build_signature_input"); | |
| 62 | + | |
| 63 | + let signature_bytes = | |
| 64 | + rsa_sha256_sign(private_pem, input.as_bytes()).expect("sign"); | |
| 65 | + let signature_b64 = | |
| 66 | + base64::engine::general_purpose::STANDARD.encode(&signature_bytes); | |
| 67 | + | |
| 68 | + format!( | |
| 69 | + r#"keyId="{key_id}",algorithm="rsa-sha256",headers="(request-target) host date",signature="{signature_b64}""#, | |
| 70 | + ) | |
| 71 | +} | |
| 72 | + | |
| 73 | +#[tokio::test] | |
| 74 | +async fn signed_request_round_trip_through_wiremock() { | |
| 75 | + let server = MockServer::start().await; | |
| 76 | + let inbox_path = "/inbox"; | |
| 77 | + | |
| 78 | + Mock::given(method("POST")) | |
| 79 | + .and(path(inbox_path)) | |
| 80 | + .and(header_exists("signature")) | |
| 81 | + .respond_with(ResponseTemplate::new(202)) | |
| 82 | + .expect(1) | |
| 83 | + .mount(&server) | |
| 84 | + .await; | |
| 85 | + | |
| 86 | + let material = ActorKeyService::generate().expect("RSA gen"); | |
| 87 | + let url = format!("{}{inbox_path}", server.uri()); | |
| 88 | + let parsed = reqwest::Url::parse(&url).unwrap(); | |
| 89 | + let host = parsed.host_str().unwrap().to_string(); | |
| 90 | + let host_with_port = match parsed.port() { | |
| 91 | + Some(p) => format!("{host}:{p}"), | |
| 92 | + None => host.clone(), | |
| 93 | + }; | |
| 94 | + let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 95 | + let key_id = "https://a.tld/users/alice#main-key"; | |
| 96 | + | |
| 97 | + let signature_header = sign_post( | |
| 98 | + &material.private_pem, | |
| 99 | + key_id, | |
| 100 | + &host_with_port, | |
| 101 | + inbox_path, | |
| 102 | + &date, | |
| 103 | + ); | |
| 104 | + | |
| 105 | + let body = br#"{"@context":"https://www.w3.org/ns/activitystreams","type":"Update"}"#; | |
| 106 | + | |
| 107 | + let response = Client::new() | |
| 108 | + .post(&url) | |
| 109 | + .header("Date", &date) | |
| 110 | + .header("Host", &host_with_port) | |
| 111 | + .header("Signature", &signature_header) | |
| 112 | + .header("Content-Type", "application/activity+json") | |
| 113 | + .body(body.to_vec()) | |
| 114 | + .send() | |
| 115 | + .await | |
| 116 | + .expect("post ok"); | |
| 117 | + assert_eq!(response.status().as_u16(), 202); | |
| 118 | + | |
| 119 | + // Capture the request that wiremock received and re-verify the | |
| 120 | + // signature against the published public PEM. This proves the | |
| 121 | + // signed input string is what reqwest actually puts on the wire. | |
| 122 | + let received: Vec<Request> = server.received_requests().await.unwrap(); | |
| 123 | + assert_eq!(received.len(), 1); | |
| 124 | + let req = &received[0]; | |
| 125 | + | |
| 126 | + let header_lookup = |name: &str| -> Option<String> { | |
| 127 | + // wiremock lowercases header names already. | |
| 128 | + req.headers | |
| 129 | + .get(name) | |
| 130 | + .map(|v| v.to_str().unwrap().to_string()) | |
| 131 | + }; | |
| 132 | + | |
| 133 | + let sig = header_lookup("signature").expect("signature header captured"); | |
| 134 | + parse_and_verify_cavage( | |
| 135 | + &sig, | |
| 136 | + &format!("post {inbox_path}"), | |
| 137 | + &req.body, | |
| 138 | + &material.public_pem, | |
| 139 | + header_lookup, | |
| 140 | + ) | |
| 141 | + .expect("signature must round-trip through wiremock"); | |
| 142 | +} | |
| 143 | + | |
| 144 | +/// Minimal `OutboundDelivery` that signs + posts via reqwest using a | |
| 145 | +/// pre-supplied keypair. Mirrors `HttpSignedDelivery::deliver` without | |
| 146 | +/// the DB lookup so we can exercise `drain_one` against wiremock with | |
| 147 | +/// only a `MockDatabase` for the queue. | |
| 148 | +struct InMemorySignedDelivery { | |
| 149 | + private_pem: String, | |
| 150 | + key_id: String, | |
| 151 | +} | |
| 152 | + | |
| 153 | +#[async_trait] | |
| 154 | +impl OutboundDelivery for InMemorySignedDelivery { | |
| 155 | + async fn deliver( | |
| 156 | + &self, | |
| 157 | + item: &fediverse_outbound_queue::Model, | |
| 158 | + ) -> Result<(), AppError> { | |
| 159 | + let url = reqwest::Url::parse(&item.target_inbox) | |
| 160 | + .map_err(|e| AppError::Internal(format!("bad URL: {e}")))?; | |
| 161 | + let host = url | |
| 162 | + .host_str() | |
| 163 | + .ok_or_else(|| AppError::Internal("no host".into()))? | |
| 164 | + .to_string(); | |
| 165 | + let host_with_port = match url.port() { | |
| 166 | + Some(p) => format!("{host}:{p}"), | |
| 167 | + None => host.clone(), | |
| 168 | + }; | |
| 169 | + let path = url.path().to_string(); | |
| 170 | + let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 171 | + | |
| 172 | + let signature_header = sign_post( | |
| 173 | + &self.private_pem, | |
| 174 | + &self.key_id, | |
| 175 | + &host_with_port, | |
| 176 | + &path, | |
| 177 | + &date, | |
| 178 | + ); | |
| 179 | + | |
| 180 | + let response = Client::new() | |
| 181 | + .post(&item.target_inbox) | |
| 182 | + .header("Date", &date) | |
| 183 | + .header("Host", &host_with_port) | |
| 184 | + .header("Signature", &signature_header) | |
| 185 | + .header("Content-Type", "application/activity+json") | |
| 186 | + .body(item.payload_json.clone()) | |
| 187 | + .send() | |
| 188 | + .await | |
| 189 | + .map_err(|e| AppError::Internal(format!("HTTP delivery failed: {e}")))?; | |
| 190 | + | |
| 191 | + if response.status().is_success() { | |
| 192 | + Ok(()) | |
| 193 | + } else { | |
| 194 | + Err(AppError::Internal(format!( | |
| 195 | + "remote inbox responded {}", | |
| 196 | + response.status(), | |
| 197 | + ))) | |
| 198 | + } | |
| 199 | + } | |
| 200 | +} | |
| 201 | + | |
| 202 | +fn pending_row(target_inbox: &str, target_domain: &str) -> fediverse_outbound_queue::Model { | |
| 203 | + fediverse_outbound_queue::Model { | |
| 204 | + id: Uuid::new_v4(), | |
| 205 | + activity_id: "https://a.tld/activities/1".to_string(), | |
| 206 | + payload_json: r#"{"type":"Update"}"#.to_string(), | |
| 207 | + target_inbox: target_inbox.to_string(), | |
| 208 | + target_domain: target_domain.to_string(), | |
| 209 | + status: "pending".to_string(), | |
| 210 | + attempts: 0, | |
| 211 | + max_attempts: 8, | |
| 212 | + last_error: None, | |
| 213 | + next_retry_at: None, | |
| 214 | + created_at: Utc::now(), | |
| 215 | + delivered_at: None, | |
| 216 | + actor_user_id: Some(Uuid::new_v4()), | |
| 217 | + } | |
| 218 | +} | |
| 219 | + | |
| 220 | +#[tokio::test] | |
| 221 | +async fn outbound_delivers_when_inbox_returns_202() { | |
| 222 | + let server = MockServer::start().await; | |
| 223 | + Mock::given(method("POST")) | |
| 224 | + .and(path("/inbox")) | |
| 225 | + .respond_with(ResponseTemplate::new(202)) | |
| 226 | + .expect(1) | |
| 227 | + .mount(&server) | |
| 228 | + .await; | |
| 229 | + | |
| 230 | + let target_inbox = format!("{}/inbox", server.uri()); | |
| 231 | + let row = pending_row(&target_inbox, "wiremock.local"); | |
| 232 | + | |
| 233 | + // Two fixtures: the SELECT-candidate then the SELECT-after-update | |
| 234 | + // (sea-orm mock returns subsequent fixtures for `.update()` reads). | |
| 235 | + let mut delivered = row.clone(); | |
| 236 | + delivered.status = OutboundStatus::Delivered.as_str().to_string(); | |
| 237 | + delivered.attempts = 1; | |
| 238 | + delivered.delivered_at = Some(Utc::now()); | |
| 239 | + | |
| 240 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 241 | + .append_query_results([vec![row.clone()]]) | |
| 242 | + .append_query_results([vec![delivered.clone()]]) | |
| 243 | + .append_exec_results([MockExecResult { | |
| 244 | + last_insert_id: 0, | |
| 245 | + rows_affected: 1, | |
| 246 | + }]) | |
| 247 | + .into_connection(); | |
| 248 | + | |
| 249 | + let material = ActorKeyService::generate().expect("RSA gen"); | |
| 250 | + let delivery = InMemorySignedDelivery { | |
| 251 | + private_pem: material.private_pem, | |
| 252 | + key_id: "https://a.tld/users/alice#main-key".to_string(), | |
| 253 | + }; | |
| 254 | + | |
| 255 | + let result = FediverseOutboundService::drain_one(&db, &delivery) | |
| 256 | + .await | |
| 257 | + .expect("drain ok") | |
| 258 | + .expect("row drained"); | |
| 259 | + | |
| 260 | + assert_eq!(result.status, OutboundStatus::Delivered.as_str()); | |
| 261 | + assert_eq!(result.attempts, 1); | |
| 262 | + assert!(result.delivered_at.is_some()); | |
| 263 | +} | |
| 264 | + | |
| 265 | +#[tokio::test] | |
| 266 | +async fn outbound_defers_when_inbox_returns_503() { | |
| 267 | + let server = MockServer::start().await; | |
| 268 | + Mock::given(method("POST")) | |
| 269 | + .and(path("/inbox")) | |
| 270 | + .respond_with(ResponseTemplate::new(503)) | |
| 271 | + .expect(1) | |
| 272 | + .mount(&server) | |
| 273 | + .await; | |
| 274 | + | |
| 275 | + let target_inbox = format!("{}/inbox", server.uri()); | |
| 276 | + let row = pending_row(&target_inbox, "wiremock.local"); | |
| 277 | + | |
| 278 | + let mut deferred = row.clone(); | |
| 279 | + deferred.status = OutboundStatus::Pending.as_str().to_string(); | |
| 280 | + deferred.attempts = 1; | |
| 281 | + deferred.last_error = Some("remote inbox responded 503".to_string()); | |
| 282 | + deferred.next_retry_at = Some(Utc::now() + chrono::Duration::minutes(5)); | |
| 283 | + | |
| 284 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 285 | + .append_query_results([vec![row.clone()]]) | |
| 286 | + .append_query_results([vec![deferred.clone()]]) | |
| 287 | + .append_exec_results([MockExecResult { | |
| 288 | + last_insert_id: 0, | |
| 289 | + rows_affected: 1, | |
| 290 | + }]) | |
| 291 | + .into_connection(); | |
| 292 | + | |
| 293 | + let material = ActorKeyService::generate().expect("RSA gen"); | |
| 294 | + let delivery = InMemorySignedDelivery { | |
| 295 | + private_pem: material.private_pem, | |
| 296 | + key_id: "https://a.tld/users/alice#main-key".to_string(), | |
| 297 | + }; | |
| 298 | + | |
| 299 | + let result = FediverseOutboundService::drain_one(&db, &delivery) | |
| 300 | + .await | |
| 301 | + .expect("drain ok") | |
| 302 | + .expect("row drained"); | |
| 303 | + | |
| 304 | + assert_eq!(result.status, OutboundStatus::Pending.as_str()); | |
| 305 | + assert_eq!(result.attempts, 1); | |
| 306 | + assert!(result.last_error.is_some()); | |
| 307 | + assert!(result.next_retry_at.is_some()); | |
| 308 | +} | |
| 309 | + | |
| 310 | +// `Arc` is unused in this file but documents the intent of sharing the | |
| 311 | +// MockServer handle across spawned tasks should we extend the harness. | |
| 312 | +#[allow(dead_code)] | |
| 313 | +fn _share_server(server: MockServer) -> Arc<MockServer> { | |
| 314 | + Arc::new(server) | |
| 315 | +} |
M
crates/rustwarden-ui/locales/en.yml
+25
-0
@@ -600,3 +600,28 @@ user_menu:
| 600 | 600 | profile: "Profile" |
| 601 | 601 | logout: "Logout" |
| 602 | 602 | settings: "Settings" |
| 603 | + | |
| 604 | +# ActivityPub federation - RFC 0021 admin foundations | |
| 605 | +fediverse: | |
| 606 | + settings: | |
| 607 | + title: "Federation settings" | |
| 608 | + breadcrumb: "Federation" | |
| 609 | + heading: "ActivityPub federation settings" | |
| 610 | + subtitle: "Enable and configure federation with other Rustwarden instances and ActivityPub-compatible servers. Experimental feature." | |
| 611 | + card_activation: "Activation" | |
| 612 | + card_trust: "Trust" | |
| 613 | + card_interop: "Interoperability" | |
| 614 | + card_security: "Advanced security" | |
| 615 | + enabled_label: "Enable ActivityPub federation (experimental)" | |
| 616 | + enabled_desc: "When disabled, /admin/fediverse/* routes other than this page return 404." | |
| 617 | + trust_mode_label: "Trust mode" | |
| 618 | + trust_mode_allowlist: "Allowlist (zero-trust, recommended)" | |
| 619 | + trust_mode_open: "Open with blocklist (Mastodon-style)" | |
| 620 | + interop_scope_label: "Interoperability scope" | |
| 621 | + interop_scope_rustwarden_only: "Rustwarden only (politely refuse other instances)" | |
| 622 | + interop_scope_rustwarden_plus: "Rustwarden + universal read" | |
| 623 | + interop_scope_open: "Open (any ActivityPub instance)" | |
| 624 | + authorized_fetch_label: "Authorized-fetch" | |
| 625 | + authorized_fetch_desc: "Require an HTTP signature to fetch a remote actor. Enable after audit." | |
| 626 | + save_button: "Save" | |
| 627 | + saved_toast: "Settings saved." |
M
crates/rustwarden-ui/locales/fr.yml
+25
-0
@@ -700,3 +700,28 @@ sharing:
| 700 | 700 | all: "Toutes les permissions" |
| 701 | 701 | all_value: "15" |
| 702 | 702 | all_description: "Peut consulter, modifier, supprimer et partager" |
| 703 | + | |
| 704 | +# Fédération ActivityPub - RFC 0021 admin foundations | |
| 705 | +fediverse: | |
| 706 | + settings: | |
| 707 | + title: "Réglages fédération" | |
| 708 | + breadcrumb: "Fédération" | |
| 709 | + heading: "Réglages de la fédération ActivityPub" | |
| 710 | + subtitle: "Activez et configurez la fédération avec les autres instances Rustwarden et compatibles ActivityPub. Fonctionnalité expérimentale." | |
| 711 | + card_activation: "Activation" | |
| 712 | + card_trust: "Confiance" | |
| 713 | + card_interop: "Interopérabilité" | |
| 714 | + card_security: "Sécurité avancée" | |
| 715 | + enabled_label: "Activer la fédération ActivityPub (expérimental)" | |
| 716 | + enabled_desc: "Quand désactivé, les routes /admin/fediverse/* hors cette page renvoient 404." | |
| 717 | + trust_mode_label: "Mode de confiance" | |
| 718 | + trust_mode_allowlist: "Liste blanche (zero-trust, recommandé)" | |
| 719 | + trust_mode_open: "Ouvert avec liste de blocage (style Mastodon)" | |
| 720 | + interop_scope_label: "Périmètre d'interopérabilité" | |
| 721 | + interop_scope_rustwarden_only: "Rustwarden uniquement (refus poli des autres instances)" | |
| 722 | + interop_scope_rustwarden_plus: "Rustwarden + lecture universelle" | |
| 723 | + interop_scope_open: "Ouvert (toute instance ActivityPub)" | |
| 724 | + authorized_fetch_label: "Authorized-fetch" | |
| 725 | + authorized_fetch_desc: "Exiger une signature HTTP pour récupérer un acteur distant. À activer après audit." | |
| 726 | + save_button: "Enregistrer" | |
| 727 | + saved_toast: "Réglages enregistrés." |
A
crates/rustwarden-ui/src/handlers/admin_fediverse.rs
+379
-0
@@ -0,0 +1,379 @@
| 1 | +//! Admin handlers for the Fediverse UI foundations slice (RFC 0021). | |
| 2 | +//! | |
| 3 | +//! Ships two endpoints behind the standard `admin_required` middleware: | |
| 4 | +//! | |
| 5 | +//! - `GET /admin/fediverse/settings` renders a form pre-filled with the | |
| 6 | +//! four runtime toggles persisted in `app_settings`: | |
| 7 | +//! `fediverse_enabled`, `fediverse_trust_mode`, | |
| 8 | +//! `fediverse_interop_scope`, `fediverse_authorized_fetch`. | |
| 9 | +//! - `POST /admin/fediverse/settings` applies the form (CSRF-checked) | |
| 10 | +//! and redirects back to the GET handler with a flash-style toast | |
| 11 | +//! parameter. | |
| 12 | +//! | |
| 13 | +//! All labels are pulled from `fediverse.*` keys in `locales/*.yml`. | |
| 14 | +//! The trust-list screen is delivered in a follow-up patch within the | |
| 15 | +//! same RFC 0021 slice. | |
| 16 | + | |
| 17 | +use std::sync::Arc; | |
| 18 | + | |
| 19 | +use askama::Template; | |
| 20 | +use axum::extract::{Extension, Form, State}; | |
| 21 | +use axum::response::{IntoResponse, Redirect}; | |
| 22 | +use sea_orm::DatabaseConnection; | |
| 23 | +use serde::Deserialize; | |
| 24 | +use tower_sessions::Session; | |
| 25 | + | |
| 26 | +use rustwarden_core::auth::jwt::Claims; | |
| 27 | +use rustwarden_core::error::AppError; | |
| 28 | +use rustwarden_core::i18n::extractor::Locale; | |
| 29 | +use rustwarden_core::i18n::service::I18nService; | |
| 30 | +use rustwarden_core::middleware::csrf; | |
| 31 | +use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 32 | + | |
| 33 | +use crate::dto::NavigationDTO; | |
| 34 | +use crate::template_helpers::build_base_context_with_claims; | |
| 35 | + | |
| 36 | +/// Navbar entry consumed by `components/navigation.html` (admin pages). | |
| 37 | +/// | |
| 38 | +/// Mirrors the private struct used in `admin_handler` so this file | |
| 39 | +/// stays standalone — duplication is intentional until a shared | |
| 40 | +/// `AdminLayoutContext` lands (deferred to a generic refactor). | |
| 41 | +#[derive(Debug, Clone)] | |
| 42 | +#[allow(dead_code)] | |
| 43 | +pub struct NavItem { | |
| 44 | + /// Stable identifier; matched against the active page id. | |
| 45 | + pub id: String, | |
| 46 | + /// Visible label (already i18n-translated). | |
| 47 | + pub label: String, | |
| 48 | + /// CSS / heroicon name. | |
| 49 | + pub icon: String, | |
| 50 | + /// Absolute URL the entry points to. | |
| 51 | + pub url: String, | |
| 52 | + /// `true` when this entry corresponds to the current page. | |
| 53 | + pub active: bool, | |
| 54 | + /// `true` when the entry is clickable. | |
| 55 | + pub enabled: bool, | |
| 56 | + /// Badge text (empty string for none). | |
| 57 | + pub badge: String, | |
| 58 | + /// Whether to render the badge. | |
| 59 | + pub has_badge: bool, | |
| 60 | + /// Tooltip shown when `enabled` is false. | |
| 61 | + pub disabled_message: String, | |
| 62 | + /// Whether to render the disabled-tooltip. | |
| 63 | + pub has_disabled_message: bool, | |
| 64 | +} | |
| 65 | + | |
| 66 | +async fn build_fediverse_admin_nav( | |
| 67 | + roles: &[String], | |
| 68 | + i18n_service: &Arc<I18nService>, | |
| 69 | + locale: &Locale, | |
| 70 | + db: &DatabaseConnection, | |
| 71 | +) -> Vec<NavItem> { | |
| 72 | + let navigation = NavigationDTO::for_user(roles, i18n_service, locale, Some(db)).await; | |
| 73 | + navigation | |
| 74 | + .items | |
| 75 | + .iter() | |
| 76 | + .map(|item| NavItem { | |
| 77 | + id: item.id.clone(), | |
| 78 | + label: item.label.clone(), | |
| 79 | + icon: item.icon.clone(), | |
| 80 | + url: item.url.clone(), | |
| 81 | + active: item.id == "admin", | |
| 82 | + enabled: item.enabled, | |
| 83 | + badge: item.badge.clone(), | |
| 84 | + has_badge: item.has_badge, | |
| 85 | + disabled_message: item.disabled_message.clone(), | |
| 86 | + has_disabled_message: item.has_disabled_message, | |
| 87 | + }) | |
| 88 | + .collect() | |
| 89 | +} | |
| 90 | + | |
| 91 | +/// Form payload for `POST /admin/fediverse/settings`. | |
| 92 | +/// | |
| 93 | +/// Checkboxes are absent from the body when unchecked, hence the | |
| 94 | +/// `Option<String>` pattern + presence test rather than `bool`. | |
| 95 | +#[derive(Debug, Deserialize)] | |
| 96 | +pub struct UpdateFediverseSettingsForm { | |
| 97 | + /// CSRF token from the hidden form field. | |
| 98 | + pub csrf_token: String, | |
| 99 | + /// Present only when the toggle is checked. | |
| 100 | + #[serde(default)] | |
| 101 | + pub fediverse_enabled: Option<String>, | |
| 102 | + /// Selector value: `allowlist` or `open_with_blocklist`. | |
| 103 | + pub fediverse_trust_mode: String, | |
| 104 | + /// Selector value: `rustwarden_only`, `rustwarden_plus_universal_read`, | |
| 105 | + /// or `open`. | |
| 106 | + pub fediverse_interop_scope: String, | |
| 107 | + /// Present only when the toggle is checked. | |
| 108 | + #[serde(default)] | |
| 109 | + pub fediverse_authorized_fetch: Option<String>, | |
| 110 | +} | |
| 111 | + | |
| 112 | +/// Subset of `app_settings` rendered into the settings template. | |
| 113 | +#[derive(Debug, Clone)] | |
| 114 | +pub struct FediverseSettingsCtx { | |
| 115 | + /// `"true"` when ActivityPub federation is active runtime-wide. | |
| 116 | + pub fediverse_enabled: String, | |
| 117 | + /// `allowlist` (default) or `open_with_blocklist`. | |
| 118 | + pub fediverse_trust_mode: String, | |
| 119 | + /// Interop scope selector value. | |
| 120 | + pub fediverse_interop_scope: String, | |
| 121 | + /// `"true"` when authorized-fetch (RFC 0017) is required. | |
| 122 | + pub fediverse_authorized_fetch: String, | |
| 123 | +} | |
| 124 | + | |
| 125 | +/// Askama template for `GET /admin/fediverse/settings`. | |
| 126 | +#[derive(Template)] | |
| 127 | +#[template(path = "admin/fediverse/settings.html")] | |
| 128 | +#[allow(dead_code)] | |
| 129 | +pub struct FediverseSettingsTemplate { | |
| 130 | + /// Browser tab + page title. | |
| 131 | + pub page_title: String, | |
| 132 | + /// CSRF token rendered as hidden input. | |
| 133 | + pub csrf_token: String, | |
| 134 | + /// Application display name. | |
| 135 | + pub app_name: String, | |
| 136 | + /// Application version (footer). | |
| 137 | + pub app_version: String, | |
| 138 | + /// Application domain (header). | |
| 139 | + pub app_domain: String, | |
| 140 | + /// Short app description (header). | |
| 141 | + pub app_description: String, | |
| 142 | + /// Username for the navbar avatar. | |
| 143 | + pub username: String, | |
| 144 | + /// Email for the navbar tooltip. | |
| 145 | + pub email: String, | |
| 146 | + /// Two-letter avatar initials. | |
| 147 | + pub initials: String, | |
| 148 | + /// CSS class for avatar background. | |
| 149 | + pub avatar_color: String, | |
| 150 | + /// Comma-joined roles for the navbar tooltip. | |
| 151 | + pub roles_text: String, | |
| 152 | + /// Whether the user has a pending password change banner. | |
| 153 | + pub has_pending_password_change: bool, | |
| 154 | + /// Navigation items for the navbar (built from `NavigationDTO`). | |
| 155 | + pub nav_items: Vec<NavItem>, | |
| 156 | + /// User menu — profile entry label. | |
| 157 | + pub user_menu_profile: String, | |
| 158 | + /// User menu — logout entry label. | |
| 159 | + pub user_menu_logout: String, | |
| 160 | + /// Banner displayed when `?saved=1` is in the URL. | |
| 161 | + pub saved_flash: bool, | |
| 162 | + /// Pre-filled form values. | |
| 163 | + pub settings: FediverseSettingsCtx, | |
| 164 | + | |
| 165 | + /// Heading "Réglages fédération". | |
| 166 | + pub heading: String, | |
| 167 | + /// Subtitle explaining the page. | |
| 168 | + pub subtitle: String, | |
| 169 | + /// Card title "Activation". | |
| 170 | + pub card_activation: String, | |
| 171 | + /// Card title "Confiance". | |
| 172 | + pub card_trust: String, | |
| 173 | + /// Card title "Interopérabilité". | |
| 174 | + pub card_interop: String, | |
| 175 | + /// Card title "Sécurité avancée". | |
| 176 | + pub card_security: String, | |
| 177 | + /// Toggle label. | |
| 178 | + pub enabled_label: String, | |
| 179 | + /// Toggle help text. | |
| 180 | + pub enabled_desc: String, | |
| 181 | + /// trust_mode label. | |
| 182 | + pub trust_mode_label: String, | |
| 183 | + /// trust_mode option: allowlist. | |
| 184 | + pub trust_mode_allowlist: String, | |
| 185 | + /// trust_mode option: open_with_blocklist. | |
| 186 | + pub trust_mode_open: String, | |
| 187 | + /// interop_scope label. | |
| 188 | + pub interop_scope_label: String, | |
| 189 | + /// interop_scope option: rustwarden_only. | |
| 190 | + pub interop_scope_rustwarden_only: String, | |
| 191 | + /// interop_scope option: rustwarden_plus_universal_read. | |
| 192 | + pub interop_scope_rustwarden_plus: String, | |
| 193 | + /// interop_scope option: open. | |
| 194 | + pub interop_scope_open: String, | |
| 195 | + /// authorized_fetch label. | |
| 196 | + pub authorized_fetch_label: String, | |
| 197 | + /// authorized_fetch help text. | |
| 198 | + pub authorized_fetch_desc: String, | |
| 199 | + /// Submit button label. | |
| 200 | + pub save_button: String, | |
| 201 | + /// Toast text shown after a successful save. | |
| 202 | + pub saved_toast: String, | |
| 203 | + /// Breadcrumb segment: home. | |
| 204 | + pub breadcrumb_home: String, | |
| 205 | + /// Breadcrumb segment: admin parent. | |
| 206 | + pub breadcrumb_admin: String, | |
| 207 | + /// Breadcrumb segment: current page (Federation). | |
| 208 | + pub breadcrumb_self: String, | |
| 209 | +} | |
| 210 | + | |
| 211 | +rustwarden_core::impl_template_response!(FediverseSettingsTemplate); | |
| 212 | + | |
| 213 | +/// Allowed values for `fediverse_trust_mode` (RFC 0004). | |
| 214 | +const TRUST_MODES: &[&str] = &["allowlist", "open_with_blocklist"]; | |
| 215 | +/// Allowed values for `fediverse_interop_scope` (master plan §2.1). | |
| 216 | +const INTEROP_SCOPES: &[&str] = &[ | |
| 217 | + "rustwarden_only", | |
| 218 | + "rustwarden_plus_universal_read", | |
| 219 | + "open", | |
| 220 | +]; | |
| 221 | + | |
| 222 | +/// Render `GET /admin/fediverse/settings`. Pre-fills the form with the | |
| 223 | +/// current `app_settings` rows and shows a one-shot toast when the | |
| 224 | +/// referrer included `?saved=1`. | |
| 225 | +pub async fn get_settings( | |
| 226 | + Extension(claims): Extension<Claims>, | |
| 227 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 228 | + Extension(locale): Extension<Locale>, | |
| 229 | + State(db): State<DatabaseConnection>, | |
| 230 | + session: Session, | |
| 231 | + axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 232 | +) -> Result<impl IntoResponse, AppError> { | |
| 233 | + let locale_str = locale.as_str(); | |
| 234 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 235 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 236 | + | |
| 237 | + let all_settings = AppSettingsService::get_all_settings(&db).await?; | |
| 238 | + let get_val = |key: &str, default: &str| -> String { | |
| 239 | + all_settings | |
| 240 | + .iter() | |
| 241 | + .find(|s| s.key == key) | |
| 242 | + .map(|s| s.value.clone()) | |
| 243 | + .unwrap_or_else(|| default.to_string()) | |
| 244 | + }; | |
| 245 | + | |
| 246 | + let settings_ctx = FediverseSettingsCtx { | |
| 247 | + fediverse_enabled: get_val("fediverse_enabled", "false"), | |
| 248 | + fediverse_trust_mode: get_val("fediverse_trust_mode", "allowlist"), | |
| 249 | + fediverse_interop_scope: get_val("fediverse_interop_scope", "rustwarden_only"), | |
| 250 | + fediverse_authorized_fetch: get_val("fediverse_authorized_fetch", "false"), | |
| 251 | + }; | |
| 252 | + | |
| 253 | + let t = |k: &str| { | |
| 254 | + let i18n = i18n_service.clone(); | |
| 255 | + let key = k.to_string(); | |
| 256 | + let loc = locale_str.to_string(); | |
| 257 | + async move { i18n.translate(&key, &loc).await } | |
| 258 | + }; | |
| 259 | + | |
| 260 | + let template = FediverseSettingsTemplate { | |
| 261 | + page_title: t("fediverse.settings.title").await, | |
| 262 | + csrf_token, | |
| 263 | + app_name: base_context.app_name, | |
| 264 | + app_version: base_context.app_version, | |
| 265 | + app_domain: base_context.app_domain, | |
| 266 | + app_description: base_context.app_description, | |
| 267 | + username: base_context.username, | |
| 268 | + email: base_context.email, | |
| 269 | + initials: base_context.initials, | |
| 270 | + avatar_color: base_context.avatar_color, | |
| 271 | + roles_text: base_context.roles.join(", "), | |
| 272 | + has_pending_password_change: false, | |
| 273 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 274 | + .await, | |
| 275 | + user_menu_profile: t("user_menu.profile").await, | |
| 276 | + user_menu_logout: t("user_menu.logout").await, | |
| 277 | + saved_flash: query.saved.unwrap_or(0) == 1, | |
| 278 | + settings: settings_ctx, | |
| 279 | + heading: t("fediverse.settings.heading").await, | |
| 280 | + subtitle: t("fediverse.settings.subtitle").await, | |
| 281 | + card_activation: t("fediverse.settings.card_activation").await, | |
| 282 | + card_trust: t("fediverse.settings.card_trust").await, | |
| 283 | + card_interop: t("fediverse.settings.card_interop").await, | |
| 284 | + card_security: t("fediverse.settings.card_security").await, | |
| 285 | + enabled_label: t("fediverse.settings.enabled_label").await, | |
| 286 | + enabled_desc: t("fediverse.settings.enabled_desc").await, | |
| 287 | + trust_mode_label: t("fediverse.settings.trust_mode_label").await, | |
| 288 | + trust_mode_allowlist: t("fediverse.settings.trust_mode_allowlist").await, | |
| 289 | + trust_mode_open: t("fediverse.settings.trust_mode_open").await, | |
| 290 | + interop_scope_label: t("fediverse.settings.interop_scope_label").await, | |
| 291 | + interop_scope_rustwarden_only: t("fediverse.settings.interop_scope_rustwarden_only") | |
| 292 | + .await, | |
| 293 | + interop_scope_rustwarden_plus: t("fediverse.settings.interop_scope_rustwarden_plus") | |
| 294 | + .await, | |
| 295 | + interop_scope_open: t("fediverse.settings.interop_scope_open").await, | |
| 296 | + authorized_fetch_label: t("fediverse.settings.authorized_fetch_label").await, | |
| 297 | + authorized_fetch_desc: t("fediverse.settings.authorized_fetch_desc").await, | |
| 298 | + save_button: t("fediverse.settings.save_button").await, | |
| 299 | + saved_toast: t("fediverse.settings.saved_toast").await, | |
| 300 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 301 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 302 | + breadcrumb_self: t("fediverse.settings.breadcrumb").await, | |
| 303 | + }; | |
| 304 | + | |
| 305 | + Ok(template) | |
| 306 | +} | |
| 307 | + | |
| 308 | +/// Optional `?saved=1` query parameter. | |
| 309 | +#[derive(Debug, Deserialize)] | |
| 310 | +pub struct SavedQuery { | |
| 311 | + /// `1` after a successful POST → triggers the success toast. | |
| 312 | + pub saved: Option<u8>, | |
| 313 | +} | |
| 314 | + | |
| 315 | +/// Apply `POST /admin/fediverse/settings`. Validates CSRF + selector | |
| 316 | +/// values, persists 4 keys via [`AppSettingsService`], and redirects | |
| 317 | +/// back to the GET handler with `?saved=1`. | |
| 318 | +pub async fn update_settings( | |
| 319 | + Extension(claims): Extension<Claims>, | |
| 320 | + session: Session, | |
| 321 | + State(db): State<DatabaseConnection>, | |
| 322 | + Form(form): Form<UpdateFediverseSettingsForm>, | |
| 323 | +) -> Result<impl IntoResponse, AppError> { | |
| 324 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 325 | + | |
| 326 | + if !TRUST_MODES.contains(&form.fediverse_trust_mode.as_str()) { | |
| 327 | + return Err(AppError::Validation(format!( | |
| 328 | + "invalid trust_mode: {}", | |
| 329 | + form.fediverse_trust_mode | |
| 330 | + ))); | |
| 331 | + } | |
| 332 | + if !INTEROP_SCOPES.contains(&form.fediverse_interop_scope.as_str()) { | |
| 333 | + return Err(AppError::Validation(format!( | |
| 334 | + "invalid interop_scope: {}", | |
| 335 | + form.fediverse_interop_scope | |
| 336 | + ))); | |
| 337 | + } | |
| 338 | + | |
| 339 | + let bool_str = |opt: &Option<String>| { | |
| 340 | + if opt.is_some() { | |
| 341 | + "true".to_string() | |
| 342 | + } else { | |
| 343 | + "false".to_string() | |
| 344 | + } | |
| 345 | + }; | |
| 346 | + | |
| 347 | + let updated_by = Some(claims.sub); | |
| 348 | + | |
| 349 | + AppSettingsService::set_setting( | |
| 350 | + &db, | |
| 351 | + "fediverse_enabled".to_string(), | |
| 352 | + bool_str(&form.fediverse_enabled), | |
| 353 | + updated_by, | |
| 354 | + ) | |
| 355 | + .await?; | |
| 356 | + AppSettingsService::set_setting( | |
| 357 | + &db, | |
| 358 | + "fediverse_trust_mode".to_string(), | |
| 359 | + form.fediverse_trust_mode, | |
| 360 | + updated_by, | |
| 361 | + ) | |
| 362 | + .await?; | |
| 363 | + AppSettingsService::set_setting( | |
| 364 | + &db, | |
| 365 | + "fediverse_interop_scope".to_string(), | |
| 366 | + form.fediverse_interop_scope, | |
| 367 | + updated_by, | |
| 368 | + ) | |
| 369 | + .await?; | |
| 370 | + AppSettingsService::set_setting( | |
| 371 | + &db, | |
| 372 | + "fediverse_authorized_fetch".to_string(), | |
| 373 | + bool_str(&form.fediverse_authorized_fetch), | |
| 374 | + updated_by, | |
| 375 | + ) | |
| 376 | + .await?; | |
| 377 | + | |
| 378 | + Ok(Redirect::to("/admin/fediverse/settings?saved=1")) | |
| 379 | +} |
M
crates/rustwarden-ui/src/handlers/admin_handler.rs
+4
-2
@@ -569,8 +569,10 @@ pub async fn list_users(
| 569 | 569 | Query(params): Query<UserListQuery>, |
| 570 | 570 | ) -> Result<impl IntoResponse, AppError> { |
| 571 | 571 | let locale_str = locale.as_str(); |
| 572 | - let page = params.page.unwrap_or(1); | |
| 573 | - let per_page = params.per_page.unwrap_or(50); | |
| 572 | + // Clamp `page` to >= 1 — `?page=0` would underflow `(page - 1) * per_page` | |
| 573 | + // below and crash the worker thread (`u64` subtraction). | |
| 574 | + let page = params.page.unwrap_or(1).max(1); | |
| 575 | + let per_page = params.per_page.unwrap_or(50).max(1); | |
| 574 | 576 | let search = params.search; |
| 575 | 577 | let role_filter = params.role_filter; |
| 576 | 578 |
M
crates/rustwarden-ui/src/handlers/mod.rs
+4
-0
@@ -4,6 +4,10 @@
| 4 | 4 | |
| 5 | 5 | /// Admin panel handlers (users, roles, settings). |
| 6 | 6 | pub mod admin_handler; |
| 7 | +/// Admin handlers for the Fediverse foundations (RFC 0021): runtime | |
| 8 | +/// settings page (`/admin/fediverse/settings`). | |
| 9 | +#[cfg(feature = "experimental-fediverse")] | |
| 10 | +pub mod admin_fediverse; | |
| 7 | 11 | /// Dashboard widget handlers. |
| 8 | 12 | pub mod dashboard_handler; |
| 9 | 13 | /// Email delivery status handlers. |
M
crates/rustwarden-ui/src/routes.rs
+20
-1
@@ -130,7 +130,26 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 130 | 130 | .route("/admin/roles/{id}", delete(admin_handler::delete_role)) |
| 131 | 131 | .route("/admin/settings", get(admin_handler::get_app_settings)) |
| 132 | 132 | .route("/admin/settings", put(admin_handler::update_app_settings)) |
| 133 | - .route("/admin/errors", get(admin_handler::admin_errors)) | |
| 133 | + .route("/admin/errors", get(admin_handler::admin_errors)); | |
| 134 | + | |
| 135 | + // Fediverse admin routes (RFC 0021 Slice A) — gated by feature so | |
| 136 | + // apps without `experimental-fediverse` keep their attack surface | |
| 137 | + // unchanged. | |
| 138 | + #[cfg(feature = "experimental-fediverse")] | |
| 139 | + let admin_routes = { | |
| 140 | + use crate::handlers::admin_fediverse; | |
| 141 | + admin_routes | |
| 142 | + .route( | |
| 143 | + "/admin/fediverse/settings", | |
| 144 | + get(admin_fediverse::get_settings), | |
| 145 | + ) | |
| 146 | + .route( | |
| 147 | + "/admin/fediverse/settings", | |
| 148 | + post(admin_fediverse::update_settings), | |
| 149 | + ) | |
| 150 | + }; | |
| 151 | + | |
| 152 | + let admin_routes = admin_routes | |
| 134 | 153 | .route( |
| 135 | 154 | "/admin/users/{id}/reset-totp", |
| 136 | 155 | post(admin_handler::reset_user_totp), |
A
crates/rustwarden-ui/templates/admin/fediverse/settings.html
+138
-0
@@ -0,0 +1,138 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %} }"> | |
| 11 | + | |
| 12 | + {# Breadcrumb (a11y P7: parent landmarks) #} | |
| 13 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 14 | + <ul> | |
| 15 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 16 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 17 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 18 | + </ul> | |
| 19 | + </nav> | |
| 20 | + | |
| 21 | + {# Page header #} | |
| 22 | + <header class="mb-8"> | |
| 23 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 24 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 25 | + </header> | |
| 26 | + | |
| 27 | + {# Save toast (Plan UI §3 P3) — auto-dismisses after 4 s #} | |
| 28 | + <div | |
| 29 | + x-show="saved" | |
| 30 | + x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 31 | + x-transition | |
| 32 | + role="status" | |
| 33 | + aria-live="polite" | |
| 34 | + class="alert alert-success mb-6" | |
| 35 | + > | |
| 36 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 37 | + <span>{{ saved_toast }}</span> | |
| 38 | + </div> | |
| 39 | + | |
| 40 | + <form method="POST" action="/admin/fediverse/settings" class="space-y-6 max-w-3xl"> | |
| 41 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 42 | + | |
| 43 | + {# Card: Activation #} | |
| 44 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-activation"> | |
| 45 | + <div class="card-body"> | |
| 46 | + <h2 id="card-activation" class="card-title text-xl mb-4">{{ card_activation }}</h2> | |
| 47 | + <label class="label cursor-pointer justify-start gap-4" for="fediverse_enabled"> | |
| 48 | + <input | |
| 49 | + id="fediverse_enabled" | |
| 50 | + type="checkbox" | |
| 51 | + name="fediverse_enabled" | |
| 52 | + value="true" | |
| 53 | + class="checkbox checkbox-primary" | |
| 54 | + {% if settings.fediverse_enabled == "true" %}checked{% endif %} | |
| 55 | + > | |
| 56 | + <span class="flex flex-col"> | |
| 57 | + <span class="label-text font-semibold">{{ enabled_label }}</span> | |
| 58 | + <span class="label-text-alt text-base-content/60">{{ enabled_desc }}</span> | |
| 59 | + </span> | |
| 60 | + </label> | |
| 61 | + </div> | |
| 62 | + </section> | |
| 63 | + | |
| 64 | + {# Card: Trust mode #} | |
| 65 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-trust"> | |
| 66 | + <div class="card-body"> | |
| 67 | + <h2 id="card-trust" class="card-title text-xl mb-4">{{ card_trust }}</h2> | |
| 68 | + <label class="label" for="fediverse_trust_mode"> | |
| 69 | + <span class="label-text font-semibold">{{ trust_mode_label }}</span> | |
| 70 | + </label> | |
| 71 | + <select | |
| 72 | + id="fediverse_trust_mode" | |
| 73 | + name="fediverse_trust_mode" | |
| 74 | + class="select select-bordered w-full max-w-md" | |
| 75 | + > | |
| 76 | + <option value="allowlist" {% if settings.fediverse_trust_mode == "allowlist" %}selected{% endif %}> | |
| 77 | + {{ trust_mode_allowlist }} | |
| 78 | + </option> | |
| 79 | + <option value="open_with_blocklist" {% if settings.fediverse_trust_mode == "open_with_blocklist" %}selected{% endif %}> | |
| 80 | + {{ trust_mode_open }} | |
| 81 | + </option> | |
| 82 | + </select> | |
| 83 | + </div> | |
| 84 | + </section> | |
| 85 | + | |
| 86 | + {# Card: Interop scope #} | |
| 87 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-interop"> | |
| 88 | + <div class="card-body"> | |
| 89 | + <h2 id="card-interop" class="card-title text-xl mb-4">{{ card_interop }}</h2> | |
| 90 | + <label class="label" for="fediverse_interop_scope"> | |
| 91 | + <span class="label-text font-semibold">{{ interop_scope_label }}</span> | |
| 92 | + </label> | |
| 93 | + <select | |
| 94 | + id="fediverse_interop_scope" | |
| 95 | + name="fediverse_interop_scope" | |
| 96 | + class="select select-bordered w-full max-w-md" | |
| 97 | + > | |
| 98 | + <option value="rustwarden_only" {% if settings.fediverse_interop_scope == "rustwarden_only" %}selected{% endif %}> | |
| 99 | + {{ interop_scope_rustwarden_only }} | |
| 100 | + </option> | |
| 101 | + <option value="rustwarden_plus_universal_read" {% if settings.fediverse_interop_scope == "rustwarden_plus_universal_read" %}selected{% endif %}> | |
| 102 | + {{ interop_scope_rustwarden_plus }} | |
| 103 | + </option> | |
| 104 | + <option value="open" {% if settings.fediverse_interop_scope == "open" %}selected{% endif %}> | |
| 105 | + {{ interop_scope_open }} | |
| 106 | + </option> | |
| 107 | + </select> | |
| 108 | + </div> | |
| 109 | + </section> | |
| 110 | + | |
| 111 | + {# Card: Advanced security #} | |
| 112 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-security"> | |
| 113 | + <div class="card-body"> | |
| 114 | + <h2 id="card-security" class="card-title text-xl mb-4">{{ card_security }}</h2> | |
| 115 | + <label class="label cursor-pointer justify-start gap-4" for="fediverse_authorized_fetch"> | |
| 116 | + <input | |
| 117 | + id="fediverse_authorized_fetch" | |
| 118 | + type="checkbox" | |
| 119 | + name="fediverse_authorized_fetch" | |
| 120 | + value="true" | |
| 121 | + class="checkbox checkbox-primary" | |
| 122 | + {% if settings.fediverse_authorized_fetch == "true" %}checked{% endif %} | |
| 123 | + > | |
| 124 | + <span class="flex flex-col"> | |
| 125 | + <span class="label-text font-semibold">{{ authorized_fetch_label }}</span> | |
| 126 | + <span class="label-text-alt text-base-content/60">{{ authorized_fetch_desc }}</span> | |
| 127 | + </span> | |
| 128 | + </label> | |
| 129 | + </div> | |
| 130 | + </section> | |
| 131 | + | |
| 132 | + {# Single primary CTA (Plan UI §3 P1) #} | |
| 133 | + <div class="flex justify-end"> | |
| 134 | + <button type="submit" class="btn btn-primary">{{ save_button }}</button> | |
| 135 | + </div> | |
| 136 | + </form> | |
| 137 | +</div> | |
| 138 | +{% endblock %} |
M
crates/rustwarden-ui/templates/admin/users_list.html
+4
-4
@@ -206,12 +206,12 @@
| 206 | 206 | {% if p == page %} |
| 207 | 207 | <button class="join-item btn btn-active">{{ p }}</button> |
| 208 | 208 | {% elif p == 1 || p == total_pages %} |
| 209 | - <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}" | |
| 209 | + <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}" | |
| 210 | 210 | class="join-item btn">{{ p }}</a> |
| 211 | - {% elif p >= page - 2 && p <= page + 2 %} | |
| 212 | - <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}" | |
| 211 | + {% elif p + 2 >= page && p <= page + 2 %} | |
| 212 | + <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}" | |
| 213 | 213 | class="join-item btn">{{ p }}</a> |
| 214 | - {% elif p == page - 3 || p == page + 3 %} | |
| 214 | + {% elif p + 3 == page || p == page + 3 %} | |
| 215 | 215 | <button class="join-item btn btn-disabled">...</button> |
| 216 | 216 | {% endif %} |
| 217 | 217 | {% endfor %} |
M
docs/SECURITY_AUDIT_2026_05.md
+4
-3
@@ -60,12 +60,13 @@
| 60 | 60 | |
| 61 | 61 | | Crate | Advisory | Impact | |
| 62 | 62 | |-------|----------|--------| |
| 63 | -| `rsa 0.9.10` | RUSTSEC-2023-0071 | Marvin Attack (timing). Pas de fix upstream. Risque limité : JWT utilise HS256. | | |
| 63 | +| `rsa 0.9.10` | RUSTSEC-2023-0071 | Marvin Attack (timing). Pas de fix upstream. Risque limité : JWT utilise HS256, RSA seulement pour clés acteur AP (RFC 0005). | | |
| 64 | 64 | | `rustls-webpki 0.103.9` | RUSTSEC-2026-0104 | Panic DoS dans CRL parsing | |
| 65 | 65 | | `rustls-webpki 0.103.9` | RUSTSEC-2026-0099 | Bypass wildcard name constraint | |
| 66 | -| `rand 0.9.2` | RUSTSEC-2026-0097 | Unsoundness avec custom loggers | | |
| 66 | +| `rand 0.9.2` / `rand 0.8.5` | RUSTSEC-2026-0097 | Unsoundness avec custom loggers globaux. Rustwarden n'en installe pas (seul `tracing-subscriber`). | | |
| 67 | +| `lexical-core 0.7.6` | RUSTSEC-2023-0086 | Multiple soundness issues. Atteint via `imap-proto` < `imap` (EmailService bounces). | | |
| 67 | 68 | |
| 68 | -- **Décision : CORRIGÉ** — `cargo update -p rustls-webpki` appliqué. | |
| 69 | +- **Décision : CORRIGÉ** — `cargo update -p rustls-webpki` appliqué pour les fix dispos. Les advisories transitives sans fix upstream sont **figées dans [`.cargo/audit.toml`](../.cargo/audit.toml)** (RFC 0020 / Phase 5) avec justification par entrée. Le gate CI `cargo audit` est désormais **propre** (0 vulnerability) tant que ces ignores restent documentées. | |
| 69 | 70 | |
| 70 | 71 | #### H4. `AuthConfig::from_env()` appelé à chaque requête |
| 71 | 72 | - **Fichiers :** |
A
docs/fediverse.md
+277
-0
@@ -0,0 +1,277 @@
| 1 | +# Fediverse — Guide opérateur | |
| 2 | + | |
| 3 | +> Guide en français pour activer, configurer et surveiller la | |
| 4 | +> fédération ActivityPub dans une application Rustwarden. | |
| 5 | + | |
| 6 | +Ce document s'adresse aux **opérateurs** déployant Rustwarden en | |
| 7 | +production. Pour les détails de design des APIs publiques, consulter | |
| 8 | +les RFCs [`docs/rfcs/0001`-`0019`](rfcs/README.md). | |
| 9 | + | |
| 10 | +--- | |
| 11 | + | |
| 12 | +## 1. Vue d'ensemble | |
| 13 | + | |
| 14 | +La fonctionnalité fédération est livrée derrière la **feature Cargo | |
| 15 | +expérimentale** `experimental-fediverse`. Elle ajoute : | |
| 16 | + | |
| 17 | +- Routes HTTP de découverte (`/.well-known/webfinger`, | |
| 18 | + `/.well-known/nodeinfo`, `/users/{username}`). | |
| 19 | +- Inbox AP (`/inbox`) qui reçoit les signaux lifecycle | |
| 20 | + `Update{Actor}` / `Delete{Actor}` / `Revoke{Membership}`. | |
| 21 | +- Flow d'authentification AP-native pour qu'un user d'un IdP fédéré | |
| 22 | + puisse se connecter à votre Rustwarden (5 endpoints | |
| 23 | + `/api/v1/auth/login/fediverse/*` + `/fediverse/auth/inbox` + | |
| 24 | + `/api/v1/fediverse/auth/consent`). | |
| 25 | +- Invitations cross-instance dans les équipes (6 endpoints | |
| 26 | + `/api/v1/teams/{id}/invites/*` + `/api/v1/fediverse/invites/*`). | |
| 27 | +- Worker outbound persistant avec retry/backoff | |
| 28 | + (5 min → 30 min → 2 h → 6 h → 24 h → 48 h, capé à 8 attempts). | |
| 29 | +- Signatures HTTP draft-cavage (RSA-SHA256) compatibles Mastodon. | |
| 30 | + | |
| 31 | +--- | |
| 32 | + | |
| 33 | +## 2. Activer la feature | |
| 34 | + | |
| 35 | +Ajoutez `experimental-fediverse` à votre `Cargo.toml` : | |
| 36 | + | |
| 37 | +```toml | |
| 38 | +[dependencies] | |
| 39 | +rustwarden-core = { version = "*", features = ["experimental-fediverse"] } | |
| 40 | +rustwarden-ui = { version = "*", features = ["experimental-fediverse"] } | |
| 41 | +``` | |
| 42 | + | |
| 43 | +Puis appelez `.with_fediverse()` sur votre `RustwardenBuilder` : | |
| 44 | + | |
| 45 | +```rust | |
| 46 | +use rustwarden_core::app::RustwardenBuilder; | |
| 47 | + | |
| 48 | +let app = RustwardenBuilder::new() | |
| 49 | + .with_db(db.clone()) | |
| 50 | + .with_fediverse()? // monte les routes fediverse | |
| 51 | + .build() | |
| 52 | + .await?; | |
| 53 | +``` | |
| 54 | + | |
| 55 | +Sans `.with_fediverse()`, la feature compile mais aucune route n'est | |
| 56 | +exposée — pratique pour les apps qui veulent picorer le service sans | |
| 57 | +exposer la couche HTTP. | |
| 58 | + | |
| 59 | +--- | |
| 60 | + | |
| 61 | +## 3. Variables d'environnement | |
| 62 | + | |
| 63 | +| Variable | Obligatoire ? | Défaut | Rôle | | |
| 64 | +|---|---|---|---| | |
| 65 | +| `FEDIVERSE_DOMAIN` | **oui** | — | Domaine canonique de l'instance (ex. `rustwarden.example.org`). Apparaît dans tous les acteurs et les inbox. | | |
| 66 | +| `FEDIVERSE_DEBUG_LOCAL_FEDERATION` | non | `false` | Quand `true`, accepte `http://` (au lieu d'imposer HTTPS) et désactive certains contrôles destinés à la prod. **Ne jamais activer en production.** | | |
| 67 | +| `FEDIVERSE_USER_AGENT` | non | `Rustwarden/0.x` | UA envoyé sur les requêtes sortantes. | | |
| 68 | +| `FEDIVERSE_AUTHORIZED_FETCH` | non | `false` | Active le mode authorized-fetch (récupération d'acteurs distants par signature). À activer après audit. | | |
| 69 | +| `FEDIVERSE_OUTBOUND_WORKERS` | non | `4` | Nombre de workers outbound parallèles (réservé : la livraison actuelle est mono-worker, RFC à venir). | | |
| 70 | +| `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` | non | `8` | Nombre maximum de tentatives avant qu'une activité ne soit marquée `dead`. | | |
| 71 | +| `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` | non | `30` | Timeout HTTP par tentative de livraison. | | |
| 72 | +| `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` | non | `86400` | TTL du cache `remote_identities`. | | |
| 73 | +| `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` | non | `300` | Durée de vie d'un challenge d'auth fédérée. | | |
| 74 | +| `ENCRYPTION_KEY` | **oui pour fédération réelle** | — | Hex 64 caractères (32 bytes). Sert à chiffrer les clés privées RSA des acteurs locaux. Sans cette variable, `ActorKeyService` refuse de générer des paires (cf. RFC 0005). | | |
| 75 | +| `JWT_SECRET` | **oui** | — | Secret HS256 des JWT (commun à tout Rustwarden). Les sessions remote utilisent le même secret. | | |
| 76 | + | |
| 77 | +Génération typique des secrets : | |
| 78 | + | |
| 79 | +```bash | |
| 80 | +# ENCRYPTION_KEY (32 bytes hex) | |
| 81 | +openssl rand -hex 32 | |
| 82 | + | |
| 83 | +# JWT_SECRET (au moins 32 bytes ; 64 hex recommandé) | |
| 84 | +openssl rand -hex 32 | |
| 85 | +``` | |
| 86 | + | |
| 87 | +--- | |
| 88 | + | |
| 89 | +## 4. Migrations SeaORM | |
| 90 | + | |
| 91 | +Les tables fédération sont créées par les migrations | |
| 92 | +`m20260510_000009` à `m20260510_000016`. Appliquez-les via : | |
| 93 | + | |
| 94 | +```bash | |
| 95 | +cargo run --bin migrate | |
| 96 | +``` | |
| 97 | + | |
| 98 | +| Migration | Effet | | |
| 99 | +|---|---| | |
| 100 | +| `…_000009_remote_identities` | Cache des acteurs distants. | | |
| 101 | +| `…_000010_fediverse_trust_and_blocks` | Allowlist + blocklist + 6 entrées `app_settings`. | | |
| 102 | +| `…_000011_extend_users_with_actor_keys` | 3 colonnes sur `users` pour stocker les clés RSA. | | |
| 103 | +| `…_000012_fediverse_outbound_queue` | File des activités sortantes. | | |
| 104 | +| `…_000013_fediverse_auth_challenges` | State machine du flow d'auth fédérée. | | |
| 105 | +| `…_000014_identity_sibling_fks` | Colonnes `remote_identity_id` sur `team_members`, `resource_shares`, `audit_logs`. | | |
| 106 | +| `…_000015_fediverse_pending_invites` | Invitations cross-instance + index `team_members_remote_unique`. | | |
| 107 | +| `…_000016_relax_team_members_pk` | PK `team_members` relaxée pour permettre `user_id NULL` (rows distantes). Active l'extension PostgreSQL `pgcrypto`. | | |
| 108 | + | |
| 109 | +--- | |
| 110 | + | |
| 111 | +## 5. Configurer le mode de confiance | |
| 112 | + | |
| 113 | +Le master plan distingue deux modes (master plan §2.1) : | |
| 114 | + | |
| 115 | +- **`allowlist`** (défaut, zero-trust) — seuls les domaines explicitement | |
| 116 | + ajoutés à la table `fediverse_trusted_domains` peuvent fédérer. | |
| 117 | +- **`open_with_blocklist`** (style Mastodon) — tout domaine accepté | |
| 118 | + sauf ceux dans `fediverse_blocked_domains`. | |
| 119 | + | |
| 120 | +Le mode est stocké dans `app_settings.fediverse_trust_mode`. Pour le | |
| 121 | +basculer en SQL : | |
| 122 | + | |
| 123 | +```sql | |
| 124 | +UPDATE app_settings | |
| 125 | +SET value = 'open_with_blocklist' | |
| 126 | +WHERE key = 'fediverse_trust_mode'; | |
| 127 | +``` | |
| 128 | + | |
| 129 | +Pour ajouter un domaine à la allowlist : | |
| 130 | + | |
| 131 | +```rust | |
| 132 | +use rustwarden_core::services::fediverse_trust_service::FediverseTrustService; | |
| 133 | + | |
| 134 | +let svc = FediverseTrustService::new(db.clone()); | |
| 135 | +svc.add_trusted_domain("mastodon.social", Some("Public Mastodon"), None).await?; | |
| 136 | +``` | |
| 137 | + | |
| 138 | +L'app consommatrice est libre d'exposer une UI admin qui pilote ce | |
| 139 | +service. | |
| 140 | + | |
| 141 | +--- | |
| 142 | + | |
| 143 | +## 6. Activer la fédération réelle (HTTP signatures) | |
| 144 | + | |
| 145 | +Par défaut, Rustwarden monte un `AlwaysAcceptVerifier` (test/dev) et un | |
| 146 | +`NoopDelivery` (logue mais n'envoie rien). **Vous devez explicitement | |
| 147 | +activer la crypto réelle en prod.** | |
| 148 | + | |
| 149 | +```rust | |
| 150 | +use std::sync::Arc; | |
| 151 | +use rustwarden_core::fediverse::http_signatures::{ | |
| 152 | + HttpSigVerifier, HttpSignedDelivery, | |
| 153 | +}; | |
| 154 | + | |
| 155 | +let app = RustwardenBuilder::new() | |
| 156 | + .with_db(db.clone()) | |
| 157 | + .with_fediverse()? | |
| 158 | + // Vérification entrante : auto-résolution du PEM via le cache | |
| 159 | + // remote_identities. | |
| 160 | + .with_signature_verifier(Arc::new(HttpSigVerifier::with_resolver(db.clone()))) | |
| 161 | + // Livraison sortante signée RSA-SHA256. | |
| 162 | + .with_delivery(Arc::new(HttpSignedDelivery::new(db.clone()))) | |
| 163 | + .build() | |
| 164 | + .await?; | |
| 165 | +``` | |
| 166 | + | |
| 167 | +> **Avertissement** : `AlwaysAcceptVerifier` accepte n'importe quelle | |
| 168 | +> requête sur `/inbox`. Ne le laissez **jamais** actif en prod : tout | |
| 169 | +> attaquant pourrait modifier vos sessions distantes via un faux | |
| 170 | +> `Delete{Actor}`. | |
| 171 | + | |
| 172 | +--- | |
| 173 | + | |
| 174 | +## 7. Démarrer le worker outbound | |
| 175 | + | |
| 176 | +Le worker draine la file `fediverse_outbound_queue` et applique le | |
| 177 | +ladder de retry de RFC 0010. Lancez-le dans une `tokio::spawn` au | |
| 178 | +démarrage : | |
| 179 | + | |
| 180 | +```rust | |
| 181 | +use std::time::Duration; | |
| 182 | +use rustwarden_core::services::fediverse_outbound_service::OutboundWorker; | |
| 183 | + | |
| 184 | +let worker = OutboundWorker::new( | |
| 185 | + db.clone(), | |
| 186 | + HttpSignedDelivery::new(db.clone()), | |
| 187 | + Duration::from_secs(5), // poll interval | |
| 188 | +); | |
| 189 | +tokio::spawn(worker.run()); | |
| 190 | +``` | |
| 191 | + | |
| 192 | +Le poll interval est laissé au choix de l'opérateur. 5 s est un bon | |
| 193 | +compromis pour la plupart des cas. | |
| 194 | + | |
| 195 | +--- | |
| 196 | + | |
| 197 | +## 8. Surveillance (RFC 0019) | |
| 198 | + | |
| 199 | +Tous les événements du worker sont émis sous le target `tracing` | |
| 200 | +**`rustwarden::fediverse::outbound`**. Niveaux : | |
| 201 | + | |
| 202 | +| Event | Level | Quand | | |
| 203 | +|---|---|---| | |
| 204 | +| `delivered` | `info` | Livraison réussie. | | |
| 205 | +| `deferred` | `warn` | Échec retryable, prochain essai dans `next_retry_at`. | | |
| 206 | +| `dead` | `error` | `attempts >= max_attempts` ; activité abandonnée. | | |
| 207 | +| `drain_failed` | `error` | Erreur DB en boucle ; le worker continue. | | |
| 208 | + | |
| 209 | +Champs disponibles : `activity_id`, `target_inbox`, `target_domain`, | |
| 210 | +`attempts`, `max_attempts`, `last_error`, `next_retry_at`. | |
| 211 | + | |
| 212 | +Pour exposer ces événements en métriques Prometheus : | |
| 213 | + | |
| 214 | +```rust | |
| 215 | +// Pseudo-code — adaptez à votre exporter de choix. | |
| 216 | +tracing_subscriber::registry() | |
| 217 | + .with(prometheus_layer.with_target("rustwarden::fediverse::outbound", true)) | |
| 218 | + .init(); | |
| 219 | +``` | |
| 220 | + | |
| 221 | +--- | |
| 222 | + | |
| 223 | +## 9. Comportement attendu en prod | |
| 224 | + | |
| 225 | +### 9.1 Première résolution d'un acteur local | |
| 226 | + | |
| 227 | +Quand un serveur distant requête `/users/alice` pour la première fois : | |
| 228 | +1. Le resolver charge `users` row pour `alice`. | |
| 229 | +2. Si `ap_public_key_pem` est `NULL` et que `ENCRYPTION_KEY` est | |
| 230 | + configuré, `ActorKeyService::ensure_keys` génère une paire | |
| 231 | + RSA-2048 (~100 ms côté CPU). | |
| 232 | +3. La paire est persistée (privée chiffrée). | |
| 233 | +4. Les requêtes suivantes utilisent la paire en cache. | |
| 234 | + | |
| 235 | +### 9.2 Réception d'un signal lifecycle | |
| 236 | + | |
| 237 | +Un signal entrant via `/inbox` : | |
| 238 | +1. Le verifier valide la signature HTTP (auto-résolution du PEM via | |
| 239 | + le cache `remote_identities`). | |
| 240 | +2. Le dispatcher appelle la méthode `apply_*` du | |
| 241 | + `FediverseSignalService`. | |
| 242 | +3. La row distante est mise à jour. Pour `Delete{Actor}`, si vous | |
| 243 | + avez câblé un `ActiveTokenIterator`, les JWT actifs sont | |
| 244 | + blacklistés. | |
| 245 | + | |
| 246 | +### 9.3 Émission d'une activité sortante | |
| 247 | + | |
| 248 | +1. Votre code consommateur appelle | |
| 249 | + `FediverseOutboundService::enqueue(...)` avec l'activité prête. | |
| 250 | +2. Le worker pop la row au prochain poll, signe avec la clé privée | |
| 251 | + du local actor (RFC 0005), POST avec headers `Date`/`Host`/`Signature`. | |
| 252 | +3. Sur succès → `Delivered`. Sur échec retryable → reste `Pending` | |
| 253 | + avec `next_retry_at`. Sur `attempts >= max_attempts` → `Dead`. | |
| 254 | + | |
| 255 | +--- | |
| 256 | + | |
| 257 | +## 10. Limites connues | |
| 258 | + | |
| 259 | +- Compatibilité **draft-cavage uniquement** (pas RFC 9421 final). C'est | |
| 260 | + ce que Mastodon parle aujourd'hui ; un suivi RFC mettra à jour quand | |
| 261 | + Mastodon migrera. | |
| 262 | +- **Pas de `Digest` body header validation** côté entrant. Mastodon | |
| 263 | + l'inclut côté sortant mais ne l'exige pas en prod. | |
| 264 | +- **Pas de support Ed25519** — Mastodon impose RSA-2048. | |
| 265 | +- **Pas de UI admin** dédiée à la file outbound. Vous pouvez écrire | |
| 266 | + vos propres handlers Askama / SPA en consommant le service. | |
| 267 | +- Aucune intégration Mastodon réelle n'a été testée en bout-de-bout | |
| 268 | + (les tests interop wiremock arrivent dans une RFC suivante). Un | |
| 269 | + premier déploiement doit être considéré comme **expérimental**. | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +## 11. Références internes | |
| 274 | + | |
| 275 | +- [Master plan](plans/2026-05-09-fediverse-identity-federation.md) — stratégie globale. | |
| 276 | +- [RFCs 0001-0019](rfcs/README.md) — décisions techniques détaillées. | |
| 277 | +- [`CHANGELOG.md`](../CHANGELOG.md) — toutes les livraisons par version. |
M
docs/plans/2026-05-09-fediverse-identity-federation.md
+1
-1
@@ -784,7 +784,7 @@ gh pr list --repo LemmyNet/activitypub-federation-rust --state all --limit 30 \
| 784 | 784 | | 2 — Auth AP-native | ✅ **Service livré** (3/3 RFCs livrées, routes HTTP différées) | RFC 0006 ✅, 0007 ✅, 0011 ✅ | 2026-05-09 | |
| 785 | 785 | | 3 — Identity abstraction & invitations | ✅ **Service livré** (RFC 0008-0009 livrées, routes HTTP & PK relax différées) | RFC 0008 ✅, 0009 ✅ | 2026-05-09 | |
| 786 | 786 | | 4 — Signaux & lifecycle | ✅ **Service livré** (RFC 0010 livrée, routes inbox & HTTP-Sig réelle différées) | RFC 0010 ✅ | 2026-05-10 | |
| 787 | -| 5 — Production | ⏳ | — | — | | |
| 787 | +| 5 — Production | 🔄 **Service, monitoring, interop, audit gate + UI Slice A iter.1 livrés** (UI Slice A trust_list + Slices B/C restants) | RFC 0015, 0016, 0017, 0018, 0019, 0020, 0021 (iter.1) + [`docs/fediverse.md`](../fediverse.md) + [`.cargo/audit.toml`](../../.cargo/audit.toml) + [`Plan UI`](2026-05-10-fediverse-ui-html.md) | 2026-05-10 | | |
| 788 | 788 | |
| 789 | 789 | ### RFCs détaillées |
| 790 | 790 |
A
docs/plans/2026-05-10-fediverse-ui-html.md
+205
-0
@@ -0,0 +1,205 @@
| 1 | +# Plan UI HTML Fediverse — décisions UX & découpage RFCs | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (rustwarden) | |
| 4 | +- **Date** : 2026-05-10 | |
| 5 | +- **Statut** : Draft | |
| 6 | +- **Périmètre** : 13 écrans listés dans | |
| 7 | + [`master plan §18`](2026-05-09-fediverse-identity-federation.md#18-surface-ui-complète-récap-parcours-utilisateur). | |
| 8 | +- **Ce plan complète** §18 (catalogue) avec la couche UX qui y manque : | |
| 9 | + hiérarchie d'écrans, états dégradés, terminologie, et découpage | |
| 10 | + RFC. Il n'inclut **pas** de mockups ; le design vit dans Askama. | |
| 11 | + | |
| 12 | +## 1. Objet | |
| 13 | + | |
| 14 | +Le master plan §18 catalogue les 13 écrans HTML que `rustwarden-ui` | |
| 15 | +doit livrer. Catalogue ≠ plan UX. Sans la couche présente, chaque RFC | |
| 16 | +écran dérive selon l'humeur du moment et l'expérience finale est un | |
| 17 | +patchwork. Ce plan fige : | |
| 18 | + | |
| 19 | +- les **principes UX intransgressables** que toute RFC UI doit honorer ; | |
| 20 | +- la **hiérarchie d'écrans** selon l'état runtime | |
| 21 | + (`fediverse_enabled`, `trust_mode`, présence d'invitations) ; | |
| 22 | +- les **états dégradés** par écran (empty/error/loading), interdits | |
| 23 | + d'oubli ; | |
| 24 | +- la **terminologie canonique** et les micro-copies pivots (FR/EN) ; | |
| 25 | +- le **découpage en 3 RFCs incrémentales** avec ordre obligatoire. | |
| 26 | + | |
| 27 | +## 2. Non-goals | |
| 28 | + | |
| 29 | +- **Pas de mockups Figma** — le design est exprimé en Askama + DaisyUI. | |
| 30 | +- **Pas de redéfinition** des règles existantes (`docs/CSS_STYLE_GUIDE.md`, | |
| 31 | + HTMX, Alpine, CSRF, i18n YAML) — on les *réutilise*. | |
| 32 | +- **Pas de nouveau design system** — DaisyUI/Tailwind suffisent. | |
| 33 | +- **Pas de mobile-first dédié** — DaisyUI est responsive par défaut ; | |
| 34 | + on vérifie les écrans denses (queue, sessions) sur mobile. | |
| 35 | + | |
| 36 | +## 3. Principes UX intransgressables | |
| 37 | + | |
| 38 | +| # | Principe | Pourquoi | | |
| 39 | +|---|---|---| | |
| 40 | +| P1 | **Une seule action primaire par écran** (CTA principal en `btn-primary`, secondaires en `btn-ghost`). | Évite les arbitrages cognitifs ; pattern existant Rustwarden. | | |
| 41 | +| P2 | **Confirmation modale Alpine** sur toute action destructive (révoquer, kill switch, supprimer trust entry). Texte : *« Cette action est irréversible. »* | Le coût d'une mauvaise révocation = lockout + audit trail à reconstruire. | | |
| 42 | +| P3 | **Feedback HTMX `hx-swap-oob` toast** sur succès *et* échec. Délai d'auto-dismiss : 4 s succès, 8 s erreur. | Sans toast, le user ignore si le POST a réussi. | | |
| 43 | +| P4 | **État vide explicite** (`empty_state.html` partial) avec illustration DaisyUI + texte expliquant pourquoi : *« Aucune session distante. »* / *« La fédération n'est pas activée. »* | Une table vide sans contexte = bug perçu. | | |
| 44 | +| P5 | **Spinner + skeleton** au-delà de 200 ms perçus. Polling auth-fédéré en SSE, pas en `setInterval`. | Auth fédérée = round-trip réseau imprévisible. | | |
| 45 | +| P6 | **Aucun libellé en dur** (FR par défaut, EN canonique pour rustdoc). 4 locales : `fr`/`en`/`de`/`es`. | Constitution Rustwarden + cible internationale. | | |
| 46 | +| P7 | **A11y** : labels `<label for>` obligatoires, `aria-live="polite"` sur les toasts, `aria-describedby` sur les confirmations destructives, focus visible. | Cible adoption élargie. | | |
| 47 | +| P8 | **Tabbing logique** : tout écran admin doit être pilotable au clavier sans souris. | Validation Lighthouse + a11y constitution §X. | | |
| 48 | +| P9 | **Mode dégradé silencieux** : si `fediverse_enabled=false`, les pages `/admin/fediverse/*` et `/login/fediverse` retournent 404 plutôt qu'une page vide. | Évite la confusion : « la page existe mais ne fait rien ». | | |
| 49 | +| P10 | **Pas de JS framework lourd** — HTMX + Alpine uniquement, conformes à la stack existante. | Réduit la surface d'attaque XSS et le poids bundle. | | |
| 50 | + | |
| 51 | +## 4. Hiérarchie d'écrans (state machine d'entrée) | |
| 52 | + | |
| 53 | +``` | |
| 54 | +fediverse_enabled ? | |
| 55 | +├── false → /admin/fediverse/settings UNIQUEMENT (toggle activation) | |
| 56 | +│ toutes les autres routes /admin/fediverse/* → 404 | |
| 57 | +│ pas de champ login fédéré | |
| 58 | +│ | |
| 59 | +└── true → trust_mode ? | |
| 60 | + ├── allowlist → /admin/fediverse/trust_list affiche allowlist | |
| 61 | + └── open_with_blocklist → trust_list affiche blocklist | |
| 62 | + + | |
| 63 | + user authentifié ? | |
| 64 | + ├── invitations en attente ? → bandeau /profile + lien | |
| 65 | + └── sessions distantes actives ? → section profil "Apps connectées" | |
| 66 | +``` | |
| 67 | + | |
| 68 | +Cette hiérarchie est implémentée en **un seul middleware** qui injecte | |
| 69 | +un `FediverseUiState` dans toutes les pages, pas en if/else dispersés. | |
| 70 | + | |
| 71 | +## 5. États dégradés — couverture obligatoire | |
| 72 | + | |
| 73 | +Toute RFC UI doit livrer **les 5 états** par écran (sinon : RFC bloquée | |
| 74 | +en review) : | |
| 75 | + | |
| 76 | +| État | Quand | Où le tester | | |
| 77 | +|---|---|---| | |
| 78 | +| **happy path** | Données présentes, action réussit | manuel + QA | | |
| 79 | +| **empty** | Liste vide légitime | seed sans données | | |
| 80 | +| **loading** | DB lente, polling AP | throttling reseau | | |
| 81 | +| **error 5xx** | Backend down, erreur DB | mock erreur | | |
| 82 | +| **forbidden 403** | RBAC refuse | utilisateur non-admin | | |
| 83 | + | |
| 84 | +Pour les pages d'auth fédérée, on ajoute : | |
| 85 | + | |
| 86 | +| État spécifique | Quand | | |
| 87 | +|---|---| | |
| 88 | +| **timeout** | `Accept{AuthRequest}` jamais reçu (>30 s) | | |
| 89 | +| **rejected** | IdP a renvoyé `Reject` | | |
| 90 | +| **untrusted** | Domaine pas dans allowlist | | |
| 91 | +| **invalid signature** | HTTP-Sig non valide | | |
| 92 | + | |
| 93 | +## 6. Terminologie canonique | |
| 94 | + | |
| 95 | +| Concept | FR (UI) | EN (rustdoc) | Banni | | |
| 96 | +|---|---|---|---| | |
| 97 | +| Identité fédérée | « identité distante » | "remote identity" | "user fédéré", "compte externe" | | |
| 98 | +| Instance / serveur AP | « instance » | "instance" | "serveur" (ambigu) | | |
| 99 | +| Domaine accepté | « domaine de confiance » | "trusted domain" | "whitelist" | | |
| 100 | +| Domaine bloqué | « domaine bloqué » | "blocked domain" | "blacklist" | | |
| 101 | +| Activité sortante | « livraison sortante » | "outbound delivery" | "envoi", "publication" | | |
| 102 | +| Challenge auth | « demande d'authentification » | "auth request" | "OAuth-like" | | |
| 103 | + | |
| 104 | +Micro-copies pivots (à figer dans `locales/{fr,en}/fediverse.yaml`) : | |
| 105 | + | |
| 106 | +- Toggle activation : *« Activer la fédération ActivityPub (expérimental) »* | |
| 107 | +- Confirmation révocation : *« Révoquer cette session distante ? L'utilisateur devra se reconnecter depuis son IdP. »* | |
| 108 | +- Empty queue : *« Aucune livraison en attente. La file outbound est saine. »* | |
| 109 | +- Login fediverse : *« Se connecter avec une identité ActivityPub »* (PAS « Login Fediverse ») | |
| 110 | + | |
| 111 | +## 7. Découpage en RFCs incrémentales | |
| 112 | + | |
| 113 | +L'ordre est **obligatoire** : chaque slice débloque les suivants en | |
| 114 | +fournissant les contrôles runtime nécessaires. | |
| 115 | + | |
| 116 | +### Slice A — Admin foundations (RFC 0021) | |
| 117 | + | |
| 118 | +**Écrans** : | |
| 119 | +- `/admin/fediverse/settings` (toggle + sélecteurs) | |
| 120 | +- `/admin/fediverse/trust_list` (allow/blocklist selon `trust_mode`) | |
| 121 | + | |
| 122 | +**Pourquoi en premier** : sans le toggle runtime, aucun autre écran | |
| 123 | +n'est utile (mode dégradé P9 = 404). La trust_list est nécessaire avant | |
| 124 | +qu'un user puisse tenter un login fédéré (sans allowlist, tout login | |
| 125 | +échoue en mode `allowlist`). | |
| 126 | + | |
| 127 | +**Done quand** : | |
| 128 | +- Admin peut activer/désactiver la fédération sans redémarrer | |
| 129 | +- Admin peut ajouter/supprimer un domaine trust ou block | |
| 130 | +- Confirmation modale sur suppression | |
| 131 | +- Quality gates verts + 5 états couverts | |
| 132 | + | |
| 133 | +### Slice B — User-facing auth (RFC 0022) | |
| 134 | + | |
| 135 | +**Écrans** : | |
| 136 | +- `partial login.html` (champ handle fédéré) | |
| 137 | +- `fediverse/login_waiting.html` (spinner + SSE) | |
| 138 | +- `fediverse/consent_screen.html` (côté IdP) | |
| 139 | +- `fediverse/error_unauthorized.html` (variantes timeout/rejected/untrusted/invalid_sig) | |
| 140 | + | |
| 141 | +**Pourquoi en deuxième** : c'est le parcours critique d'adoption — le | |
| 142 | +premier moment où un utilisateur externe touche l'instance. Polish UX | |
| 143 | +max requis. Dépend de Slice A pour le toggle. | |
| 144 | + | |
| 145 | +**Done quand** : | |
| 146 | +- Bout-en-bout local fonctionnel (deux instances Rustwarden en debug) | |
| 147 | +- 4 variantes d'erreur explicites (pas de générique « Erreur 500 ») | |
| 148 | +- A11y validée (Lighthouse ≥ 95) | |
| 149 | +- Polling SSE robuste (pas de `setInterval` de fallback caché) | |
| 150 | + | |
| 151 | +### Slice C — Invitations & ops (RFC 0023) | |
| 152 | + | |
| 153 | +**Écrans** : | |
| 154 | +- `fediverse/invite_accept.html` (côté IdP) | |
| 155 | +- partial `fediverse/profile_connected_apps.html` | |
| 156 | +- `fediverse/admin/invites.html` | |
| 157 | +- `fediverse/admin/sessions_remote.html` | |
| 158 | +- `fediverse/admin/queue.html` | |
| 159 | +- `fediverse/admin/nodeinfo.html` | |
| 160 | + | |
| 161 | +**Pourquoi en dernier** : volumineux (6 écrans) mais homogène (tables + | |
| 162 | +actions). Ne bloque pas l'adoption ; les premiers déploiements peuvent | |
| 163 | +fonctionner sans (admin SQL direct si besoin). | |
| 164 | + | |
| 165 | +**Done quand** : | |
| 166 | +- Tous les écrans existent et passent les 5 états | |
| 167 | +- Kill switch + révocation testés bout-en-bout | |
| 168 | +- Pagination sur les tables denses (>50 rows) | |
| 169 | +- RFC 0023 acceptée | |
| 170 | + | |
| 171 | +## 8. Critères de done UX globaux (par RFC) | |
| 172 | + | |
| 173 | +| Critère | Comment mesurer | | |
| 174 | +|---|---| | |
| 175 | +| Lighthouse a11y | Score ≥ 95 sur chaque page nouvelle | | |
| 176 | +| Lighthouse perf | Score ≥ 80 (DaisyUI a un coût CSS) | | |
| 177 | +| Tabbing complet | Test manuel `Tab`/`Shift+Tab` sans piège | | |
| 178 | +| i18n complète | 6 locales remplies (`fr`, `en`, `de`, `es`, `it`, `pt`) | | |
| 179 | +| Empty state | Capture d'écran vide montrée en revue | | |
| 180 | +| Error state | Capture d'écran 500 montrée en revue | | |
| 181 | +| HTMX feedback | Toast présent succès + erreur | | |
| 182 | + | |
| 183 | +## 9. Risques UX & mitigations | |
| 184 | + | |
| 185 | +| Risque | Probabilité | Impact | Mitigation | | |
| 186 | +|---|---|---|---| | |
| 187 | +| Patchwork visuel inter-RFCs | Moyenne | Élevé | Slice A fixe les composants réutilisables (Card, Modal confirm, Toast) ; Slices B/C les réutilisent obligatoirement. | | |
| 188 | +| Sur-ingénierie spinner / SSE | Faible | Moyen | Polling SSE déjà éprouvé Rustwarden ; pas de nouvelle techno. | | |
| 189 | +| Libellés FR-only oubliés | Élevée | Faible | CI grep `class="..">[A-ZÀ-Ü]` dans templates pour détecter strings non-i18n. | | |
| 190 | +| Confirmation modale ignorée | Moyenne | Élevé | Pattern `<Modal>` partagé Slice A ; review bloque toute action destructive sans modale. | | |
| 191 | +| RBAC trous (user voit /admin) | Faible | Élevé | Tous les `/admin/fediverse/*` derrière le middleware `admin_required` existant. | | |
| 192 | +| HTMX swap rate (flash UI) | Faible | Moyen | `hx-swap="outerHTML transition:true"` + classes DaisyUI fade. | | |
| 193 | + | |
| 194 | +## 10. Liens | |
| 195 | + | |
| 196 | +- [Master plan §18](2026-05-09-fediverse-identity-federation.md#18-surface-ui-complète-récap-parcours-utilisateur) — catalogue d'écrans | |
| 197 | +- [Process RFC](../RUSTWARDEN_EVOLUTION_PROCESS.md) — règles d'évolution | |
| 198 | +- [`docs/CSS_STYLE_GUIDE.md`](../CSS_STYLE_GUIDE.md) — patterns CSS existants | |
| 199 | +- [`locales/fr/fediverse.yaml`](../../locales/fr/fediverse.yaml) — à créer en Slice A | |
| 200 | + | |
| 201 | +--- | |
| 202 | + | |
| 203 | +*Le plan est volontairement court : il fige les **décisions UX**, pas | |
| 204 | +les détails d'implémentation. Les détails vivent dans les RFCs 0021 / | |
| 205 | +0022 / 0023 et dans le code Askama.* |
A
docs/rfcs/0016-fediverse-signed-delivery-and-verifier-widening.md
+209
-0
@@ -0,0 +1,209 @@
| 1 | +# RFC 0016 — Fediverse: élargissement `SignatureVerifier` + `HttpSignedDelivery` complet | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (rustwarden) | |
| 4 | +- **Date** : 2026-05-10 | |
| 5 | +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 27/35) | |
| 6 | +- **Apps consommatrices concernées** : toute app fediverse en production qui veut activer la vérification HTTP-Sig réelle entrante et la livraison signée sortante. | |
| 7 | +- **Plan associé** : Phase 5 — production. Suite directe de RFC 0015. | |
| 8 | +- **Dépendances** : RFC 0010 (`SignatureVerifier`), RFC 0011 (`OutboundDelivery`), RFC 0015 (primitifs HTTP-Sig). | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Deux changements techniquement liés : | |
| 13 | + | |
| 14 | +1. **Élargir le trait `SignatureVerifier`** : la signature actuelle ne porte que `(actor_ap_id, signature_header, body, public_key_pem)`. La vérification draft-cavage exige aussi method, path et le `HeaderMap` (host, date, digest, etc.). Nouvelle signature : | |
| 15 | + ```rust | |
| 16 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>; | |
| 17 | + ``` | |
| 18 | + où `VerifyCtx` contient les champs nécessaires. Les impls existantes (`AlwaysAcceptVerifier`, mocks de tests) sont mises à jour. Les handlers `inbox::handle` et `auth_routes::auth_inbox` consomment désormais le contexte complet. | |
| 19 | + | |
| 20 | +2. **Livrer `HttpSignedDelivery`** — impl `OutboundDelivery` qui utilise `reqwest::Client` + `rsa_sha256_sign` (RFC 0015) pour signer puis POSTer chaque activité. Branchable sur `RustwardenBuilder` via `with_delivery(Arc::new(HttpSignedDelivery::new(...)))`. | |
| 21 | + | |
| 22 | +## Motivation | |
| 23 | + | |
| 24 | +Sans l'élargissement, `parse_and_verify_cavage` (RFC 0015) reste inutilisable depuis les inbox handlers — qui n'ont pas accès au `HeaderMap` à travers le trait. Sans `HttpSignedDelivery`, les activités sortantes restent en `NoopDelivery`. | |
| 25 | + | |
| 26 | +Cette RFC est le dernier maillon avant que rustwarden parle réellement avec Mastodon. | |
| 27 | + | |
| 28 | +## Non-goals | |
| 29 | + | |
| 30 | +- **Pas de support `Digest` body header** dans cette RFC — Mastodon le set en sortant mais ne l'exige pas en entrant pour notre usage initial. Follow-up. | |
| 31 | +- **Pas de retry HTTP-spécifique** (4xx vs 5xx, `Retry-After`) — RFC 0010 retry ladder s'applique. | |
| 32 | +- **Pas de pool de connexion HTTP custom** — `reqwest::Client` par défaut. | |
| 33 | +- **Pas de migration des callers `verify(actor, sig, body, pem)`** vers la nouvelle signature au-delà des sites internes ; les apps consommatrices qui implémentaient leur `SignatureVerifier` doivent migrer. | |
| 34 | + | |
| 35 | +## API proposée | |
| 36 | + | |
| 37 | +### Nouvelle struct `VerifyCtx` | |
| 38 | + | |
| 39 | +```rust | |
| 40 | +/// Contexte d'une requête entrante à vérifier (RFC 0016). | |
| 41 | +pub struct VerifyCtx<'a> { | |
| 42 | + /// AP id de l'actor signataire (extrait du body). | |
| 43 | + pub actor_ap_id: &'a str, | |
| 44 | + /// HTTP method en lowercase (`"post"`). | |
| 45 | + pub method: &'a str, | |
| 46 | + /// Path-and-query (`"/inbox"`, `"/fediverse/auth/inbox"`). | |
| 47 | + pub path: &'a str, | |
| 48 | + /// Tous les headers de la requête (lookup case-insensitive). | |
| 49 | + pub headers: &'a axum::http::HeaderMap, | |
| 50 | + /// Body brut (pour digest/audit). | |
| 51 | + pub body: &'a [u8], | |
| 52 | + /// PEM public résolu pour `actor_ap_id` (passé par le caller — | |
| 53 | + /// laissé vide quand le resolveur est délégué à l'impl du trait). | |
| 54 | + pub public_key_pem: &'a str, | |
| 55 | +} | |
| 56 | +``` | |
| 57 | + | |
| 58 | +### Trait `SignatureVerifier` v2 | |
| 59 | + | |
| 60 | +```rust | |
| 61 | +#[async_trait] | |
| 62 | +pub trait SignatureVerifier: Send + Sync { | |
| 63 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>; | |
| 64 | +} | |
| 65 | +``` | |
| 66 | + | |
| 67 | +### Impls livrées | |
| 68 | + | |
| 69 | +- `AlwaysAcceptVerifier` — refactor trivial. | |
| 70 | +- `HttpSigVerifier` — nouvelle impl. Lit `ctx.headers["signature"]`, construit `request_target = format!("{} {}", ctx.method, ctx.path)`, appelle `parse_and_verify_cavage(...)`. Quand `public_key_pem` est vide, l'impl peut consulter `RemoteIdentityService` (mais cette RFC reste minimaliste : si vide → `Err(Validation("public key not resolved"))`). | |
| 71 | + | |
| 72 | +### `HttpSignedDelivery` | |
| 73 | + | |
| 74 | +```rust | |
| 75 | +pub struct HttpSignedDelivery { | |
| 76 | + db: DatabaseConnection, | |
| 77 | + http: reqwest::Client, | |
| 78 | + base_url: String, | |
| 79 | +} | |
| 80 | + | |
| 81 | +#[async_trait] | |
| 82 | +impl OutboundDelivery for HttpSignedDelivery { | |
| 83 | + async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError>; | |
| 84 | +} | |
| 85 | +``` | |
| 86 | + | |
| 87 | +Algorithme : | |
| 88 | +1. Si `item.actor_user_id` est `None` → `Err(Config("HttpSignedDelivery requires actor_user_id"))`. | |
| 89 | +2. Charger la paire RSA via `ActorKeyService::ensure_keys(db, actor_user_id)`. | |
| 90 | +3. Construire `(request-target) host date` + signer. | |
| 91 | +4. POST `item.payload_json` à `item.target_inbox` avec headers `Date`, `Host`, `Signature`, `Content-Type: application/activity+json`. | |
| 92 | +5. `2xx` → `Ok(())`. Tout le reste → `Err(Internal("HTTP <status>"))`. La logique retry/backoff de RFC 0010 reprend depuis `drain_one`. | |
| 93 | + | |
| 94 | +## Analyse de compatibilité | |
| 95 | + | |
| 96 | +- [ ] **Breaking** au niveau du trait `SignatureVerifier` — toute app consommatrice qui a implémenté son propre verifier doit migrer. Mitigation : `experimental-fediverse` gating + l'API était déjà labellisée expérimentale dans RFC 0010. | |
| 97 | +- [x] Pas de schéma DB. | |
| 98 | +- [x] `HttpSignedDelivery` est purement additif. | |
| 99 | + | |
| 100 | +## Alternatives considérées | |
| 101 | + | |
| 102 | +### Alternative A (recommandée) — Trait v2 + `HttpSignedDelivery` en une RFC | |
| 103 | + | |
| 104 | +**Pros** : cohérence ; one-shot pour la prod ; les deux changements partagent le contexte. | |
| 105 | + | |
| 106 | +**Cons** : breaking pour le trait. Mitigation par feature gating. | |
| 107 | + | |
| 108 | +### Alternative B — Garder le trait v1, ajouter une seconde méthode `verify_with_request` | |
| 109 | + | |
| 110 | +**Cons** : surface API plus large à maintenir, deux chemins de vérif divergents. | |
| 111 | + | |
| 112 | +**Rejeté.** | |
| 113 | + | |
| 114 | +### Alternative C — Ne rien faire (différer encore) | |
| 115 | + | |
| 116 | +**Cons** : interop Mastodon impossible. | |
| 117 | + | |
| 118 | +**Rejeté.** | |
| 119 | + | |
| 120 | +## Testing plan | |
| 121 | + | |
| 122 | +8 tests : | |
| 123 | + | |
| 124 | +**Trait migration** : | |
| 125 | +1. `always_accept_v2_accepts_any_ctx`. | |
| 126 | +2. `http_sig_verifier_returns_err_when_signature_header_missing`. | |
| 127 | +3. `http_sig_verifier_returns_err_when_public_key_pem_empty`. | |
| 128 | +4. `http_sig_verifier_accepts_valid_signature` — round-trip avec une paire générée. | |
| 129 | + | |
| 130 | +**Inbox/auth integration** : | |
| 131 | +5. `inbox_handle_uses_widened_verifier` — smoke test que les handlers passent bien `VerifyCtx`. | |
| 132 | +6. `auth_inbox_uses_widened_verifier`. | |
| 133 | + | |
| 134 | +**HttpSignedDelivery (mocked)** : | |
| 135 | +7. `delivery_returns_err_when_actor_user_id_none`. | |
| 136 | +8. `delivery_signs_input_string_with_actor_keys` — pas d'envoi HTTP réel ; on intercepte la signature via une impl test du `reqwest::Client` … en pratique on extrait la primitive sign pour la tester séparément (déjà couvert par RFC 0015). | |
| 137 | + | |
| 138 | +Note : un test d'intégration end-to-end avec un fake HTTP server arrivera dans un follow-up `tests/interop/`. | |
| 139 | + | |
| 140 | +## Migration guide | |
| 141 | + | |
| 142 | +```markdown | |
| 143 | +### Changed (rustwarden-core, feature `experimental-fediverse`) | |
| 144 | + | |
| 145 | +- **Breaking** : `SignatureVerifier::verify(...)` change de signature. | |
| 146 | + Avant : | |
| 147 | + ```rust | |
| 148 | + async fn verify(&self, actor_ap_id: &str, sig: &str, body: &[u8], pem: &str) | |
| 149 | + -> Result<(), AppError>; | |
| 150 | + ``` | |
| 151 | + Après : | |
| 152 | + ```rust | |
| 153 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>; | |
| 154 | + ``` | |
| 155 | +- Les handlers `inbox::handle` et `auth_routes::auth_inbox` reconstituent | |
| 156 | + `VerifyCtx` à partir du `Request`. | |
| 157 | + | |
| 158 | +### Added | |
| 159 | + | |
| 160 | +- `rustwarden_core::services::fediverse_signal_service::VerifyCtx`. | |
| 161 | +- `rustwarden_core::fediverse::http_signatures::HttpSigVerifier`. | |
| 162 | +- `rustwarden_core::fediverse::http_signatures::HttpSignedDelivery`. | |
| 163 | +- `reqwest` ajouté à `experimental-fediverse` dependencies. | |
| 164 | + | |
| 165 | +### Migration steps pour les apps consommatrices | |
| 166 | + | |
| 167 | +1. Mettre à jour les impls custom de `SignatureVerifier` : | |
| 168 | + ```rust | |
| 169 | + // Avant | |
| 170 | + async fn verify(&self, actor: &str, sig: &str, body: &[u8], pem: &str) { … } | |
| 171 | + // Après | |
| 172 | + async fn verify(&self, ctx: &VerifyCtx<'_>) { … } | |
| 173 | + ``` | |
| 174 | +2. Activer la production-grade verification : | |
| 175 | + ```rust | |
| 176 | + builder.with_signature_verifier(Arc::new(HttpSigVerifier::default())) | |
| 177 | + ``` | |
| 178 | +3. Activer la livraison signée : | |
| 179 | + ```rust | |
| 180 | + builder.with_delivery(Arc::new(HttpSignedDelivery::new(db, base_url))) | |
| 181 | + ``` | |
| 182 | +``` | |
| 183 | + | |
| 184 | +## Open questions | |
| 185 | + | |
| 186 | +Toutes résolues à l'auto-revue : | |
| 187 | + | |
| 188 | +- [x] **Tolerance horloge `Date`** : 1 h, contrôlée par `HttpSigVerifier::clock_skew_secs` (paramétrable, 3600 par défaut). | |
| 189 | +- [x] **`actor_user_id` obligatoire pour HttpSignedDelivery** : oui, `Err(Config)` sinon. Les apps qui veulent que des activités non-attribuées soient livrées doivent piocher un actor system par défaut. | |
| 190 | +- [x] **`reqwest` partagé avec `oauth`** : oui, on ajoute `reqwest` à `experimental-fediverse` ; pas de duplication de version. | |
| 191 | + | |
| 192 | +## Tracking issue | |
| 193 | + | |
| 194 | +À ouvrir au moment de la PR. | |
| 195 | + | |
| 196 | +--- | |
| 197 | + | |
| 198 | +## Score d'évaluation | |
| 199 | + | |
| 200 | +| Critère | Note (1-5) | Commentaire | | |
| 201 | +|---|---|---| | |
| 202 | +| Généralité | 4 | `VerifyCtx` réutilisable pour autres protocoles signés. | | |
| 203 | +| Stabilité API | 3 | Breaking sur le trait, mitigé par gating expérimental. | | |
| 204 | +| Performance | 4 | Une signature RSA par activité — coût attendu de la fédération. | | |
| 205 | +| Sécurité | 5 | C'est **le** déblocage prod ; la TLS est gérée par `reqwest`. | | |
| 206 | +| Complexité d'implémentation | 3 | ~700 lignes (struct + 2 impls + 8 tests). | | |
| 207 | +| Maintenance | 4 | Pas de nouvelle dep majeure. | | |
| 208 | +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide complet. | | |
| 209 | +| **Total** | **27/35** | ≥ 25 → Accepted | |
A
docs/rfcs/0017-fediverse-auto-resolve-public-key.md
+142
-0
@@ -0,0 +1,142 @@
| 1 | +# RFC 0017 — Fediverse: auto-résolution du PEM public dans `HttpSigVerifier` | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (rustwarden) | |
| 4 | +- **Date** : 2026-05-10 | |
| 5 | +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 27/35) | |
| 6 | +- **Apps consommatrices concernées** : toute app fediverse en production qui veut une vérification HTTP-Sig sans avoir à pré-résoudre la clé publique côté handler. | |
| 7 | +- **Plan associé** : Phase 5 — production polish. | |
| 8 | +- **Dépendances** : RFC 0003 (`RemoteIdentityService`), RFC 0010 (`SignatureVerifier`), RFC 0015 / 0016 (HTTP-Sig). | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Brancher `HttpSigVerifier` (RFC 0016) sur `RemoteIdentityService` (RFC 0003) pour qu'il résolve lui-même la clé publique de l'acteur signataire à partir de `ctx.actor_ap_id` quand `ctx.public_key_pem` est vide. Élimine le contrat « le caller doit pré-résoudre la clé », réduit le boilerplate côté inbox handlers et ferme le dernier TODO documenté de RFC 0016. | |
| 13 | + | |
| 14 | +## Motivation | |
| 15 | + | |
| 16 | +Aujourd'hui `HttpSigVerifier::verify` retourne `Err(Validation("public key not resolved"))` quand `ctx.public_key_pem` est vide — ce qui est **toujours le cas** depuis `inbox::handle` et `auth_routes::auth_inbox` car ceux-ci ne touchent pas à `RemoteIdentityService`. La vérification réelle est donc en pratique **désactivée** sans configuration manuelle des apps consommatrices. | |
| 17 | + | |
| 18 | +Cette RFC rend le wiring transparent : par défaut le verifier résout via le service de cache. | |
| 19 | + | |
| 20 | +## Non-goals | |
| 21 | + | |
| 22 | +- **Pas de cache supplémentaire** — `RemoteIdentityService` a déjà son TTL. | |
| 23 | +- **Pas de fallback `WebFinger`** ici — si l'acteur n'est pas dans le cache, le verifier échoue avec `Err`. Une RFC suivante ajoutera la résolution paresseuse via `RemoteFetcher` si nécessaire. | |
| 24 | +- **Pas de modification de l'API publique de `HttpSigVerifier`** — `new()` reste sans argument ; on ajoute un constructeur `with_resolver(db)`. | |
| 25 | + | |
| 26 | +## API proposée | |
| 27 | + | |
| 28 | +```rust | |
| 29 | +pub struct HttpSigVerifier { | |
| 30 | + db: Option<sea_orm::DatabaseConnection>, | |
| 31 | +} | |
| 32 | + | |
| 33 | +impl HttpSigVerifier { | |
| 34 | + /// Verifier non-resolving. Si `ctx.public_key_pem` est vide, le | |
| 35 | + /// verifier rejette la requête (comportement RFC 0016 inchangé). | |
| 36 | + pub fn new() -> Self; | |
| 37 | + | |
| 38 | + /// Verifier auto-resolving. Si `ctx.public_key_pem` est vide, | |
| 39 | + /// le verifier appelle `RemoteIdentityService::find_by_ap_id` sur | |
| 40 | + /// la connection fournie pour récupérer le PEM avant la vérif. | |
| 41 | + pub fn with_resolver(db: sea_orm::DatabaseConnection) -> Self; | |
| 42 | +} | |
| 43 | +``` | |
| 44 | + | |
| 45 | +L'impl `verify` : | |
| 46 | + | |
| 47 | +1. Si `ctx.public_key_pem` non-vide → comportement RFC 0016. | |
| 48 | +2. Sinon, si `self.db.is_some()` → lookup `remote_identities` par `ap_id = ctx.actor_ap_id`. PEM trouvé → vérification. Pas trouvé → `Err(Validation("actor not in cache"))`. | |
| 49 | +3. Sinon → `Err(Validation("public key not resolved"))` (comportement RFC 0016). | |
| 50 | + | |
| 51 | +## Analyse de compatibilité | |
| 52 | + | |
| 53 | +- [x] Non-breaking : nouveau constructeur, ancien `new()` inchangé. | |
| 54 | +- [x] Pas de schéma DB. | |
| 55 | +- [x] Pas de modif de la signature `verify`. | |
| 56 | + | |
| 57 | +## Alternatives considérées | |
| 58 | + | |
| 59 | +### Alternative A (recommandée) — Constructeur séparé `with_resolver(db)` | |
| 60 | + | |
| 61 | +**Pros** : explicite, test-friendly, pas d'effet de bord sur les callers existants. | |
| 62 | + | |
| 63 | +### Alternative B — Toujours résoudre, paramètre `db: Option<…>` dans `new` | |
| 64 | + | |
| 65 | +**Cons** : casse l'API minimaliste actuelle ; aucun test ne s'exécute sans DB. | |
| 66 | + | |
| 67 | +**Rejeté.** | |
| 68 | + | |
| 69 | +### Alternative C — Trait `PublicKeyResolver` injectable | |
| 70 | + | |
| 71 | +**Pros** : flexibilité maximale. | |
| 72 | + | |
| 73 | +**Cons** : sur-engineered pour un cas d'usage à 1 impl ; complexité immédiate sans bénéfice tangible. | |
| 74 | + | |
| 75 | +**Rejeté pour cette RFC** — pourra être revisitée si plusieurs impls émergent. | |
| 76 | + | |
| 77 | +### Alternative D — Ne rien faire (caller pré-résout) | |
| 78 | + | |
| 79 | +**Cons** : tous les inbox handlers doivent porter le boilerplate. Une faute de wiring = vérif désactivée silencieusement. | |
| 80 | + | |
| 81 | +**Rejeté.** | |
| 82 | + | |
| 83 | +## Testing plan | |
| 84 | + | |
| 85 | +5 tests : | |
| 86 | + | |
| 87 | +1. `with_resolver_constructor_stores_db`. | |
| 88 | +2. `verify_uses_provided_pem_when_present` (régression du chemin RFC 0016). | |
| 89 | +3. `verify_resolves_pem_from_remote_identities_when_empty` — DB-mocked, PEM stockée → succès round-trip. | |
| 90 | +4. `verify_rejects_when_actor_not_in_cache` — DB-mocked, lookup retourne `None` → `Err(Validation)`. | |
| 91 | +5. `verify_without_resolver_rejects_empty_pem` (régression du chemin RFC 0016). | |
| 92 | + | |
| 93 | +## Migration guide | |
| 94 | + | |
| 95 | +```markdown | |
| 96 | +### Added | |
| 97 | + | |
| 98 | +- `HttpSigVerifier::with_resolver(db)` — verifier qui résout | |
| 99 | + automatiquement le PEM via `RemoteIdentityService`. | |
| 100 | + | |
| 101 | +### Migration steps | |
| 102 | + | |
| 103 | +Avant : | |
| 104 | +```rust | |
| 105 | +builder.with_signature_verifier(Arc::new(HttpSigVerifier::new())) | |
| 106 | +// + caller-side resolution | |
| 107 | +``` | |
| 108 | + | |
| 109 | +Après (recommandé) : | |
| 110 | +```rust | |
| 111 | +builder.with_signature_verifier(Arc::new(HttpSigVerifier::with_resolver(db.clone()))) | |
| 112 | +``` | |
| 113 | + | |
| 114 | +`HttpSigVerifier::new()` reste disponible pour les apps qui veulent | |
| 115 | +gérer la résolution elles-mêmes. | |
| 116 | +``` | |
| 117 | + | |
| 118 | +## Open questions | |
| 119 | + | |
| 120 | +Toutes résolues à l'auto-revue : | |
| 121 | + | |
| 122 | +- [x] **Cache miss** : `Err(Validation)` — pas de fetch paresseux dans cette RFC. | |
| 123 | +- [x] **Service vs trait** : on appelle `RemoteIdentityService` directement (pas un trait) car c'est le seul backend prévu et le service est déjà gated `experimental-fediverse`. | |
| 124 | + | |
| 125 | +## Tracking issue | |
| 126 | + | |
| 127 | +À ouvrir au moment de la PR. | |
| 128 | + | |
| 129 | +--- | |
| 130 | + | |
| 131 | +## Score d'évaluation | |
| 132 | + | |
| 133 | +| Critère | Note (1-5) | Commentaire | | |
| 134 | +|---|---|---| | |
| 135 | +| Généralité | 4 | Pattern réutilisable pour toute résolution lazy à venir. | | |
| 136 | +| Stabilité API | 5 | Additif pur. | | |
| 137 | +| Performance | 4 | Une lookup DB par requête entrante ; cache RFC 0003 limite. | | |
| 138 | +| Sécurité | 5 | Ferme le trou « verifier accepte tout silencieusement ». | | |
| 139 | +| Complexité d'implémentation | 4 | ~80 lignes (constructeur + branch + 5 tests). | | |
| 140 | +| Maintenance | 3 | Une dépendance directe `RemoteIdentityService`. | | |
| 141 | +| Documentation | 2 | Rustdoc EN, doctest. | | |
| 142 | +| **Total** | **27/35** | ≥ 25 → Accepted | |
A
docs/rfcs/0018-fediverse-tech-debt-closure.md
+206
-0
@@ -0,0 +1,206 @@
| 1 | +# RFC 0018 — Fediverse: clôture dette technique (PK relax + JWT blacklist par remote identity) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (rustwarden) | |
| 4 | +- **Date** : 2026-05-10 | |
| 5 | +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35) | |
| 6 | +- **Apps consommatrices concernées** : toute app fediverse en production qui veut (a) que les memberships remote soient réellement persistés en DB, (b) que la suppression d'un acteur distant invalide ses sessions actives. | |
| 7 | +- **Plan associé** : Phase 5 — production polish. | |
| 8 | +- **Dépendances** : RFC 0008 (sibling FK), RFC 0009 (invitations), RFC 0010 (signal service). | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Deux changements techniques pour clôturer les dettes documentées au fil des RFCs précédentes : | |
| 13 | + | |
| 14 | +1. **Relax la PK composite `team_members(team_id, user_id)`** vers `(team_id, user_id, remote_identity_id)` avec `user_id` nullable + CHECK XOR. Permet à `team_service::add_member_identity(Identity::Remote(_))` de réellement persister une row, et lève le `#[ignore]` sur le test d'intégration de RFC 0009. | |
| 15 | +2. **Ajouter `JwtBlacklistService::blacklist_by_remote_identity_id(...)`** qui flag tous les tokens actifs d'une identité distante. Branche `FediverseSignalService::apply_delete_actor` pour qu'il fasse passer `DeleteOutcome::jwts_blacklisted` à un compteur réel (au lieu de toujours `0`). | |
| 16 | + | |
| 17 | +## Motivation | |
| 18 | + | |
| 19 | +- Sans le PK relax, RFC 0009 promet une API `add_member_identity` qui plante en prod sur les rows distantes (`fk_team_members_user_id` rejette `Uuid::nil()`). Le `#[ignore]` du test cache le bug en CI mais l'API publique reste fausse. | |
| 20 | +- Sans la nouvelle méthode JWT, la promesse Phase 4 « `Delete{Actor}` → sessions invalidées en moins de 30 s » est tenue à 50 % : la row `remote_identities` est marquée `disabled_at` mais les JWT déjà émis restent valides jusqu'à expiration. | |
| 21 | + | |
| 22 | +## Non-goals | |
| 23 | + | |
| 24 | +- **Pas de refonte du blocklist storage** — on réutilise la table `jwt_blacklist` existante. | |
| 25 | +- **Pas de changement de l'API `add_member`** locale — l'API legacy continue de fonctionner inchangée. | |
| 26 | +- **Pas de purge proactive du cache de session** côté app — les apps qui maintiennent un cache distinct doivent l'invalider via leur propre hook (déjà disponible). | |
| 27 | + | |
| 28 | +## API proposée | |
| 29 | + | |
| 30 | +### Migration `m20260510_000016_relax_team_members_pk` | |
| 31 | + | |
| 32 | +```sql | |
| 33 | +-- 1. Drop the existing composite PK so user_id can be nullable. | |
| 34 | +ALTER TABLE team_members DROP CONSTRAINT team_members_pkey; | |
| 35 | + | |
| 36 | +-- 2. user_id becomes nullable (remote-only rows have user_id IS NULL). | |
| 37 | +ALTER TABLE team_members ALTER COLUMN user_id DROP NOT NULL; | |
| 38 | + | |
| 39 | +-- 3. CHECK XOR: at least one of (user_id, remote_identity_id) is set. | |
| 40 | +ALTER TABLE team_members ADD CONSTRAINT chk_team_members_principal | |
| 41 | + CHECK ((user_id IS NOT NULL)::int + (remote_identity_id IS NOT NULL)::int = 1); | |
| 42 | + | |
| 43 | +-- 4. Synthetic surrogate id for the new PK (preserves SeaORM | |
| 44 | +-- composite-PK lookups via the existing partial unique indexes). | |
| 45 | +ALTER TABLE team_members ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid(); | |
| 46 | +ALTER TABLE team_members ADD PRIMARY KEY (id); | |
| 47 | + | |
| 48 | +-- 5. Preserve uniqueness for local rows (replaces the old composite PK | |
| 49 | +-- semantics for `user_id IS NOT NULL`). | |
| 50 | +CREATE UNIQUE INDEX team_members_local_unique | |
| 51 | + ON team_members (team_id, user_id) | |
| 52 | + WHERE user_id IS NOT NULL; | |
| 53 | +-- (Index `team_members_remote_unique` already exists from RFC 0009.) | |
| 54 | +``` | |
| 55 | + | |
| 56 | +`team_member::Model` gagne le champ `pub id: Uuid` et le `primary_key` SeaORM bascule sur ce champ. `(team_id, user_id)` cesse d'être la PK SeaORM ; les lookups `Entity::find_by_id((team_id, user_id))` existants doivent migrer vers `Entity::find().filter(...)`. La RFC liste les call-sites concernés et fournit le diff complet. | |
| 57 | + | |
| 58 | +### Méthode `JwtBlacklistService::blacklist_by_remote_identity_id` | |
| 59 | + | |
| 60 | +```rust | |
| 61 | +impl JwtBlacklistService { | |
| 62 | + /// Blacklist every non-expired token whose `claims.remote_id` | |
| 63 | + /// matches the given identity. Returns the number of rows | |
| 64 | + /// inserted (0 when no active token exists). | |
| 65 | + pub async fn blacklist_by_remote_identity_id( | |
| 66 | + db: &DatabaseConnection, | |
| 67 | + remote_identity_id: Uuid, | |
| 68 | + ) -> Result<u64, AppError>; | |
| 69 | +} | |
| 70 | +``` | |
| 71 | + | |
| 72 | +L'algorithme : | |
| 73 | +1. Pour chaque token actif (table `jwt_blacklist` ne stocke **pas** les actifs — il faudra parcourir les sessions ou la table `refresh_tokens`). | |
| 74 | +2. Décoder `Claims` (sans valider la signature, payload uniquement). | |
| 75 | +3. Si `claims.remote_id == Some(remote_identity_id)` → ajouter à `jwt_blacklist`. | |
| 76 | + | |
| 77 | +> **Note importante** : la table `jwt_blacklist` n'a actuellement aucune source canonique des "tokens actifs". Si rustwarden ne maintient pas une table d'audit des émissions, **on ne peut pas énumérer rétroactivement**. La nouvelle méthode délègue à un trait `ActiveTokenIterator` que les apps consommatrices implémentent (par défaut, une impl `NoopActiveTokenIterator` retourne 0 — sémantique : « pas pire que l'état actuel »). | |
| 78 | + | |
| 79 | +```rust | |
| 80 | +#[async_trait] | |
| 81 | +pub trait ActiveTokenIterator: Send + Sync { | |
| 82 | + /// Yield every active (non-expired, non-blacklisted) JWT for the | |
| 83 | + /// given remote identity. The iterator returns the raw token | |
| 84 | + /// string so the blacklist service can hash it consistently. | |
| 85 | + async fn active_tokens_for_remote( | |
| 86 | + &self, | |
| 87 | + remote_identity_id: Uuid, | |
| 88 | + ) -> Result<Vec<String>, AppError>; | |
| 89 | +} | |
| 90 | + | |
| 91 | +pub struct NoopActiveTokenIterator; | |
| 92 | +``` | |
| 93 | + | |
| 94 | +`apply_delete_actor` est mis à jour pour accepter un `Arc<dyn ActiveTokenIterator>` (param optionnel via une signature surchargée — `apply_delete_actor` reste, `apply_delete_actor_with_iterator` est ajoutée). | |
| 95 | + | |
| 96 | +### Mise à jour du wiring | |
| 97 | + | |
| 98 | +`team_service::add_member_identity(Identity::Remote(_))` : | |
| 99 | +- Insère sans `Uuid::nil()` (user_id reste `None`). | |
| 100 | +- Le test d'intégration `rfc0009_add_member_identity_remote_writes_remote_identity_id_column` perd son `#[ignore]`. | |
| 101 | + | |
| 102 | +## Analyse de compatibilité | |
| 103 | + | |
| 104 | +- [ ] **Breaking** sur `team_member::Model` (PK change). Les apps qui font `Entity::find_by_id((team_id, user_id))` doivent migrer. Mitigation : tous les call-sites internes sont listés et migrés dans la même PR. | |
| 105 | +- [x] Migration up/down réversible (snapshots SQL clairs). | |
| 106 | +- [x] `JwtBlacklistService` reste rétrocompatible — nouvelle méthode purement additive. | |
| 107 | + | |
| 108 | +## Alternatives considérées | |
| 109 | + | |
| 110 | +### Alternative A (recommandée) — PK surrogate `id` + CHECK XOR + nouvelle méthode JWT | |
| 111 | + | |
| 112 | +**Pros** : ferme les deux dettes en une RFC ; pattern propre. | |
| 113 | + | |
| 114 | +**Cons** : breaking sur `Model`. Mitigation par PR-bundle. | |
| 115 | + | |
| 116 | +### Alternative B — Garder la PK composite et stocker un sentinel `user_id = nil` | |
| 117 | + | |
| 118 | +**Cons** : viole la FK `users.id` ; requiert de désactiver la FK ; pollue les requêtes locales. | |
| 119 | + | |
| 120 | +**Rejeté.** | |
| 121 | + | |
| 122 | +### Alternative C — Table séparée `team_members_remote` | |
| 123 | + | |
| 124 | +**Cons** : duplique le service ; complexifie chaque endpoint listing. | |
| 125 | + | |
| 126 | +**Rejeté.** | |
| 127 | + | |
| 128 | +### Alternative D — Ne rien faire | |
| 129 | + | |
| 130 | +**Cons** : RFC 0009 reste fausse en prod, RFC 0010 promet l'invalidation et délivre 0. | |
| 131 | + | |
| 132 | +**Rejeté.** | |
| 133 | + | |
| 134 | +## Testing plan | |
| 135 | + | |
| 136 | +8 tests : | |
| 137 | + | |
| 138 | +**PK relax** : | |
| 139 | +1. Lever `#[ignore]` sur `rfc0009_add_member_identity_remote_writes_remote_identity_id_column` (cf. RFC 0009). | |
| 140 | +2. `add_member_identity_remote_persists_with_null_user_id`. | |
| 141 | +3. `team_member_lookup_by_id_works_after_migration` (sanity). | |
| 142 | + | |
| 143 | +**JWT blacklist by remote** : | |
| 144 | +4. `blacklist_by_remote_returns_zero_for_noop_iterator`. | |
| 145 | +5. `blacklist_by_remote_inserts_one_row_per_token` (DB-mocked). | |
| 146 | +6. `blacklist_by_remote_skips_tokens_for_other_identities`. | |
| 147 | + | |
| 148 | +**Signal integration** : | |
| 149 | +7. `apply_delete_actor_with_iterator_returns_jwts_blacklisted`. | |
| 150 | +8. `apply_delete_actor_with_noop_iterator_keeps_jwts_blacklisted_zero` (régression). | |
| 151 | + | |
| 152 | +## Migration guide | |
| 153 | + | |
| 154 | +```markdown | |
| 155 | +### Changed (rustwarden-core) | |
| 156 | + | |
| 157 | +- Migration `m20260510_000016_relax_team_members_pk` (BREAKING schéma). | |
| 158 | +- `team_member::Model` : nouveau champ `id: Uuid` + bascule de la PK | |
| 159 | + SeaORM. Les call-sites `Entity::find_by_id((team_id, user_id))` | |
| 160 | + doivent migrer vers `Entity::find().filter(...)`. | |
| 161 | + | |
| 162 | +### Added (rustwarden-core, feature `experimental-fediverse`) | |
| 163 | + | |
| 164 | +- `JwtBlacklistService::blacklist_by_remote_identity_id(db, remote_id)`. | |
| 165 | +- Trait `ActiveTokenIterator` + impl `NoopActiveTokenIterator`. | |
| 166 | +- `FediverseSignalService::apply_delete_actor_with_iterator(db, ap_id, iter)`. | |
| 167 | + | |
| 168 | +### Migration steps | |
| 169 | + | |
| 170 | +1. `cargo run --bin migrate`. | |
| 171 | +2. Apps consommatrices : | |
| 172 | + - Si vous appelez `team_member::Entity::find_by_id`, remplacer par | |
| 173 | + un filter explicite. | |
| 174 | + - Pour activer l'invalidation JWT-on-Delete{Actor}, implémenter | |
| 175 | + `ActiveTokenIterator` selon votre stockage (sessions DB, JWT cache, | |
| 176 | + etc.) et le câbler via `with_active_token_iterator(...)`. | |
| 177 | +3. Le test d'intégration `rfc0009_add_member_identity_remote…` n'est | |
| 178 | + plus `#[ignore]`. | |
| 179 | +``` | |
| 180 | + | |
| 181 | +## Open questions | |
| 182 | + | |
| 183 | +Toutes résolues à l'auto-revue : | |
| 184 | + | |
| 185 | +- [x] **PK surrogate `id` vs PK ternaire `(team_id, user_id, remote_identity_id)`** : surrogate `id` car SeaORM gère mal les PK composites avec colonnes nullable. | |
| 186 | +- [x] **Trait `ActiveTokenIterator` vs étendre `JwtBlacklistService`** : trait pluggable car le storage des tokens actifs varie selon les apps. | |
| 187 | +- [x] **`NoopActiveTokenIterator` par défaut** : oui, sémantique « pas pire que l'existant », évite de forcer toutes les apps consommatrices à implémenter immédiatement. | |
| 188 | + | |
| 189 | +## Tracking issue | |
| 190 | + | |
| 191 | +À ouvrir au moment de la PR. | |
| 192 | + | |
| 193 | +--- | |
| 194 | + | |
| 195 | +## Score d'évaluation | |
| 196 | + | |
| 197 | +| Critère | Note (1-5) | Commentaire | | |
| 198 | +|---|---|---| | |
| 199 | +| Généralité | 4 | Le pattern PK surrogate s'applique à d'autres tables avec sibling FK. | | |
| 200 | +| Stabilité API | 3 | Breaking sur `team_member::Model` (PK surrogate) — gating par PR bundle. | | |
| 201 | +| Performance | 4 | Indexes partiels couvrent les deux chemins. | | |
| 202 | +| Sécurité | 5 | Ferme la promesse `Delete{Actor}` → invalidation JWT. | | |
| 203 | +| Complexité d'implémentation | 3 | ~500 lignes (migration + Model + service + trait + 8 tests). | | |
| 204 | +| Maintenance | 3 | Trait à maintenir. | | |
| 205 | +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide complet. | | |
| 206 | +| **Total** | **26/35** | ≥ 25 → Accepted | |
A
docs/rfcs/0019-fediverse-outbound-metrics.md
+146
-0
@@ -0,0 +1,146 @@
| 1 | +# RFC 0019 — Fediverse: observabilité du worker outbound (tracing counters) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (rustwarden) | |
| 4 | +- **Date** : 2026-05-10 | |
| 5 | +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 27/35) | |
| 6 | +- **Apps consommatrices concernées** : toute app fediverse en production qui veut surveiller la santé de la file outbound (livraisons, échecs, instances mortes). | |
| 7 | +- **Plan associé** : Phase 5 — production polish. | |
| 8 | +- **Dépendances** : RFC 0011 (file outbound), RFC 0010 (retry/backoff). | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Émettre des événements `tracing` structurés à chaque transition de [`FediverseOutboundService::drain_one`](../../crates/rustwarden-core/src/services/fediverse_outbound_service.rs) (`pending → delivered`, `pending → pending+next_retry_at`, `pending → dead`). Ces événements servent de **signaux primaires** pour les exporters Prometheus / Loki / OpenTelemetry que les apps consommatrices configurent au niveau `tracing-subscriber`. | |
| 13 | + | |
| 14 | +Aucune nouvelle dépendance, aucun schéma DB. | |
| 15 | + | |
| 16 | +## Motivation | |
| 17 | + | |
| 18 | +Sans cette RFC, le worker outbound silencieusement bascule des rows en `dead` sans aucune trace. En prod un opérateur ne sait pas combien de livraisons réussissent, ni quelle instance pose problème. Le master plan §10 Phase 5 « monitoring file outbound, métriques `tracing` (compteurs livraisons, retries, instances mortes) » est ouvert. | |
| 19 | + | |
| 20 | +## Non-goals | |
| 21 | + | |
| 22 | +- **Pas d'exporter Prometheus intégré** — les apps configurent leur subscriber. | |
| 23 | +- **Pas de dashboard Grafana** — différé à un livrable doc. | |
| 24 | +- **Pas de table d'audit** — `fediverse_outbound_queue` contient déjà l'historique persistant ; cette RFC vise la **dernière livraison**, pas l'historique. | |
| 25 | + | |
| 26 | +## API proposée | |
| 27 | + | |
| 28 | +Aucune nouvelle API publique. Quatre points d'instrumentation sont ajoutés dans `drain_one` : | |
| 29 | + | |
| 30 | +```rust | |
| 31 | +tracing::info!( | |
| 32 | + target: "rustwarden::fediverse::outbound", | |
| 33 | + activity_id = %item.activity_id, | |
| 34 | + target_inbox = %item.target_inbox, | |
| 35 | + target_domain = %item.target_domain, | |
| 36 | + attempts = updated.attempts, | |
| 37 | + event = "delivered", | |
| 38 | +); | |
| 39 | + | |
| 40 | +tracing::warn!( | |
| 41 | + target: "rustwarden::fediverse::outbound", | |
| 42 | + activity_id = …, | |
| 43 | + next_retry_at = ?next_retry_at, | |
| 44 | + last_error = %err, | |
| 45 | + attempts = next_attempts, | |
| 46 | + event = "deferred", | |
| 47 | +); | |
| 48 | + | |
| 49 | +tracing::error!( | |
| 50 | + target: "rustwarden::fediverse::outbound", | |
| 51 | + activity_id = …, | |
| 52 | + last_error = %err, | |
| 53 | + attempts = next_attempts, | |
| 54 | + max_attempts = max_attempts, | |
| 55 | + event = "dead", | |
| 56 | +); | |
| 57 | + | |
| 58 | +tracing::error!( | |
| 59 | + target: "rustwarden::fediverse::outbound", | |
| 60 | + error = %e, | |
| 61 | + event = "drain_failed", | |
| 62 | +); | |
| 63 | +``` | |
| 64 | + | |
| 65 | +Le `target` `rustwarden::fediverse::outbound` permet aux exporters de filtrer précisément sans interférer avec les autres logs rustwarden. | |
| 66 | + | |
| 67 | +## Analyse de compatibilité | |
| 68 | + | |
| 69 | +- [x] Strictement additif (logs uniquement). | |
| 70 | +- [x] Aucune signature publique modifiée. | |
| 71 | +- [x] Aucune dépendance supplémentaire — `tracing` déjà dans le workspace. | |
| 72 | + | |
| 73 | +## Alternatives considérées | |
| 74 | + | |
| 75 | +### Alternative A (recommandée) — `tracing` events avec target dédié | |
| 76 | + | |
| 77 | +**Pros** : zéro dep nouvelle ; compatible avec tous les exporters Rust (`tracing-subscriber` → Prometheus / Loki / OTEL) ; les apps consommatrices choisissent leur backend. | |
| 78 | + | |
| 79 | +### Alternative B — Crate `metrics-rs` direct | |
| 80 | + | |
| 81 | +**Cons** : dépendance opinionée ; pollue le workspace pour les apps qui n'utilisent pas `metrics-rs`. | |
| 82 | + | |
| 83 | +**Rejeté.** | |
| 84 | + | |
| 85 | +### Alternative C — Pas d'instrumentation, laisser les apps wrap leur propre couche | |
| 86 | + | |
| 87 | +**Cons** : duplication ; cohérence des nommages perdue. | |
| 88 | + | |
| 89 | +**Rejeté.** | |
| 90 | + | |
| 91 | +## Testing plan | |
| 92 | + | |
| 93 | +3 tests : | |
| 94 | + | |
| 95 | +1. `delivered_event_emitted_on_success` — capture les events via `tracing-subscriber::test` et vérifie qu'un `event=delivered` est émis avec les champs attendus. | |
| 96 | +2. `deferred_event_emitted_on_failure_below_max_attempts`. | |
| 97 | +3. `dead_event_emitted_at_max_attempts`. | |
| 98 | + | |
| 99 | +Les tests existants de `drain_one` continuent de passer (les events sont best-effort, ne changent pas la valeur de retour). | |
| 100 | + | |
| 101 | +## Migration guide | |
| 102 | + | |
| 103 | +```markdown | |
| 104 | +### Added (rustwarden-core, feature `experimental-fediverse`) | |
| 105 | + | |
| 106 | +- Quatre points d'instrumentation `tracing` dans | |
| 107 | + `FediverseOutboundService::drain_one` sous le target | |
| 108 | + `rustwarden::fediverse::outbound`. | |
| 109 | + | |
| 110 | +### Migration steps | |
| 111 | + | |
| 112 | +Aucune obligatoire. Pour exposer les métriques : | |
| 113 | + | |
| 114 | +```rust | |
| 115 | +// Apps Prometheus : | |
| 116 | +let prom_layer = tracing_prometheus::PrometheusBuilder::new() | |
| 117 | + .with_target("rustwarden::fediverse::outbound", true) | |
| 118 | + .build(); | |
| 119 | +tracing_subscriber::registry().with(prom_layer).init(); | |
| 120 | +``` | |
| 121 | +``` | |
| 122 | + | |
| 123 | +## Open questions | |
| 124 | + | |
| 125 | +- [x] **Naming `target`** : `rustwarden::fediverse::outbound` (cohérent avec la convention rustwarden existante). | |
| 126 | +- [x] **Niveau** : `info` pour delivered, `warn` pour deferred, `error` pour dead/drain_failed. | |
| 127 | +- [x] **Champs** : alignés sur le master plan §10 Phase 5. | |
| 128 | + | |
| 129 | +## Tracking issue | |
| 130 | + | |
| 131 | +À ouvrir au moment de la PR. | |
| 132 | + | |
| 133 | +--- | |
| 134 | + | |
| 135 | +## Score d'évaluation | |
| 136 | + | |
| 137 | +| Critère | Note (1-5) | Commentaire | | |
| 138 | +|---|---|---| | |
| 139 | +| Généralité | 4 | Pattern réutilisable pour le reste de la stack rustwarden. | | |
| 140 | +| Stabilité API | 5 | Strictement additif, logs uniquement. | | |
| 141 | +| Performance | 5 | `tracing::*!` est zero-cost quand le target est filtré out. | | |
| 142 | +| Sécurité | 4 | Aide à détecter les attaques de retry / floods. | | |
| 143 | +| Complexité d'implémentation | 5 | ~40 lignes (4 tracing + 3 tests). | | |
| 144 | +| Maintenance | 4 | Pas de nouvelle dep. | | |
| 145 | +| Documentation | 4 | Migration guide complet, target documenté. | | |
| 146 | +| **Total** | **27/35** | ≥ 25 → Accepted | |
A
docs/rfcs/0020-fediverse-interop-tests.md
+83
-0
@@ -0,0 +1,83 @@
| 1 | +# RFC 0020 — Fediverse: tests d'interopérabilité wiremock (signed delivery round-trip) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (rustwarden) | |
| 4 | +- **Date** : 2026-05-10 | |
| 5 | +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35) | |
| 6 | +- **Apps consommatrices concernées** : toute app fediverse en production qui veut une garantie reproductible que les signatures HTTP draft-cavage produites localement sont vérifiables par un récepteur tiers (Mastodon-style). | |
| 7 | +- **Plan associé** : Phase 5 — production polish. | |
| 8 | +- **Dépendances** : RFC 0015 (signatures), RFC 0016 (HttpSignedDelivery), RFC 0017 (auto-resolve), RFC 0011 (file outbound). | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Ajouter un binaire de tests d'intégration `tests/fediverse_interop.rs` dans `rustwarden-core` qui monte un serveur HTTP local via [`wiremock`](https://docs.rs/wiremock) joué comme « faux Mastodon ». Trois scénarios : | |
| 13 | + | |
| 14 | +1. **Sign + verify round-trip** : la couche `HttpSignedDelivery` (couche privée) signe un POST `application/activity+json`, le serveur capture la requête, et le test re-vérifie la signature en local avec `parse_and_verify_cavage`. | |
| 15 | +2. **Inbox 202 → delivered** : `drain_one` contre une route wiremock qui répond `202 Accepted` met la row à `Delivered`. | |
| 16 | +3. **Inbox 503 → deferred** : même chose mais avec `503 Service Unavailable` ; la row reste `Pending` avec `next_retry_at` calé sur le ladder RFC 0010. | |
| 17 | + | |
| 18 | +Aucune nouvelle API publique. Aucune nouvelle migration. Une seule nouvelle dev-dependency : `wiremock = "0.6"`. | |
| 19 | + | |
| 20 | +## Motivation | |
| 21 | + | |
| 22 | +Sans tests interop, les RFCs 0015–0017 reposent sur des unit tests qui exercent les fonctions pures (`rsa_sha256_sign`, `parse_signature_header`). Aucun chemin n'exerce le moteur HTTP réel ni la sérialisation `Date`/`Host`/`Signature` *telle que reqwest la transmet*. Un mismatch silencieux (capitalisation des noms d'header, espaces, ré-encodage URL) ne sera détecté qu'en production face à Mastodon. | |
| 23 | + | |
| 24 | +Le master plan §10 Phase 5 : « tests interop avec Mastodon : profil discoverable mais auth refusée proprement (degrade gracieux selon `interop_scope`) ». Cette RFC livre le squelette générique réutilisable ; les scénarios spécifiques (NodeInfo refus, auth fédérée e2e) restent ouverts pour des RFCs successives qui ré-utiliseront le même harness. | |
| 25 | + | |
| 26 | +## Non-goals | |
| 27 | + | |
| 28 | +- **Pas de Docker Mastodon réel** : trop coûteux en CI (image Ruby + DB Postgres dédiée). Le master plan §11 prévoit `docker-compose.fediverse-test.yml` pour les tests `#[ignore]` manuels. | |
| 29 | +- **Pas de tests `#[ignore]`** : les tests de cette RFC tournent dans le suite par défaut (`cargo test --features experimental-fediverse`). | |
| 30 | +- **Pas de couverture HTTP/2** : Mastodon parle HTTP/1.1 ; wiremock supporte les deux mais on cible la cible commune. | |
| 31 | +- **Pas de mesure de latence** : couvert par le bench Phase 5 (charge 1×1000×50, hors scope CI). | |
| 32 | + | |
| 33 | +## API proposée | |
| 34 | + | |
| 35 | +Aucune. Les tests sont *binaires* sous `crates/rustwarden-core/tests/fediverse_interop.rs`. Ils consomment uniquement les APIs publiques existantes : | |
| 36 | + | |
| 37 | +- `crate::fediverse::http_signatures::{rsa_sha256_sign, build_signature_input, parse_and_verify_cavage}` | |
| 38 | +- `crate::services::actor_key_service::ActorKeyService` (`generate`) | |
| 39 | +- `crate::services::fediverse_outbound_service::{FediverseOutboundService, OutboundDelivery, OutboundStatus}` | |
| 40 | +- `crate::fediverse::http_signatures::HttpSignedDelivery` (instance avec `MockDatabase`) | |
| 41 | + | |
| 42 | +## Compatibilité | |
| 43 | + | |
| 44 | +Aucun impact sur les apps consommatrices : ces tests sont *internes* à `rustwarden-core` et n'exposent ni route ni type. La feature `experimental-fediverse` reste obligatoire (`#[cfg(feature = "experimental-fediverse")]` au sommet du fichier). | |
| 45 | + | |
| 46 | +## Alternatives | |
| 47 | + | |
| 48 | +1. **Ne rien faire** — *rejeté* : régressions silencieuses sur le format de signature passeraient le CI. | |
| 49 | +2. **Mock `reqwest::Client` avec `mockito`** — *rejeté* : `reqwest::Client` n'expose pas d'injection de transport ; il faudrait passer par `Builder::resolve` + serveur local, ce que `wiremock` simplifie déjà. | |
| 50 | +3. **Lancer un Mastodon réel via `testcontainers`** — *rejeté* à ce stade : démarrage 30 s+, dépendances Ruby, instabilité CI ; gardé en option future pour un binaire `tests/interop_mastodon.rs` `#[ignore]`. | |
| 51 | +4. **Tester uniquement la couche pure** (déjà couverte) — *insuffisant* : ne lève pas les bugs « signature passe localement mais reqwest envoie un header capitalisé que Mastodon rejette ». | |
| 52 | + | |
| 53 | +## Testing plan | |
| 54 | + | |
| 55 | +Trois cas dans `tests/fediverse_interop.rs` : | |
| 56 | + | |
| 57 | +| Test | Comportement attendu | | |
| 58 | +|---|---| | |
| 59 | +| `signed_request_round_trip_through_wiremock` | POST signé reçu, `Signature` parsable, vérifié contre la public key locale. | | |
| 60 | +| `outbound_delivers_when_inbox_returns_202` | `drain_one` retourne `Delivered`, `delivered_at` non-NULL. | | |
| 61 | +| `outbound_defers_when_inbox_returns_503` | `drain_one` retourne `Pending` (count++) avec `next_retry_at = now + 5min`. | | |
| 62 | + | |
| 63 | +Les tests utilisent un `MockDatabase` SeaORM pour fixturer la `users` row (clés générées en RAM via `ActorKeyService::generate()`) et la `fediverse_outbound_queue` row. Pas de PostgreSQL requis. | |
| 64 | + | |
| 65 | +## Migration guide | |
| 66 | + | |
| 67 | +Aucune. Pure addition de tests + dev-dep. | |
| 68 | + | |
| 69 | +## Open questions | |
| 70 | + | |
| 71 | +- **Q1** : faut-il un harness réutilisable (`tests/common/wiremock_inbox.rs`) pour les futures RFCs interop ? Réponse provisoire : non, on attend la troisième RFC qui en aurait besoin (règle de trois). | |
| 72 | + | |
| 73 | +## Score (26/35) | |
| 74 | + | |
| 75 | +| Critère | Note | Note | | |
| 76 | +|---|---|---| | |
| 77 | +| Généralité | 4/5 | Tests utiles à toutes les apps consommatrices. | | |
| 78 | +| Stabilité API | 5/5 | Pas d'API publique modifiée. | | |
| 79 | +| Performance | 4/5 | wiremock ~50–100 ms par test ; négligeable en CI. | | |
| 80 | +| Sécurité | 3/5 | Couvre la chaîne signature mais pas les attaques (replay, tampering) — RFC distincte. | | |
| 81 | +| Complexité | 4/5 | Squelette de ~150 LoC + 1 dev-dep. | | |
| 82 | +| Maintenance | 3/5 | wiremock 0.6 stable depuis 2024 ; surface d'API minimale utilisée. | | |
| 83 | +| Documentation | 3/5 | Cette RFC + commentaires inline ; pas de guide séparé. | |
A
docs/rfcs/0021-fediverse-ui-admin-foundations.md
+136
-0
@@ -0,0 +1,136 @@
| 1 | +# RFC 0021 — Fediverse UI: admin foundations (settings + trust list) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (rustwarden) | |
| 4 | +- **Date** : 2026-05-10 | |
| 5 | +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35) | |
| 6 | +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-ui` qui veut piloter la fédération via UI HTML. | |
| 7 | +- **Plan associé** : [Plan UI HTML Fediverse §7 Slice A](../plans/2026-05-10-fediverse-ui-html.md#7-découpage-en-rfcs-incrémentales). | |
| 8 | +- **Dépendances** : RFC 0001 (`FediverseConfig`), RFC 0004 (`FediverseTrustService`), constitution Rustwarden (HTMX/Alpine/CSRF/i18n). | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Livrer les **deux écrans admin foundationnels** qui débloquent toute la | |
| 13 | +suite de l'UI fediverse : | |
| 14 | + | |
| 15 | +1. `/admin/fediverse/settings` — toggle runtime `fediverse_enabled`, | |
| 16 | + sélecteurs `trust_mode` (`allowlist` / `open_with_blocklist`), | |
| 17 | + `interop_scope` (`rustwarden_only` / `rustwarden_plus_universal_read` | |
| 18 | + / `open`), checkbox `authorized_fetch`. | |
| 19 | +2. `/admin/fediverse/trust_list` — table des domaines avec form | |
| 20 | + d'ajout (handle + raison) et bouton suppression confirmé. Affiche la | |
| 21 | + **allowlist** ou la **blocklist** selon `trust_mode`. | |
| 22 | + | |
| 23 | +Cette RFC livre aussi **3 partials Askama partagés** réutilisés par les | |
| 24 | +Slices B et C : `_modal_confirm.html`, `_toast.html`, `_empty_state.html`. | |
| 25 | + | |
| 26 | +## Motivation | |
| 27 | + | |
| 28 | +Sans ces deux écrans, la fédération est pilotée uniquement par SQL | |
| 29 | +direct ou variables d'environnement. Aucun admin non-technique ne peut | |
| 30 | +activer/désactiver la fonctionnalité ni gérer la liste de confiance — | |
| 31 | +le master plan §10 Phase 5 identifie cela comme un blocant adoption. | |
| 32 | + | |
| 33 | +## Non-goals | |
| 34 | + | |
| 35 | +- **Pas de gestion fine par scope** (`trust_scope` per-domain) — différé à une RFC future si la demande émerge. | |
| 36 | +- **Pas de bulk-import** de domaines — un par un suffit pour le MVP. | |
| 37 | +- **Pas d'auto-discovery** d'instances Mastodon — l'admin sait quoi ajouter. | |
| 38 | +- **Pas de page mobile dédiée** — DaisyUI responsive suffit (denses : pas de problème ≤ 50 rows). | |
| 39 | + | |
| 40 | +## API proposée | |
| 41 | + | |
| 42 | +### Routes UI HTML (rustwarden-ui) | |
| 43 | + | |
| 44 | +| Méthode | Path | Handler | Auth | | |
| 45 | +|---|---|---|---| | |
| 46 | +| GET | `/admin/fediverse/settings` | `admin_fediverse::get_settings` | admin | | |
| 47 | +| POST | `/admin/fediverse/settings` | `admin_fediverse::update_settings` | admin + CSRF | | |
| 48 | +| GET | `/admin/fediverse/trust_list` | `admin_fediverse::get_trust_list` | admin | | |
| 49 | +| POST | `/admin/fediverse/trust_list/add` | `admin_fediverse::add_trust_entry` | admin + CSRF | | |
| 50 | +| POST | `/admin/fediverse/trust_list/{id}/delete` | `admin_fediverse::delete_trust_entry` | admin + CSRF | | |
| 51 | + | |
| 52 | +Si `fediverse_enabled=false` au runtime : toutes ces routes **sauf | |
| 53 | +`get_settings`/`update_settings`** retournent `404` (Plan UI §3 P9). | |
| 54 | +La page settings reste accessible pour permettre la (ré)activation. | |
| 55 | + | |
| 56 | +### Templates Askama (nouveaux) | |
| 57 | + | |
| 58 | +``` | |
| 59 | +crates/rustwarden-ui/templates/admin/fediverse/ | |
| 60 | +├── settings.html # Slice A — toggle + sélecteurs | |
| 61 | +├── trust_list.html # Slice A — table + form | |
| 62 | +└── _empty_state.html # partial réutilisable Slices B/C | |
| 63 | +crates/rustwarden-ui/templates/components/fediverse/ | |
| 64 | +├── _modal_confirm.html # partial confirmation destructive | |
| 65 | +└── _toast.html # partial feedback HTMX (oob) | |
| 66 | +``` | |
| 67 | + | |
| 68 | +### Handlers (nouveau fichier) | |
| 69 | + | |
| 70 | +`crates/rustwarden-ui/src/handlers/admin_fediverse.rs` — 5 handlers Axum. | |
| 71 | + | |
| 72 | +### i18n (nouveau) | |
| 73 | + | |
| 74 | +`locales/{fr,en,de,es}/fediverse.yaml` — clés : | |
| 75 | +- `settings.*` (toggle, selectors, save button, save toast) | |
| 76 | +- `trust.*` (add, delete, confirm, empty state) | |
| 77 | +- `common.*` (cancel, confirm, error_500) | |
| 78 | + | |
| 79 | +## Compatibilité | |
| 80 | + | |
| 81 | +**Aucun impact** sur les apps consommatrices qui n'utilisent pas | |
| 82 | +`rustwarden-ui`. Les routes API JSON parallèles existent déjà (RFC 0004 | |
| 83 | +`FediverseTrustService`) et restent inchangées. | |
| 84 | + | |
| 85 | +Les templates s'ajoutent ; aucun template existant n'est modifié. | |
| 86 | + | |
| 87 | +## Alternatives | |
| 88 | + | |
| 89 | +1. **Ne rien faire** — *rejeté* : adoption bloquée pour les admins non-SQL. | |
| 90 | +2. **Tout sur un seul écran** (settings + trust_list fusionnés) — *rejeté* : viole P1 (une action primaire par écran), 4 sélecteurs + table + 2 forms = surcharge. | |
| 91 | +3. **Séparer en 2 RFCs (settings d'abord, trust_list après)** — *rejeté* : trust_list est inutilisable sans settings et inversement (le `trust_mode` détermine quelle table afficher) ; les coupler force la cohérence. | |
| 92 | +4. **SPA Alpine au lieu de HTMX** — *rejeté* : viole la convention Rustwarden HTMX-first. | |
| 93 | + | |
| 94 | +## Testing plan | |
| 95 | + | |
| 96 | +| Test | Type | | |
| 97 | +|---|---| | |
| 98 | +| `get_settings` non-admin → 403 | handler | | |
| 99 | +| `update_settings` toggle off → DB row mise à jour | handler + mock DB | | |
| 100 | +| `update_settings` invalid trust_mode → 400 + form rendu | handler | | |
| 101 | +| `get_trust_list` mode allowlist → table allowlist | handler | | |
| 102 | +| `get_trust_list` mode blocklist → table blocklist | handler | | |
| 103 | +| `add_trust_entry` doublon → 409 + toast erreur | handler | | |
| 104 | +| `delete_trust_entry` not found → 404 | handler | | |
| 105 | +| `fediverse_enabled=false` : `/trust_list` → 404 | route guard | | |
| 106 | +| Tabbing complet sur chaque page | manuel | | |
| 107 | +| Lighthouse a11y ≥ 95 | manuel | | |
| 108 | + | |
| 109 | +Les handlers utilisent `MockDatabase` ; aucun PostgreSQL nécessaire. | |
| 110 | + | |
| 111 | +## Migration guide | |
| 112 | + | |
| 113 | +Aucune migration SQL. Les apps qui utilisent `rustwarden-ui` doivent : | |
| 114 | + | |
| 115 | +1. Activer la feature `experimental-fediverse` sur **les deux** crates. | |
| 116 | +2. Monter le router via `rustwarden_ui::routes::router_with_fediverse(...)` | |
| 117 | + (helper ajouté dans cette RFC). | |
| 118 | +3. Fournir le pack i18n `fediverse.yaml` (livré dans `locales/`). | |
| 119 | + | |
| 120 | +## Open questions | |
| 121 | + | |
| 122 | +- **Q1** : faut-il un toast de confirmation après chaque toggle, ou un | |
| 123 | + banner persistant *« Fédération activée »* en haut des pages | |
| 124 | + fediverse ? *Réponse retenue* : toast (cohérent avec le reste). | |
| 125 | + | |
| 126 | +## Score (26/35) | |
| 127 | + | |
| 128 | +| Critère | Note | Détail | | |
| 129 | +|---|---|---| | |
| 130 | +| Généralité | 4/5 | Réutilisable par toute app `rustwarden-ui` ; les partials servent Slices B/C. | | |
| 131 | +| Stabilité API | 5/5 | Pas de breaking change ; pures additions. | | |
| 132 | +| Performance | 4/5 | Templates Askama compilés ; pas de N+1. | | |
| 133 | +| Sécurité | 3/5 | CSRF + admin_required couvrent les bases ; pas d'audit dédié. | | |
| 134 | +| Complexité | 3/5 | 5 handlers + 5 templates + i18n × 4 = ~600 LoC. | | |
| 135 | +| Maintenance | 4/5 | Patterns existants Rustwarden réutilisés. | | |
| 136 | +| Documentation | 3/5 | Cette RFC + rustdoc handlers ; pas de guide opérateur dédié. | |
M
docs/rfcs/README.md
+6
-0
@@ -74,6 +74,12 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
| 74 | 74 | | [0013](0013-fediverse-auth-flow-routes.md) | Fediverse — Routes HTTP du flow d'auth AP-native (slice routes 2/N) | **Accepted** (26/35) | EBO | 2026-05-10 | |
| 75 | 75 | | [0014](0014-fediverse-invite-routes.md) | Fediverse — Routes HTTP des invitations cross-instance (slice routes 3/N) | **Accepted** (26/35) | EBO | 2026-05-10 | |
| 76 | 76 | | [0015](0015-fediverse-http-signatures.md) | Fediverse — Signatures HTTP draft-cavage (RSA-SHA256) | **Accepted** (27/35) | EBO | 2026-05-10 | |
| 77 | +| [0016](0016-fediverse-signed-delivery-and-verifier-widening.md) | Fediverse — Élargissement `SignatureVerifier` + `HttpSignedDelivery` | **Accepted** (27/35) | EBO | 2026-05-10 | | |
| 78 | +| [0017](0017-fediverse-auto-resolve-public-key.md) | Fediverse — Auto-résolution PEM public dans `HttpSigVerifier` | **Accepted** (27/35) | EBO | 2026-05-10 | | |
| 79 | +| [0018](0018-fediverse-tech-debt-closure.md) | Fediverse — Clôture dette technique (PK relax + JWT blacklist par remote) | **Accepted** (26/35) | EBO | 2026-05-10 | | |
| 80 | +| [0019](0019-fediverse-outbound-metrics.md) | Fediverse — Observabilité du worker outbound (tracing counters) | **Accepted** (27/35) | EBO | 2026-05-10 | | |
| 81 | +| [0020](0020-fediverse-interop-tests.md) | Fediverse — Tests d'interopérabilité wiremock (signed delivery round-trip) | **Accepted** (26/35) | EBO | 2026-05-10 | | |
| 82 | +| [0021](0021-fediverse-ui-admin-foundations.md) | Fediverse UI — Admin foundations (settings + trust list) | **Accepted** (26/35) | EBO | 2026-05-10 | | |
| 77 | 83 | |
| 78 | 84 | À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant. |
| 79 | 85 |
GitRust