update pour gitrust rfc 29 -30

EBO <eric.bouhana@softalys.com> committé le 2026-05-15 21:20

c3b9aca1b41951507bde4e8a0e0420fbd8a22326

1 parent(s)

20 fichiers modifiés +3960 -6
M CHANGELOG.md
+19 -0
@@ -76,6 +76,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
76 76
77 77 ### Added
78 78
79 +- **RFC 0029 — `RemoteResourceShareService`** ([rustwarden_fediverse::services::remote_resource_share_service](crates/rustwarden-fediverse/src/services/remote_resource_share_service.rs)) :
80 + partage de ressources métier (`repository`, `wall`, …) avec une identité
81 + ActivityPub remote, sans passer par une team. 5 méthodes publiques (`share`,
82 + `unshare`, `effective_permission_for_remote_identity`, `list_shares`,
83 + `list_for_remote_identity`) — upsert par `(resource_id, remote_identity_id)`,
84 + hiérarchie `read < write < admin`, FK cascade sur `resources` / `remote_identities`
85 + / `users`. Migration `m20260516_000002_remote_resource_shares`. 11 tests
86 + unitaires `MockDatabase`. Origine : RFC 0001 gitrust. Doc : SERVICES_REFERENCE
87 + §21.15.
88 +- **RFC 0030 — `RemotePersonalAccessTokenService<S>`** ([rustwarden_fediverse::services::remote_personal_access_token_service](crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs)) :
89 + PAT pour identités ActivityPub remote, generic sur le trait `TokenScope`
90 + framework. 5 méthodes publiques (`create`, `verify`, `revoke`,
91 + `list_for_remote_identity`, `revoke_all_for_remote_identity`) — SHA-256 +
92 + constant-time compare via `subtle`, soft-revoke, refus du token si l'identité
93 + remote est `disabled_at`. Préfixe distinct `rwfed_pat_<64 hex>` pour
94 + discrimination côté consommateur. Migration `m20260516_000003_remote_pat`
95 + (table + partial index sur token_hash WHERE revoked_at IS NULL). 20 tests
96 + unitaires `MockDatabase`. Origine : RFC 0002 gitrust. Doc : SERVICES_REFERENCE
97 + §21.16.
79 98 - **Documentation opérateur** : nouveau guide
80 99 [`docs/fediverse-operations.md`](docs/fediverse-operations.md) (10 sections, Diátaxis
81 100 how-to + reference) : activation, env vars, `app_settings` clés, trust/block list,
M crates/rustwarden-fediverse/migration/src/lib.rs
+10 -6
@@ -1,14 +1,14 @@
1 1 //! SeaORM migrations for the rustwarden-fediverse crate.
2 2 //!
3 -//! Single fused migration that creates all fediverse-protocol-only
4 -//! tables and seeds default fediverse `app_settings` keys.
3 +//! Tables created (chronological order):
5 4 //!
6 -//! Tables created:
7 5 //! - `fediverse_trusted_domains` (RFC 0004)
8 6 //! - `fediverse_blocked_domains` (RFC 0004)
9 7 //! - `fediverse_outbound_queue` (RFC 0011)
10 8 //! - `fediverse_auth_challenges` (RFC 0006)
11 9 //! - `fediverse_pending_invites` (RFC 0009)
10 +//! - `fediverse_resource_shares` (RFC 0029)
11 +//! - `fediverse_personal_access_tokens` (RFC 0030)
12 12 //!
13 13 //! Run after the core migrator:
14 14 //! ```bash
@@ -19,6 +19,8 @@
19 19 pub use sea_orm_migration::prelude::*;
20 20
21 21 pub mod m20260512_000001_fediverse_protocol_schema;
22 +pub mod m20260516_000002_remote_resource_shares;
23 +pub mod m20260516_000003_remote_pat;
22 24
23 25 /// Migrator for the rustwarden-fediverse crate.
24 26 pub struct FediverseMigrator;
@@ -30,9 +32,11 @@ impl MigratorTrait for FediverseMigrator {
30 32 }
31 33
32 34 fn migrations() -> Vec<Box<dyn MigrationTrait>> {
33 - vec![Box::new(
34 - m20260512_000001_fediverse_protocol_schema::Migration,
35 - )]
35 + vec![
36 + Box::new(m20260512_000001_fediverse_protocol_schema::Migration),
37 + Box::new(m20260516_000002_remote_resource_shares::Migration),
38 + Box::new(m20260516_000003_remote_pat::Migration),
39 + ]
36 40 }
37 41 }
38 42
A crates/rustwarden-fediverse/migration/src/m20260516_000002_remote_resource_shares.rs
+184 -0
@@ -0,0 +1,184 @@
1 +//! Migration `m20260516_000002_remote_resource_shares` (RFC 0029).
2 +//!
3 +//! Creates the `fediverse_resource_shares` table that records permission
4 +//! grants of a generic application resource (identified by the
5 +//! `(resource_type, resource_id)` pair in the core `resources` registry)
6 +//! to a remote ActivityPub identity (`remote_identities.id`).
7 +//!
8 +//! Schema (one share = one `(resource_id, remote_identity_id)` pair) :
9 +//!
10 +//! | column | type | notes |
11 +//! |-----------------------|-------------|-------|
12 +//! | `id` | uuid PK | stable identifier |
13 +//! | `resource_id` | uuid NOT NULL FK `resources(id)` ON DELETE CASCADE | core registry entry |
14 +//! | `remote_identity_id` | uuid NOT NULL FK `remote_identities(id)` ON DELETE CASCADE | grantee |
15 +//! | `permission_level` | varchar(32) NOT NULL | `"read" \| "write" \| "admin"` |
16 +//! | `shared_by_user_id` | uuid NOT NULL FK `users(id)` ON DELETE CASCADE | grantor (local user) |
17 +//! | `created_at` | timestamptz NOT NULL DEFAULT NOW() | bookkeeping |
18 +//!
19 +//! Unique index on `(resource_id, remote_identity_id)` guarantees upsert
20 +//! semantics in [`RemoteResourceShareService::share`]. Secondary index on
21 +//! `remote_identity_id` accelerates the "list resources shared with this
22 +//! remote identity" lookup.
23 +
24 +use sea_orm_migration::prelude::*;
25 +
26 +#[derive(DeriveMigrationName)]
27 +pub struct Migration;
28 +
29 +#[async_trait::async_trait]
30 +impl MigrationTrait for Migration {
31 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
32 + manager
33 + .create_table(
34 + Table::create()
35 + .table(FediverseResourceShares::Table)
36 + .col(
37 + ColumnDef::new(FediverseResourceShares::Id)
38 + .uuid()
39 + .not_null()
40 + .primary_key(),
41 + )
42 + .col(
43 + ColumnDef::new(FediverseResourceShares::ResourceId)
44 + .uuid()
45 + .not_null(),
46 + )
47 + .col(
48 + ColumnDef::new(FediverseResourceShares::RemoteIdentityId)
49 + .uuid()
50 + .not_null(),
51 + )
52 + .col(
53 + ColumnDef::new(FediverseResourceShares::PermissionLevel)
54 + .string_len(32)
55 + .not_null(),
56 + )
57 + .col(
58 + ColumnDef::new(FediverseResourceShares::SharedByUserId)
59 + .uuid()
60 + .not_null(),
61 + )
62 + .col(
63 + ColumnDef::new(FediverseResourceShares::CreatedAt)
64 + .timestamp_with_time_zone()
65 + .not_null(),
66 + )
67 + .foreign_key(
68 + ForeignKey::create()
69 + .name("fk_fediverse_resource_shares_resource")
70 + .from(
71 + FediverseResourceShares::Table,
72 + FediverseResourceShares::ResourceId,
73 + )
74 + .to(Resources::Table, Resources::Id)
75 + .on_delete(ForeignKeyAction::Cascade),
76 + )
77 + .foreign_key(
78 + ForeignKey::create()
79 + .name("fk_fediverse_resource_shares_remote_identity")
80 + .from(
81 + FediverseResourceShares::Table,
82 + FediverseResourceShares::RemoteIdentityId,
83 + )
84 + .to(RemoteIdentities::Table, RemoteIdentities::Id)
85 + .on_delete(ForeignKeyAction::Cascade),
86 + )
87 + .foreign_key(
88 + ForeignKey::create()
89 + .name("fk_fediverse_resource_shares_shared_by")
90 + .from(
91 + FediverseResourceShares::Table,
92 + FediverseResourceShares::SharedByUserId,
93 + )
94 + .to(Users::Table, Users::Id)
95 + .on_delete(ForeignKeyAction::Cascade),
96 + )
97 + .to_owned(),
98 + )
99 + .await?;
100 +
101 + manager
102 + .create_index(
103 + Index::create()
104 + .name("fediverse_resource_shares_unique_idx")
105 + .table(FediverseResourceShares::Table)
106 + .col(FediverseResourceShares::ResourceId)
107 + .col(FediverseResourceShares::RemoteIdentityId)
108 + .unique()
109 + .to_owned(),
110 + )
111 + .await?;
112 +
113 + manager
114 + .create_index(
115 + Index::create()
116 + .name("fediverse_resource_shares_remote_idx")
117 + .table(FediverseResourceShares::Table)
118 + .col(FediverseResourceShares::RemoteIdentityId)
119 + .to_owned(),
120 + )
121 + .await?;
122 +
123 + Ok(())
124 + }
125 +
126 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
127 + manager
128 + .drop_index(
129 + Index::drop()
130 + .name("fediverse_resource_shares_remote_idx")
131 + .table(FediverseResourceShares::Table)
132 + .to_owned(),
133 + )
134 + .await?;
135 +
136 + manager
137 + .drop_index(
138 + Index::drop()
139 + .name("fediverse_resource_shares_unique_idx")
140 + .table(FediverseResourceShares::Table)
141 + .to_owned(),
142 + )
143 + .await?;
144 +
145 + manager
146 + .drop_table(
147 + Table::drop()
148 + .table(FediverseResourceShares::Table)
149 + .to_owned(),
150 + )
151 + .await?;
152 +
153 + Ok(())
154 + }
155 +}
156 +
157 +#[derive(Iden)]
158 +enum FediverseResourceShares {
159 + Table,
160 + Id,
161 + ResourceId,
162 + RemoteIdentityId,
163 + PermissionLevel,
164 + SharedByUserId,
165 + CreatedAt,
166 +}
167 +
168 +#[derive(Iden)]
169 +enum Resources {
170 + Table,
171 + Id,
172 +}
173 +
174 +#[derive(Iden)]
175 +enum RemoteIdentities {
176 + Table,
177 + Id,
178 +}
179 +
180 +#[derive(Iden)]
181 +enum Users {
182 + Table,
183 + Id,
184 +}
A crates/rustwarden-fediverse/migration/src/m20260516_000003_remote_pat.rs
+171 -0
@@ -0,0 +1,171 @@
1 +//! Migration `m20260516_000003_remote_pat` (RFC 0030).
2 +//!
3 +//! Creates the `fediverse_personal_access_tokens` table — Personal
4 +//! Access Tokens issued by the local instance to remote ActivityPub
5 +//! identities.
6 +//!
7 +//! Symmetry with the core `personal_access_tokens` table (different
8 +//! owner column: `remote_identity_id` instead of `user_id`):
9 +//!
10 +//! | column | type | notes |
11 +//! |-----------------------|------------------|-------|
12 +//! | `id` | uuid PK | stable identifier |
13 +//! | `remote_identity_id` | uuid NOT NULL FK `remote_identities(id)` ON DELETE CASCADE | grantee |
14 +//! | `name` | varchar(64) NOT NULL | user-chosen label |
15 +//! | `token_hash` | varchar(64) NOT NULL UNIQUE | SHA-256 hex |
16 +//! | `scopes` | text[] | reuses the framework `TokenScope` vocabulary |
17 +//! | `expires_at` | timestamptz NULL | `NULL` = permanent |
18 +//! | `last_used_at` | timestamptz NULL | UX bookkeeping |
19 +//! | `created_at` | timestamptz NOT NULL | bookkeeping |
20 +//! | `revoked_at` | timestamptz NULL | soft-revoke for audit |
21 +//!
22 +//! Indexes:
23 +//! - `idx_fediverse_pat_remote_identity` on `remote_identity_id`
24 +//! - `idx_fediverse_pat_active` partial index on `token_hash` where
25 +//! `revoked_at IS NULL` — used by [`RemotePersonalAccessTokenService::verify`]
26 +//! for sub-millisecond lookups.
27 +
28 +use sea_orm_migration::prelude::*;
29 +use sea_orm_migration::sea_orm::ConnectionTrait;
30 +
31 +#[derive(DeriveMigrationName)]
32 +pub struct Migration;
33 +
34 +#[async_trait::async_trait]
35 +impl MigrationTrait for Migration {
36 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
37 + manager
38 + .create_table(
39 + Table::create()
40 + .table(FediversePersonalAccessTokens::Table)
41 + .col(
42 + ColumnDef::new(FediversePersonalAccessTokens::Id)
43 + .uuid()
44 + .not_null()
45 + .primary_key(),
46 + )
47 + .col(
48 + ColumnDef::new(FediversePersonalAccessTokens::RemoteIdentityId)
49 + .uuid()
50 + .not_null(),
51 + )
52 + .col(
53 + ColumnDef::new(FediversePersonalAccessTokens::Name)
54 + .string_len(64)
55 + .not_null(),
56 + )
57 + .col(
58 + ColumnDef::new(FediversePersonalAccessTokens::TokenHash)
59 + .string_len(64)
60 + .not_null()
61 + .unique_key(),
62 + )
63 + .col(
64 + ColumnDef::new(FediversePersonalAccessTokens::Scopes)
65 + .array(ColumnType::Text)
66 + .not_null(),
67 + )
68 + .col(
69 + ColumnDef::new(FediversePersonalAccessTokens::ExpiresAt)
70 + .timestamp_with_time_zone()
71 + .null(),
72 + )
73 + .col(
74 + ColumnDef::new(FediversePersonalAccessTokens::LastUsedAt)
75 + .timestamp_with_time_zone()
76 + .null(),
77 + )
78 + .col(
79 + ColumnDef::new(FediversePersonalAccessTokens::CreatedAt)
80 + .timestamp_with_time_zone()
81 + .not_null(),
82 + )
83 + .col(
84 + ColumnDef::new(FediversePersonalAccessTokens::RevokedAt)
85 + .timestamp_with_time_zone()
86 + .null(),
87 + )
88 + .foreign_key(
89 + ForeignKey::create()
90 + .name("fk_fediverse_pat_remote_identity")
91 + .from(
92 + FediversePersonalAccessTokens::Table,
93 + FediversePersonalAccessTokens::RemoteIdentityId,
94 + )
95 + .to(RemoteIdentities::Table, RemoteIdentities::Id)
96 + .on_delete(ForeignKeyAction::Cascade),
97 + )
98 + .to_owned(),
99 + )
100 + .await?;
101 +
102 + manager
103 + .create_index(
104 + Index::create()
105 + .name("idx_fediverse_pat_remote_identity")
106 + .table(FediversePersonalAccessTokens::Table)
107 + .col(FediversePersonalAccessTokens::RemoteIdentityId)
108 + .to_owned(),
109 + )
110 + .await?;
111 +
112 + // Partial index on token_hash for non-revoked rows.
113 + // SeaORM's index builder does not expose `WHERE` clauses
114 + // portably, so we emit raw SQL.
115 + manager
116 + .get_connection()
117 + .execute_unprepared(
118 + "CREATE INDEX idx_fediverse_pat_active \
119 + ON fediverse_personal_access_tokens (token_hash) \
120 + WHERE revoked_at IS NULL",
121 + )
122 + .await?;
123 +
124 + Ok(())
125 + }
126 +
127 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
128 + let conn = manager.get_connection();
129 + conn.execute_unprepared("DROP INDEX IF EXISTS idx_fediverse_pat_active")
130 + .await?;
131 +
132 + manager
133 + .drop_index(
134 + Index::drop()
135 + .name("idx_fediverse_pat_remote_identity")
136 + .table(FediversePersonalAccessTokens::Table)
137 + .to_owned(),
138 + )
139 + .await?;
140 +
141 + manager
142 + .drop_table(
143 + Table::drop()
144 + .table(FediversePersonalAccessTokens::Table)
145 + .to_owned(),
146 + )
147 + .await?;
148 +
149 + Ok(())
150 + }
151 +}
152 +
153 +#[derive(Iden)]
154 +enum FediversePersonalAccessTokens {
155 + Table,
156 + Id,
157 + RemoteIdentityId,
158 + Name,
159 + TokenHash,
160 + Scopes,
161 + ExpiresAt,
162 + LastUsedAt,
163 + CreatedAt,
164 + RevokedAt,
165 +}
166 +
167 +#[derive(Iden)]
168 +enum RemoteIdentities {
169 + Table,
170 + Id,
171 +}
A crates/rustwarden-fediverse/src/models/fediverse_personal_access_token.rs
+79 -0
@@ -0,0 +1,79 @@
1 +//! SeaORM entity for `fediverse_personal_access_tokens` (RFC 0030).
2 +//!
3 +//! Personal access tokens issued by the local instance to remote
4 +//! ActivityPub identities. Companion of the core
5 +//! [`personal_access_token`](rustwarden_core::models::personal_access_token)
6 +//! but scoped to `remote_identities` instead of `users`.
7 +//!
8 +//! # Storage
9 +//!
10 +//! Only the SHA-256 hex digest of the plaintext token is persisted in
11 +//! `token_hash` — the plaintext is returned once from
12 +//! [`RemotePersonalAccessTokenService::create`] and never stored.
13 +//!
14 +//! # Token format
15 +//!
16 +//! Tokens emitted by this service are prefixed `rwfed_pat_` followed by
17 +//! 64 hex characters (32 bytes of entropy). The prefix lets the
18 +//! consumer disambiguate remote PATs from framework PATs (`rw_pat_…`)
19 +//! when both populations can present a token on the same endpoint.
20 +
21 +use chrono::{DateTime, Utc};
22 +use sea_orm::entity::prelude::*;
23 +use serde::{Deserialize, Serialize};
24 +use uuid::Uuid;
25 +
26 +/// Persistent record of one remote-identity-owned PAT.
27 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
28 +#[sea_orm(table_name = "fediverse_personal_access_tokens")]
29 +pub struct Model {
30 + /// Stable PK.
31 + #[sea_orm(primary_key, auto_increment = false)]
32 + pub id: Uuid,
33 + /// FK to `remote_identities.id` (owner).
34 + pub remote_identity_id: Uuid,
35 + /// User-chosen label (max 64 chars, validated at service layer).
36 + pub name: String,
37 + /// SHA-256 hex digest of the plaintext token (64 chars).
38 + #[sea_orm(unique)]
39 + pub token_hash: String,
40 + /// Requested scopes (consumer vocabulary via
41 + /// [`rustwarden_core::services::personal_token_service::TokenScope`]).
42 + pub scopes: Vec<String>,
43 + /// Optional expiration. `None` = permanent token.
44 + #[sea_orm(nullable)]
45 + pub expires_at: Option<DateTime<Utc>>,
46 + /// Last successful `verify()` (UX).
47 + #[sea_orm(nullable)]
48 + pub last_used_at: Option<DateTime<Utc>>,
49 + /// Creation timestamp.
50 + pub created_at: DateTime<Utc>,
51 + /// Soft-revocation timestamp. A revoked token is kept for audit but
52 + /// refuses authentication.
53 + #[sea_orm(nullable)]
54 + pub revoked_at: Option<DateTime<Utc>>,
55 +}
56 +
57 +/// SeaORM relations (none modelled here — cross-table FK to
58 +/// `remote_identities` is enforced by the migration).
59 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
60 +pub enum Relation {}
61 +
62 +impl ActiveModelBehavior for ActiveModel {}
63 +
64 +impl Model {
65 + /// Returns true if `expires_at` is set and in the past.
66 + pub fn is_expired(&self) -> bool {
67 + self.expires_at.is_some_and(|t| Utc::now() > t)
68 + }
69 +
70 + /// Returns true if `revoked_at` is set.
71 + pub fn is_revoked(&self) -> bool {
72 + self.revoked_at.is_some()
73 + }
74 +
75 + /// Returns true if the token is neither revoked nor expired.
76 + pub fn is_active(&self) -> bool {
77 + !self.is_revoked() && !self.is_expired()
78 + }
79 +}
A crates/rustwarden-fediverse/src/models/fediverse_resource_share.rs
+44 -0
@@ -0,0 +1,44 @@
1 +//! SeaORM entity for `fediverse_resource_shares` (RFC 0029).
2 +//!
3 +//! Records the grant of a permission level (`"read" | "write" | "admin"`)
4 +//! on a generic application resource (identified by the
5 +//! `(resource_type, resource_id)` pair in the core `resources` registry)
6 +//! to a remote ActivityPub identity. Companion of the core
7 +//! [`resource_share`](rustwarden_core::models::resource_share) but scoped
8 +//! to `remote_identities` instead of users / teams.
9 +//!
10 +//! Schema: see migration
11 +//! `m20260516_000002_remote_resource_shares`. A unique partial index on
12 +//! `(resource_id, remote_identity_id)` guarantees that the service
13 +//! [`RemoteResourceShareService::share`] performs an upsert per pair.
14 +
15 +use chrono::{DateTime, Utc};
16 +use sea_orm::entity::prelude::*;
17 +use serde::{Deserialize, Serialize};
18 +use uuid::Uuid;
19 +
20 +/// Persistent record of one resource share with a remote identity.
21 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
22 +#[sea_orm(table_name = "fediverse_resource_shares")]
23 +pub struct Model {
24 + /// Stable PK.
25 + #[sea_orm(primary_key, auto_increment = false)]
26 + pub id: Uuid,
27 + /// FK to `resources.id` (core resource registry).
28 + pub resource_id: Uuid,
29 + /// FK to `remote_identities.id` (grantee).
30 + pub remote_identity_id: Uuid,
31 + /// Permission level — `"read"`, `"write"` or `"admin"`.
32 + pub permission_level: String,
33 + /// FK to `users.id` of the local user who issued the share.
34 + pub shared_by_user_id: Uuid,
35 + /// Insertion timestamp.
36 + pub created_at: DateTime<Utc>,
37 +}
38 +
39 +/// SeaORM relations (none modelled here — cross-crate FKs are enforced
40 +/// by the migration but not surfaced as SeaORM relations).
41 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
42 +pub enum Relation {}
43 +
44 +impl ActiveModelBehavior for ActiveModel {}
M crates/rustwarden-fediverse/src/models/mod.rs
+4 -0
@@ -12,6 +12,10 @@ pub mod fediverse_blocked_domain;
12 12 pub mod fediverse_outbound_queue;
13 13 /// Cross-instance team-membership invitation (RFC 0009).
14 14 pub mod fediverse_pending_invite;
15 +/// Personal access token issued to a remote ActivityPub identity (RFC 0030).
16 +pub mod fediverse_personal_access_token;
17 +/// Resource share grant for a remote ActivityPub identity (RFC 0029).
18 +pub mod fediverse_resource_share;
15 19 /// Federation allowlist (RFC 0004).
16 20 pub mod fediverse_trusted_domain;
17 21 /// Remote ActivityPub identity cache (RFC 0003).
M crates/rustwarden-fediverse/src/services/mod.rs
+4 -0
@@ -14,6 +14,10 @@ pub mod fediverse_signal_service;
14 14 pub mod fediverse_trust_service;
15 15 /// Remote ActivityPub identity store (RFC 0003).
16 16 pub mod remote_identity_service;
17 +/// PAT issuance / verification / revocation for remote identities (RFC 0030).
18 +pub mod remote_personal_access_token_service;
19 +/// Sharing of generic application resources with remote identities (RFC 0029).
20 +pub mod remote_resource_share_service;
17 21 /// SeaORM-backed
18 22 /// [`RemoteIdentityRevocationChecker`](rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker)
19 23 /// implementation injected at federation mount time.
A crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs
+672 -0
@@ -0,0 +1,672 @@
1 +//! Personal access tokens issued by the local instance to remote
2 +//! ActivityPub identities (RFC 0030).
3 +//!
4 +//! Companion of
5 +//! [`rustwarden_core::services::personal_token_service::PersonalTokenService`]
6 +//! but scoped to `remote_identities` instead of `users`. Generic over a
7 +//! [`TokenScope`] type so consumer apps reuse their existing scope
8 +//! vocabularies (e.g. gitrust's `RepoScope`) for both local and remote
9 +//! PATs.
10 +//!
11 +//! # Token format
12 +//!
13 +//! Plaintext tokens are prefixed `rwfed_pat_` followed by 64 hex
14 +//! characters (32 bytes of entropy). The prefix discriminates remote
15 +//! PATs from framework PATs (`rw_pat_…` historically — the core
16 +//! implementation currently emits bare hex but the consumer must route
17 +//! by prefix). Tokens emitted by this service always include the
18 +//! prefix; the verifier strips it before hashing.
19 +//!
20 +//! # Security
21 +//!
22 +//! - SHA-256 of the plaintext is stored; the plaintext is returned once
23 +//! from [`RemotePersonalAccessTokenService::create`].
24 +//! - Constant-time hash comparison via [`subtle`] in
25 +//! [`RemotePersonalAccessTokenService::verify`].
26 +//! - A token whose owning `remote_identity` is `disabled_at IS NOT NULL`
27 +//! is rejected uniformly (returns `None`), defending against the
28 +//! "actor offline since" race after a `Delete{Actor}` signal.
29 +
30 +use crate::models::{fediverse_personal_access_token, remote_identity};
31 +use chrono::{DateTime, Utc};
32 +use rand::RngCore;
33 +use rustwarden_core::error::AppError;
34 +use rustwarden_core::services::personal_token_service::TokenScope;
35 +use sea_orm::{
36 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter,
37 + QueryOrder, Set,
38 +};
39 +use sha2::{Digest, Sha256};
40 +use std::marker::PhantomData;
41 +use subtle::ConstantTimeEq;
42 +use uuid::Uuid;
43 +
44 +/// Prefix prepended to every plaintext token emitted by this service.
45 +///
46 +/// Tokens whose plaintext does not start with this prefix are rejected
47 +/// up-front by [`RemotePersonalAccessTokenService::verify`].
48 +pub const TOKEN_PREFIX: &str = "rwfed_pat_";
49 +
50 +/// Maximum length of the user-chosen `name` field.
51 +const MAX_NAME_LEN: usize = 64;
52 +
53 +/// Personal access tokens for remote ActivityPub identities,
54 +/// parameterised by a scope vocabulary.
55 +///
56 +/// All methods are associated `async fn` — the `PhantomData<S>` only
57 +/// binds the scope type for the trait impl. Construct as
58 +/// `RemotePersonalAccessTokenService::<MyScope>::create(...)` or keep a
59 +/// type alias on the consumer side.
60 +///
61 +/// # Example
62 +///
63 +/// ```rust,ignore
64 +/// use rustwarden_fediverse::services::remote_personal_access_token_service::{
65 +/// RemotePersonalAccessTokenService, TOKEN_PREFIX,
66 +/// };
67 +///
68 +/// // Issue a PAT for a remote user authenticated via SSO.
69 +/// let (model, plaintext) = RemotePersonalAccessTokenService::<MyScope>::create(
70 +/// &db, remote_id, "deploy", &[MyScope::Write], None,
71 +/// ).await?;
72 +/// assert!(plaintext.starts_with(TOKEN_PREFIX));
73 +///
74 +/// // Consumer-side dispatch on prefix.
75 +/// if plaintext.starts_with(TOKEN_PREFIX) {
76 +/// let _ = RemotePersonalAccessTokenService::<MyScope>::verify(&db, &plaintext).await?;
77 +/// }
78 +/// ```
79 +pub struct RemotePersonalAccessTokenService<S: TokenScope> {
80 + _phantom: PhantomData<S>,
81 +}
82 +
83 +impl<S: TokenScope> RemotePersonalAccessTokenService<S> {
84 + /// Issue a new PAT for a remote identity. Returns the persisted
85 + /// row and the **plaintext** token (one-time display).
86 + ///
87 + /// # Errors
88 + ///
89 + /// - [`AppError::NotFound`] if `remote_identity_id` does not exist.
90 + /// - [`AppError::Validation`] if `name` is empty or > 64 chars, or
91 + /// if the remote identity is disabled.
92 + /// - [`AppError::Database`] on persistence error.
93 + pub async fn create(
94 + db: &DatabaseConnection,
95 + remote_identity_id: Uuid,
96 + name: &str,
97 + scopes: &[S],
98 + expires_at: Option<DateTime<Utc>>,
99 + ) -> Result<(fediverse_personal_access_token::Model, String), AppError> {
100 + let trimmed = name.trim();
101 + if trimmed.is_empty() {
102 + return Err(AppError::Validation(
103 + "Token name cannot be empty".to_string(),
104 + ));
105 + }
106 + if trimmed.chars().count() > MAX_NAME_LEN {
107 + return Err(AppError::Validation(format!(
108 + "Token name must be {MAX_NAME_LEN} characters or fewer"
109 + )));
110 + }
111 +
112 + let identity = remote_identity::Entity::find_by_id(remote_identity_id)
113 + .one(db)
114 + .await?
115 + .ok_or_else(|| {
116 + AppError::NotFound(format!("remote_identity {remote_identity_id} not found"))
117 + })?;
118 +
119 + if identity.disabled_at.is_some() {
120 + return Err(AppError::Validation(
121 + "Cannot issue a PAT for a disabled remote identity".to_string(),
122 + ));
123 + }
124 +
125 + let plain = generate_token_plain();
126 + let token_hash = hash_token(&plain);
127 + let scope_strings: Vec<String> = scopes.iter().map(|s| s.as_str().to_string()).collect();
128 +
129 + let active = fediverse_personal_access_token::ActiveModel {
130 + id: Set(Uuid::new_v4()),
131 + remote_identity_id: Set(remote_identity_id),
132 + name: Set(trimmed.to_string()),
133 + token_hash: Set(token_hash),
134 + scopes: Set(scope_strings),
135 + expires_at: Set(expires_at),
136 + last_used_at: NotSet,
137 + created_at: Set(Utc::now()),
138 + revoked_at: NotSet,
139 + };
140 +
141 + let model = active.insert(db).await?;
142 + Ok((model, plain))
143 + }
144 +
145 + /// Verify a plaintext token. Returns `Some((model, scopes))` if the
146 + /// token is valid, active and its owning remote identity is not
147 + /// disabled. Returns `None` for any failure path uniformly, to
148 + /// avoid leaking the reason. Touches `last_used_at` on success.
149 + ///
150 + /// # Errors
151 + ///
152 + /// - [`AppError::Database`] on DB error. All other failures map to
153 + /// `Ok(None)`.
154 + pub async fn verify(
155 + db: &DatabaseConnection,
156 + plaintext: &str,
157 + ) -> Result<Option<(fediverse_personal_access_token::Model, Vec<S>)>, AppError> {
158 + // 1. Format check (fast reject before any DB lookup).
159 + let Some(bare) = plaintext.strip_prefix(TOKEN_PREFIX) else {
160 + return Ok(None);
161 + };
162 + if bare.len() != 64 || !bare.chars().all(|c| c.is_ascii_hexdigit()) {
163 + return Ok(None);
164 + }
165 +
166 + // 2. Hash + DB lookup.
167 + let candidate_hash = hash_token(plaintext);
168 + let Some(row) = fediverse_personal_access_token::Entity::find()
169 + .filter(fediverse_personal_access_token::Column::TokenHash.eq(&candidate_hash))
170 + .one(db)
171 + .await?
172 + else {
173 + return Ok(None);
174 + };
175 +
176 + // 3. Constant-time re-check (defends against DB-side comparison
177 + // being variable-time).
178 + if !bool::from(row.token_hash.as_bytes().ct_eq(candidate_hash.as_bytes())) {
179 + return Ok(None);
180 + }
181 +
182 + // 4. Lifecycle checks on the token itself.
183 + if !row.is_active() {
184 + return Ok(None);
185 + }
186 +
187 + // 5. Owning remote identity must still be active.
188 + let Some(identity) = remote_identity::Entity::find_by_id(row.remote_identity_id)
189 + .one(db)
190 + .await?
191 + else {
192 + return Ok(None);
193 + };
194 + if identity.disabled_at.is_some() {
195 + return Ok(None);
196 + }
197 +
198 + // 6. Best-effort last_used_at update.
199 + let id = row.id;
200 + let mut touched: fediverse_personal_access_token::ActiveModel = row.clone().into();
201 + touched.last_used_at = Set(Some(Utc::now()));
202 + if let Err(e) = touched.update(db).await {
203 + tracing::warn!(
204 + token_id = %id,
205 + "fediverse PAT last_used_at update failed: {}", e
206 + );
207 + }
208 +
209 + let parsed_scopes: Vec<S> = row.scopes.iter().filter_map(|s| S::parse(s)).collect();
210 + Ok(Some((row, parsed_scopes)))
211 + }
212 +
213 + /// Revoke a PAT by id (soft-revoke: sets `revoked_at`, keeps row
214 + /// for audit). Idempotent — revoking an already-revoked or
215 + /// non-existing token returns `false`, never errors.
216 + ///
217 + /// # Errors
218 + ///
219 + /// - [`AppError::Database`] on persistence error.
220 + pub async fn revoke(
221 + db: &DatabaseConnection,
222 + token_id: Uuid,
223 + ) -> Result<bool, AppError> {
224 + let Some(row) = fediverse_personal_access_token::Entity::find_by_id(token_id)
225 + .one(db)
226 + .await?
227 + else {
228 + return Ok(false);
229 + };
230 +
231 + if row.revoked_at.is_some() {
232 + return Ok(false);
233 + }
234 +
235 + let mut active: fediverse_personal_access_token::ActiveModel = row.into();
236 + active.revoked_at = Set(Some(Utc::now()));
237 + active.update(db).await?;
238 + Ok(true)
239 + }
240 +
241 + /// List all non-revoked PATs owned by a remote identity (most
242 + /// recent first). Empty vector if none.
243 + pub async fn list_for_remote_identity(
244 + db: &DatabaseConnection,
245 + remote_identity_id: Uuid,
246 + ) -> Result<Vec<fediverse_personal_access_token::Model>, AppError> {
247 + let rows = fediverse_personal_access_token::Entity::find()
248 + .filter(
249 + fediverse_personal_access_token::Column::RemoteIdentityId
250 + .eq(remote_identity_id),
251 + )
252 + .filter(fediverse_personal_access_token::Column::RevokedAt.is_null())
253 + .order_by_desc(fediverse_personal_access_token::Column::CreatedAt)
254 + .all(db)
255 + .await?;
256 + Ok(rows)
257 + }
258 +
259 + /// Bulk-revoke every PAT owned by a remote identity. Designed to be
260 + /// called from the `Delete{Actor}` cascade — returns the number of
261 + /// rows updated (rows already revoked are skipped).
262 + pub async fn revoke_all_for_remote_identity(
263 + db: &DatabaseConnection,
264 + remote_identity_id: Uuid,
265 + ) -> Result<u64, AppError> {
266 + let now = Utc::now();
267 + let result = fediverse_personal_access_token::Entity::update_many()
268 + .col_expr(
269 + fediverse_personal_access_token::Column::RevokedAt,
270 + sea_orm::sea_query::Expr::value(now),
271 + )
272 + .filter(
273 + fediverse_personal_access_token::Column::RemoteIdentityId
274 + .eq(remote_identity_id),
275 + )
276 + .filter(fediverse_personal_access_token::Column::RevokedAt.is_null())
277 + .exec(db)
278 + .await?;
279 + Ok(result.rows_affected)
280 + }
281 +}
282 +
283 +/// Generate a fresh prefixed token: `rwfed_pat_<64 hex chars>`.
284 +fn generate_token_plain() -> String {
285 + let mut bytes = [0u8; 32];
286 + rand::rng().fill_bytes(&mut bytes);
287 + format!("{TOKEN_PREFIX}{}", hex::encode(bytes))
288 +}
289 +
290 +/// SHA-256 hex of the plaintext token — deterministic indexing key.
291 +fn hash_token(plain: &str) -> String {
292 + let mut hasher = Sha256::new();
293 + hasher.update(plain.as_bytes());
294 + hex::encode(hasher.finalize())
295 +}
296 +
297 +#[cfg(test)]
298 +mod tests {
299 + use super::*;
300 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
301 +
302 + #[derive(Clone, Debug, PartialEq, Eq)]
303 + enum TestScope {
304 + Read,
305 + Write,
306 + }
307 +
308 + impl TokenScope for TestScope {
309 + fn as_str(&self) -> &str {
310 + match self {
311 + TestScope::Read => "read",
312 + TestScope::Write => "write",
313 + }
314 + }
315 + fn parse(s: &str) -> Option<Self> {
316 + match s {
317 + "read" => Some(Self::Read),
318 + "write" => Some(Self::Write),
319 + _ => None,
320 + }
321 + }
322 + }
323 +
324 + fn remote_row(disabled: bool) -> remote_identity::Model {
325 + let now = Utc::now();
326 + remote_identity::Model {
327 + id: Uuid::new_v4(),
328 + ap_id: "https://a.tld/users/alice".to_string(),
329 + username: "alice".to_string(),
330 + domain: "a.tld".to_string(),
331 + display_name: None,
332 + summary_html: None,
333 + inbox_url: "https://a.tld/inbox".to_string(),
334 + shared_inbox_url: None,
335 + public_key_pem: String::new(),
336 + icon_url: None,
337 + actor_type: "Person".to_string(),
338 + raw_json: None,
339 + last_fetched_at: now,
340 + disabled_at: if disabled { Some(now) } else { None },
341 + created_at: now,
342 + updated_at: now,
343 + }
344 + }
345 +
346 + fn pat_row(
347 + remote_id: Uuid,
348 + hash: &str,
349 + scopes: Vec<String>,
350 + revoked: bool,
351 + ) -> fediverse_personal_access_token::Model {
352 + let now = Utc::now();
353 + fediverse_personal_access_token::Model {
354 + id: Uuid::new_v4(),
355 + remote_identity_id: remote_id,
356 + name: "test".to_string(),
357 + token_hash: hash.to_string(),
358 + scopes,
359 + expires_at: None,
360 + last_used_at: None,
361 + created_at: now,
362 + revoked_at: if revoked { Some(now) } else { None },
363 + }
364 + }
365 +
366 + // ─── Pure helpers ────────────────────────────────────────────────
367 +
368 + #[test]
369 + fn generate_token_plain_has_prefix_and_correct_length() {
370 + let t = generate_token_plain();
371 + assert!(t.starts_with(TOKEN_PREFIX));
372 + let bare = t.trim_start_matches(TOKEN_PREFIX);
373 + assert_eq!(bare.len(), 64);
374 + assert!(bare.chars().all(|c| c.is_ascii_hexdigit()));
375 + }
376 +
377 + #[test]
378 + fn hash_token_is_deterministic_and_64_hex() {
379 + let h1 = hash_token("rwfed_pat_abc");
380 + let h2 = hash_token("rwfed_pat_abc");
381 + assert_eq!(h1, h2);
382 + assert_eq!(h1.len(), 64);
383 + assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
384 + }
385 +
386 + // ─── create() ────────────────────────────────────────────────────
387 +
388 + #[tokio::test]
389 + async fn create_rejects_empty_name() {
390 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
391 + let err = RemotePersonalAccessTokenService::<TestScope>::create(
392 + &db,
393 + Uuid::new_v4(),
394 + " ",
395 + &[TestScope::Read],
396 + None,
397 + )
398 + .await
399 + .unwrap_err();
400 + assert!(matches!(err, AppError::Validation(_)));
401 + }
402 +
403 + #[tokio::test]
404 + async fn create_rejects_name_too_long() {
405 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
406 + let long = "a".repeat(65);
407 + let err = RemotePersonalAccessTokenService::<TestScope>::create(
408 + &db,
409 + Uuid::new_v4(),
410 + &long,
411 + &[TestScope::Read],
412 + None,
413 + )
414 + .await
415 + .unwrap_err();
416 + assert!(matches!(err, AppError::Validation(_)));
417 + }
418 +
419 + #[tokio::test]
420 + async fn create_rejects_unknown_remote_identity() {
421 + let db = MockDatabase::new(DatabaseBackend::Postgres)
422 + .append_query_results([Vec::<remote_identity::Model>::new()])
423 + .into_connection();
424 + let err = RemotePersonalAccessTokenService::<TestScope>::create(
425 + &db,
426 + Uuid::new_v4(),
427 + "deploy",
428 + &[TestScope::Read],
429 + None,
430 + )
431 + .await
432 + .unwrap_err();
433 + assert!(matches!(err, AppError::NotFound(_)));
434 + }
435 +
436 + #[tokio::test]
437 + async fn create_rejects_disabled_remote_identity() {
438 + let row = remote_row(true);
439 + let db = MockDatabase::new(DatabaseBackend::Postgres)
440 + .append_query_results([vec![row]])
441 + .into_connection();
442 + let err = RemotePersonalAccessTokenService::<TestScope>::create(
443 + &db,
444 + Uuid::new_v4(),
445 + "deploy",
446 + &[TestScope::Read],
447 + None,
448 + )
449 + .await
450 + .unwrap_err();
451 + assert!(matches!(err, AppError::Validation(_)));
452 + }
453 +
454 + #[tokio::test]
455 + async fn create_inserts_and_returns_plaintext() {
456 + let row = remote_row(false);
457 + let rid = row.id;
458 + let inserted = pat_row(rid, &"a".repeat(64), vec!["read".to_string()], false);
459 + let db = MockDatabase::new(DatabaseBackend::Postgres)
460 + .append_query_results([vec![row]])
461 + .append_query_results([vec![inserted.clone()]])
462 + .into_connection();
463 + let (model, plain) = RemotePersonalAccessTokenService::<TestScope>::create(
464 + &db,
465 + rid,
466 + "deploy",
467 + &[TestScope::Read],
468 + None,
469 + )
470 + .await
471 + .expect("create should succeed");
472 + assert!(plain.starts_with(TOKEN_PREFIX));
473 + assert_eq!(plain.len(), TOKEN_PREFIX.len() + 64);
474 + assert_eq!(model.remote_identity_id, rid);
475 + }
476 +
477 + // ─── verify() ────────────────────────────────────────────────────
478 +
479 + #[tokio::test]
480 + async fn verify_rejects_missing_prefix() {
481 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
482 + let result = RemotePersonalAccessTokenService::<TestScope>::verify(
483 + &db, "no_prefix_token",
484 + )
485 + .await
486 + .unwrap();
487 + assert!(result.is_none());
488 + }
489 +
490 + #[tokio::test]
491 + async fn verify_rejects_bad_length() {
492 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
493 + let result = RemotePersonalAccessTokenService::<TestScope>::verify(
494 + &db,
495 + &format!("{TOKEN_PREFIX}deadbeef"),
496 + )
497 + .await
498 + .unwrap();
499 + assert!(result.is_none());
500 + }
501 +
502 + #[tokio::test]
503 + async fn verify_rejects_non_hex() {
504 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
505 + let bogus: String = std::iter::repeat_n('z', 64).collect();
506 + let result = RemotePersonalAccessTokenService::<TestScope>::verify(
507 + &db,
508 + &format!("{TOKEN_PREFIX}{bogus}"),
509 + )
510 + .await
511 + .unwrap();
512 + assert!(result.is_none());
513 + }
514 +
515 + #[tokio::test]
516 + async fn verify_returns_none_when_hash_unknown() {
517 + let plain = generate_token_plain();
518 + let db = MockDatabase::new(DatabaseBackend::Postgres)
519 + .append_query_results([Vec::<fediverse_personal_access_token::Model>::new()])
520 + .into_connection();
521 + let result = RemotePersonalAccessTokenService::<TestScope>::verify(&db, &plain)
522 + .await
523 + .unwrap();
524 + assert!(result.is_none());
525 + }
526 +
527 + #[tokio::test]
528 + async fn verify_returns_none_when_revoked() {
529 + let plain = generate_token_plain();
530 + let hash = hash_token(&plain);
531 + let remote_id = Uuid::new_v4();
532 + let row = pat_row(remote_id, &hash, vec!["read".to_string()], true);
533 + let db = MockDatabase::new(DatabaseBackend::Postgres)
534 + .append_query_results([vec![row]])
535 + .into_connection();
536 + let result = RemotePersonalAccessTokenService::<TestScope>::verify(&db, &plain)
537 + .await
538 + .unwrap();
539 + assert!(result.is_none());
540 + }
541 +
542 + #[tokio::test]
543 + async fn verify_returns_none_when_remote_identity_disabled() {
544 + let plain = generate_token_plain();
545 + let hash = hash_token(&plain);
546 + let identity = remote_row(true);
547 + let row = pat_row(identity.id, &hash, vec!["read".to_string()], false);
548 + let db = MockDatabase::new(DatabaseBackend::Postgres)
549 + .append_query_results([vec![row]])
550 + .append_query_results([vec![identity]])
551 + .into_connection();
552 + let result = RemotePersonalAccessTokenService::<TestScope>::verify(&db, &plain)
553 + .await
554 + .unwrap();
555 + assert!(result.is_none());
556 + }
557 +
558 + #[tokio::test]
559 + async fn verify_returns_some_when_active() {
560 + let plain = generate_token_plain();
561 + let hash = hash_token(&plain);
562 + let identity = remote_row(false);
563 + let row = pat_row(
564 + identity.id,
565 + &hash,
566 + vec!["read".to_string(), "write".to_string()],
567 + false,
568 + );
569 + let updated = row.clone();
570 + let db = MockDatabase::new(DatabaseBackend::Postgres)
571 + .append_query_results([vec![row]])
572 + .append_query_results([vec![identity]])
573 + .append_query_results([vec![updated]])
574 + .into_connection();
575 + let (model, scopes) =
576 + RemotePersonalAccessTokenService::<TestScope>::verify(&db, &plain)
577 + .await
578 + .unwrap()
579 + .expect("verify should yield a valid match");
580 + assert_eq!(scopes, vec![TestScope::Read, TestScope::Write]);
581 + assert_eq!(model.token_hash, hash);
582 + }
583 +
584 + // ─── revoke() ────────────────────────────────────────────────────
585 +
586 + #[tokio::test]
587 + async fn revoke_returns_false_when_token_missing() {
588 + let db = MockDatabase::new(DatabaseBackend::Postgres)
589 + .append_query_results([Vec::<fediverse_personal_access_token::Model>::new()])
590 + .into_connection();
591 + let revoked = RemotePersonalAccessTokenService::<TestScope>::revoke(&db, Uuid::new_v4())
592 + .await
593 + .unwrap();
594 + assert!(!revoked);
595 + }
596 +
597 + #[tokio::test]
598 + async fn revoke_is_idempotent_on_already_revoked() {
599 + let row = pat_row(Uuid::new_v4(), &"a".repeat(64), vec![], true);
600 + let db = MockDatabase::new(DatabaseBackend::Postgres)
601 + .append_query_results([vec![row]])
602 + .into_connection();
603 + let revoked = RemotePersonalAccessTokenService::<TestScope>::revoke(&db, Uuid::new_v4())
604 + .await
605 + .unwrap();
606 + assert!(!revoked);
607 + }
608 +
609 + #[tokio::test]
610 + async fn revoke_marks_token_revoked() {
611 + let row = pat_row(Uuid::new_v4(), &"a".repeat(64), vec![], false);
612 + let updated = row.clone();
613 + let db = MockDatabase::new(DatabaseBackend::Postgres)
614 + .append_query_results([vec![row]])
615 + .append_query_results([vec![updated]])
616 + .into_connection();
617 + let revoked = RemotePersonalAccessTokenService::<TestScope>::revoke(&db, Uuid::new_v4())
618 + .await
619 + .unwrap();
620 + assert!(revoked);
621 + }
622 +
623 + // ─── list / bulk revoke ──────────────────────────────────────────
624 +
625 + #[tokio::test]
626 + async fn list_for_remote_identity_returns_empty_when_none() {
627 + let db = MockDatabase::new(DatabaseBackend::Postgres)
628 + .append_query_results([Vec::<fediverse_personal_access_token::Model>::new()])
629 + .into_connection();
630 + let rows = RemotePersonalAccessTokenService::<TestScope>::list_for_remote_identity(
631 + &db,
632 + Uuid::new_v4(),
633 + )
634 + .await
635 + .unwrap();
636 + assert!(rows.is_empty());
637 + }
638 +
639 + #[tokio::test]
640 + async fn revoke_all_returns_zero_when_no_rows() {
641 + let db = MockDatabase::new(DatabaseBackend::Postgres)
642 + .append_exec_results([MockExecResult {
643 + last_insert_id: 0,
644 + rows_affected: 0,
645 + }])
646 + .into_connection();
647 + let n = RemotePersonalAccessTokenService::<TestScope>::revoke_all_for_remote_identity(
648 + &db,
649 + Uuid::new_v4(),
650 + )
651 + .await
652 + .unwrap();
653 + assert_eq!(n, 0);
654 + }
655 +
656 + #[tokio::test]
657 + async fn revoke_all_returns_count_when_rows_affected() {
658 + let db = MockDatabase::new(DatabaseBackend::Postgres)
659 + .append_exec_results([MockExecResult {
660 + last_insert_id: 0,
661 + rows_affected: 3,
662 + }])
663 + .into_connection();
664 + let n = RemotePersonalAccessTokenService::<TestScope>::revoke_all_for_remote_identity(
665 + &db,
666 + Uuid::new_v4(),
667 + )
668 + .await
669 + .unwrap();
670 + assert_eq!(n, 3);
671 + }
672 +}
A crates/rustwarden-fediverse/src/services/remote_resource_share_service.rs
+483 -0
@@ -0,0 +1,483 @@
1 +//! Sharing of generic application resources with remote ActivityPub
2 +//! identities (RFC 0029).
3 +//!
4 +//! Companion of
5 +//! [`rustwarden_core::services::resource_service::ResourceService`] but
6 +//! scoped to `remote_identities` instead of users / teams. The consumer
7 +//! composes the two services when resolving the effective permission of
8 +//! a [`rustwarden_core::identity::Identity`] — see
9 +//! [`docs/fediverse-consumer-guide.md`](../../../../docs/fediverse-consumer-guide.md).
10 +//!
11 +//! Schema lives in migration
12 +//! `m20260516_000002_remote_resource_shares`. Permission levels mirror
13 +//! the framework hierarchy: `"read" < "write" < "admin"`.
14 +
15 +use crate::models::fediverse_resource_share;
16 +use chrono::Utc;
17 +use rustwarden_core::error::AppError;
18 +use rustwarden_core::services::resource_service::ResourceService;
19 +use sea_orm::{
20 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
21 +};
22 +use uuid::Uuid;
23 +
24 +/// Permission levels accepted by [`RemoteResourceShareService::share`],
25 +/// in ascending order of capability.
26 +///
27 +/// Mirrors `rustwarden_core::services::resource_service`'s hierarchy —
28 +/// composition is straightforward (the higher level wins).
29 +pub const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"];
30 +
31 +/// Sharing of generic application resources with remote ActivityPub
32 +/// identities.
33 +///
34 +/// Stateless service — all methods are associated `async fn` and take
35 +/// `&DatabaseConnection` as their first parameter, following the
36 +/// rustwarden conventions (see
37 +/// [`docs/SERVICES_REFERENCE.md`](../../../../docs/SERVICES_REFERENCE.md)).
38 +///
39 +/// # Example
40 +///
41 +/// ```rust,ignore
42 +/// use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService;
43 +///
44 +/// // Share `repo_id` with `@alice@autre.org` (resolved as `remote_id`) in write mode.
45 +/// RemoteResourceShareService::share(
46 +/// &db, "repository", repo_id, remote_id, "write", admin_user_id,
47 +/// ).await?;
48 +///
49 +/// // Later, resolve the effective permission on a read attempt.
50 +/// let level = RemoteResourceShareService::effective_permission_for_remote_identity(
51 +/// &db, "repository", repo_id, remote_id,
52 +/// ).await?;
53 +/// assert_eq!(level.as_deref(), Some("write"));
54 +/// ```
55 +pub struct RemoteResourceShareService;
56 +
57 +impl RemoteResourceShareService {
58 + /// Share a resource with a remote identity at the given permission
59 + /// level. Upsert by `(resource_id, remote_identity_id)` — an existing
60 + /// share is updated to the new permission level, rather than
61 + /// inserting a duplicate.
62 + ///
63 + /// # Errors
64 + ///
65 + /// - [`AppError::NotFound`] if the resource registry entry does not
66 + /// exist for `(resource_type, resource_id)`.
67 + /// - [`AppError::Validation`] if `permission_level` is not one of
68 + /// `"read" | "write" | "admin"`.
69 + /// - [`AppError::Database`] on FK violation (e.g. unknown
70 + /// `remote_identity_id`) or other persistence error.
71 + pub async fn share(
72 + db: &DatabaseConnection,
73 + resource_type: &str,
74 + resource_id: Uuid,
75 + remote_identity_id: Uuid,
76 + permission_level: &str,
77 + shared_by_user_id: Uuid,
78 + ) -> Result<fediverse_resource_share::Model, AppError> {
79 + let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id)
80 + .await?
81 + .ok_or_else(|| {
82 + AppError::NotFound(format!(
83 + "Resource not found: {resource_type}/{resource_id}"
84 + ))
85 + })?;
86 +
87 + if !PERMISSION_HIERARCHY.contains(&permission_level) {
88 + return Err(AppError::Validation(format!(
89 + "Invalid permission level: {permission_level}"
90 + )));
91 + }
92 +
93 + // Upsert by (resource_id, remote_identity_id).
94 + let existing = fediverse_resource_share::Entity::find()
95 + .filter(fediverse_resource_share::Column::ResourceId.eq(entry.id))
96 + .filter(
97 + fediverse_resource_share::Column::RemoteIdentityId.eq(remote_identity_id),
98 + )
99 + .one(db)
100 + .await?;
101 +
102 + match existing {
103 + Some(row) => {
104 + // Update permission level + grantor in case it changed.
105 + let mut active: fediverse_resource_share::ActiveModel = row.into();
106 + active.permission_level = Set(permission_level.to_string());
107 + active.shared_by_user_id = Set(shared_by_user_id);
108 + Ok(active.update(db).await?)
109 + }
110 + None => {
111 + let active = fediverse_resource_share::ActiveModel {
112 + id: Set(Uuid::new_v4()),
113 + resource_id: Set(entry.id),
114 + remote_identity_id: Set(remote_identity_id),
115 + permission_level: Set(permission_level.to_string()),
116 + shared_by_user_id: Set(shared_by_user_id),
117 + created_at: Set(Utc::now()),
118 + };
119 + Ok(active.insert(db).await?)
120 + }
121 + }
122 + }
123 +
124 + /// Revoke a remote share by its `(resource, remote_identity)` pair.
125 + /// Returns `true` if a row was deleted, `false` if no share existed.
126 + ///
127 + /// # Errors
128 + ///
129 + /// - [`AppError::NotFound`] if the resource registry entry is
130 + /// missing — distinguishes "resource never existed" from "share
131 + /// does not exist".
132 + /// - [`AppError::Database`] on persistence error.
133 + pub async fn unshare(
134 + db: &DatabaseConnection,
135 + resource_type: &str,
136 + resource_id: Uuid,
137 + remote_identity_id: Uuid,
138 + ) -> Result<bool, AppError> {
139 + let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id)
140 + .await?
141 + .ok_or_else(|| {
142 + AppError::NotFound(format!(
143 + "Resource not found: {resource_type}/{resource_id}"
144 + ))
145 + })?;
146 +
147 + let result = fediverse_resource_share::Entity::delete_many()
148 + .filter(fediverse_resource_share::Column::ResourceId.eq(entry.id))
149 + .filter(
150 + fediverse_resource_share::Column::RemoteIdentityId.eq(remote_identity_id),
151 + )
152 + .exec(db)
153 + .await?;
154 + Ok(result.rows_affected > 0)
155 + }
156 +
157 + /// Resolve the effective permission level of a remote identity on a
158 + /// resource. Returns `Some("read" | "write" | "admin")` if a share
159 + /// exists, `None` otherwise.
160 + ///
161 + /// The consumer composes this with
162 + /// [`ResourceService::effective_permission_for_user`] when resolving
163 + /// an [`rustwarden_core::identity::Identity::Remote`].
164 + ///
165 + /// # Errors
166 + ///
167 + /// - [`AppError::NotFound`] if the resource registry entry is missing.
168 + /// - [`AppError::Database`] on persistence error.
169 + pub async fn effective_permission_for_remote_identity(
170 + db: &DatabaseConnection,
171 + resource_type: &str,
172 + resource_id: Uuid,
173 + remote_identity_id: Uuid,
174 + ) -> Result<Option<String>, AppError> {
175 + let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id)
176 + .await?
177 + .ok_or_else(|| {
178 + AppError::NotFound(format!(
179 + "Resource not found: {resource_type}/{resource_id}"
180 + ))
181 + })?;
182 +
183 + let row = fediverse_resource_share::Entity::find()
184 + .filter(fediverse_resource_share::Column::ResourceId.eq(entry.id))
185 + .filter(
186 + fediverse_resource_share::Column::RemoteIdentityId.eq(remote_identity_id),
187 + )
188 + .one(db)
189 + .await?;
190 + Ok(row.map(|m| m.permission_level))
191 + }
192 +
193 + /// List all remote shares for a resource (admin display, ordered by
194 + /// most recent first).
195 + ///
196 + /// # Errors
197 + ///
198 + /// - [`AppError::NotFound`] if the resource registry entry is missing.
199 + /// - [`AppError::Database`] on persistence error.
200 + pub async fn list_shares(
201 + db: &DatabaseConnection,
202 + resource_type: &str,
203 + resource_id: Uuid,
204 + ) -> Result<Vec<fediverse_resource_share::Model>, AppError> {
205 + let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id)
206 + .await?
207 + .ok_or_else(|| {
208 + AppError::NotFound(format!(
209 + "Resource not found: {resource_type}/{resource_id}"
210 + ))
211 + })?;
212 +
213 + let rows = fediverse_resource_share::Entity::find()
214 + .filter(fediverse_resource_share::Column::ResourceId.eq(entry.id))
215 + .order_by_desc(fediverse_resource_share::Column::CreatedAt)
216 + .all(db)
217 + .await?;
218 + Ok(rows)
219 + }
220 +
221 + /// List all resources shared with a given remote identity (for a
222 + /// "my access" page on the remote identity's side). Ordered by most
223 + /// recent first.
224 + pub async fn list_for_remote_identity(
225 + db: &DatabaseConnection,
226 + remote_identity_id: Uuid,
227 + ) -> Result<Vec<fediverse_resource_share::Model>, AppError> {
228 + let rows = fediverse_resource_share::Entity::find()
229 + .filter(
230 + fediverse_resource_share::Column::RemoteIdentityId.eq(remote_identity_id),
231 + )
232 + .order_by_desc(fediverse_resource_share::Column::CreatedAt)
233 + .all(db)
234 + .await?;
235 + Ok(rows)
236 + }
237 +}
238 +
239 +#[cfg(test)]
240 +mod tests {
241 + use super::*;
242 + use rustwarden_core::models::resource;
243 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
244 +
245 + fn resource_row(rtype: &str, rid: Uuid) -> resource::Model {
246 + resource::Model {
247 + id: Uuid::new_v4(),
248 + resource_type: rtype.to_string(),
249 + resource_id: rid,
250 + owner_id: Uuid::new_v4(),
251 + is_public: false,
252 + created_at: Utc::now(),
253 + }
254 + }
255 +
256 + fn share_row(resource_id: Uuid, remote_id: Uuid, level: &str) -> fediverse_resource_share::Model {
257 + fediverse_resource_share::Model {
258 + id: Uuid::new_v4(),
259 + resource_id,
260 + remote_identity_id: remote_id,
261 + permission_level: level.to_string(),
262 + shared_by_user_id: Uuid::new_v4(),
263 + created_at: Utc::now(),
264 + }
265 + }
266 +
267 + // ─── Pure helpers ────────────────────────────────────────────────
268 +
269 + #[test]
270 + fn permission_hierarchy_is_ordered() {
271 + assert_eq!(PERMISSION_HIERARCHY, &["read", "write", "admin"]);
272 + }
273 +
274 + // ─── share() ─────────────────────────────────────────────────────
275 +
276 + #[tokio::test]
277 + async fn share_rejects_unknown_resource() {
278 + let db = MockDatabase::new(DatabaseBackend::Postgres)
279 + .append_query_results([Vec::<resource::Model>::new()])
280 + .into_connection();
281 + let err = RemoteResourceShareService::share(
282 + &db,
283 + "repository",
284 + Uuid::new_v4(),
285 + Uuid::new_v4(),
286 + "read",
287 + Uuid::new_v4(),
288 + )
289 + .await
290 + .unwrap_err();
291 + assert!(matches!(err, AppError::NotFound(_)));
292 + }
293 +
294 + #[tokio::test]
295 + async fn share_rejects_invalid_permission_level() {
296 + let entry = resource_row("repository", Uuid::new_v4());
297 + let db = MockDatabase::new(DatabaseBackend::Postgres)
298 + .append_query_results([vec![entry]])
299 + .into_connection();
300 + let err = RemoteResourceShareService::share(
301 + &db,
302 + "repository",
303 + Uuid::new_v4(),
304 + Uuid::new_v4(),
305 + "owner",
306 + Uuid::new_v4(),
307 + )
308 + .await
309 + .unwrap_err();
310 + assert!(matches!(err, AppError::Validation(_)));
311 + }
312 +
313 + #[tokio::test]
314 + async fn share_inserts_when_no_existing_row() {
315 + let entry = resource_row("repository", Uuid::new_v4());
316 + let entry_id = entry.id;
317 + let remote_id = Uuid::new_v4();
318 + let grantor = Uuid::new_v4();
319 + let db = MockDatabase::new(DatabaseBackend::Postgres)
320 + .append_query_results([vec![entry]])
321 + // No existing share row.
322 + .append_query_results([Vec::<fediverse_resource_share::Model>::new()])
323 + // Insert returns the new row.
324 + .append_query_results([vec![share_row(entry_id, remote_id, "read")]])
325 + .into_connection();
326 +
327 + let model = RemoteResourceShareService::share(
328 + &db,
329 + "repository",
330 + Uuid::new_v4(),
331 + remote_id,
332 + "read",
333 + grantor,
334 + )
335 + .await
336 + .expect("share should succeed");
337 +
338 + assert_eq!(model.resource_id, entry_id);
339 + assert_eq!(model.remote_identity_id, remote_id);
340 + assert_eq!(model.permission_level, "read");
341 + }
342 +
343 + #[tokio::test]
344 + async fn share_updates_when_existing_row() {
345 + let entry = resource_row("repository", Uuid::new_v4());
346 + let entry_id = entry.id;
347 + let remote_id = Uuid::new_v4();
348 + let existing = share_row(entry_id, remote_id, "read");
349 + let updated = fediverse_resource_share::Model {
350 + permission_level: "write".to_string(),
351 + ..existing.clone()
352 + };
353 + let db = MockDatabase::new(DatabaseBackend::Postgres)
354 + .append_query_results([vec![entry]])
355 + .append_query_results([vec![existing]])
356 + // Update returns the updated row.
357 + .append_query_results([vec![updated.clone()]])
358 + .into_connection();
359 +
360 + let model = RemoteResourceShareService::share(
361 + &db,
362 + "repository",
363 + Uuid::new_v4(),
364 + remote_id,
365 + "write",
366 + Uuid::new_v4(),
367 + )
368 + .await
369 + .expect("share should succeed (upsert)");
370 + assert_eq!(model.permission_level, "write");
371 + }
372 +
373 + // ─── unshare() ───────────────────────────────────────────────────
374 +
375 + #[tokio::test]
376 + async fn unshare_returns_true_when_row_deleted() {
377 + let entry = resource_row("repository", Uuid::new_v4());
378 + let db = MockDatabase::new(DatabaseBackend::Postgres)
379 + .append_query_results([vec![entry]])
380 + .append_exec_results([MockExecResult {
381 + last_insert_id: 0,
382 + rows_affected: 1,
383 + }])
384 + .into_connection();
385 + let deleted = RemoteResourceShareService::unshare(
386 + &db,
387 + "repository",
388 + Uuid::new_v4(),
389 + Uuid::new_v4(),
390 + )
391 + .await
392 + .unwrap();
393 + assert!(deleted);
394 + }
395 +
396 + #[tokio::test]
397 + async fn unshare_returns_false_when_no_row() {
398 + let entry = resource_row("repository", Uuid::new_v4());
399 + let db = MockDatabase::new(DatabaseBackend::Postgres)
400 + .append_query_results([vec![entry]])
401 + .append_exec_results([MockExecResult {
402 + last_insert_id: 0,
403 + rows_affected: 0,
404 + }])
405 + .into_connection();
406 + let deleted = RemoteResourceShareService::unshare(
407 + &db,
408 + "repository",
409 + Uuid::new_v4(),
410 + Uuid::new_v4(),
411 + )
412 + .await
413 + .unwrap();
414 + assert!(!deleted);
415 + }
416 +
417 + // ─── effective_permission_for_remote_identity() ──────────────────
418 +
419 + #[tokio::test]
420 + async fn effective_permission_returns_some_when_share_exists() {
421 + let entry = resource_row("repository", Uuid::new_v4());
422 + let entry_id = entry.id;
423 + let remote_id = Uuid::new_v4();
424 + let db = MockDatabase::new(DatabaseBackend::Postgres)
425 + .append_query_results([vec![entry]])
426 + .append_query_results([vec![share_row(entry_id, remote_id, "admin")]])
427 + .into_connection();
428 + let level = RemoteResourceShareService::effective_permission_for_remote_identity(
429 + &db,
430 + "repository",
431 + Uuid::new_v4(),
432 + remote_id,
433 + )
434 + .await
435 + .unwrap();
436 + assert_eq!(level.as_deref(), Some("admin"));
437 + }
438 +
439 + #[tokio::test]
440 + async fn effective_permission_returns_none_when_no_share() {
441 + let entry = resource_row("repository", Uuid::new_v4());
442 + let db = MockDatabase::new(DatabaseBackend::Postgres)
443 + .append_query_results([vec![entry]])
444 + .append_query_results([Vec::<fediverse_resource_share::Model>::new()])
445 + .into_connection();
446 + let level = RemoteResourceShareService::effective_permission_for_remote_identity(
447 + &db,
448 + "repository",
449 + Uuid::new_v4(),
450 + Uuid::new_v4(),
451 + )
452 + .await
453 + .unwrap();
454 + assert!(level.is_none());
455 + }
456 +
457 + // ─── list_shares() / list_for_remote_identity() ──────────────────
458 +
459 + #[tokio::test]
460 + async fn list_shares_returns_empty_when_resource_unshared() {
461 + let entry = resource_row("repository", Uuid::new_v4());
462 + let db = MockDatabase::new(DatabaseBackend::Postgres)
463 + .append_query_results([vec![entry]])
464 + .append_query_results([Vec::<fediverse_resource_share::Model>::new()])
465 + .into_connection();
466 + let rows = RemoteResourceShareService::list_shares(&db, "repository", Uuid::new_v4())
467 + .await
468 + .unwrap();
469 + assert!(rows.is_empty());
470 + }
471 +
472 + #[tokio::test]
473 + async fn list_for_remote_identity_returns_empty_when_no_shares() {
474 + let db = MockDatabase::new(DatabaseBackend::Postgres)
475 + .append_query_results([Vec::<fediverse_resource_share::Model>::new()])
476 + .into_connection();
477 + let rows =
478 + RemoteResourceShareService::list_for_remote_identity(&db, Uuid::new_v4())
479 + .await
480 + .unwrap();
481 + assert!(rows.is_empty());
482 + }
483 +}
M docs/SERVICES_REFERENCE.md
+68 -0
@@ -1403,6 +1403,74 @@ Deux constructeurs de routeur. Tous deux retournent `Router<DatabaseConnection>`
1403 1403
1404 1404 Côté `rustwarden-fediverse-ui` : `default_routes()` retourne le routeur UI complet (admin trust/blocks/outbound + écrans user consent/login), et `dashboard_card()` la `DashboardCardDTO` injectable via `builder.add_dashboard_card(..)`.
1405 1405
1406 +### 21.15 `RemoteResourceShareService`
1407 +
1408 +**Module** : `rustwarden_fediverse::services::remote_resource_share_service`
1409 +
1410 +Sharing de ressources métier (repo, document, …) avec une identité remote sans passer par une team. Companion de [`ResourceService` §8](#8-ressources-et-partage) — le core gère les principals `User`/`Team`, ce service gère le principal `Remote(remote_identity_id)`. Table dédiée `fediverse_resource_shares`, schéma dans la migration `m20260516_000002_remote_resource_shares`. **RFC 0029.**
1411 +
1412 +```rust
1413 +pub const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"];
1414 +```
1415 +
1416 +| Méthode | Signature | Description |
1417 +|---|---|---|
1418 +| `share` | `(db, resource_type: &str, resource_id: Uuid, remote_identity_id, permission_level: &str, shared_by_user_id) → Result<Model>` | **Upsert** par `(resource_id, remote_identity_id)`. Refuse une ressource inconnue (`NotFound`) ou un `permission_level` hors hiérarchie (`Validation`). |
1419 +| `unshare` | `(db, resource_type, resource_id, remote_identity_id) → Result<bool>` | Supprime la row. Retourne `true` si une row a été retirée, `false` sinon. |
1420 +| `effective_permission_for_remote_identity` | `(db, resource_type, resource_id, remote_identity_id) → Result<Option<String>>` | Résout la permission effective sur la ressource — `Some("read"\|"write"\|"admin")` ou `None`. |
1421 +| `list_shares` | `(db, resource_type, resource_id) → Result<Vec<Model>>` | Tous les remote shares d'une ressource (UI admin). Tri desc par `created_at`. |
1422 +| `list_for_remote_identity` | `(db, remote_identity_id) → Result<Vec<Model>>` | Ressources accessibles par une identité distante (UI "my access"). |
1423 +
1424 +**Composition côté consommateur** :
1425 +
1426 +```rust
1427 +use rustwarden_core::identity::Identity;
1428 +use rustwarden_core::services::resource_service::ResourceService;
1429 +use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService;
1430 +
1431 +let level = match identity {
1432 + Identity::Local(uid) => ResourceService::effective_permission_for_user(&db, uid, "repo", repo_id).await?,
1433 + Identity::Remote(rid) => RemoteResourceShareService::effective_permission_for_remote_identity(&db, "repo", repo_id, rid).await?,
1434 +};
1435 +```
1436 +
1437 +> **Asymétrie volontaire avec §8** : les permissions `User`/`Team` vivent dans `resource_shares` (core) ; les permissions `Remote` vivent dans `fediverse_resource_shares` (fediverse). Le `Principal` enum reste à 2 variants. La colonne `resource_shares.shared_with_remote_identity_id` héritée de la RFC 0008 est non utilisée — RFC 0031 (à venir) la marquera `#[deprecated]`.
1438 +
1439 +### 21.16 `RemotePersonalAccessTokenService<S>`
1440 +
1441 +**Module** : `rustwarden_fediverse::services::remote_personal_access_token_service`
1442 +
1443 +Émission, vérification et révocation de Personal Access Tokens pour des identités remote. Companion de [`PersonalTokenService` §7](#7-tokens-et-sessions) — même crypto (SHA-256 + constant-time compare via `subtle`), même trait `TokenScope`, owner = `remote_identities.id` au lieu de `users.id`. Table dédiée `fediverse_personal_access_tokens`, schéma dans la migration `m20260516_000003_remote_pat`. **RFC 0030.**
1444 +
1445 +```rust
1446 +pub const TOKEN_PREFIX: &str = "rwfed_pat_"; // distinct du core
1447 +```
1448 +
1449 +Generic over le trait `TokenScope` du framework (`rustwarden_core::services::personal_token_service::TokenScope`). Le consommateur réutilise sa hiérarchie de scopes existante (e.g. `RepoScope` côté gitrust).
1450 +
1451 +| Méthode | Signature | Description |
1452 +|---|---|---|
1453 +| `create` | `(db, remote_identity_id, name: &str, scopes: &[S], expires_at) → Result<(Model, String)>` | Émet un PAT. Retourne le plaintext **une seule fois** (`rwfed_pat_<64 hex>`). Refuse `name` vide / > 64 chars (`Validation`), `remote_identity_id` inconnu (`NotFound`), identité distante `disabled_at` (`Validation`). |
1454 +| `verify` | `(db, plaintext: &str) → Result<Option<(Model, Vec<S>)>>` | Parse le préfixe + lookup + check active + check `disabled_at` du remote. Retourne `None` pour toute défaillance, jamais `Err` sauf erreur DB. Touche `last_used_at` sur succès (best-effort). |
1455 +| `revoke` | `(db, token_id) → Result<bool>` | Soft-revoke (set `revoked_at`). Idempotent — retourne `true` si la row passe de actif à révoqué, `false` sinon. |
1456 +| `list_for_remote_identity` | `(db, remote_identity_id) → Result<Vec<Model>>` | Tokens non révoqués d'une identité distante (UI settings). Tri desc par `created_at`. |
1457 +| `revoke_all_for_remote_identity` | `(db, remote_identity_id) → Result<u64>` | Bulk soft-revoke — câblé sur cascade `Delete{Actor}`. Retourne le nombre de rows mises à jour. |
1458 +
1459 +**Routing par préfixe côté consommateur** :
1460 +
1461 +```rust
1462 +if plaintext.starts_with(TOKEN_PREFIX) {
1463 + RemotePersonalAccessTokenService::<MyScope>::verify(&db, &plaintext).await?
1464 + .map(|(m, s)| (Identity::Remote(m.remote_identity_id), s))
1465 +} else {
1466 + PersonalTokenService::<MyScope>::verify(&db, &plaintext).await?
1467 + .ok()
1468 + .map(|(m, s)| (Identity::Local(m.user_id), s))
1469 +}
1470 +```
1471 +
1472 +> **Cascade `Delete{Actor}`** : `revoke_all_for_remote_identity` est l'API que la pipeline lifecycle ([FediverseSignalService::apply_delete_actor_with_iterator](#215-fediversesignalservice)) doit appeler pour neutraliser les PAT d'une identité distante désactivée. Le câblage automatique n'est pas (encore) en place — à empiler.
1473 +
1406 1474 ### 21.14 Patterns d'intégration
1407 1475
1408 1476 Pour le câblage complet du `main.rs`, les variables d'environnement, les migrations et la check-list d'adoption, voir [`fediverse-consumer-guide.md`](fediverse-consumer-guide.md). Récap des bonnes pratiques côté code :
A docs/plans/2026-05-15-rfcs-gitrust-evaluation.md
+237 -0
@@ -0,0 +1,237 @@
1 +# Plan — Évaluation des 3 RFCs gitrust pour Rustwarden : 0001 & 0002 acceptées, 0003 refusée
2 +
3 +> **Note projet** : après `ExitPlanMode`, ce fichier doit être copié vers `docs/plans/2026-05-15-rfcs-gitrust-evaluation.md` puis supprimé d'ici (cf. CLAUDE.md du projet : « les plans vivent dans `docs/plans/`, jamais dans `~/.claude/plans/` »).
4 +
5 +## Context
6 +
7 +gitrust (premier consommateur de Rustwarden) a rédigé 3 RFCs pour étendre la fédération ActivityPub livrée upstream (RFCs framework 0001-0025) avec les capacités manquantes côté autonomie des identités remote.
8 +
9 +**Décision** :
10 +
11 +| # | Titre | But métier | Verdict |
12 +|---|---|---|---|
13 +| **gitrust-0001** | `RemoteResourceShareService` | Partager une ressource (repo gitrust, wall pasterman, …) directement avec `@alice@instance-B`, sans passer par une team. | ✅ **Accept** |
14 +| **gitrust-0002** | `RemotePersonalAccessTokenService` | Permettre à une identité remote de générer un PAT local pour `git push` HTTPS / API. | ✅ **Accept** |
15 +| **gitrust-0003** | `RemoteTeamMembershipService` | Matérialiser la membership après acceptation d'une invitation cross-instance. | ❌ **Reject** — capacité déjà livrée par le core ET par `FediverseInviteService::accept`. Duplication, pas de besoin résiduel. |
16 +
17 +Les 3 RFCs adoptent un **principe directeur commun explicite** ([docs/rfcs/gitrust/README.md:17-22](docs/rfcs/gitrust/README.md#L17-L22)) :
18 +
19 +> 1. Ne **PAS** étendre `rustwarden-core::Principal` ni les services core (`ResourceService`, `TeamService`, `PersonalTokenService`).
20 +> 2. Tout vit dans `rustwarden-fediverse` (tables `fediverse_*`, services dédiés, migrations séparées).
21 +> 3. Composition côté consommateur via match sur `Identity::{Local,Remote}`.
22 +> 4. Zéro impact si la feature `fediverse` n'est pas activée.
23 +
24 +Ce principe est **valide pour les RFCs 0001 et 0002** (cas où aucune capacité n'existe encore côté core), mais **incohérent pour 0003** (la capacité team-membership-remote existe déjà côté core depuis la RFC framework 0009).
25 +
26 +## Avis par RFC
27 +
28 +### gitrust-0001 — `RemoteResourceShareService` → **Accept (score 33/35)**
29 +
30 +Pur ajout, table nouvelle (`fediverse_resource_shares`), zéro breaking, ~200 LOC + 1 migration + 1 modèle SeaORM. La colonne sibling `resource_shares.shared_with_remote_identity_id` existe déjà côté core (migration `m20260510_000014`) mais le service core n'écrit jamais dedans — c'est cette dette qui justifie la divergence proposée (cf. section « Tension architecturale » plus bas). **Aucun blocker.**
31 +
32 +### gitrust-0002 — `RemotePersonalAccessTokenService` → **Accept (score 34/35)**
33 +
34 +Pur ajout symétrique de 0001, table `fediverse_personal_access_tokens`, réutilisation **directe** du trait `TokenScope` existant ([crates/rustwarden-core/src/services/personal_token_service.rs:65-70](crates/rustwarden-core/src/services/personal_token_service.rs#L65-L70)), même conventions de hash SHA-256 + `subtle::ConstantTimeEq`, préfixe distinct `rwfed_pat_` vs `rw_pat_` pour le routing côté consommateur. **Aucun blocker.** RFC la mieux notée des trois.
35 +
36 +### gitrust-0003 — `RemoteTeamMembershipService` → **Reject (score 18/35)**
37 +
38 +**Motif de refus** : la capacité demandée existe déjà côté framework, livrée par les RFCs 0008 + 0009. Cette RFC propose de **dupliquer** une infrastructure fonctionnelle.
39 +
40 +**Preuves dans le code actuel** :
41 +
42 +| Couche | Symbole | Statut |
43 +|---|---|---|
44 +| Schéma DB | `team_members.remote_identity_id` (colonne + partial unique index) | Livré, migration `m20260510_000014`, RFC framework 0008 |
45 +| Schéma DB | PK `team_members` relaxée (`user_id` nullable, PK synthétique `id`) | Livré, migration `m20260510_000016` |
46 +| Service core | `TeamService::add_member_identity(db, team_id, Identity::Remote(remote_id), role, hooks)` | Livré, [team_service.rs:184-207](crates/rustwarden-core/src/services/team_service.rs#L184-L207), RFC framework 0009 |
47 +| Service core | `TeamService::teams_of_identity(db, Identity::Remote(remote_id))` | Livré, [team_service.rs:212-227](crates/rustwarden-core/src/services/team_service.rs#L212-L227), RFC framework 0009 |
48 +| Service core | `FediverseSignalService::apply_revoke_membership(db, team_id, remote_id)` | Livré, RFC framework 0010 |
49 +
50 +**Vérification dans le code** (lecture du 2026-05-15) : `FediverseInviteService::accept` ([fediverse_invite_service.rs:103-145](crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs#L103-L145)) **fait déjà tout ce que la RFC promet** :
51 +
52 +```rust
53 +pub async fn accept(db, invite_id, remote_identity_id) -> Result<team_member::Model> {
54 + // 1. Récupère + valide la row fediverse_pending_invites
55 + // 2. Check status == Pending (protège contre double-acceptation)
56 + // 3. Bascule l'invite à Accepted (UPDATE)
57 + // 4. Insère la row team_members { user_id: None, remote_identity_id: Some(id), … }
58 + Ok(member.insert(db).await?)
59 +}
60 +```
61 +
62 +L'invite **matérialise** bien la membership. La RFC 0003 propose donc de **dupliquer** une fonctionnalité opérationnelle, sans rien apporter de neuf.
63 +
64 +**Score sur la grille §4** :
65 +
66 +| Critère | Note | Justification |
67 +|---|---|---|
68 +| Généralité | 3 | Sert le besoin gitrust mais duplique l'existant — incohérent avec RFC framework 0009. |
69 +| Stabilité API | 2 | Forcerait à `#[deprecated]` immédiatement `add_member_identity` / `teams_of_identity` qui viennent d'être livrés. Coût SemVer évitable. |
70 +| Performance | 3 | Neutre — nouvelle table indexée. |
71 +| Sécurité | 3 | Neutre. |
72 +| Complexité impl | 2 | ~180 LOC service + 1 migration **pour ré-écrire** ce qui existe + cleanup ultérieur dans le core. Coût > bénéfice. |
73 +| Maintenance | 2 | Deux chemins équivalents pour le même cas — bug magnet. |
74 +| Documentation | 3 | Bien rédigée mais ferait diverger `SERVICES_REFERENCE.md §9` et `§21`. |
75 +
76 +**Total : 18/35 — sous le seuil de 25 requis** par §4 du processus d'évolution.
77 +
78 +**Contre-proposition** : RFC rejetée sans remplacement. Optionnellement, deux **hardenings** mineurs peuvent être faits par PR directes (§2 du processus : fix sans changement d'API) — strictement facultatifs, à arbitrer séparément :
79 +
80 +1. **Atomicité transactionnelle** : actuellement `accept` fait `update` puis `insert` sans transaction explicite. En cas de crash entre les deux, l'invite est marquée `Accepted` mais la row `team_members` manque. Effort : XS (~2 h).
81 +2. **Test E2E** `invite → accept → teams_of_identity(Remote)` qui prouve que la membership est effective bout-en-bout. Effort : XS (~3 h).
82 +
83 +Ces deux hardenings n'invalident pas la décision de rejet : le flow fonctionne sans eux. Ils sont du "nice-to-have" à empiler sur le backlog framework, pas une condition de rejet de la RFC.
84 +
85 +## Cohérence d'architecture — décision retenue
86 +
87 +L'analyse RFC par RFC clarifie un partage cohérent entre core et fediverse :
88 +
89 +| Domaine | Stratégie cible | Justification |
90 +|---|---|---|
91 +| **Team membership remote** | **Core sibling FK** (`team_members.remote_identity_id`) | Capacité déjà livrée par RFCs framework 0008/0009. `TeamService` reste source unique de vérité pour le RBAC, qu'il soit local ou remote. La RFC 0003 est refusée pour préserver cette cohérence. |
92 +| **Resource share remote** | **Table fediverse dédiée** (`fediverse_resource_shares`) | Le core a une colonne `resource_shares.shared_with_remote_identity_id` mais ne l'utilise pas. La RFC 0001 propose proprement une table dédiée + un service séparé, sans toucher au core. La colonne sibling du core sera marquée `#[deprecated]` puis supprimée. |
93 +| **Personal Access Token remote** | **Table fediverse dédiée** (`fediverse_personal_access_tokens`) | Aucun équivalent côté core (les PAT remote n'existaient pas). Pur ajout dans fediverse, préfixe de token distinct pour le routing. |
94 +
95 +**Conséquence importante** : la stratégie résultante est volontairement **asymétrique** :
96 +
97 +- Les capacités déjà câblées sur le core (teams) **restent** dans le core.
98 +- Les capacités nouvelles ou non câblées (resource shares, PAT) **vivent dans fediverse**.
99 +
100 +Cette asymétrie est cohérente avec le principe de **moindre surprise** (§1 du processus d'évolution) : on n'ajoute pas de duplication, on ne casse pas l'existant, et on ferme la dette des colonnes sibling inutilisées par une RFC de cleanup séparée.
101 +
102 +## Plan d'évolutions
103 +
104 +### Étape 0 — Documenter la décision côté gitrust
105 +
106 +1. Marquer `docs/rfcs/gitrust/0003-fediverse-remote-team-memberships.md` en statut **Rejected** dans l'index [README.md](../rfcs/gitrust/README.md), avec un lien vers ce plan et le résumé du motif (capacité **et** matérialisation déjà livrées par les RFCs framework 0008/0009 + `FediverseInviteService::accept`).
107 +2. Ouvrir une **tracking issue umbrella** sur le repo framework intitulée « Fediverse remote identities — autonomy capabilities » qui pointe vers les 2 RFCs gitrust acceptées (0029, 0030) et la RFC de cleanup des colonnes sibling inutilisées (0031 dans la numérotation finale).
108 +
109 +### Étape 1 — Promotion des RFCs en numéros canoniques
110 +
111 +Numéros framework attribués (0026-0028 réservés à des travaux framework en cours) :
112 +
113 +| Numéro framework | Source | Type | Effort |
114 +|---|---|---|---|
115 +| **0029** | gitrust-0001 (resource shares) | RFC + PR implémentation | S (3-4j) |
116 +| **0030** | gitrust-0002 (PAT remote) | RFC + PR implémentation | S (3-4j) |
117 +| **0031** | Cleanup colonnes sibling inutilisées | RFC + PR (deprecation `shared_with_remote_identity_id` et `audit_logs.remote_identity_id` côté core) | XS (1-2j) |
118 +
119 +Statut au 2026-05-15 :
120 +
121 +| Action | Statut |
122 +|---|---|
123 +| Promouvoir gitrust-0001 → `docs/rfcs/0029-*.md` | ✅ fait |
124 +| Promouvoir gitrust-0002 → `docs/rfcs/0030-*.md` | ✅ fait |
125 +| Mettre à jour `docs/rfcs/README.md` (index framework) | ✅ fait |
126 +| Mettre à jour `docs/rfcs/gitrust/README.md` (0003 Rejected) | ✅ fait |
127 +| Rédiger la RFC `0031-deprecate-unused-resource-share-sibling-fk.md` | ⏳ à venir (après acceptation de 0029) |
128 +| Ouvrir tracking issue + PRs sur Gitea (`git.ebii.fr`) | ⏳ géré manuellement par l'utilisateur |
129 +
130 +### Étape 2 — Ordre d'implémentation recommandé
131 +
132 +| Ordre | # | Effort | Justification |
133 +|---|---|---|---|
134 +| 1️⃣ | **0029** (resource shares) | S (3-4j) | Cas d'usage le plus large. Indépendant. |
135 +| 2️⃣ | **0030** (PAT remote) | S (3-4j) | Complète l'autonomie HTTPS. Indépendant. |
136 +| 3️⃣ | **0031** (deprecation) | XS (1-2j) | Annotations `#[deprecated]` + CHANGELOG. À faire après 0029 pour que la table fediverse soit la cible. |
137 +
138 +**Total** : **~7-10 jours-homme**, étalés sur 4-6 semaines (2 cycles minor fediverse selon §5 du processus). Économie vs version initiale du plan : la pseudo-RFC 0003 et le pseudo bug-fix « accept silencieux » ont tous deux disparu après vérification du code réel ; le périmètre se réduit à 2 RFCs + 1 cleanup.
139 +
140 +### Étape 3 — Pour chaque RFC, livrables côté framework
141 +
142 +Pattern identique pour les 3 RFCs métier :
143 +
144 +```
145 +crates/rustwarden-fediverse/src/
146 +├── models/
147 +│ └── fediverse_<feature>.rs # modèle SeaORM
148 +├── services/
149 +│ ├── mod.rs # +1 ligne d'export
150 +│ └── <feature>_service.rs # service principal ~150-200 LOC
151 +├── migrations/
152 +│ └── m20260601_*_fediverse_<feature>.rs
153 +└── lib.rs # +1 ligne d'export public
154 +```
155 +
156 +Plus côté tests :
157 +
158 +```
159 +crates/rustwarden-fediverse/tests/
160 +└── <feature>_e2e.rs # tests E2E SeaORM (DB réelle)
161 +```
162 +
163 +Plus côté doc :
164 +
165 +```
166 +docs/
167 +├── SERVICES_REFERENCE.md # nouvelle sous-section §21.x
168 +├── fediverse-consumer-guide.md # nouveau pattern d'usage dans §9
169 +└── CHANGELOG.md # entrée Added
170 +```
171 +
172 +### Étape 4 — Côté gitrust (consommateur)
173 +
174 +Une fois les 3 RFCs livrées upstream :
175 +
176 +1. Bump `rustwarden-fediverse = "=0.X.Y"` dans `Cargo.toml` de gitrust.
177 +2. Ajouter dans `docs/UPSTREAM_RUSTWARDEN.md` les 3 services adoptés.
178 +3. Supprimer les éventuels workarounds locaux dans gitrust (cf. section §11.3 du processus d'évolution).
179 +4. Écrire les handlers gitrust qui composent `Identity::{Local,Remote}` → service core ou service fediverse.
180 +
181 +## Fichiers critiques cités dans ce plan
182 +
183 +**À lire pour l'instruction de chaque RFC** :
184 +- [docs/rfcs/gitrust/README.md](docs/rfcs/gitrust/README.md) — principe directeur explicite
185 +- [docs/rfcs/gitrust/0001-fediverse-remote-resource-shares.md](docs/rfcs/gitrust/0001-fediverse-remote-resource-shares.md)
186 +- [docs/rfcs/gitrust/0002-fediverse-remote-personal-access-tokens.md](docs/rfcs/gitrust/0002-fediverse-remote-personal-access-tokens.md)
187 +- [docs/rfcs/gitrust/0003-fediverse-remote-team-memberships.md](docs/rfcs/gitrust/0003-fediverse-remote-team-memberships.md)
188 +
189 +**À examiner pour valider la tension sibling-FK** :
190 +- [crates/rustwarden-core/src/services/team_service.rs:184-227](crates/rustwarden-core/src/services/team_service.rs#L184-L227) — `add_member_identity` / `teams_of_identity`
191 +- [crates/rustwarden-core/src/services/resource_service.rs](crates/rustwarden-core/src/services/resource_service.rs) — enum `Principal`, méthode `share_with`
192 +- [crates/rustwarden-core/src/services/personal_token_service.rs:65-70](crates/rustwarden-core/src/services/personal_token_service.rs#L65-L70) — trait `TokenScope` réutilisable
193 +- [crates/rustwarden-core/src/identity.rs](crates/rustwarden-core/src/identity.rs) — enum `Identity`
194 +- [crates/rustwarden-core/migrations/m20260510_000014_identity_sibling_fks.rs](crates/rustwarden-core/migrations/) — colonnes sibling déjà migrées
195 +- [crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs:103-145](crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs#L103-L145) — `accept` **fonctionne** (insère bien `team_members` avec sibling FK). Lecture du 2026-05-15 a invalidé l'hypothèse initiale de no-op.
196 +
197 +**Processus** :
198 +- [docs/RUSTWARDEN_EVOLUTION_PROCESS.md](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) — §2 (RFC obligatoire), §4 (grille 35 pts), §5 (versioning), §11 (rôle consommateur)
199 +- [docs/rfcs/README.md](docs/rfcs/README.md) — index des RFCs framework existantes
200 +
201 +## Risques transverses
202 +
203 +1. **Pas de mainteneur upstream identifié distinct** — gitrust est encore l'unique consommateur. EBO arbitre seul, ce qui marche aujourd'hui mais grave les choix dans le marbre. La décision d'asymétrie (teams sur core, shares/PAT sur fediverse) doit être documentée explicitement dans le CHANGELOG framework pour que les futurs consommateurs comprennent la convention.
204 +2. **Cascade `Delete{Actor}` à tester de bout en bout** — l'arrivée des shares remote (0029) + PAT remote (0030) **étend** la cascade existante. `FediverseSignalService::apply_delete_actor_with_iterator` doit aussi révoquer les PAT remote et soft-delete les shares remote. Suite E2E unifiée nécessaire.
205 +3. **Préfixe de token `rwfed_pat_`** (0030) — non négociable une fois livré. Valider maintenant (vs `rw_remote_pat_`, `rwap_pat_`, etc.).
206 +4. **Statut `experimental-fediverse`** — les 2 RFCs acceptées sortent pendant que le bundle entier est encore expérimental ([cf. plan promotion stable](docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md)). Soit elles entrent dans le périmètre stable, soit elles retardent la promotion. À confirmer dans la tracking issue umbrella.
207 +
208 +## Vérification (après implémentation upstream)
209 +
210 +1. **Compile** : `cargo check --workspace --all-features` zéro warning.
211 +2. **Tests** : `cargo test --workspace --features fediverse` — chaque RFC acceptée ajoute 15-20 tests unitaires + 1 E2E.
212 +3. **Clippy** : `cargo clippy --workspace --all-features -- -D warnings`.
213 +4. **Smoke test E2E** : suite gitrust qui exerce les 3 flows complets :
214 + - **Flow team** (existant, à confirmer non-régression) : user remote accepte une invite team → `TeamService::teams_of_identity(Remote(id))` retourne bien la team → accès au repo via les permissions team. **Code déjà présent** dans `FediverseInviteService::accept` ; test E2E à ajouter pour cadenasser.
215 + - **Flow shares** (0029) : user remote reçoit un share direct sur un repo gitrust → `RemoteResourceShareService::effective_permission_for_remote_identity` retourne le niveau attendu.
216 + - **Flow PAT** (0030) : user remote crée un PAT → `git push https://…@host/repo.git` réussit, audit log tracé.
217 + - **Cascade `Delete{Actor}`** : un `Delete{Actor}` entrant révoque en cascade : memberships team (déjà câblé via `apply_revoke_membership`), shares remote (à câbler dans 0029), PAT remote (à câbler dans 0030).
218 +5. **Doc** : `docs/SERVICES_REFERENCE.md §21` ajoute 2 nouvelles sous-sections (21.15 ResourceShares remote, 21.16 PAT remote), `docs/fediverse-consumer-guide.md` actualisé, `CHANGELOG.md` à jour avec entrées « Added » pour 0029 + 0030 + « Deprecated » pour la colonne sibling de 0031.
219 +6. **Côté consommateur gitrust** : bump du pin `rustwarden-fediverse = "=0.X.Y"` + actualisation de `docs/UPSTREAM_RUSTWARDEN.md`.
220 +
221 +## Recommandation finale
222 +
223 +**Avis** :
224 +
225 +- **gitrust-0001** et **gitrust-0002** sont techniquement saines (33-34/35), zéro breaking, pure composition avec les briques framework existantes. **Go push upstream** (promues en RFCs framework 0029 et 0030).
226 +- **gitrust-0003** est techniquement bancale (18/35) parce qu'elle re-propose une capacité **déjà entièrement livrée et fonctionnelle** : `TeamService::add_member_identity` insère bien dans `team_members` (RFC framework 0009) ET `FediverseInviteService::accept` appelle effectivement cette logique (lecture du 2026-05-15). **Rejet ferme, sans remplacement** ; deux hardenings facultatifs identifiés à empiler sur le backlog framework si besoin.
227 +
228 +**Statut au 2026-05-15** :
229 +
230 +1. ✅ gitrust-0003 **Rejected** dans `docs/rfcs/gitrust/README.md`.
231 +2. ✅ Promotion locale : gitrust-0001 → `docs/rfcs/0029-*.md`, gitrust-0002 → `docs/rfcs/0030-*.md`.
232 +3. ✅ Index `docs/rfcs/README.md` mis à jour.
233 +4. ⏳ Rédiger **0031** (RFC cleanup deprecation des colonnes sibling inutilisées : `resource_shares.shared_with_remote_identity_id`, `audit_logs.*remote_identity_id`) après acceptation de 0029.
234 +5. ⏳ Tracking issue umbrella sur `git.ebii.fr` (Gitea) — géré manuellement par l'utilisateur ; brouillon prêt à copier-coller dans [`docs/plans/2026-05-15-tracking-issue-fediverse-remote-autonomy-draft.md`](2026-05-15-tracking-issue-fediverse-remote-autonomy-draft.md).
235 +6. ⏳ Implémentation effective des 2 services dans `rustwarden-fediverse` après revue upstream.
236 +
237 +**Effort total restant** : **~7-10 jours-homme**, étalés sur 4-6 semaines.
A docs/plans/2026-05-15-tracking-issue-fediverse-remote-autonomy-draft.md
+90 -0
@@ -0,0 +1,90 @@
1 +# Tracking issue umbrella — brouillon à coller dans Gitea
2 +
3 +> **À faire manuellement** : copier le bloc ci-dessous dans `git.ebii.fr/ebii/rustwarden` → New Issue. Labels suggérés : `rfc`, `area:fediverse`, `umbrella`. Une fois l'issue ouverte, mettre à jour l'index `docs/rfcs/README.md` et le plan d'évaluation avec le numéro d'issue attribué.
4 +
5 +---
6 +
7 +## Title
8 +
9 +```
10 +Fediverse remote identities — autonomy capabilities (RFCs 0029, 0030 + cleanup 0031)
11 +```
12 +
13 +## Body
14 +
15 +```markdown
16 +## Contexte
17 +
18 +Le bundle fédération (RFCs 0001-0025) a livré l'infrastructure d'identité fédérée : SSO AP-native, invitations cross-instance, signatures HTTP, signaux lifecycle. Il manque deux capacités pour rendre une identité remote pleinement autonome côté instance locale :
19 +
20 +1. Pouvoir être collaboratrice directe (non-team) d'une ressource métier (repo gitrust, document, …).
21 +2. Pouvoir émettre un Personal Access Token local pour `git push` HTTPS / API.
22 +
23 +Cette umbrella tracke ces deux capacités, ainsi que le cleanup associé.
24 +
25 +## RFCs couvertes
26 +
27 +| # | Titre | Statut | Effort |
28 +|---|---|---|---|
29 +| [0029](docs/rfcs/0029-fediverse-remote-resource-shares.md) | `RemoteResourceShareService` — partage de ressources cross-instance | Accepted (33/35) | S (3-4j) |
30 +| [0030](docs/rfcs/0030-fediverse-remote-personal-access-tokens.md) | `RemotePersonalAccessTokenService` — PAT pour identités remote | Accepted (34/35) | S (3-4j) |
31 +| **0031** (à rédiger) | Cleanup : deprecation des colonnes sibling inutilisées dans `resource_shares` / `audit_logs` | À ouvrir après 0029 | XS (1-2j) |
32 +
33 +## RFC explicitement rejetée
34 +
35 +- **`gitrust-0003 RemoteTeamMembershipService`** — Rejected (18/35). Motif : la capacité team-membership-remote est déjà entièrement livrée par les RFCs 0008/0009 (`TeamService::add_member_identity`, `team_members.remote_identity_id`) ET déjà matérialisée par `FediverseInviteService::accept`. Détails : [docs/plans/2026-05-15-rfcs-gitrust-evaluation.md](docs/plans/2026-05-15-rfcs-gitrust-evaluation.md).
36 +
37 +## Cohérence d'architecture
38 +
39 +| Domaine | Stratégie | Pourquoi |
40 +|---|---|---|
41 +| Team membership remote | Core sibling FK (existant) | Déjà câblé, source unique de vérité RBAC |
42 +| Resource share remote | Table fediverse dédiée (nouvelle) | Aucun service core ne l'écrit, pure addition |
43 +| Personal Access Token remote | Table fediverse dédiée (nouvelle) | Aucun équivalent côté core |
44 +
45 +L'asymétrie est volontaire — moindre surprise, pas de duplication, pas de breaking. La RFC 0031 cleanup nettoie les colonnes sibling inutilisées laissées par la migration `m20260510_000014`.
46 +
47 +## Ordre d'implémentation
48 +
49 +1️⃣ **0029** (resource shares) — indépendante, valeur immédiate (collaboration cross-instance).
50 +2️⃣ **0030** (PAT remote) — indépendante, complète l'autonomie HTTPS.
51 +3️⃣ **0031** (cleanup) — annotations `#[deprecated]` + CHANGELOG, après acceptation de 0029.
52 +
53 +Total estimé : ~7-10 jours-homme, étalés sur 4-6 semaines (2 cycles minor fediverse).
54 +
55 +## Hardenings facultatifs sur `FediverseInviteService::accept`
56 +
57 +Identifiés lors de la lecture du code (2026-05-15). Hors scope de cette umbrella, à empiler sur le backlog si pertinent :
58 +
59 +- Wrapper `update + insert` dans une transaction SeaORM explicite (race-safety).
60 +- Test E2E `invite → accept → teams_of_identity(Remote)` (cadenasse la non-régression).
61 +
62 +## Critères d'acceptation de cette umbrella
63 +
64 +- [ ] RFC 0029 implémentée + tests verts + doc à jour dans `docs/SERVICES_REFERENCE.md §21`
65 +- [ ] RFC 0030 implémentée + tests verts + doc à jour
66 +- [ ] RFC 0031 rédigée + acceptée + annotations `#[deprecated]` déposées
67 +- [ ] CHANGELOG framework à jour (Added × 2 + Deprecated × 1)
68 +- [ ] CHANGELOG gitrust à jour (workarounds locaux résorbés, pin bumpé)
69 +- [ ] `cargo test --workspace --features fediverse` reste vert
70 +- [ ] `cargo clippy --workspace --all-features -- -D warnings` reste vert
71 +
72 +## Premier consommateur
73 +
74 +- **gitrust** — prêt à adopter dès livraison. Workaround local en place côté gitrust en attendant.
75 +
76 +## Origine
77 +
78 +Issue ouverte suite à l'évaluation du 2026-05-15 des 3 RFCs gitrust ([docs/plans/2026-05-15-rfcs-gitrust-evaluation.md](docs/plans/2026-05-15-rfcs-gitrust-evaluation.md)).
79 +```
80 +
81 +---
82 +
83 +## Après ouverture de l'issue
84 +
85 +Une fois l'issue créée sur Gitea, ces fichiers doivent être mis à jour avec le numéro d'issue attribué :
86 +
87 +- `docs/rfcs/0029-fediverse-remote-resource-shares.md` — section "Tracking issue" en bas, remplacer "À ouvrir" par le numéro réel.
88 +- `docs/rfcs/0030-fediverse-remote-personal-access-tokens.md` — idem.
89 +- `docs/plans/2026-05-15-rfcs-gitrust-evaluation.md` — section "Recommandation finale" point 5, remplacer "⏳" par le lien.
90 +- `docs/rfcs/gitrust/README.md` — table d'index, ajouter la colonne ou note "Tracking : #N".
A docs/rfcs/0029-fediverse-remote-resource-shares.md
+406 -0
@@ -0,0 +1,406 @@
1 +# RFC 0029 — `RemoteResourceShareService` dans `rustwarden-fediverse`
2 +
3 +- **Auteur** : EBO (gitrust)
4 +- **Date** : 2026-05-15
5 +- **Statut** : **Accepted (33/35)**
6 +- **Origine** : RFC 0001 du tracking gitrust ([docs/rfcs/gitrust/0001-fediverse-remote-resource-shares.md](gitrust/0001-fediverse-remote-resource-shares.md))
7 +- **Apps consommatrices concernées** : gitrust (immédiat), tout futur consommateur Rustwarden qui active `experimental-fediverse` et veut autoriser des identités remote sur ses ressources métier.
8 +- **Crate cible** : `rustwarden-fediverse` (PAS `rustwarden-core`)
9 +- **Évaluation détaillée** : [docs/plans/2026-05-15-rfcs-gitrust-evaluation.md](../plans/2026-05-15-rfcs-gitrust-evaluation.md)
10 +
11 +## Summary
12 +
13 +Ajouter à `rustwarden-fediverse` un service `RemoteResourceShareService` + une table `fediverse_resource_shares` qui permet d'attribuer une permission sur une ressource générique (identifiée par `resource_type` + `resource_id`) à une `remote_identities.id`. Le service vit **dans le crate fédération**, sans toucher au `ResourceService` ni au schéma `resource_shares` de `rustwarden-core`.
14 +
15 +## Motivation
16 +
17 +### Problème concret
18 +
19 +Les RFCs 0001-0028 du framework ont livré une infrastructure d'identité fédérée complète : WebFinger, Actor, JWT avec `identity_kind: Remote`, revocation, invites de teams cross-instance. Mais **un user remote ne peut être collaborateur d'aucune ressource métier non-team** :
20 +
21 +- Dans gitrust : Owner d'un repo ne peut pas inviter `@alice@autre-instance.org` comme Developer sur son repository (autre qu'à travers une team).
22 +- Dans un futur consommateur (par exemple un outil de gestion de documents) : impossible de partager un document avec un user remote sans le faire passer par une team.
23 +
24 +Aujourd'hui, `rustwarden-core::ResourceService` connaît `Principal::{User, Team}` mais pas `Remote`. Le champ `resource_share::shared_with_remote_identity_id` existe dans le modèle SeaORM (`crates/rustwarden-core/src/models/resource_share.rs:26-30`) mais n'est ni matérialisé en SQL, ni consommé par `share_with` (toujours set à `None`, ligne 234 de `resource_service.rs`), ni consulté par `effective_permission_for_user`. C'est un stub mort.
25 +
26 +### Impact si on ne fait rien
27 +
28 +- Tout consommateur de `experimental-fediverse` qui veut une collaboration cross-instance au-delà des teams est forcé d'écrire un workaround local (table dédiée + service + refactor d'autorisation). Trois workarounds en parallèle dans trois consommateurs = dette duplicate × 3 et risques de divergence sécuritaire.
29 +- Le SSO fédéré perd 80 % de son intérêt fonctionnel : `@alice@…` peut se logger, mais ne peut rien faire d'utile sauf rejoindre une team.
30 +
31 +### Pourquoi un nouveau service dans fediverse plutôt qu'étendre `ResourceService` du core ?
32 +
33 +C'est la question centrale, plusieurs raisons :
34 +
35 +1. **Séparation des préoccupations explicite** (choix architectural existant du framework). Les crates fédération sont **isolés** : leur activation est optionnelle (feature `experimental-fediverse`), leur schéma est tracké séparément (`seaql_fediverse_migrations`), leurs migrations vivent dans `crates/rustwarden-fediverse/migration/`. Étendre `ResourceService` casserait cette frontière en injectant un concept fédération dans le crate core.
36 +
37 +2. **Stabilité du core**. `rustwarden-core` est en cycle de release stable. Ajouter `Principal::Remote` impose un breaking minor (nouveau variant exhaustivement matché par tous les consommateurs qui pattern-match sur `Principal`). Ajouter un service séparé dans fediverse n'impose **rien** au consommateur qui n'active pas la fédération.
38 +
39 +3. **Cycle de vie API**. `experimental-fediverse` autorise des breaking sans bump major (cf. processus §5). C'est exactement le bon endroit pour itérer sur ce nouveau service tant qu'il n'a pas atteint la maturité requise pour la promotion stable.
40 +
41 +4. **Composabilité**. Le consommateur orchestre la résolution finale de permission en combinant `ResourceService::effective_permission_for_user(Local)` + `RemoteResourceShareService::effective_role_for_remote_identity(Remote)`. Cette composition est triviale (max sur permission level) et reste maîtrisée par le consommateur, qui connaît son modèle d'autorisation.
42 +
43 +### Urgence
44 +
45 +Non bloquant. Le consommateur gitrust peut implémenter un workaround local (table `gitrust_remote_collaborators`) en attendant. Mais chaque jour sans la solution upstream est une journée de dette qui sera à résorber. Idéalement livré dans la prochaine minor `rustwarden-fediverse` (cycle 2 semaines selon processus §9).
46 +
47 +## Non-goals
48 +
49 +- **Ne PAS modifier `rustwarden-core::ResourceService`**. Pas de variant `Principal::Remote`, pas de migration sur `resource_shares`, pas de nouveau paramètre dans les API existantes.
50 +- **Ne PAS toucher au champ orphelin `shared_with_remote_identity_id`** dans `resource_share::Model`. Il est cosmétique et peut rester en place ou être supprimé par une RFC séparée — hors scope.
51 +- **Ne PAS introduire de notion d'ownership remote**. Un owner reste un user local. Le remote ne peut être que collaborateur (read/write/admin).
52 +- **Ne PAS gérer la fédération outbound des partages**. Émettre un `Announce{Add}` AP quand un partage est créé, ou propager un `Remove` sur revocation, c'est l'objet d'une RFC ultérieure (peut être empilée sur celle-ci). Cette RFC se limite à **stocker et résoudre** les permissions remote.
53 +- **Ne PAS gérer les invites cross-instance**. C'est `fediverse_invite_service` et `fediverse_pending_invites` qui en sont responsables (RFC 0009/0014). Les deux services sont complémentaires : l'invite gère le "consentement préalable", le share gère l'autorisation effective. Selon les flux applicatifs, le consommateur peut exiger qu'un accept d'invite crée automatiquement un share, ou pas.
54 +
55 +## API proposée
56 +
57 +### Migration SQL (nouvelle migration `m20260516_000002_remote_resource_shares.rs`)
58 +
59 +```sql
60 +CREATE TABLE fediverse_resource_shares (
61 + id UUID PRIMARY KEY,
62 + resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
63 + remote_identity_id UUID NOT NULL REFERENCES remote_identities(id) ON DELETE CASCADE,
64 + permission_level VARCHAR(32) NOT NULL, -- "read" | "write" | "admin"
65 + shared_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
66 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
67 +);
68 +
69 +CREATE UNIQUE INDEX idx_fediverse_resource_shares_unique
70 + ON fediverse_resource_shares (resource_id, remote_identity_id);
71 +
72 +CREATE INDEX idx_fediverse_resource_shares_remote
73 + ON fediverse_resource_shares (remote_identity_id);
74 +```
75 +
76 +**FK sur `resources(id)`** : la table référence l'**entrée du registry framework** (`resources` table de core), pas la ressource concrète (`repositories` côté gitrust). Cohérent avec le pattern de `resource_shares`. Le crate fediverse dépend déjà de `rustwarden-core` (cf. `Cargo.toml`).
77 +
78 +**Pas de CHECK constraint à plusieurs colonnes** : table monolithique, un share = un (resource, remote_identity) pair. Simple et lisible.
79 +
80 +### Modèle SeaORM (`crates/rustwarden-fediverse/src/models/fediverse_resource_share.rs`)
81 +
82 +```rust
83 +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
84 +#[sea_orm(table_name = "fediverse_resource_shares")]
85 +pub struct Model {
86 + #[sea_orm(primary_key, auto_increment = false)]
87 + pub id: Uuid,
88 + pub resource_id: Uuid,
89 + pub remote_identity_id: Uuid,
90 + pub permission_level: String,
91 + pub shared_by_user_id: Uuid,
92 + pub created_at: DateTime<Utc>,
93 +}
94 +```
95 +
96 +### Service (`crates/rustwarden-fediverse/src/services/remote_resource_share_service.rs`)
97 +
98 +```rust
99 +use crate::error::FediverseError;
100 +use rustwarden_core::error::AppError;
101 +use rustwarden_core::services::resource_service::ResourceService;
102 +use sea_orm::DatabaseConnection;
103 +use uuid::Uuid;
104 +
105 +/// Permission levels (must mirror `rustwarden_core::services::resource_service::PERMISSION_HIERARCHY`).
106 +const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"];
107 +
108 +/// Sharing of generic application resources with remote ActivityPub identities.
109 +///
110 +/// Companion of [`rustwarden_core::services::resource_service::ResourceService`]
111 +/// but scoped to `remote_identities`. The consumer composes the two services
112 +/// when resolving the effective permission of an [`rustwarden_core::identity::Identity`].
113 +pub struct RemoteResourceShareService;
114 +
115 +impl RemoteResourceShareService {
116 + /// Share a resource with a remote identity at the given permission level.
117 + ///
118 + /// # Errors
119 + ///
120 + /// - [`AppError::NotFound`] if the resource registry entry does not exist.
121 + /// - [`AppError::Validation`] if `permission_level` is unknown.
122 + /// - [`AppError::Database`] on persistence error.
123 + pub async fn share(
124 + db: &DatabaseConnection,
125 + resource_type: &str,
126 + resource_id: Uuid,
127 + remote_identity_id: Uuid,
128 + permission_level: &str,
129 + shared_by_user_id: Uuid,
130 + ) -> Result<fediverse_resource_share::Model, AppError> {
131 + // 1. Look up resources registry entry (composition with core).
132 + let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id)
133 + .await?
134 + .ok_or_else(|| AppError::NotFound("Resource not found".into()))?;
135 +
136 + // 2. Validate permission level.
137 + if !PERMISSION_HIERARCHY.contains(&permission_level) {
138 + return Err(AppError::Validation(format!(
139 + "Invalid permission level: {}",
140 + permission_level
141 + )));
142 + }
143 +
144 + // 3. Upsert by (resource_id, remote_identity_id).
145 + // Implementation détaillée — voir tests pour le contrat.
146 + // ...
147 + }
148 +
149 + /// Revoke a remote share by its (resource, remote_identity) pair.
150 + pub async fn unshare(
151 + db: &DatabaseConnection,
152 + resource_type: &str,
153 + resource_id: Uuid,
154 + remote_identity_id: Uuid,
155 + ) -> Result<bool, AppError> { /* ... */ }
156 +
157 + /// Resolve the effective permission level of a remote identity on a resource.
158 + ///
159 + /// Returns `None` if no share exists.
160 + /// Returns the permission level (`"read" | "write" | "admin"`) otherwise.
161 + ///
162 + /// The consumer composes this with
163 + /// [`ResourceService::effective_permission_for_user`] when resolving an
164 + /// [`rustwarden_core::identity::Identity::Remote`].
165 + pub async fn effective_permission_for_remote_identity(
166 + db: &DatabaseConnection,
167 + resource_type: &str,
168 + resource_id: Uuid,
169 + remote_identity_id: Uuid,
170 + ) -> Result<Option<String>, AppError> { /* ... */ }
171 +
172 + /// List all remote shares for a resource (for admin display).
173 + pub async fn list_shares(
174 + db: &DatabaseConnection,
175 + resource_type: &str,
176 + resource_id: Uuid,
177 + ) -> Result<Vec<fediverse_resource_share::Model>, AppError> { /* ... */ }
178 +
179 + /// List all resources shared with a given remote identity (for "my access" page).
180 + pub async fn list_for_remote_identity(
181 + db: &DatabaseConnection,
182 + remote_identity_id: Uuid,
183 + ) -> Result<Vec<fediverse_resource_share::Model>, AppError> { /* ... */ }
184 +}
185 +```
186 +
187 +### Exemple d'usage côté consommateur
188 +
189 +```rust
190 +// gitrust-core/src/services/repository_service.rs
191 +
192 +use rustwarden_core::identity::Identity;
193 +use rustwarden_core::services::resource_service::ResourceService;
194 +use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService;
195 +
196 +pub async fn effective_role_for_identity(
197 + db: &DatabaseConnection,
198 + repo_id: Uuid,
199 + identity: Identity,
200 +) -> Result<Option<Role>, AppError> {
201 + let level = match identity {
202 + Identity::Local(user_id) => {
203 + ResourceService::effective_permission_for_user(
204 + db, "repository", repo_id, user_id,
205 + ).await?
206 + }
207 + Identity::Remote(remote_id) => {
208 + RemoteResourceShareService::effective_permission_for_remote_identity(
209 + db, "repository", repo_id, remote_id,
210 + ).await?
211 + }
212 + };
213 + Ok(level.map(Role::from_permission_str))
214 +}
215 +```
216 +
217 +Note : la composition `max(local, remote)` n'a pas de sens ici puisque l'identity est **soit** local **soit** remote, jamais les deux. Le consommateur n'a pas besoin de combiner.
218 +
219 +## Analyse de compatibilité
220 +
221 +- [x] **API existante non modifiée** → **non-breaking** (patch/minor possible).
222 +- [x] **Nouvelle API ajoutée** : `RemoteResourceShareService` (5 méthodes) + nouveau modèle SeaORM.
223 +- [x] **Schéma DB** : nouvelle migration **additive** dans `FediverseMigrator`. Aucun ALTER sur table existante.
224 +- [x] **Format sérialisé inchangé** : pas de modification des cookies, JWT claims, events JSON.
225 +- [x] **Pattern matching consommateurs** : aucun enum modifié. Le pattern match sur `Principal` côté `rustwarden-core` reste exhaustif.
226 +- [x] **Doc rustdoc** : tout item public livré avec doctest exécutable.
227 +
228 +**Verdict** : 100 % additif, mergeable en minor `rustwarden-fediverse` sans risque pour les consommateurs existants.
229 +
230 +## Alternatives considérées
231 +
232 +### Alternative A (recommandée) : nouveau service dans `rustwarden-fediverse`
233 +
234 +**Pros** :
235 +- Respecte la séparation core/fédération posée par l'architecture du framework.
236 +- Aucun breaking change.
237 +- Cycle de vie API isolé sur `experimental-fediverse` jusqu'à promotion stable.
238 +- Composition simple côté consommateur (un match sur `Identity`).
239 +- Aucun consommateur "non-fediverse" n'est impacté ni à la compile ni au runtime.
240 +
241 +**Cons** :
242 +- Le consommateur doit faire la composition lui-même (vs. une API unifiée côté core).
243 +- Si une 4ème catégorie de principal apparaît (ex: API key), la composition côté consommateur devient un match à 4 branches.
244 +
245 +### Alternative B : étendre `rustwarden-core::Principal` avec `Remote(Uuid)`
246 +
247 +**Pros** :
248 +- API unifiée côté consommateur : un seul `effective_permission_for_user`/`identity_can_access`.
249 +
250 +**Cons** :
251 +- Casse la séparation : `rustwarden-core` devient conscient du concept "remote identity" alors qu'il n'a aucun moyen de fetcher / valider / révoquer une remote_identity.
252 +- Breaking minor : tous les `match Principal { … }` dans les consommateurs doivent ajouter une branche.
253 +- Migration `ALTER TABLE resource_shares` avec CHECK constraint à 3 termes : plus complexe à reverter.
254 +- Couple le cycle de release : pour ajouter ou faire évoluer le concept Remote, il faut une release core même quand seul fediverse change.
255 +- Le consommateur sans fediverse paie quand même le coût (pattern match exhaustif obligatoire) pour une feature qu'il n'utilise pas.
256 +
257 +### Alternative C : ne rien faire upstream, workaround dans chaque consommateur
258 +
259 +**Pros** : zéro coût upstream, zéro risque côté framework.
260 +
261 +**Cons** :
262 +- Dette dupliquée dans chaque consommateur qui voudra de la collaboration cross-instance.
263 +- Risque de divergence sécuritaire (un consommateur applique la check, un autre non).
264 +- Difficile à promouvoir `experimental-fediverse` en stable si un cas d'usage majeur reste hors framework.
265 +- Pour gitrust spécifiquement : ~8j de workaround + ~3j de retrait quand la solution arrive upstream = **~11j**, contre **~5j** si upstream livre directement.
266 +
267 +### Alternative D : trait `RemoteResourceShareProvider` dans core, impl dans fediverse
268 +
269 +**Pros** :
270 +- Core expose une abstraction qui peut être implémentée par fediverse OU par n'importe quel autre provider d'identité distribuée future (matrix, OIDC, …).
271 +
272 +**Cons** :
273 +- Sur-ingénierie pour 1 seul provider identifié.
274 +- Le trait dans core devrait pré-déclarer un type `RemoteId` opaque, ce qui complique l'API.
275 +- Bénéfice théorique seulement : aucun autre provider d'identité distribuée n'est sur la roadmap.
276 +
277 +**Choix** : Alternative A. C'est la plus économique en coût aujourd'hui ET la plus propre architecturalement. L'Alternative D pourra être adoptée plus tard (refactor en interne) sans casser l'API publique livrée par A.
278 +
279 +## Testing plan
280 +
281 +### Unit (obligatoire)
282 +
283 +Dans `crates/rustwarden-fediverse/src/services/remote_resource_share_service.rs#tests` :
284 +
285 +- Happy path × méthode (5 méthodes × 1 cas = 5 tests)
286 +- Cas d'erreur par méthode (minimum 3 par méthode publique) :
287 + - `share` : resource_id inexistant → `NotFound`
288 + - `share` : permission_level invalide → `Validation`
289 + - `share` : remote_identity_id inexistant → `Database` (FK violation)
290 + - `share` × 2 sur même pair → upsert, pas duplicate
291 + - `unshare` sur pair inexistant → retourne `false`, pas d'erreur
292 + - `effective_permission_for_remote_identity` : aucun share → `None`
293 + - `effective_permission_for_remote_identity` : share présent → renvoie level
294 + - `list_shares` sur resource sans share → `Vec::new()`
295 + - `list_for_remote_identity` sur identity sans share → `Vec::new()`
296 +
297 +### Doctest (obligatoire)
298 +
299 +Exemple `share` + `effective_permission_for_remote_identity` dans le doc comment de struct. Marqué `no_run` (touche la DB).
300 +
301 +### Integration (recommandé)
302 +
303 +Test avec SQLite in-memory ou sea-orm-mock pour vérifier que la composition core (`ResourceService::find_by_type_and_id`) + fediverse (`share`) fonctionne bout en bout.
304 +
305 +### Compat (obligatoire)
306 +
307 +Test que `rustwarden-fediverse` compile sans warning **avec et sans** la feature `fediverse-resource-shares` (si jamais on flag, sinon directement par défaut).
308 +
309 +Test que `cargo test -p rustwarden-core` reste vert (rien ne doit changer côté core).
310 +
311 +### Bench
312 +
313 +Non requis : la table est en lecture seule sur le hot path (résolution permission, indexée). Pas de regression attendue.
314 +
315 +### Fuzz
316 +
317 +Non requis : pas de parsing externe (les inputs sont des UUIDs typés, déjà sanitized par le framework HTTP).
318 +
319 +## Migration guide
320 +
321 +À copier dans le `CHANGELOG.md` de `rustwarden-fediverse` au merge :
322 +
323 +### Added (0.X+1.0)
324 +
325 +- `RemoteResourceShareService` (services/remote_resource_share_service.rs) — partager une ressource avec une identité ActivityPub remote, via 5 méthodes : `share`, `unshare`, `effective_permission_for_remote_identity`, `list_shares`, `list_for_remote_identity`.
326 +- Nouveau modèle `models::fediverse_resource_share::Model`.
327 +- Migration `m20260516_000002_remote_resource_shares` ajoutée à `FediverseMigrator`.
328 +
329 +### Migration depuis l'absence
330 +
331 +Aucune. Première livraison de l'API. Les consommateurs qui ne l'utilisent pas ne paient aucun coût.
332 +
333 +### Exemple d'adoption côté consommateur
334 +
335 +**Avant (workaround local nécessaire)** :
336 +```rust
337 +// Table locale gitrust_remote_collaborators dans le consommateur
338 +struct LocalRemoteCollabService;
339 +// ~200 LOC de duplication
340 +```
341 +
342 +**Après (0.X+1.0)** :
343 +```rust
344 +use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService;
345 +
346 +RemoteResourceShareService::share(
347 + &db, "repository", repo_id, remote_id, "write", admin_user_id,
348 +).await?;
349 +```
350 +
351 +### Steps pour gitrust spécifiquement
352 +
353 +1. Bump `rustwarden-fediverse` dans `Cargo.toml`.
354 +2. Migrer les rows de `gitrust_remote_collaborators` vers `fediverse_resource_shares` :
355 + ```sql
356 + INSERT INTO fediverse_resource_shares
357 + (id, resource_id, remote_identity_id, permission_level, shared_by_user_id, created_at)
358 + SELECT
359 + gen_random_uuid(),
360 + (SELECT id FROM resources WHERE resource_type='repository' AND resource_id = grc.repository_id),
361 + grc.remote_identity_id,
362 + CASE grc.role
363 + WHEN 'reader' THEN 'read'
364 + WHEN 'developer' THEN 'write'
365 + WHEN 'maintainer' THEN 'admin'
366 + WHEN 'owner' THEN 'admin'
367 + END,
368 + grc.created_by,
369 + grc.created_at
370 + FROM gitrust_remote_collaborators grc;
371 + ```
372 +3. Refactor `RepositoryService::effective_role` pour appeler le service framework.
373 +4. Supprimer `RemoteCollaboratorService` local et la table `gitrust_remote_collaborators`.
374 +5. Section "RÉSOLU UPSTREAM" dans `docs/UPSTREAM_RUSTWARDEN.md`.
375 +
376 +## Open questions
377 +
378 +- [ ] **Audit log** : le service doit-il appeler `on_audit_event` du framework à `share`/`unshare` ? (Probablement oui, cohérent avec `ResourceService::share_with`.)
379 +- [ ] **Hook `on_resource_remote_shared`/`on_resource_remote_unshared`** : utile pour qu'un consommateur émette un `Announce{Add}` AP outbound ? À discuter — peut être empilé dans une RFC 0002 dédiée.
380 +- [ ] **Listing combiné** : faut-il une méthode helper qui retourne **toutes** les permissions (locales + remote) sur une ressource pour l'UI admin ? Probablement plus propre côté consommateur (qui maîtrise son rendu).
381 +- [ ] **Cascading sur `Delete{Actor}`** : quand `FediverseSignalService::apply_delete_actor` désactive une `remote_identity`, faut-il supprimer ses shares ? Aujourd'hui le `ON DELETE CASCADE` de la FK le ferait — mais on ne supprime pas physiquement la remote_identity (soft-delete via `disabled_at`). À clarifier dans la doc du service ou via une méthode `purge_for_disabled_identities`.
382 +
383 +## Score sur la grille du processus §4
384 +
385 +| Critère | Note | Justification |
386 +|---|---|---|
387 +| **Généralité** | 5 | Sert tout consommateur AP, pas une logique app-specific |
388 +| **Stabilité API** | 5 | Non-breaking par ajout pur, zéro impact sur consommateurs sans fediverse |
389 +| **Performance** | 5 | Pas de regression possible (nouvelle table indexée) |
390 +| **Sécurité** | 4 | Ferme un gap d'autorisation cross-instance (fonctionnalité, pas un fix) |
391 +| **Complexité impl** | 4 | < 200 LOC service + 1 migration + 1 modèle SeaORM, patterns existants |
392 +| **Maintenance** | 5 | Zéro nouvelle dépendance, composition de briques stables |
393 +| **Documentation** | 5 | Rustdoc + doctest + entrée CHANGELOG + ce guide migration |
394 +| **Total** | **33/35** | ≥ 30 = accélération possible sans second avis |
395 +
396 +## Tracking issue
397 +
398 +À ouvrir sur le repo `rustwarden-core` (où vit la canonical RFC dir) avec :
399 +- Label `rfc` + `area:fediverse`
400 +- Copie du Summary + lien vers ce fichier (côté gitrust pour l'instant).
401 +- Mention de gitrust comme premier consommateur prêt à adopter dès la livraison.
402 +
403 +---
404 +
405 +**Historique** :
406 +- 2026-05-15 : Draft initial rédigé côté gitrust suite à l'identification du gap pendant la planification de l'intégration fediverse.
A docs/rfcs/0030-fediverse-remote-personal-access-tokens.md
+336 -0
@@ -0,0 +1,336 @@
1 +# RFC 0030 — `RemotePersonalAccessTokenService` dans `rustwarden-fediverse`
2 +
3 +- **Auteur** : EBO (gitrust)
4 +- **Date** : 2026-05-15
5 +- **Statut** : **Accepted (34/35)**
6 +- **Origine** : RFC 0002 du tracking gitrust ([docs/rfcs/gitrust/0002-fediverse-remote-personal-access-tokens.md](gitrust/0002-fediverse-remote-personal-access-tokens.md))
7 +- **Apps consommatrices concernées** : gitrust (immédiat). Tout futur consommateur qui active `experimental-fediverse` et veut permettre à une identité remote d'émettre des PAT locaux pour des opérations git/API.
8 +- **Crate cible** : `rustwarden-fediverse` (PAS `rustwarden-core`)
9 +- **Dépendance** : indépendante de la [RFC 0029](0029-fediverse-remote-resource-shares.md) (peut être mergée avant ou après).
10 +- **Évaluation détaillée** : [docs/plans/2026-05-15-rfcs-gitrust-evaluation.md](../plans/2026-05-15-rfcs-gitrust-evaluation.md)
11 +
12 +## Summary
13 +
14 +Ajouter à `rustwarden-fediverse` un service `RemotePersonalAccessTokenService` + table `fediverse_personal_access_tokens` qui permet à une `remote_identities.id` d'émettre, lister et révoquer ses propres Personal Access Tokens locaux à l'instance courante. Le service vit **dans le crate fédération**, sans toucher au `PersonalTokenService<S>` ni au schéma `personal_access_tokens` de `rustwarden-core`. Symétrie complète avec la RFC 0001.
15 +
16 +## Motivation
17 +
18 +### Contexte fonctionnel
19 +
20 +Le modèle d'identité fédérée livré par les RFCs 0001-0028 fait qu'une identité remote (`@alice@autre-instance.org`) peut :
21 +
22 +- ✅ S'authentifier sur l'instance locale via SSO (RFC 0006/0013).
23 +- ✅ Être membre de teams cross-instance (RFC 0009 — sous réserve de la matérialisation `team_members.remote_identity_id`, cf. RFC 0003).
24 +- ✅ Être collaboratrice d'une ressource (sous réserve de la livraison RFC 0001).
25 +- ❌ Émettre un Personal Access Token local pour authentifier ses opérations git HTTPS / API.
26 +
27 +Le scénario type côté gitrust :
28 +
29 +```
30 +1. alice se logge sur gitrust-B via SSO fédéré → session web active.
31 +2. alice veut faire `git push https://alice:TOKEN@gitrust-b.test/bob/widgets.git`.
32 +3. alice va sur /settings/tokens, génère un PAT scopé "repo:write".
33 +4. Échec actuel : `personal_access_tokens.user_id` est NOT NULL FK users(id).
34 + alice n'a pas d'entrée dans users, son identité vit dans remote_identities.
35 +```
36 +
37 +Sans PAT remote, l'utilisateur autonome (cf. modèle "user remote = collaborateur autonome" établi pour gitrust) ne peut **pas** utiliser git via HTTPS. Reste l'option SSH avec clé locale (à traiter séparément, gap local gitrust), mais la couverture HTTPS reste manquante.
38 +
39 +### Pourquoi un nouveau service dans fediverse, pas étendre le PersonalTokenService du core ?
40 +
41 +Mêmes arguments que la RFC 0001 :
42 +
43 +1. **Séparation core/fédération** : `rustwarden-core::PersonalTokenService<S: TokenScope>` est générique et stable. Ajouter une notion de "remote identity owner" l'oblige à connaître un concept fédération qu'il ne devrait pas connaître.
44 +2. **Stabilité API** : le trait `TokenScope` du framework est exposé aux consommateurs. Ajouter un paramètre `owner: Principal` casserait toutes les implémentations downstream.
45 +3. **Cycle de vie** : `experimental-fediverse` permet l'itération libre tant qu'on n'a pas atteint la promotion stable.
46 +4. **Composabilité** : le consommateur fait son lookup côté DB selon l'identité présentée (PAT local OU PAT remote, via le préfixe du token ou un flag) puis appelle le service approprié.
47 +
48 +### Urgence
49 +
50 +Non bloquant, mais paire fonctionnelle avec la RFC 0001 : sans PAT remote, le scénario "user remote autonome" reste boiteux (SSH ok, HTTPS pas ok). Idéalement livré dans la même minor `rustwarden-fediverse` que la RFC 0001 pour une expérience consommateur cohérente.
51 +
52 +## Non-goals
53 +
54 +- **Ne PAS modifier `rustwarden-core::PersonalTokenService<S>`** ni le schéma `personal_access_tokens`.
55 +- **Ne PAS récupérer un PAT existant d'une autre instance** (e.g. via un protocole AP de propagation de credentials). Chaque instance émet ses propres PAT pour les identités qu'elle reconnaît. Cohérent avec le modèle "autonomie locale".
56 +- **Ne PAS unifier le format/préfixe** des PAT locaux et remote. Le service remote peut utiliser un préfixe distinct (`rwfed_pat_` au lieu de `rw_pat_`) pour faciliter le routing côté consommateur. À discuter en Open Question.
57 +- **Ne PAS gérer la révocation par cascade Delete{Actor}** dans cette RFC : à empiler dans une RFC dédiée hooks lifecycle (ou via `ON DELETE CASCADE` sur la FK `remote_identity_id`, qui couvre l'essentiel).
58 +
59 +## API proposée
60 +
61 +### Migration SQL (nouvelle migration `m20260516_000003_remote_pat.rs`)
62 +
63 +```sql
64 +CREATE TABLE fediverse_personal_access_tokens (
65 + id UUID PRIMARY KEY,
66 + remote_identity_id UUID NOT NULL REFERENCES remote_identities(id) ON DELETE CASCADE,
67 + name VARCHAR(64) NOT NULL,
68 + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex, comme PersonalTokenService
69 + scope TEXT NOT NULL, -- format identique au PersonalTokenService<S>::serialize
70 + expires_at TIMESTAMPTZ NULL,
71 + last_used_at TIMESTAMPTZ NULL,
72 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
73 + revoked_at TIMESTAMPTZ NULL
74 +);
75 +
76 +CREATE INDEX idx_fediverse_pat_remote_identity
77 + ON fediverse_personal_access_tokens (remote_identity_id);
78 +CREATE INDEX idx_fediverse_pat_active
79 + ON fediverse_personal_access_tokens (token_hash) WHERE revoked_at IS NULL;
80 +```
81 +
82 +Sémantique des colonnes alignée 1:1 sur la table framework `personal_access_tokens` (hash SHA-256, scope sérialisé, soft-revocation via `revoked_at`). Différences :
83 +- `remote_identity_id` au lieu de `user_id`.
84 +- Pas de FK vers `users(id)`.
85 +
86 +### Modèle SeaORM (`crates/rustwarden-fediverse/src/models/fediverse_personal_access_token.rs`)
87 +
88 +```rust
89 +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
90 +#[sea_orm(table_name = "fediverse_personal_access_tokens")]
91 +pub struct Model {
92 + #[sea_orm(primary_key, auto_increment = false)]
93 + pub id: Uuid,
94 + pub remote_identity_id: Uuid,
95 + pub name: String,
96 + pub token_hash: String,
97 + pub scope: String,
98 + pub expires_at: Option<DateTime<Utc>>,
99 + pub last_used_at: Option<DateTime<Utc>>,
100 + pub created_at: DateTime<Utc>,
101 + pub revoked_at: Option<DateTime<Utc>>,
102 +}
103 +```
104 +
105 +### Service (`crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs`)
106 +
107 +```rust
108 +use rustwarden_core::error::AppError;
109 +use rustwarden_core::services::personal_token_service::TokenScope;
110 +use sea_orm::DatabaseConnection;
111 +use uuid::Uuid;
112 +
113 +/// Personal access tokens emitted by the local instance for ActivityPub remote
114 +/// identities. Companion of [`rustwarden_core::services::personal_token_service::PersonalTokenService`]
115 +/// but scoped to `remote_identities` instead of `users`.
116 +///
117 +/// Generic over a [`TokenScope`] type so consumers can reuse their existing
118 +/// scope definitions (e.g. gitrust's repository/git scopes) for remote PATs.
119 +pub struct RemotePersonalAccessTokenService<S: TokenScope>(std::marker::PhantomData<S>);
120 +
121 +impl<S: TokenScope> RemotePersonalAccessTokenService<S> {
122 + /// Issue a new PAT for a remote identity.
123 + ///
124 + /// Returns the **plaintext token** (one-time display). The DB stores only
125 + /// the SHA-256 hash. Token format: `rwfed_pat_<43 base32 chars>`.
126 + ///
127 + /// # Errors
128 + /// - [`AppError::NotFound`] if `remote_identity_id` does not exist or is disabled.
129 + /// - [`AppError::Validation`] if `name` is empty or > 64 chars.
130 + /// - [`AppError::Database`] on persistence error.
131 + pub async fn create(
132 + db: &DatabaseConnection,
133 + remote_identity_id: Uuid,
134 + name: &str,
135 + scope: S,
136 + expires_at: Option<DateTime<Utc>>,
137 + ) -> Result<(fediverse_personal_access_token::Model, String), AppError> { /* ... */ }
138 +
139 + /// Verify a plaintext token and return the (model, scope) if valid + active.
140 + ///
141 + /// Constant-time hash comparison via `subtle`. Updates `last_used_at` on success.
142 + ///
143 + /// Returns `None` if:
144 + /// - Token format is invalid.
145 + /// - No matching `token_hash`.
146 + /// - Token is revoked or expired.
147 + /// - The associated `remote_identity` is disabled.
148 + pub async fn verify(
149 + db: &DatabaseConnection,
150 + plaintext_token: &str,
151 + ) -> Result<Option<(fediverse_personal_access_token::Model, S)>, AppError> { /* ... */ }
152 +
153 + /// Revoke a token by its id.
154 + pub async fn revoke(
155 + db: &DatabaseConnection,
156 + token_id: Uuid,
157 + ) -> Result<bool, AppError> { /* ... */ }
158 +
159 + /// List all (non-revoked) tokens for a remote identity (UI display).
160 + pub async fn list_for_remote_identity(
161 + db: &DatabaseConnection,
162 + remote_identity_id: Uuid,
163 + ) -> Result<Vec<fediverse_personal_access_token::Model>, AppError> { /* ... */ }
164 +
165 + /// Revoke all tokens for a remote identity (called on Delete{Actor}).
166 + pub async fn revoke_all_for_remote_identity(
167 + db: &DatabaseConnection,
168 + remote_identity_id: Uuid,
169 + ) -> Result<u64, AppError> { /* ... */ }
170 +}
171 +```
172 +
173 +### Exemple d'usage côté consommateur (gitrust)
174 +
175 +```rust
176 +// Création d'un PAT depuis /settings/tokens pour un user remote
177 +let claims: Claims = /* extraite de la session JWT */;
178 +let identity = Identity::from_claims(&claims);
179 +
180 +let scope = GitrustScope::repo_write(repo_id); // scope app-specific
181 +
182 +let (model, plaintext) = match identity {
183 + Identity::Local(user_id) => {
184 + let (m, p) = PersonalTokenService::<GitrustScope>::create(
185 + db, user_id, "deploy", scope, None,
186 + ).await?;
187 + (m.id, p)
188 + }
189 + Identity::Remote(remote_id) => {
190 + let (m, p) = RemotePersonalAccessTokenService::<GitrustScope>::create(
191 + db, remote_id, "deploy", scope, None,
192 + ).await?;
193 + (m.id, p)
194 + }
195 +};
196 +
197 +// Verify côté handler git push HTTPS
198 +// Discrimination par préfixe du token (rw_pat_ vs rwfed_pat_)
199 +let result = if plaintext.starts_with("rwfed_pat_") {
200 + RemotePersonalAccessTokenService::<GitrustScope>::verify(db, &plaintext).await?
201 + .map(|(m, s)| (Identity::Remote(m.remote_identity_id), s))
202 +} else {
203 + PersonalTokenService::<GitrustScope>::verify(db, &plaintext).await?
204 + .map(|(m, s)| (Identity::Local(m.user_id), s))
205 +};
206 +```
207 +
208 +Le consommateur orchestre proprement les deux services via le préfixe du token, sans aucun changement côté framework core.
209 +
210 +## Analyse de compatibilité
211 +
212 +- [x] **API existante non modifiée** → **non-breaking**.
213 +- [x] **Nouvelle API ajoutée** : `RemotePersonalAccessTokenService<S>` (5 méthodes) + nouveau modèle SeaORM.
214 +- [x] **Schéma DB** : nouvelle table dans `FediverseMigrator`. Aucun ALTER sur table existante.
215 +- [x] **Format sérialisé** : préfixe `rwfed_pat_` distinct du `rw_pat_` framework → pas de collision possible.
216 +- [x] **Trait `TokenScope` du framework** : réutilisé tel quel, aucune modification.
217 +
218 +**Verdict** : 100 % additif, mergeable en minor `rustwarden-fediverse`.
219 +
220 +## Alternatives considérées
221 +
222 +### Alternative A (recommandée) : table + service séparés dans fediverse
223 +
224 +Voir API proposée ci-dessus. Cf. Pros/Cons RFC 0001 §"Alternatives" — l'analyse est isomorphe.
225 +
226 +### Alternative B : étendre `personal_access_tokens` avec sibling FK `remote_identity_id`
227 +
228 +**Pros** :
229 +- Une seule table à interroger pour vérifier un PAT (pas de discrimination par préfixe).
230 +
231 +**Cons** :
232 +- Modification du schéma core → breaking migration.
233 +- Le `PersonalTokenService<S>` du framework devrait prendre un `Principal` au lieu d'un `Uuid` → breaking API.
234 +- Le préfixe de token unique deviendrait trompeur (un même préfixe `rw_pat_` couvrirait deux populations distinctes).
235 +- Couple le cycle de release des deux concepts.
236 +
237 +### Alternative C : pas de PAT remote, ne supporter que SSH local
238 +
239 +**Pros** : zéro travail upstream, zéro nouveau service.
240 +
241 +**Cons** :
242 +- Brise la parité fonctionnelle entre user local et user remote (le local peut générer des PAT, le remote non).
243 +- Empêche les workflows CI/CD remote d'utiliser HTTPS+token (pattern le plus courant).
244 +- Force chaque consommateur à imaginer un workaround si HTTPS est important pour lui.
245 +
246 +**Choix** : Alternative A. Coût équivalent en LOC à l'alternative B mais zéro impact non-consommateur.
247 +
248 +## Testing plan
249 +
250 +### Unit
251 +
252 +Dans `crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs#tests` :
253 +
254 +- Happy paths (5 méthodes × 1 cas = 5 tests).
255 +- Cas d'erreur :
256 + - `create` : `remote_identity_id` inexistant → `NotFound`.
257 + - `create` : name vide ou > 64 chars → `Validation`.
258 + - `create` : remote_identity `disabled_at IS NOT NULL` → `Validation`.
259 + - `verify` : token mal formé → `None`.
260 + - `verify` : token révoqué → `None`.
261 + - `verify` : token expiré → `None`.
262 + - `verify` : remote_identity disabled entre création et verify → `None`.
263 + - `revoke` × 2 sur même id → idempotent.
264 + - `revoke_all_for_remote_identity` : retourne nombre exact d'updates.
265 +- Constant-time hash check : verify avec un token dont le SHA-256 partage les N premiers bytes d'un hash existant doit échouer (pas de timing leak).
266 +
267 +### Doctest
268 +
269 +Exemple `create` + `verify` dans le doc comment de struct. Marqué `no_run` (touche la DB).
270 +
271 +### Integration
272 +
273 +Test bout en bout avec SQLite in-memory : create → verify → revoke → verify (None) → revoke_all → list (empty).
274 +
275 +### Compat
276 +
277 +`cargo test -p rustwarden-core` reste vert (rien ne change côté core).
278 +
279 +### Bench
280 +
281 +Non requis (lookup indexé sur token_hash, déjà optimal).
282 +
283 +### Fuzz
284 +
285 +Optionnel : fuzz le parsing du préfixe `rwfed_pat_` dans `verify` (10 min `cargo fuzz`). Robustesse pure, pas d'enjeu sécurité critique (le hash final est constant-time).
286 +
287 +## Migration guide
288 +
289 +À copier dans le `CHANGELOG.md` de `rustwarden-fediverse` au merge :
290 +
291 +### Added (0.X+1.0)
292 +
293 +- `RemotePersonalAccessTokenService<S>` — émettre, vérifier, lister et révoquer des PAT locaux pour des identités ActivityPub remote.
294 +- Nouveau modèle `models::fediverse_personal_access_token::Model`.
295 +- Migration `m20260516_000003_remote_pat` ajoutée à `FediverseMigrator`.
296 +- Préfixe de token `rwfed_pat_` (43 chars base32 après le préfixe), distinct du `rw_pat_` du framework.
297 +
298 +### Migration depuis l'absence
299 +
300 +Aucune. Première livraison de l'API.
301 +
302 +### Steps pour gitrust spécifiquement (post-merge)
303 +
304 +1. Bump `rustwarden-fediverse` dans `Cargo.toml`.
305 +2. Étendre `/settings/tokens` (handler gitrust) pour discriminer `Identity::Local` vs `Identity::Remote` et appeler le bon service.
306 +3. Étendre les middlewares d'auth HTTPS (push/clone, API) pour discriminer le préfixe du token entrant et appeler le bon `verify`.
307 +4. Ajouter un test E2E : alice (remote, Developer de B/widgets) génère un PAT scope "repo:write", l'utilise pour `git push https://alice:TOKEN@gitrust-b/widgets.git`, push accepté.
308 +
309 +## Open questions
310 +
311 +- [ ] **Format du préfixe** : `rwfed_pat_` vs un préfixe purement consumer-defined ? Argument pour : auto-routing trivial côté consommateur sans flag DB. Argument contre : impose un format au framework qui devrait rester agnostique. **Recommandation** : laisser le framework choisir le préfixe canonique mais documenter qu'un consommateur peut overrider via une méthode statique helper.
312 +- [ ] **Audit log** : émettre `on_audit_event` à `create`/`revoke` ? Probablement oui (parité avec `PersonalTokenService`).
313 +- [ ] **Listing UI cross-instance** : un admin local doit-il pouvoir lister TOUS les PAT remote actifs sur son instance (pour incident response) ? Si oui : ajouter `list_all_active(db, limit, offset)`. Hors scope V1, à empiler.
314 +- [ ] **Cascade Delete{Actor}** : `ON DELETE CASCADE` sur la FK fait disparaître les rows. Faut-il à la place soft-delete (`revoked_at = NOW()`) pour conserver une trace dans les audit logs ? À discuter.
315 +
316 +## Score sur la grille du processus §4
317 +
318 +| Critère | Note | Justification |
319 +|---|---|---|
320 +| **Généralité** | 5 | Pair fonctionnel de la RFC 0001, tout consommateur AP |
321 +| **Stabilité API** | 5 | Additif pur |
322 +| **Performance** | 5 | Lookup indexé identique au PAT framework |
323 +| **Sécurité** | 5 | Hash SHA-256 + constant-time + cascade Delete{Actor} → posture égale au framework |
324 +| **Complexité impl** | 4 | < 200 LOC service (réutilise les patterns du PersonalTokenService) |
325 +| **Maintenance** | 5 | Zéro nouvelle dépendance |
326 +| **Documentation** | 5 | Rustdoc + doctest + entrée CHANGELOG + guide migration |
327 +| **Total** | **34/35** | ≥ 30 = accélération possible |
328 +
329 +## Tracking issue
330 +
331 +À ouvrir sur le repo `rustwarden-core` avec labels `rfc` + `area:fediverse`. Mentionner que gitrust est consommateur prêt à adopter dès livraison. Référencer la RFC 0001 (RemoteResourceShareService) pour cohérence des paires fonctionnelles.
332 +
333 +---
334 +
335 +**Historique** :
336 +- 2026-05-15 : Draft initial — gap identifié lors de la planification de l'autonomie des users remote côté gitrust (auth HTTPS git via PAT).
M docs/rfcs/README.md
+5 -0
@@ -84,9 +84,14 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
84 84 | [0023](0023-fediverse-ui-user-auth.md) | Fediverse UI — Parcours utilisateur d'authentification fédérée (Slice B) | **Accepted** (26/35) | EBO | 2026-05-10 |
85 85 | [0024](0024-fediverse-ui-ops-slice-c.md) | Fediverse UI — Invitations & ops admin (Slice C, 6 écrans) | **Accepted** (26/35) | EBO | 2026-05-10 |
86 86 | [0025](0025-fediverse-auth-back-channel.md) | Fediverse — Back-channel `Accept(AuthRequest)` (RFC 0006 Phase 3) | **Accepted** (livré + 5 tests E2E verts) | EBO | 2026-05-11 |
87 +| 0026-0028 | _réservés_ pour travaux framework en cours (cf. backlog interne) | — | — | — |
88 +| [0029](0029-fediverse-remote-resource-shares.md) | Fediverse — `RemoteResourceShareService` (partages cross-instance de ressources génériques) | **Accepted** (33/35, livré + 11 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 |
89 +| [0030](0030-fediverse-remote-personal-access-tokens.md) | Fediverse — `RemotePersonalAccessTokenService` (PAT pour identités remote) | **Accepted** (34/35, livré + 20 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 |
87 90
88 91 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
89 92
93 +> **Note sur les RFCs 0029-0030** : ces deux RFCs trouvent leur origine côté gitrust (premier consommateur), tracking dans [`gitrust/`](gitrust/), évaluation détaillée dans [`docs/plans/2026-05-15-rfcs-gitrust-evaluation.md`](../plans/2026-05-15-rfcs-gitrust-evaluation.md). Une 3ᵉ RFC initialement proposée (`gitrust-0003 RemoteTeamMembershipService`) a été **rejetée** car la capacité est déjà livrée par les RFCs 0008/0009.
94 +
90 95 ## RFCs liées au plan fediverse
91 96
92 97 Le plan d'intégration de la fédération d'identité ([`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md) §17) prévoit 11 RFCs :
A docs/rfcs/gitrust/0001-fediverse-remote-resource-shares.md
+409 -0
@@ -0,0 +1,409 @@
1 +# RFC 0001 — `RemoteResourceShareService` dans `rustwarden-fediverse`
2 +
3 +- **Auteur** : EBO (gitrust)
4 +- **Date** : 2026-05-15
5 +- **Statut** : Draft
6 +- **Apps consommatrices concernées** : gitrust (immédiat), tout futur consommateur Rustwarden qui active `experimental-fediverse` et veut autoriser des identités remote sur ses ressources métier.
7 +- **Crate cible** : `rustwarden-fediverse` (PAS `rustwarden-core`)
8 +
9 +> Cette RFC vit dans le dépôt gitrust pour tracking. À pousser dans le dépôt
10 +> upstream du framework (`docs/rfcs/`) une fois revue par l'utilisateur.
11 +> Document rédigé en français pour cohérence avec les autres RFCs du framework
12 +> (cf. `docs/RUSTWARDEN_EVOLUTION_PROCESS.md` §3).
13 +
14 +## Summary
15 +
16 +Ajouter à `rustwarden-fediverse` un service `RemoteResourceShareService` + une table `fediverse_resource_shares` qui permet d'attribuer une permission sur une ressource générique (identifiée par `resource_type` + `resource_id`) à une `remote_identities.id`. Le service vit **dans le crate fédération**, sans toucher au `ResourceService` ni au schéma `resource_shares` de `rustwarden-core`.
17 +
18 +## Motivation
19 +
20 +### Problème concret
21 +
22 +Les RFCs 0001-0028 du framework ont livré une infrastructure d'identité fédérée complète : WebFinger, Actor, JWT avec `identity_kind: Remote`, revocation, invites de teams cross-instance. Mais **un user remote ne peut être collaborateur d'aucune ressource métier non-team** :
23 +
24 +- Dans gitrust : Owner d'un repo ne peut pas inviter `@alice@autre-instance.org` comme Developer sur son repository (autre qu'à travers une team).
25 +- Dans un futur consommateur (par exemple un outil de gestion de documents) : impossible de partager un document avec un user remote sans le faire passer par une team.
26 +
27 +Aujourd'hui, `rustwarden-core::ResourceService` connaît `Principal::{User, Team}` mais pas `Remote`. Le champ `resource_share::shared_with_remote_identity_id` existe dans le modèle SeaORM (`crates/rustwarden-core/src/models/resource_share.rs:26-30`) mais n'est ni matérialisé en SQL, ni consommé par `share_with` (toujours set à `None`, ligne 234 de `resource_service.rs`), ni consulté par `effective_permission_for_user`. C'est un stub mort.
28 +
29 +### Impact si on ne fait rien
30 +
31 +- Tout consommateur de `experimental-fediverse` qui veut une collaboration cross-instance au-delà des teams est forcé d'écrire un workaround local (table dédiée + service + refactor d'autorisation). Trois workarounds en parallèle dans trois consommateurs = dette duplicate × 3 et risques de divergence sécuritaire.
32 +- Le SSO fédéré perd 80 % de son intérêt fonctionnel : `@alice@…` peut se logger, mais ne peut rien faire d'utile sauf rejoindre une team.
33 +
34 +### Pourquoi un nouveau service dans fediverse plutôt qu'étendre `ResourceService` du core ?
35 +
36 +C'est la question centrale, plusieurs raisons :
37 +
38 +1. **Séparation des préoccupations explicite** (choix architectural existant du framework). Les crates fédération sont **isolés** : leur activation est optionnelle (feature `experimental-fediverse`), leur schéma est tracké séparément (`seaql_fediverse_migrations`), leurs migrations vivent dans `crates/rustwarden-fediverse/migration/`. Étendre `ResourceService` casserait cette frontière en injectant un concept fédération dans le crate core.
39 +
40 +2. **Stabilité du core**. `rustwarden-core` est en cycle de release stable. Ajouter `Principal::Remote` impose un breaking minor (nouveau variant exhaustivement matché par tous les consommateurs qui pattern-match sur `Principal`). Ajouter un service séparé dans fediverse n'impose **rien** au consommateur qui n'active pas la fédération.
41 +
42 +3. **Cycle de vie API**. `experimental-fediverse` autorise des breaking sans bump major (cf. processus §5). C'est exactement le bon endroit pour itérer sur ce nouveau service tant qu'il n'a pas atteint la maturité requise pour la promotion stable.
43 +
44 +4. **Composabilité**. Le consommateur orchestre la résolution finale de permission en combinant `ResourceService::effective_permission_for_user(Local)` + `RemoteResourceShareService::effective_role_for_remote_identity(Remote)`. Cette composition est triviale (max sur permission level) et reste maîtrisée par le consommateur, qui connaît son modèle d'autorisation.
45 +
46 +### Urgence
47 +
48 +Non bloquant. Le consommateur gitrust peut implémenter un workaround local (table `gitrust_remote_collaborators`) en attendant. Mais chaque jour sans la solution upstream est une journée de dette qui sera à résorber. Idéalement livré dans la prochaine minor `rustwarden-fediverse` (cycle 2 semaines selon processus §9).
49 +
50 +## Non-goals
51 +
52 +- **Ne PAS modifier `rustwarden-core::ResourceService`**. Pas de variant `Principal::Remote`, pas de migration sur `resource_shares`, pas de nouveau paramètre dans les API existantes.
53 +- **Ne PAS toucher au champ orphelin `shared_with_remote_identity_id`** dans `resource_share::Model`. Il est cosmétique et peut rester en place ou être supprimé par une RFC séparée — hors scope.
54 +- **Ne PAS introduire de notion d'ownership remote**. Un owner reste un user local. Le remote ne peut être que collaborateur (read/write/admin).
55 +- **Ne PAS gérer la fédération outbound des partages**. Émettre un `Announce{Add}` AP quand un partage est créé, ou propager un `Remove` sur revocation, c'est l'objet d'une RFC ultérieure (peut être empilée sur celle-ci). Cette RFC se limite à **stocker et résoudre** les permissions remote.
56 +- **Ne PAS gérer les invites cross-instance**. C'est `fediverse_invite_service` et `fediverse_pending_invites` qui en sont responsables (RFC 0009/0014). Les deux services sont complémentaires : l'invite gère le "consentement préalable", le share gère l'autorisation effective. Selon les flux applicatifs, le consommateur peut exiger qu'un accept d'invite crée automatiquement un share, ou pas.
57 +
58 +## API proposée
59 +
60 +### Migration SQL (nouvelle migration `m20260516_000002_remote_resource_shares.rs`)
61 +
62 +```sql
63 +CREATE TABLE fediverse_resource_shares (
64 + id UUID PRIMARY KEY,
65 + resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
66 + remote_identity_id UUID NOT NULL REFERENCES remote_identities(id) ON DELETE CASCADE,
67 + permission_level VARCHAR(32) NOT NULL, -- "read" | "write" | "admin"
68 + shared_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
69 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
70 +);
71 +
72 +CREATE UNIQUE INDEX idx_fediverse_resource_shares_unique
73 + ON fediverse_resource_shares (resource_id, remote_identity_id);
74 +
75 +CREATE INDEX idx_fediverse_resource_shares_remote
76 + ON fediverse_resource_shares (remote_identity_id);
77 +```
78 +
79 +**FK sur `resources(id)`** : la table référence l'**entrée du registry framework** (`resources` table de core), pas la ressource concrète (`repositories` côté gitrust). Cohérent avec le pattern de `resource_shares`. Le crate fediverse dépend déjà de `rustwarden-core` (cf. `Cargo.toml`).
80 +
81 +**Pas de CHECK constraint à plusieurs colonnes** : table monolithique, un share = un (resource, remote_identity) pair. Simple et lisible.
82 +
83 +### Modèle SeaORM (`crates/rustwarden-fediverse/src/models/fediverse_resource_share.rs`)
84 +
85 +```rust
86 +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
87 +#[sea_orm(table_name = "fediverse_resource_shares")]
88 +pub struct Model {
89 + #[sea_orm(primary_key, auto_increment = false)]
90 + pub id: Uuid,
91 + pub resource_id: Uuid,
92 + pub remote_identity_id: Uuid,
93 + pub permission_level: String,
94 + pub shared_by_user_id: Uuid,
95 + pub created_at: DateTime<Utc>,
96 +}
97 +```
98 +
99 +### Service (`crates/rustwarden-fediverse/src/services/remote_resource_share_service.rs`)
100 +
101 +```rust
102 +use crate::error::FediverseError;
103 +use rustwarden_core::error::AppError;
104 +use rustwarden_core::services::resource_service::ResourceService;
105 +use sea_orm::DatabaseConnection;
106 +use uuid::Uuid;
107 +
108 +/// Permission levels (must mirror `rustwarden_core::services::resource_service::PERMISSION_HIERARCHY`).
109 +const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"];
110 +
111 +/// Sharing of generic application resources with remote ActivityPub identities.
112 +///
113 +/// Companion of [`rustwarden_core::services::resource_service::ResourceService`]
114 +/// but scoped to `remote_identities`. The consumer composes the two services
115 +/// when resolving the effective permission of an [`rustwarden_core::identity::Identity`].
116 +pub struct RemoteResourceShareService;
117 +
118 +impl RemoteResourceShareService {
119 + /// Share a resource with a remote identity at the given permission level.
120 + ///
121 + /// # Errors
122 + ///
123 + /// - [`AppError::NotFound`] if the resource registry entry does not exist.
124 + /// - [`AppError::Validation`] if `permission_level` is unknown.
125 + /// - [`AppError::Database`] on persistence error.
126 + pub async fn share(
127 + db: &DatabaseConnection,
128 + resource_type: &str,
129 + resource_id: Uuid,
130 + remote_identity_id: Uuid,
131 + permission_level: &str,
132 + shared_by_user_id: Uuid,
133 + ) -> Result<fediverse_resource_share::Model, AppError> {
134 + // 1. Look up resources registry entry (composition with core).
135 + let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id)
136 + .await?
137 + .ok_or_else(|| AppError::NotFound("Resource not found".into()))?;
138 +
139 + // 2. Validate permission level.
140 + if !PERMISSION_HIERARCHY.contains(&permission_level) {
141 + return Err(AppError::Validation(format!(
142 + "Invalid permission level: {}",
143 + permission_level
144 + )));
145 + }
146 +
147 + // 3. Upsert by (resource_id, remote_identity_id).
148 + // Implementation détaillée — voir tests pour le contrat.
149 + // ...
150 + }
151 +
152 + /// Revoke a remote share by its (resource, remote_identity) pair.
153 + pub async fn unshare(
154 + db: &DatabaseConnection,
155 + resource_type: &str,
156 + resource_id: Uuid,
157 + remote_identity_id: Uuid,
158 + ) -> Result<bool, AppError> { /* ... */ }
159 +
160 + /// Resolve the effective permission level of a remote identity on a resource.
161 + ///
162 + /// Returns `None` if no share exists.
163 + /// Returns the permission level (`"read" | "write" | "admin"`) otherwise.
164 + ///
165 + /// The consumer composes this with
166 + /// [`ResourceService::effective_permission_for_user`] when resolving an
167 + /// [`rustwarden_core::identity::Identity::Remote`].
168 + pub async fn effective_permission_for_remote_identity(
169 + db: &DatabaseConnection,
170 + resource_type: &str,
171 + resource_id: Uuid,
172 + remote_identity_id: Uuid,
173 + ) -> Result<Option<String>, AppError> { /* ... */ }
174 +
175 + /// List all remote shares for a resource (for admin display).
176 + pub async fn list_shares(
177 + db: &DatabaseConnection,
178 + resource_type: &str,
179 + resource_id: Uuid,
180 + ) -> Result<Vec<fediverse_resource_share::Model>, AppError> { /* ... */ }
181 +
182 + /// List all resources shared with a given remote identity (for "my access" page).
183 + pub async fn list_for_remote_identity(
184 + db: &DatabaseConnection,
185 + remote_identity_id: Uuid,
186 + ) -> Result<Vec<fediverse_resource_share::Model>, AppError> { /* ... */ }
187 +}
188 +```
189 +
190 +### Exemple d'usage côté consommateur
191 +
192 +```rust
193 +// gitrust-core/src/services/repository_service.rs
194 +
195 +use rustwarden_core::identity::Identity;
196 +use rustwarden_core::services::resource_service::ResourceService;
197 +use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService;
198 +
199 +pub async fn effective_role_for_identity(
200 + db: &DatabaseConnection,
201 + repo_id: Uuid,
202 + identity: Identity,
203 +) -> Result<Option<Role>, AppError> {
204 + let level = match identity {
205 + Identity::Local(user_id) => {
206 + ResourceService::effective_permission_for_user(
207 + db, "repository", repo_id, user_id,
208 + ).await?
209 + }
210 + Identity::Remote(remote_id) => {
211 + RemoteResourceShareService::effective_permission_for_remote_identity(
212 + db, "repository", repo_id, remote_id,
213 + ).await?
214 + }
215 + };
216 + Ok(level.map(Role::from_permission_str))
217 +}
218 +```
219 +
220 +Note : la composition `max(local, remote)` n'a pas de sens ici puisque l'identity est **soit** local **soit** remote, jamais les deux. Le consommateur n'a pas besoin de combiner.
221 +
222 +## Analyse de compatibilité
223 +
224 +- [x] **API existante non modifiée** → **non-breaking** (patch/minor possible).
225 +- [x] **Nouvelle API ajoutée** : `RemoteResourceShareService` (5 méthodes) + nouveau modèle SeaORM.
226 +- [x] **Schéma DB** : nouvelle migration **additive** dans `FediverseMigrator`. Aucun ALTER sur table existante.
227 +- [x] **Format sérialisé inchangé** : pas de modification des cookies, JWT claims, events JSON.
228 +- [x] **Pattern matching consommateurs** : aucun enum modifié. Le pattern match sur `Principal` côté `rustwarden-core` reste exhaustif.
229 +- [x] **Doc rustdoc** : tout item public livré avec doctest exécutable.
230 +
231 +**Verdict** : 100 % additif, mergeable en minor `rustwarden-fediverse` sans risque pour les consommateurs existants.
232 +
233 +## Alternatives considérées
234 +
235 +### Alternative A (recommandée) : nouveau service dans `rustwarden-fediverse`
236 +
237 +**Pros** :
238 +- Respecte la séparation core/fédération posée par l'architecture du framework.
239 +- Aucun breaking change.
240 +- Cycle de vie API isolé sur `experimental-fediverse` jusqu'à promotion stable.
241 +- Composition simple côté consommateur (un match sur `Identity`).
242 +- Aucun consommateur "non-fediverse" n'est impacté ni à la compile ni au runtime.
243 +
244 +**Cons** :
245 +- Le consommateur doit faire la composition lui-même (vs. une API unifiée côté core).
246 +- Si une 4ème catégorie de principal apparaît (ex: API key), la composition côté consommateur devient un match à 4 branches.
247 +
248 +### Alternative B : étendre `rustwarden-core::Principal` avec `Remote(Uuid)`
249 +
250 +**Pros** :
251 +- API unifiée côté consommateur : un seul `effective_permission_for_user`/`identity_can_access`.
252 +
253 +**Cons** :
254 +- Casse la séparation : `rustwarden-core` devient conscient du concept "remote identity" alors qu'il n'a aucun moyen de fetcher / valider / révoquer une remote_identity.
255 +- Breaking minor : tous les `match Principal { … }` dans les consommateurs doivent ajouter une branche.
256 +- Migration `ALTER TABLE resource_shares` avec CHECK constraint à 3 termes : plus complexe à reverter.
257 +- Couple le cycle de release : pour ajouter ou faire évoluer le concept Remote, il faut une release core même quand seul fediverse change.
258 +- Le consommateur sans fediverse paie quand même le coût (pattern match exhaustif obligatoire) pour une feature qu'il n'utilise pas.
259 +
260 +### Alternative C : ne rien faire upstream, workaround dans chaque consommateur
261 +
262 +**Pros** : zéro coût upstream, zéro risque côté framework.
263 +
264 +**Cons** :
265 +- Dette dupliquée dans chaque consommateur qui voudra de la collaboration cross-instance.
266 +- Risque de divergence sécuritaire (un consommateur applique la check, un autre non).
267 +- Difficile à promouvoir `experimental-fediverse` en stable si un cas d'usage majeur reste hors framework.
268 +- Pour gitrust spécifiquement : ~8j de workaround + ~3j de retrait quand la solution arrive upstream = **~11j**, contre **~5j** si upstream livre directement.
269 +
270 +### Alternative D : trait `RemoteResourceShareProvider` dans core, impl dans fediverse
271 +
272 +**Pros** :
273 +- Core expose une abstraction qui peut être implémentée par fediverse OU par n'importe quel autre provider d'identité distribuée future (matrix, OIDC, …).
274 +
275 +**Cons** :
276 +- Sur-ingénierie pour 1 seul provider identifié.
277 +- Le trait dans core devrait pré-déclarer un type `RemoteId` opaque, ce qui complique l'API.
278 +- Bénéfice théorique seulement : aucun autre provider d'identité distribuée n'est sur la roadmap.
279 +
280 +**Choix** : Alternative A. C'est la plus économique en coût aujourd'hui ET la plus propre architecturalement. L'Alternative D pourra être adoptée plus tard (refactor en interne) sans casser l'API publique livrée par A.
281 +
282 +## Testing plan
283 +
284 +### Unit (obligatoire)
285 +
286 +Dans `crates/rustwarden-fediverse/src/services/remote_resource_share_service.rs#tests` :
287 +
288 +- Happy path × méthode (5 méthodes × 1 cas = 5 tests)
289 +- Cas d'erreur par méthode (minimum 3 par méthode publique) :
290 + - `share` : resource_id inexistant → `NotFound`
291 + - `share` : permission_level invalide → `Validation`
292 + - `share` : remote_identity_id inexistant → `Database` (FK violation)
293 + - `share` × 2 sur même pair → upsert, pas duplicate
294 + - `unshare` sur pair inexistant → retourne `false`, pas d'erreur
295 + - `effective_permission_for_remote_identity` : aucun share → `None`
296 + - `effective_permission_for_remote_identity` : share présent → renvoie level
297 + - `list_shares` sur resource sans share → `Vec::new()`
298 + - `list_for_remote_identity` sur identity sans share → `Vec::new()`
299 +
300 +### Doctest (obligatoire)
301 +
302 +Exemple `share` + `effective_permission_for_remote_identity` dans le doc comment de struct. Marqué `no_run` (touche la DB).
303 +
304 +### Integration (recommandé)
305 +
306 +Test avec SQLite in-memory ou sea-orm-mock pour vérifier que la composition core (`ResourceService::find_by_type_and_id`) + fediverse (`share`) fonctionne bout en bout.
307 +
308 +### Compat (obligatoire)
309 +
310 +Test que `rustwarden-fediverse` compile sans warning **avec et sans** la feature `fediverse-resource-shares` (si jamais on flag, sinon directement par défaut).
311 +
312 +Test que `cargo test -p rustwarden-core` reste vert (rien ne doit changer côté core).
313 +
314 +### Bench
315 +
316 +Non requis : la table est en lecture seule sur le hot path (résolution permission, indexée). Pas de regression attendue.
317 +
318 +### Fuzz
319 +
320 +Non requis : pas de parsing externe (les inputs sont des UUIDs typés, déjà sanitized par le framework HTTP).
321 +
322 +## Migration guide
323 +
324 +À copier dans le `CHANGELOG.md` de `rustwarden-fediverse` au merge :
325 +
326 +### Added (0.X+1.0)
327 +
328 +- `RemoteResourceShareService` (services/remote_resource_share_service.rs) — partager une ressource avec une identité ActivityPub remote, via 5 méthodes : `share`, `unshare`, `effective_permission_for_remote_identity`, `list_shares`, `list_for_remote_identity`.
329 +- Nouveau modèle `models::fediverse_resource_share::Model`.
330 +- Migration `m20260516_000002_remote_resource_shares` ajoutée à `FediverseMigrator`.
331 +
332 +### Migration depuis l'absence
333 +
334 +Aucune. Première livraison de l'API. Les consommateurs qui ne l'utilisent pas ne paient aucun coût.
335 +
336 +### Exemple d'adoption côté consommateur
337 +
338 +**Avant (workaround local nécessaire)** :
339 +```rust
340 +// Table locale gitrust_remote_collaborators dans le consommateur
341 +struct LocalRemoteCollabService;
342 +// ~200 LOC de duplication
343 +```
344 +
345 +**Après (0.X+1.0)** :
346 +```rust
347 +use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService;
348 +
349 +RemoteResourceShareService::share(
350 + &db, "repository", repo_id, remote_id, "write", admin_user_id,
351 +).await?;
352 +```
353 +
354 +### Steps pour gitrust spécifiquement
355 +
356 +1. Bump `rustwarden-fediverse` dans `Cargo.toml`.
357 +2. Migrer les rows de `gitrust_remote_collaborators` vers `fediverse_resource_shares` :
358 + ```sql
359 + INSERT INTO fediverse_resource_shares
360 + (id, resource_id, remote_identity_id, permission_level, shared_by_user_id, created_at)
361 + SELECT
362 + gen_random_uuid(),
363 + (SELECT id FROM resources WHERE resource_type='repository' AND resource_id = grc.repository_id),
364 + grc.remote_identity_id,
365 + CASE grc.role
366 + WHEN 'reader' THEN 'read'
367 + WHEN 'developer' THEN 'write'
368 + WHEN 'maintainer' THEN 'admin'
369 + WHEN 'owner' THEN 'admin'
370 + END,
371 + grc.created_by,
372 + grc.created_at
373 + FROM gitrust_remote_collaborators grc;
374 + ```
375 +3. Refactor `RepositoryService::effective_role` pour appeler le service framework.
376 +4. Supprimer `RemoteCollaboratorService` local et la table `gitrust_remote_collaborators`.
377 +5. Section "RÉSOLU UPSTREAM" dans `docs/UPSTREAM_RUSTWARDEN.md`.
378 +
379 +## Open questions
380 +
381 +- [ ] **Audit log** : le service doit-il appeler `on_audit_event` du framework à `share`/`unshare` ? (Probablement oui, cohérent avec `ResourceService::share_with`.)
382 +- [ ] **Hook `on_resource_remote_shared`/`on_resource_remote_unshared`** : utile pour qu'un consommateur émette un `Announce{Add}` AP outbound ? À discuter — peut être empilé dans une RFC 0002 dédiée.
383 +- [ ] **Listing combiné** : faut-il une méthode helper qui retourne **toutes** les permissions (locales + remote) sur une ressource pour l'UI admin ? Probablement plus propre côté consommateur (qui maîtrise son rendu).
384 +- [ ] **Cascading sur `Delete{Actor}`** : quand `FediverseSignalService::apply_delete_actor` désactive une `remote_identity`, faut-il supprimer ses shares ? Aujourd'hui le `ON DELETE CASCADE` de la FK le ferait — mais on ne supprime pas physiquement la remote_identity (soft-delete via `disabled_at`). À clarifier dans la doc du service ou via une méthode `purge_for_disabled_identities`.
385 +
386 +## Score sur la grille du processus §4
387 +
388 +| Critère | Note | Justification |
389 +|---|---|---|
390 +| **Généralité** | 5 | Sert tout consommateur AP, pas une logique app-specific |
391 +| **Stabilité API** | 5 | Non-breaking par ajout pur, zéro impact sur consommateurs sans fediverse |
392 +| **Performance** | 5 | Pas de regression possible (nouvelle table indexée) |
393 +| **Sécurité** | 4 | Ferme un gap d'autorisation cross-instance (fonctionnalité, pas un fix) |
394 +| **Complexité impl** | 4 | < 200 LOC service + 1 migration + 1 modèle SeaORM, patterns existants |
395 +| **Maintenance** | 5 | Zéro nouvelle dépendance, composition de briques stables |
396 +| **Documentation** | 5 | Rustdoc + doctest + entrée CHANGELOG + ce guide migration |
397 +| **Total** | **33/35** | ≥ 30 = accélération possible sans second avis |
398 +
399 +## Tracking issue
400 +
401 +À ouvrir sur le repo `rustwarden-core` (où vit la canonical RFC dir) avec :
402 +- Label `rfc` + `area:fediverse`
403 +- Copie du Summary + lien vers ce fichier (côté gitrust pour l'instant).
404 +- Mention de gitrust comme premier consommateur prêt à adopter dès la livraison.
405 +
406 +---
407 +
408 +**Historique** :
409 +- 2026-05-15 : Draft initial rédigé côté gitrust suite à l'identification du gap pendant la planification de l'intégration fediverse.
A docs/rfcs/gitrust/0002-fediverse-remote-personal-access-tokens.md
+334 -0
@@ -0,0 +1,334 @@
1 +# RFC 0002 — `RemotePersonalAccessTokenService` dans `rustwarden-fediverse`
2 +
3 +- **Auteur** : EBO (gitrust)
4 +- **Date** : 2026-05-15
5 +- **Statut** : Draft
6 +- **Apps consommatrices concernées** : gitrust (immédiat). Tout futur consommateur qui active `experimental-fediverse` et veut permettre à une identité remote d'émettre des PAT locaux pour des opérations git/API.
7 +- **Crate cible** : `rustwarden-fediverse` (PAS `rustwarden-core`)
8 +- **Dépendance** : indépendante de la [RFC 0001](0001-fediverse-remote-resource-shares.md) (peut être mergée avant ou après).
9 +
10 +## Summary
11 +
12 +Ajouter à `rustwarden-fediverse` un service `RemotePersonalAccessTokenService` + table `fediverse_personal_access_tokens` qui permet à une `remote_identities.id` d'émettre, lister et révoquer ses propres Personal Access Tokens locaux à l'instance courante. Le service vit **dans le crate fédération**, sans toucher au `PersonalTokenService<S>` ni au schéma `personal_access_tokens` de `rustwarden-core`. Symétrie complète avec la RFC 0001.
13 +
14 +## Motivation
15 +
16 +### Contexte fonctionnel
17 +
18 +Le modèle d'identité fédérée livré par les RFCs 0001-0028 fait qu'une identité remote (`@alice@autre-instance.org`) peut :
19 +
20 +- ✅ S'authentifier sur l'instance locale via SSO (RFC 0006/0013).
21 +- ✅ Être membre de teams cross-instance (RFC 0009 — sous réserve de la matérialisation `team_members.remote_identity_id`, cf. RFC 0003).
22 +- ✅ Être collaboratrice d'une ressource (sous réserve de la livraison RFC 0001).
23 +- ❌ Émettre un Personal Access Token local pour authentifier ses opérations git HTTPS / API.
24 +
25 +Le scénario type côté gitrust :
26 +
27 +```
28 +1. alice se logge sur gitrust-B via SSO fédéré → session web active.
29 +2. alice veut faire `git push https://alice:TOKEN@gitrust-b.test/bob/widgets.git`.
30 +3. alice va sur /settings/tokens, génère un PAT scopé "repo:write".
31 +4. Échec actuel : `personal_access_tokens.user_id` est NOT NULL FK users(id).
32 + alice n'a pas d'entrée dans users, son identité vit dans remote_identities.
33 +```
34 +
35 +Sans PAT remote, l'utilisateur autonome (cf. modèle "user remote = collaborateur autonome" établi pour gitrust) ne peut **pas** utiliser git via HTTPS. Reste l'option SSH avec clé locale (à traiter séparément, gap local gitrust), mais la couverture HTTPS reste manquante.
36 +
37 +### Pourquoi un nouveau service dans fediverse, pas étendre le PersonalTokenService du core ?
38 +
39 +Mêmes arguments que la RFC 0001 :
40 +
41 +1. **Séparation core/fédération** : `rustwarden-core::PersonalTokenService<S: TokenScope>` est générique et stable. Ajouter une notion de "remote identity owner" l'oblige à connaître un concept fédération qu'il ne devrait pas connaître.
42 +2. **Stabilité API** : le trait `TokenScope` du framework est exposé aux consommateurs. Ajouter un paramètre `owner: Principal` casserait toutes les implémentations downstream.
43 +3. **Cycle de vie** : `experimental-fediverse` permet l'itération libre tant qu'on n'a pas atteint la promotion stable.
44 +4. **Composabilité** : le consommateur fait son lookup côté DB selon l'identité présentée (PAT local OU PAT remote, via le préfixe du token ou un flag) puis appelle le service approprié.
45 +
46 +### Urgence
47 +
48 +Non bloquant, mais paire fonctionnelle avec la RFC 0001 : sans PAT remote, le scénario "user remote autonome" reste boiteux (SSH ok, HTTPS pas ok). Idéalement livré dans la même minor `rustwarden-fediverse` que la RFC 0001 pour une expérience consommateur cohérente.
49 +
50 +## Non-goals
51 +
52 +- **Ne PAS modifier `rustwarden-core::PersonalTokenService<S>`** ni le schéma `personal_access_tokens`.
53 +- **Ne PAS récupérer un PAT existant d'une autre instance** (e.g. via un protocole AP de propagation de credentials). Chaque instance émet ses propres PAT pour les identités qu'elle reconnaît. Cohérent avec le modèle "autonomie locale".
54 +- **Ne PAS unifier le format/préfixe** des PAT locaux et remote. Le service remote peut utiliser un préfixe distinct (`rwfed_pat_` au lieu de `rw_pat_`) pour faciliter le routing côté consommateur. À discuter en Open Question.
55 +- **Ne PAS gérer la révocation par cascade Delete{Actor}** dans cette RFC : à empiler dans une RFC dédiée hooks lifecycle (ou via `ON DELETE CASCADE` sur la FK `remote_identity_id`, qui couvre l'essentiel).
56 +
57 +## API proposée
58 +
59 +### Migration SQL (nouvelle migration `m20260516_000003_remote_pat.rs`)
60 +
61 +```sql
62 +CREATE TABLE fediverse_personal_access_tokens (
63 + id UUID PRIMARY KEY,
64 + remote_identity_id UUID NOT NULL REFERENCES remote_identities(id) ON DELETE CASCADE,
65 + name VARCHAR(64) NOT NULL,
66 + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex, comme PersonalTokenService
67 + scope TEXT NOT NULL, -- format identique au PersonalTokenService<S>::serialize
68 + expires_at TIMESTAMPTZ NULL,
69 + last_used_at TIMESTAMPTZ NULL,
70 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
71 + revoked_at TIMESTAMPTZ NULL
72 +);
73 +
74 +CREATE INDEX idx_fediverse_pat_remote_identity
75 + ON fediverse_personal_access_tokens (remote_identity_id);
76 +CREATE INDEX idx_fediverse_pat_active
77 + ON fediverse_personal_access_tokens (token_hash) WHERE revoked_at IS NULL;
78 +```
79 +
80 +Sémantique des colonnes alignée 1:1 sur la table framework `personal_access_tokens` (hash SHA-256, scope sérialisé, soft-revocation via `revoked_at`). Différences :
81 +- `remote_identity_id` au lieu de `user_id`.
82 +- Pas de FK vers `users(id)`.
83 +
84 +### Modèle SeaORM (`crates/rustwarden-fediverse/src/models/fediverse_personal_access_token.rs`)
85 +
86 +```rust
87 +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
88 +#[sea_orm(table_name = "fediverse_personal_access_tokens")]
89 +pub struct Model {
90 + #[sea_orm(primary_key, auto_increment = false)]
91 + pub id: Uuid,
92 + pub remote_identity_id: Uuid,
93 + pub name: String,
94 + pub token_hash: String,
95 + pub scope: String,
96 + pub expires_at: Option<DateTime<Utc>>,
97 + pub last_used_at: Option<DateTime<Utc>>,
98 + pub created_at: DateTime<Utc>,
99 + pub revoked_at: Option<DateTime<Utc>>,
100 +}
101 +```
102 +
103 +### Service (`crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs`)
104 +
105 +```rust
106 +use rustwarden_core::error::AppError;
107 +use rustwarden_core::services::personal_token_service::TokenScope;
108 +use sea_orm::DatabaseConnection;
109 +use uuid::Uuid;
110 +
111 +/// Personal access tokens emitted by the local instance for ActivityPub remote
112 +/// identities. Companion of [`rustwarden_core::services::personal_token_service::PersonalTokenService`]
113 +/// but scoped to `remote_identities` instead of `users`.
114 +///
115 +/// Generic over a [`TokenScope`] type so consumers can reuse their existing
116 +/// scope definitions (e.g. gitrust's repository/git scopes) for remote PATs.
117 +pub struct RemotePersonalAccessTokenService<S: TokenScope>(std::marker::PhantomData<S>);
118 +
119 +impl<S: TokenScope> RemotePersonalAccessTokenService<S> {
120 + /// Issue a new PAT for a remote identity.
121 + ///
122 + /// Returns the **plaintext token** (one-time display). The DB stores only
123 + /// the SHA-256 hash. Token format: `rwfed_pat_<43 base32 chars>`.
124 + ///
125 + /// # Errors
126 + /// - [`AppError::NotFound`] if `remote_identity_id` does not exist or is disabled.
127 + /// - [`AppError::Validation`] if `name` is empty or > 64 chars.
128 + /// - [`AppError::Database`] on persistence error.
129 + pub async fn create(
130 + db: &DatabaseConnection,
131 + remote_identity_id: Uuid,
132 + name: &str,
133 + scope: S,
134 + expires_at: Option<DateTime<Utc>>,
135 + ) -> Result<(fediverse_personal_access_token::Model, String), AppError> { /* ... */ }
136 +
137 + /// Verify a plaintext token and return the (model, scope) if valid + active.
138 + ///
139 + /// Constant-time hash comparison via `subtle`. Updates `last_used_at` on success.
140 + ///
141 + /// Returns `None` if:
142 + /// - Token format is invalid.
143 + /// - No matching `token_hash`.
144 + /// - Token is revoked or expired.
145 + /// - The associated `remote_identity` is disabled.
146 + pub async fn verify(
147 + db: &DatabaseConnection,
148 + plaintext_token: &str,
149 + ) -> Result<Option<(fediverse_personal_access_token::Model, S)>, AppError> { /* ... */ }
150 +
151 + /// Revoke a token by its id.
152 + pub async fn revoke(
153 + db: &DatabaseConnection,
154 + token_id: Uuid,
155 + ) -> Result<bool, AppError> { /* ... */ }
156 +
157 + /// List all (non-revoked) tokens for a remote identity (UI display).
158 + pub async fn list_for_remote_identity(
159 + db: &DatabaseConnection,
160 + remote_identity_id: Uuid,
161 + ) -> Result<Vec<fediverse_personal_access_token::Model>, AppError> { /* ... */ }
162 +
163 + /// Revoke all tokens for a remote identity (called on Delete{Actor}).
164 + pub async fn revoke_all_for_remote_identity(
165 + db: &DatabaseConnection,
166 + remote_identity_id: Uuid,
167 + ) -> Result<u64, AppError> { /* ... */ }
168 +}
169 +```
170 +
171 +### Exemple d'usage côté consommateur (gitrust)
172 +
173 +```rust
174 +// Création d'un PAT depuis /settings/tokens pour un user remote
175 +let claims: Claims = /* extraite de la session JWT */;
176 +let identity = Identity::from_claims(&claims);
177 +
178 +let scope = GitrustScope::repo_write(repo_id); // scope app-specific
179 +
180 +let (model, plaintext) = match identity {
181 + Identity::Local(user_id) => {
182 + let (m, p) = PersonalTokenService::<GitrustScope>::create(
183 + db, user_id, "deploy", scope, None,
184 + ).await?;
185 + (m.id, p)
186 + }
187 + Identity::Remote(remote_id) => {
188 + let (m, p) = RemotePersonalAccessTokenService::<GitrustScope>::create(
189 + db, remote_id, "deploy", scope, None,
190 + ).await?;
191 + (m.id, p)
192 + }
193 +};
194 +
195 +// Verify côté handler git push HTTPS
196 +// Discrimination par préfixe du token (rw_pat_ vs rwfed_pat_)
197 +let result = if plaintext.starts_with("rwfed_pat_") {
198 + RemotePersonalAccessTokenService::<GitrustScope>::verify(db, &plaintext).await?
199 + .map(|(m, s)| (Identity::Remote(m.remote_identity_id), s))
200 +} else {
201 + PersonalTokenService::<GitrustScope>::verify(db, &plaintext).await?
202 + .map(|(m, s)| (Identity::Local(m.user_id), s))
203 +};
204 +```
205 +
206 +Le consommateur orchestre proprement les deux services via le préfixe du token, sans aucun changement côté framework core.
207 +
208 +## Analyse de compatibilité
209 +
210 +- [x] **API existante non modifiée** → **non-breaking**.
211 +- [x] **Nouvelle API ajoutée** : `RemotePersonalAccessTokenService<S>` (5 méthodes) + nouveau modèle SeaORM.
212 +- [x] **Schéma DB** : nouvelle table dans `FediverseMigrator`. Aucun ALTER sur table existante.
213 +- [x] **Format sérialisé** : préfixe `rwfed_pat_` distinct du `rw_pat_` framework → pas de collision possible.
214 +- [x] **Trait `TokenScope` du framework** : réutilisé tel quel, aucune modification.
215 +
216 +**Verdict** : 100 % additif, mergeable en minor `rustwarden-fediverse`.
217 +
218 +## Alternatives considérées
219 +
220 +### Alternative A (recommandée) : table + service séparés dans fediverse
221 +
222 +Voir API proposée ci-dessus. Cf. Pros/Cons RFC 0001 §"Alternatives" — l'analyse est isomorphe.
223 +
224 +### Alternative B : étendre `personal_access_tokens` avec sibling FK `remote_identity_id`
225 +
226 +**Pros** :
227 +- Une seule table à interroger pour vérifier un PAT (pas de discrimination par préfixe).
228 +
229 +**Cons** :
230 +- Modification du schéma core → breaking migration.
231 +- Le `PersonalTokenService<S>` du framework devrait prendre un `Principal` au lieu d'un `Uuid` → breaking API.
232 +- Le préfixe de token unique deviendrait trompeur (un même préfixe `rw_pat_` couvrirait deux populations distinctes).
233 +- Couple le cycle de release des deux concepts.
234 +
235 +### Alternative C : pas de PAT remote, ne supporter que SSH local
236 +
237 +**Pros** : zéro travail upstream, zéro nouveau service.
238 +
239 +**Cons** :
240 +- Brise la parité fonctionnelle entre user local et user remote (le local peut générer des PAT, le remote non).
241 +- Empêche les workflows CI/CD remote d'utiliser HTTPS+token (pattern le plus courant).
242 +- Force chaque consommateur à imaginer un workaround si HTTPS est important pour lui.
243 +
244 +**Choix** : Alternative A. Coût équivalent en LOC à l'alternative B mais zéro impact non-consommateur.
245 +
246 +## Testing plan
247 +
248 +### Unit
249 +
250 +Dans `crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs#tests` :
251 +
252 +- Happy paths (5 méthodes × 1 cas = 5 tests).
253 +- Cas d'erreur :
254 + - `create` : `remote_identity_id` inexistant → `NotFound`.
255 + - `create` : name vide ou > 64 chars → `Validation`.
256 + - `create` : remote_identity `disabled_at IS NOT NULL` → `Validation`.
257 + - `verify` : token mal formé → `None`.
258 + - `verify` : token révoqué → `None`.
259 + - `verify` : token expiré → `None`.
260 + - `verify` : remote_identity disabled entre création et verify → `None`.
261 + - `revoke` × 2 sur même id → idempotent.
262 + - `revoke_all_for_remote_identity` : retourne nombre exact d'updates.
263 +- Constant-time hash check : verify avec un token dont le SHA-256 partage les N premiers bytes d'un hash existant doit échouer (pas de timing leak).
264 +
265 +### Doctest
266 +
267 +Exemple `create` + `verify` dans le doc comment de struct. Marqué `no_run` (touche la DB).
268 +
269 +### Integration
270 +
271 +Test bout en bout avec SQLite in-memory : create → verify → revoke → verify (None) → revoke_all → list (empty).
272 +
273 +### Compat
274 +
275 +`cargo test -p rustwarden-core` reste vert (rien ne change côté core).
276 +
277 +### Bench
278 +
279 +Non requis (lookup indexé sur token_hash, déjà optimal).
280 +
281 +### Fuzz
282 +
283 +Optionnel : fuzz le parsing du préfixe `rwfed_pat_` dans `verify` (10 min `cargo fuzz`). Robustesse pure, pas d'enjeu sécurité critique (le hash final est constant-time).
284 +
285 +## Migration guide
286 +
287 +À copier dans le `CHANGELOG.md` de `rustwarden-fediverse` au merge :
288 +
289 +### Added (0.X+1.0)
290 +
291 +- `RemotePersonalAccessTokenService<S>` — émettre, vérifier, lister et révoquer des PAT locaux pour des identités ActivityPub remote.
292 +- Nouveau modèle `models::fediverse_personal_access_token::Model`.
293 +- Migration `m20260516_000003_remote_pat` ajoutée à `FediverseMigrator`.
294 +- Préfixe de token `rwfed_pat_` (43 chars base32 après le préfixe), distinct du `rw_pat_` du framework.
295 +
296 +### Migration depuis l'absence
297 +
298 +Aucune. Première livraison de l'API.
299 +
300 +### Steps pour gitrust spécifiquement (post-merge)
301 +
302 +1. Bump `rustwarden-fediverse` dans `Cargo.toml`.
303 +2. Étendre `/settings/tokens` (handler gitrust) pour discriminer `Identity::Local` vs `Identity::Remote` et appeler le bon service.
304 +3. Étendre les middlewares d'auth HTTPS (push/clone, API) pour discriminer le préfixe du token entrant et appeler le bon `verify`.
305 +4. Ajouter un test E2E : alice (remote, Developer de B/widgets) génère un PAT scope "repo:write", l'utilise pour `git push https://alice:TOKEN@gitrust-b/widgets.git`, push accepté.
306 +
307 +## Open questions
308 +
309 +- [ ] **Format du préfixe** : `rwfed_pat_` vs un préfixe purement consumer-defined ? Argument pour : auto-routing trivial côté consommateur sans flag DB. Argument contre : impose un format au framework qui devrait rester agnostique. **Recommandation** : laisser le framework choisir le préfixe canonique mais documenter qu'un consommateur peut overrider via une méthode statique helper.
310 +- [ ] **Audit log** : émettre `on_audit_event` à `create`/`revoke` ? Probablement oui (parité avec `PersonalTokenService`).
311 +- [ ] **Listing UI cross-instance** : un admin local doit-il pouvoir lister TOUS les PAT remote actifs sur son instance (pour incident response) ? Si oui : ajouter `list_all_active(db, limit, offset)`. Hors scope V1, à empiler.
312 +- [ ] **Cascade Delete{Actor}** : `ON DELETE CASCADE` sur la FK fait disparaître les rows. Faut-il à la place soft-delete (`revoked_at = NOW()`) pour conserver une trace dans les audit logs ? À discuter.
313 +
314 +## Score sur la grille du processus §4
315 +
316 +| Critère | Note | Justification |
317 +|---|---|---|
318 +| **Généralité** | 5 | Pair fonctionnel de la RFC 0001, tout consommateur AP |
319 +| **Stabilité API** | 5 | Additif pur |
320 +| **Performance** | 5 | Lookup indexé identique au PAT framework |
321 +| **Sécurité** | 5 | Hash SHA-256 + constant-time + cascade Delete{Actor} → posture égale au framework |
322 +| **Complexité impl** | 4 | < 200 LOC service (réutilise les patterns du PersonalTokenService) |
323 +| **Maintenance** | 5 | Zéro nouvelle dépendance |
324 +| **Documentation** | 5 | Rustdoc + doctest + entrée CHANGELOG + guide migration |
325 +| **Total** | **34/35** | ≥ 30 = accélération possible |
326 +
327 +## Tracking issue
328 +
329 +À ouvrir sur le repo `rustwarden-core` avec labels `rfc` + `area:fediverse`. Mentionner que gitrust est consommateur prêt à adopter dès livraison. Référencer la RFC 0001 (RemoteResourceShareService) pour cohérence des paires fonctionnelles.
330 +
331 +---
332 +
333 +**Historique** :
334 +- 2026-05-15 : Draft initial — gap identifié lors de la planification de l'autonomie des users remote côté gitrust (auth HTTPS git via PAT).
A docs/rfcs/gitrust/0003-fediverse-remote-team-memberships.md
+356 -0
@@ -0,0 +1,356 @@
1 +# RFC 0003 — `RemoteTeamMembershipService` dans `rustwarden-fediverse`
2 +
3 +- **Auteur** : EBO (gitrust)
4 +- **Date** : 2026-05-15
5 +- **Statut** : Draft
6 +- **Apps consommatrices concernées** : gitrust (immédiat). Tout futur consommateur qui active `experimental-fediverse` et veut ajouter une identité remote comme membre d'une team locale.
7 +- **Crate cible** : `rustwarden-fediverse` (PAS `rustwarden-core`)
8 +- **Dépendance** : indépendante des RFCs 0001 et 0002. Pourrait être livrée en premier.
9 +
10 +## Summary
11 +
12 +Ajouter à `rustwarden-fediverse` un service `RemoteTeamMembershipService` + table `fediverse_team_memberships` qui permet d'ajouter une `remote_identities.id` comme membre d'une `teams.id` locale avec un rôle. Le service vit **dans le crate fédération**, sans toucher au `TeamService` ni au schéma `team_members` de `rustwarden-core`. Symétrie complète avec les RFCs 0001 et 0002.
13 +
14 +## Motivation
15 +
16 +### Contexte fonctionnel
17 +
18 +Les RFCs 0009 et 0014 du framework livrent l'infrastructure d'invitations cross-instance (`fediverse_pending_invites` + flow accept/reject). Mais une fois l'invite acceptée, **rien ne matérialise effectivement la membership** :
19 +
20 +- Le modèle SeaORM `team_member::Model` contient bien un champ `remote_identity_id: Option<Uuid>` ([crates/rustwarden-core/src/models/team_member.rs:28-31](../../crates/rustwarden-core/src/models/team_member.rs#L28-L31)) prévu par RFC 0008.
21 +- Mais la migration framework `m20260423_000008_create_teams.rs` crée `team_members` **sans cette colonne**.
22 +- Et `TeamService::add_member(db, team_id, user_id, role)` prend strictement un `user_id`, jamais un `remote_identity_id`.
23 +
24 +Le champ orphelin trompe la lecture du code : un appel `team_members.remote_identity_id` retourne toujours `None`, et un INSERT tenterait d'écrire dans une colonne inexistante (panique SeaORM à l'exécution).
25 +
26 +### Scénario type côté gitrust
27 +
28 +```
29 +1. Bob (admin de team Dev sur gitrust-B) → invite-fediverse @alice@gitrust-a.test
30 +2. alice reçoit l'invite, accepte via /api/v1/fediverse/invites/{id}/accept (RFC 0014)
31 +3. Le flow framework valide l'invite, marque le row pending_invite comme accepted
32 +4. Échec actuel : impossible de matérialiser alice dans `team_members`.
33 + - Variante (a) : on créerait un row team_members avec user_id=NULL (rejeté par NOT NULL).
34 + - Variante (b) : on créerait une shadow user (anti-pattern, cf. discussion gitrust).
35 + - Variante (c) : la colonne `remote_identity_id` n'existe pas en SQL → panique.
36 +```
37 +
38 +Conséquence : la RFC 0014 (flow d'invitation) **livre le rail sans le wagon**. Tout consommateur qui veut utiliser les invites cross-instance se retrouve obligé d'écrire un workaround pour stocker effectivement le résultat.
39 +
40 +### Pourquoi un nouveau service dans fediverse plutôt qu'étendre `TeamService` ?
41 +
42 +Cohérence avec RFC 0001 et 0002 (mêmes arguments) :
43 +
44 +1. **Séparation core/fédération**. Le crate fediverse maintient son propre schéma cross-instance, le core reste agnostique.
45 +2. **Champ orphelin à supprimer** : la présence de `remote_identity_id` dans `team_member::Model` côté core est un héritage à nettoyer. Cette RFC propose de l'**enlever** dans le même mouvement (cf. Migration guide).
46 +3. **Stabilité API** : `TeamService::add_member(db, team_id, user_id, role)` reste inchangé. Aucun consommateur "non-fediverse" n'est cassé.
47 +4. **Cycle de vie** : itération libre tant que `experimental-fediverse`.
48 +
49 +### Pour gitrust
50 +
51 +C'est probablement la RFC la plus urgente des trois (0001, 0002, 0003) car :
52 +
53 +- L'infrastructure framework pour les **invites** est déjà livrée (RFC 0014).
54 +- Tant que la matérialisation n'existe pas, le crate fediverse expose une API utilisateur (`/api/v1/fediverse/invites/{id}/accept`) qui retourne un succès apparent mais **ne fait rien d'utile** (rien n'est créé dans `team_members`).
55 +- C'est une dette de cohérence visible immédiatement par le premier consommateur.
56 +
57 +## Non-goals
58 +
59 +- **Ne PAS modifier `rustwarden-core::TeamService`** ni le schéma `team_members`.
60 +- **Ne PAS modifier `fediverse_pending_invites`** ni le flow d'acceptation existant — cette RFC ajoute juste l'étape "stocker la membership" qui manque après l'accept.
61 +- **Ne PAS gérer la propagation outbound** (un membership locale émet un `Add{Actor}` AP vers l'instance home du remote ?). Hors scope, à empiler dans une RFC dédiée si pertinent.
62 +- **Ne PAS unifier l'API** : `TeamService::list_members` retourne des `team_member::Model` (locaux), `RemoteTeamMembershipService::list_remote_members` retourne des rows séparés. Le consommateur compose les deux pour son rendu (liste mixte).
63 +- **Ne PAS supprimer le champ orphelin** `team_members.remote_identity_id` dans cette même RFC — c'est une **Open Question** : soit on l'enlève proprement dans une autre RFC pour limiter le scope, soit on l'enlève ici en bonus.
64 +
65 +## API proposée
66 +
67 +### Migration SQL (nouvelle migration `m20260516_000004_remote_team_memberships.rs`)
68 +
69 +```sql
70 +CREATE TABLE fediverse_team_memberships (
71 + id UUID PRIMARY KEY,
72 + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
73 + remote_identity_id UUID NOT NULL REFERENCES remote_identities(id) ON DELETE CASCADE,
74 + role VARCHAR(32) NOT NULL, -- "member" | "admin" (réutilise la même typologie que team_members.role)
75 + invited_by_user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
76 + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
77 + source_invite_id UUID NULL REFERENCES fediverse_pending_invites(id) ON DELETE SET NULL,
78 + UNIQUE (team_id, remote_identity_id)
79 +);
80 +
81 +CREATE INDEX idx_fediverse_team_memberships_team
82 + ON fediverse_team_memberships (team_id);
83 +CREATE INDEX idx_fediverse_team_memberships_remote
84 + ON fediverse_team_memberships (remote_identity_id);
85 +```
86 +
87 +Notes :
88 +- `source_invite_id` est une traçabilité bonus : on garde le lien avec l'invite qui a créé la membership, pour audit ou pour révoquer la membership en cas de retrait d'invite a posteriori. Nullable car une membership peut aussi être créée directement par admin sans passer par une invite.
89 +- `role` réutilise la sémantique existante de `team_members.role` (`"member" | "admin"`). Pas de nouvelle hiérarchie à apprendre.
90 +- `joined_at` au lieu de `created_at` pour cohérence avec `team_members.joined_at`.
91 +
92 +### Modèle SeaORM (`crates/rustwarden-fediverse/src/models/fediverse_team_membership.rs`)
93 +
94 +```rust
95 +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
96 +#[sea_orm(table_name = "fediverse_team_memberships")]
97 +pub struct Model {
98 + #[sea_orm(primary_key, auto_increment = false)]
99 + pub id: Uuid,
100 + pub team_id: Uuid,
101 + pub remote_identity_id: Uuid,
102 + pub role: String,
103 + pub invited_by_user_id: Option<Uuid>,
104 + pub joined_at: DateTime<Utc>,
105 + pub source_invite_id: Option<Uuid>,
106 +}
107 +```
108 +
109 +### Service (`crates/rustwarden-fediverse/src/services/remote_team_membership_service.rs`)
110 +
111 +```rust
112 +use rustwarden_core::error::AppError;
113 +use sea_orm::DatabaseConnection;
114 +use uuid::Uuid;
115 +
116 +/// Cross-instance team memberships: link a `teams.id` local with a
117 +/// `remote_identities.id`. Companion of [`rustwarden_core::services::team_service::TeamService`]
118 +/// but scoped to remote identities.
119 +pub struct RemoteTeamMembershipService;
120 +
121 +impl RemoteTeamMembershipService {
122 + /// Add a remote identity as a member of a local team.
123 + ///
124 + /// # Errors
125 + /// - [`AppError::NotFound`] if `team_id` or `remote_identity_id` does not exist.
126 + /// - [`AppError::Validation`] if `role` is unknown.
127 + /// - [`AppError::Database`] on persistence error (incl. UNIQUE constraint violation,
128 + /// which is mapped to `AlreadyExists`).
129 + pub async fn add_member(
130 + db: &DatabaseConnection,
131 + team_id: Uuid,
132 + remote_identity_id: Uuid,
133 + role: &str,
134 + invited_by_user_id: Option<Uuid>,
135 + source_invite_id: Option<Uuid>,
136 + ) -> Result<fediverse_team_membership::Model, AppError> { /* ... */ }
137 +
138 + /// Remove a remote membership.
139 + pub async fn remove_member(
140 + db: &DatabaseConnection,
141 + team_id: Uuid,
142 + remote_identity_id: Uuid,
143 + ) -> Result<bool, AppError> { /* ... */ }
144 +
145 + /// Change the role of an existing remote member.
146 + pub async fn update_role(
147 + db: &DatabaseConnection,
148 + team_id: Uuid,
149 + remote_identity_id: Uuid,
150 + new_role: &str,
151 + ) -> Result<bool, AppError> { /* ... */ }
152 +
153 + /// List remote members of a team (for admin UI display).
154 + pub async fn list_remote_members(
155 + db: &DatabaseConnection,
156 + team_id: Uuid,
157 + ) -> Result<Vec<fediverse_team_membership::Model>, AppError> { /* ... */ }
158 +
159 + /// List all teams a remote identity belongs to.
160 + pub async fn list_teams_for_remote_identity(
161 + db: &DatabaseConnection,
162 + remote_identity_id: Uuid,
163 + ) -> Result<Vec<fediverse_team_membership::Model>, AppError> { /* ... */ }
164 +
165 + /// Check whether a remote identity is a member of a given team
166 + /// (with optional role check). Used by permission resolution.
167 + pub async fn is_member(
168 + db: &DatabaseConnection,
169 + team_id: Uuid,
170 + remote_identity_id: Uuid,
171 + ) -> Result<Option<String>, AppError> { /* ... */ }
172 +}
173 +```
174 +
175 +### Branchement dans le flow d'acceptation d'invite (RFC 0014)
176 +
177 +L'acceptation d'une invite cross-instance ([crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs](../../crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs)) doit, après validation, appeler :
178 +
179 +```rust
180 +RemoteTeamMembershipService::add_member(
181 + db,
182 + pending_invite.team_id,
183 + pending_invite.remote_identity_id,
184 + pending_invite.role.as_str(),
185 + Some(pending_invite.inviter_id),
186 + Some(pending_invite.id),
187 +).await?;
188 +```
189 +
190 +C'est cette ligne qui ferme la boucle entre l'infrastructure d'invite (déjà livrée) et la matérialisation de la membership (livrée par cette RFC).
191 +
192 +### Exemple d'usage côté consommateur (gitrust)
193 +
194 +```rust
195 +// Permission resolution sur un repository, dans gitrust-core
196 +pub async fn effective_role_via_teams_for_identity(
197 + db: &DatabaseConnection,
198 + repo_id: Uuid,
199 + identity: Identity,
200 +) -> Result<Option<Role>, AppError> {
201 + match identity {
202 + Identity::Local(user_id) => {
203 + // Logique existante : list teams of user, then check resource_share.shared_with_team_id
204 + // ...
205 + }
206 + Identity::Remote(remote_id) => {
207 + // Nouveau : list teams of remote identity
208 + let teams = RemoteTeamMembershipService::list_teams_for_remote_identity(
209 + db, remote_id,
210 + ).await?;
211 + // Puis pour chaque team, check si la team a un resource_share sur ce repo
212 + // (utilise ResourceService::list_shares existant côté core)
213 + // ...
214 + }
215 + }
216 +}
217 +```
218 +
219 +Composition propre : `RemoteTeamMembershipService` côté fediverse pour "qui est dans quelles teams" + `ResourceService` côté core pour "quelles teams ont accès à quoi", combinés côté consommateur.
220 +
221 +## Analyse de compatibilité
222 +
223 +- [x] **API existante non modifiée** → **non-breaking**.
224 +- [x] **Nouvelle API ajoutée** : `RemoteTeamMembershipService` (6 méthodes) + nouveau modèle SeaORM.
225 +- [x] **Schéma DB** : nouvelle table dans `FediverseMigrator`. Aucun ALTER sur table existante.
226 +- [x] **`TeamService::add_member` du framework** : signature inchangée.
227 +- [x] **Champ orphelin `team_members.remote_identity_id`** : peut être supprimé proprement (cf. Open Questions). N'a jamais été matérialisé en SQL, donc pas de migration de données.
228 +- [x] **Flow accept invite** : doit appeler le nouveau service, c'est une modif interne au crate fediverse, pas un breaking pour les consommateurs.
229 +
230 +**Verdict** : 100 % additif côté API publique. La seule modif interne (flow accept invite branche `add_member`) est transparente pour le consommateur, qui voit juste "ça marche enfin".
231 +
232 +## Alternatives considérées
233 +
234 +### Alternative A (recommandée) : table + service séparés dans fediverse
235 +
236 +Voir API proposée. Cohérence avec RFCs 0001 et 0002.
237 +
238 +### Alternative B : matérialiser `team_members.remote_identity_id` côté core
239 +
240 +**Pros** :
241 +- Une seule table à interroger pour lister les membres d'une team.
242 +- Le champ orphelin disparaît naturellement (il devient fonctionnel).
243 +
244 +**Cons** :
245 +- Migration `ALTER TABLE team_members` côté core + CHECK constraint XOR.
246 +- `TeamService` doit apprendre le concept "remote identity".
247 +- Couple le cycle release des deux concepts.
248 +- Casse la séparation core/fédération posée par le framework.
249 +
250 +### Alternative C : passer par `ResourceService::share_with(Principal::Team)` uniquement, jamais membres directs
251 +
252 +**Pros** : zéro nouvelle table.
253 +
254 +**Cons** :
255 +- Modèle d'invitation perd son sens (RFC 0014 livre une infrastructure d'invite, on ne pourrait que rejeter ou … rien).
256 +- Force le consommateur à inventer un autre mécanisme pour exprimer "alice est dans la team Dev".
257 +- Régression fonctionnelle par rapport à l'intention des RFCs 0009/0014.
258 +
259 +**Choix** : Alternative A. Cohérent avec 0001 et 0002, débloque immédiatement la valeur de la RFC 0014.
260 +
261 +## Testing plan
262 +
263 +### Unit
264 +
265 +Dans `crates/rustwarden-fediverse/src/services/remote_team_membership_service.rs#tests` :
266 +
267 +- Happy paths (6 méthodes × 1 cas = 6 tests).
268 +- Cas d'erreur :
269 + - `add_member` : `team_id` inexistant → `NotFound`.
270 + - `add_member` : `remote_identity_id` inexistant → `NotFound`.
271 + - `add_member` : `role` non reconnu → `Validation`.
272 + - `add_member` × 2 sur même `(team_id, remote_identity_id)` → `AlreadyExists` (via UNIQUE).
273 + - `remove_member` sur pair inexistant → retourne `false`, pas d'erreur.
274 + - `update_role` sur pair inexistant → retourne `false`.
275 + - `update_role` avec rôle invalide → `Validation`.
276 + - `is_member` sans row → `None`.
277 + - `is_member` avec row → `Some(role)`.
278 +
279 +### Doctest
280 +
281 +Exemple `add_member` + `list_remote_members` dans le doc comment de struct (`no_run`).
282 +
283 +### Integration
284 +
285 +Test du flow complet : `invite-fediverse` → user remote `accept` → vérifier `RemoteTeamMembershipService::is_member` retourne `Some(role)`.
286 +
287 +### Compat
288 +
289 +`cargo test -p rustwarden-core` vert.
290 +
291 +Vérifier que `team_members::ActiveModel::insert` continue de fonctionner sans `remote_identity_id`.
292 +
293 +### Bench
294 +
295 +Non requis.
296 +
297 +### Fuzz
298 +
299 +Non requis.
300 +
301 +## Migration guide
302 +
303 +À copier dans le `CHANGELOG.md` de `rustwarden-fediverse` au merge :
304 +
305 +### Added (0.X+1.0)
306 +
307 +- `RemoteTeamMembershipService` — gérer les memberships cross-instance dans les teams locales.
308 +- Nouveau modèle `models::fediverse_team_membership::Model`.
309 +- Migration `m20260516_000004_remote_team_memberships` ajoutée à `FediverseMigrator`.
310 +- Le flow d'acceptation d'invite cross-instance (RFC 0014) appelle désormais `add_member` automatiquement, fermant la boucle d'invitation. **C'est la livraison effective de la RFC 0014.**
311 +
312 +### Changed
313 +
314 +- `FediverseInviteService::accept_invite` matérialise désormais une membership dans `fediverse_team_memberships`. **Comportement avant la RFC** : l'invite passait en statut `accepted` mais aucune membership n'était créée (silent no-op). **Après** : la membership est créée et lisible via `list_remote_members`.
315 +
316 +**Impact consommateurs** : positif uniquement. Tout consommateur qui avait abandonné l'usage de `accept-fediverse` faute de matérialisation peut le rebrancher directement.
317 +
318 +### Deprecated (proposition, à valider)
319 +
320 +- Le champ `team_member::Model::remote_identity_id` ([crates/rustwarden-core/src/models/team_member.rs:28-31](../../crates/rustwarden-core/src/models/team_member.rs#L28-L31)) est marqué `#[deprecated]` puisque jamais matérialisé et supplanté par `fediverse_team_memberships`. À supprimer en major suivante.
321 +
322 +### Steps pour gitrust spécifiquement
323 +
324 +1. Bump `rustwarden-fediverse` dans `Cargo.toml`.
325 +2. Re-tester le scénario E2E #2 du plan d'intégration (invite cross-instance accept → vérifier membership effective).
326 +3. Mettre à jour `RepositoryService::effective_role_for_identity` côté gitrust pour interroger `RemoteTeamMembershipService::list_teams_for_remote_identity` quand l'identité est `Remote` (pour résoudre les permissions via teams).
327 +
328 +## Open questions
329 +
330 +- [ ] **Suppression du champ orphelin** `team_members.remote_identity_id` : enlever dans cette RFC (bonus, additif au workflow) ou dans une RFC dédiée ? **Recommandation** : laisser dans cette RFC sous forme `#[deprecated]` only, retirer physiquement dans une major future.
331 +- [ ] **Rôles supportés** : aligner exactement sur `team_members.role` (`"member" | "admin"`) ou élargir ? Le framework ne semble pas avoir de hiérarchie plus riche, donc juste 2 niveaux suffisent.
332 +- [ ] **Outbound `Add{Actor}` AP** : émettre une activité notifiant l'instance home du remote qu'il a été ajouté à une team locale ? Pratique pour audit / dashboard remote. Hors scope V1, à empiler.
333 +- [ ] **Listing combiné** : faut-il une méthode helper `list_all_members(team_id) -> Vec<TeamMember | FediverseTeamMembership>` ? Probablement plus propre côté consommateur via deux appels successifs et un merge.
334 +- [ ] **Cascade Delete{Actor}** : `ON DELETE CASCADE` sur `remote_identity_id` purge la row. OK pour V1.
335 +
336 +## Score sur la grille du processus §4
337 +
338 +| Critère | Note | Justification |
339 +|---|---|---|
340 +| **Généralité** | 5 | Débloque la RFC 0014 pour tout consommateur AP |
341 +| **Stabilité API** | 5 | Additif pur (sauf le `#[deprecated]` proposé, qui est non-breaking) |
342 +| **Performance** | 5 | Lookup indexé, aucune regression possible |
343 +| **Sécurité** | 4 | Pas de nouveau vecteur (rôles validés, cascade Delete{Actor}) |
344 +| **Complexité impl** | 4 | < 200 LOC service + petit branchement dans `accept_invite` |
345 +| **Maintenance** | 5 | Zéro nouvelle dépendance |
346 +| **Documentation** | 5 | Rustdoc + doctest + entrée CHANGELOG + guide migration |
347 +| **Total** | **33/35** | ≥ 30 = accélération possible |
348 +
349 +## Tracking issue
350 +
351 +À ouvrir sur le repo `rustwarden-core` avec labels `rfc` + `area:fediverse` + `priority:high` (débloque RFC 0014 livrée).
352 +
353 +---
354 +
355 +**Historique** :
356 +- 2026-05-15 : Draft initial — gap identifié pendant la planification de l'intégration fediverse côté gitrust ; champ orphelin `team_members.remote_identity_id` jamais matérialisé en SQL malgré sa présence dans le modèle SeaORM.
A docs/rfcs/gitrust/README.md
+49 -0
@@ -0,0 +1,49 @@
1 +# RFCs gitrust pour `rustwarden-*`
2 +
3 +Ce dossier contient les **propositions d'évolution upstream** rédigées côté gitrust en tant que premier consommateur du framework. Le canonical lieu de vie d'une RFC est `docs/rfcs/` dans le dépôt **du framework** ; les copies locales ici servent de tracking pour gitrust et de support de discussion avant push.
4 +
5 +Toute RFC suit le template de [`docs/RUSTWARDEN_EVOLUTION_PROCESS.md` §3](../RUSTWARDEN_EVOLUTION_PROCESS.md#3-template-rfc) et est notée sur la grille §4 (min 25/35 pour acceptation).
6 +
7 +## Index
8 +
9 +| Numéro | Titre | Crate cible | Statut | Score | Pousse | Bloque |
10 +|---|---|---|---|---|---|---|
11 +| [0001](0001-fediverse-remote-resource-shares.md) | `RemoteResourceShareService` (partages de ressources cross-instance) | `rustwarden-fediverse` | **Implemented** (upstream RFC 0029, livré + 11 tests) | 33/35 | Collaborateurs repo remote (gitrust) | Lot 5 du plan fédération |
12 +| [0002](0002-fediverse-remote-personal-access-tokens.md) | `RemotePersonalAccessTokenService` (PAT pour identités remote) | `rustwarden-fediverse` | **Implemented** (upstream RFC 0030, livré + 20 tests) | 34/35 | git push HTTPS pour users remote (gitrust) | Lot 5 (partie HTTPS) |
13 +| [0003](0003-fediverse-remote-team-memberships.md) | `RemoteTeamMembershipService` (matérialisation des invites cross-instance) | `rustwarden-fediverse` | **Rejected** | 18/35 | — | Capacité déjà livrée par RFC framework 0009 (`TeamService::add_member_identity`) — duplication injustifiée. Détails : [docs/plans/2026-05-15-rfcs-gitrust-evaluation.md](../../plans/2026-05-15-rfcs-gitrust-evaluation.md) |
14 +
15 +## Principe directeur commun
16 +
17 +Les 3 RFCs partagent la même approche architecturale (cf. critique utilisateur 2026-05-15) :
18 +
19 +- **Ne PAS étendre `rustwarden-core::Principal`** ou les services core (`ResourceService`, `TeamService`, `PersonalTokenService`).
20 +- **Tout vit dans `rustwarden-fediverse`** : tables `fediverse_*`, services dédiés, migrations trackées dans `seaql_fediverse_migrations`.
21 +- **Composition côté consommateur** : un match sur `Identity::{Local, Remote}` dans le code applicatif appelle le service core ou le service fediverse selon l'identité présentée.
22 +- **Zéro impact** sur les consommateurs qui n'activent pas la feature `experimental-fediverse`.
23 +
24 +## Ordre de push suggéré
25 +
26 +| Ordre | RFC | Justification |
27 +|---|---|---|
28 +| 1️⃣ | **0001** | Couvre le cas d'usage le plus large (collaboration cross-instance générique sur ressources arbitraires). |
29 +| 2️⃣ | **0002** | Pair fonctionnel de 0001 pour l'auth HTTPS. Peut être livrée plus tard sans bloquer 0001. |
30 +| ❌ | **0003** | **Rejetée** — la capacité team membership remote est déjà livrée par RFCs framework 0008/0009. `TeamService::add_member_identity` insère bien la row `team_members` avec `remote_identity_id`, et `FediverseInviteService::accept` ([fediverse_invite_service.rs:103-145](../../crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs)) le fait déjà aujourd'hui. Détails : [docs/plans/2026-05-15-rfcs-gitrust-evaluation.md](../../plans/2026-05-15-rfcs-gitrust-evaluation.md). |
31 +
32 +Les 2 RFCs restantes sont **indépendantes** entre elles côté implémentation — l'ordre n'est qu'une priorité d'attention upstream.
33 +
34 +## Workflow de push upstream
35 +
36 +1. Ouvrir le repo framework localement (clone séparé).
37 +2. Copier chaque RFC vers `<framework>/docs/rfcs/NNNN-slug.md` (renuméroter si conflit avec le numérotage upstream, cf. RFCs framework 0001-0028 déjà déposées — utiliser **0029, 0030, 0031** très probablement).
38 +3. Ouvrir une tracking issue par RFC sur le repo framework avec labels `rfc` + `area:fediverse` + corps = Summary de la RFC + lien vers le fichier.
39 +4. Mettre à jour cet index avec le numéro upstream attribué et le lien tracking issue.
40 +5. Suivre la grille d'évaluation §4 : score min 25/35 → acceptation directe possible.
41 +
42 +## Statuts possibles
43 +
44 +- **Draft** : rédigée côté gitrust, pas encore poussée upstream.
45 +- **Submitted** : copie poussée upstream + tracking issue ouverte.
46 +- **Under Review** : revue en cours par les mainteneurs framework.
47 +- **Accepted** : acceptée, implémentation upstream démarrée ou livrée.
48 +- **Rejected** : refusée, garder pour archive et raisonnement.
49 +- **Superseded by #MMMM** : remplacée par une autre RFC.