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. |
GitRust