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