06 — Modèle de données
Ce document explique comment les données sont organisées dans Rustwarden : quelles tables existent, pourquoi elles ont cette forme, comment elles évoluent dans le temps, et comment SeaORM les manipule. L'objectif est de donner à un développeur qui lit le code pour la première fois une carte mentale claire avant de plonger dans les migrations ou les services.
Vue d'ensemble des domaines
Le schéma est découpé en domaines fonctionnels cohérents. Chaque domaine regroupe les tables qui partagent une responsabilité métier.
| Domaine | Tables principales | Migration source |
|---|---|---|
| Auth / Utilisateurs | users, user_preferences | m20260305_000001 |
| RBAC | roles, permissions, role_permissions, user_roles | m20260305_000001, m20260309_000003 |
| Sessions & tokens | refresh_token, jwt_blacklist | m20260305_000001 |
| Tokens personnels | personal_access_tokens | m20260422_000007 |
| Mots de passe | password_reset_tokens, email_validation_tokens, password_change_requests | m20260305_000001 |
| OAuth | oauth_accounts | m20260310_000005 |
| TOTP / 2FA | user_totp, totp_challenges | m20260409_000006 |
| Équipes | teams, team_members | m20260423_000008 |
| Ressources | resources, resource_shares | m20260309_000004 |
email_queue, email_delivery_status | m20260305_000001 | |
| Paramètres | app_settings | m20260306_000002 |
| Audit | audit_logs | m20260305_000001 |
| Fediverse | remote_identities + colonnes AP sur users + colonnes FK sur team_members, resource_shares, audit_logs | m20260510_000009 |
Diagramme ER global
Le diagramme suivant représente toutes les tables du schéma core et leurs relations de clé étrangère. Les tables fediverse pures (fediverse_trusted_domains, fediverse_outbound_queue, fediverse_auth_challenges, fediverse_pending_invites) vivent dans le migrateur de rustwarden-fediverse et sont représentées de façon simplifiée — voir 08-fediverse-integration.md.
erDiagram
users {
uuid id PK
string username UK
string email UK
string password_hash
bool must_change_password
bool email_verified
timestamptz email_verified_at
timestamptz last_login_at
string last_login_ip
timestamptz password_changed_at
text ap_public_key_pem
text ap_private_key_pem
bool ap_discoverable
timestamptz created_at
timestamptz updated_at
}
user_preferences {
uuid id PK
uuid user_id FK
string theme
string language
timestamptz created_at
timestamptz updated_at
}
roles {
uuid id PK
string name UK
text description
timestamptz created_at
timestamptz updated_at
}
user_roles {
uuid user_id FK
uuid role_id FK
timestamptz created_at
}
permissions {
uuid id PK
string name UK
text description
string category
timestamptz created_at
}
role_permissions {
uuid role_id FK
uuid permission_id FK
}
refresh_token {
uuid id PK
uuid user_id FK
string token UK
timestamptz expires_at
bool revoked
timestamptz revoked_at
uuid replaced_by FK
timestamptz created_at
}
jwt_blacklist {
uuid id PK
string token_hash UK
uuid user_id FK
timestamptz expires_at
timestamptz revoked_at
timestamptz created_at
}
personal_access_tokens {
uuid id PK
uuid user_id FK
string name
string token_hash UK
text[] scopes
timestamptz expires_at
timestamptz last_used_at
timestamptz revoked_at
timestamptz created_at
}
password_reset_tokens {
uuid id PK
uuid user_id FK
string token_hash UK
timestamptz expires_at
timestamptz used_at
timestamptz created_at
}
email_validation_tokens {
uuid id PK
uuid user_id FK
string token_hash UK
timestamptz expires_at
timestamptz used_at
timestamptz created_at
}
password_change_requests {
uuid id PK
uuid user_id FK
text new_password_hash
string confirmation_token UK
timestamptz expires_at
timestamptz confirmed_at
timestamptz email_sent_at
int retry_count
timestamptz created_at
}
oauth_accounts {
uuid id PK
uuid user_id FK
string provider
string provider_user_id
string email
string display_name
string avatar_url
timestamptz created_at
timestamptz updated_at
}
user_totp {
uuid id PK
uuid user_id FK UK
text secret_encrypted
bool enabled
text backup_codes_encrypted
int backup_codes_remaining
timestamptz enabled_at
timestamptz created_at
timestamptz updated_at
}
totp_challenges {
uuid id PK
uuid user_id FK
string challenge_token UK
int attempts
int max_attempts
timestamptz expires_at
timestamptz created_at
}
teams {
uuid id PK
string slug UK
string name
uuid owner_id FK
timestamptz created_at
}
team_members {
uuid id PK
uuid team_id FK
uuid user_id
uuid remote_identity_id
string role
timestamptz joined_at
}
resources {
uuid id PK
string resource_type
uuid resource_id
uuid owner_id FK
bool is_public
timestamptz created_at
}
resource_shares {
uuid id PK
uuid resource_id FK
uuid shared_with_user_id
uuid shared_with_team_id
uuid shared_with_remote_identity_id
string permission_level
uuid shared_by_user_id FK
timestamptz created_at
}
email_queue {
uuid id PK
string to_email
string to_name
string from_email
string from_name
string subject
text body_html
text body_text
string email_type
string status
int attempts
timestamptz last_attempt_at
timestamptz sent_at
text error_message
string smtp_code
text smtp_response
timestamptz created_at
timestamptz updated_at
}
email_delivery_status {
uuid id PK
string email_type
string recipient
string template
string state
string smtp_code
text smtp_response
int attempt_count
timestamptz last_attempt_at
timestamptz last_state_at
uuid correlation_id
timestamptz created_at
timestamptz updated_at
}
app_settings {
uuid id PK
string key UK
text value
text description
uuid updated_by FK
timestamptz updated_at
}
audit_logs {
uuid id PK
uuid user_id FK
string action
uuid target_user_id FK
uuid actor_remote_identity_id FK
uuid target_remote_identity_id FK
string level
text message
timestamptz created_at
}
remote_identities {
uuid id PK
text ap_id UK
text username
text domain
text display_name
text summary_html
text inbox_url
text shared_inbox_url
text public_key_pem
text icon_url
text actor_type
jsonb raw_json
timestamptz last_fetched_at
timestamptz disabled_at
timestamptz created_at
timestamptz updated_at
}
users ||--o{ user_preferences : "1:1"
users ||--o{ user_roles : "a"
roles ||--o{ user_roles : "a"
roles ||--o{ role_permissions : "a"
permissions ||--o{ role_permissions : "a"
users ||--o{ refresh_token : "a"
refresh_token ||--o| refresh_token : "replaced_by"
users ||--o{ jwt_blacklist : "a"
users ||--o{ personal_access_tokens : "a"
users ||--o{ password_reset_tokens : "a"
users ||--o{ email_validation_tokens : "a"
users ||--o{ password_change_requests : "a"
users ||--o{ oauth_accounts : "a"
users ||--o| user_totp : "1:1"
users ||--o{ totp_challenges : "a"
users ||--o{ teams : "owner"
teams ||--o{ team_members : "a"
users ||--o{ team_members : "member local"
remote_identities ||--o{ team_members : "member remote"
users ||--o{ resources : "owner"
resources ||--o{ resource_shares : "a"
users ||--o{ resource_shares : "shared_with"
teams ||--o{ resource_shares : "shared_with_team"
remote_identities ||--o{ resource_shares : "shared_with_remote"
users ||--o{ audit_logs : "actor"
users ||--o{ audit_logs : "target"
remote_identities ||--o{ audit_logs : "actor_remote"
remote_identities ||--o{ audit_logs : "target_remote"
users ||--o{ app_settings : "updated_by"
Conventions de nommage
Rustwarden suit des conventions strictes et homogènes sur l'ensemble du schéma.
Identifiants
Toutes les clés primaires sont de type UUID. Les valeurs sont générées avec gen_random_uuid() (via l'extension PostgreSQL pgcrypto). Il n'y a aucun entier auto-incrémenté en tant que PK.
Clés étrangères
Les colonnes FK portent le suffixe _id et référencent explicitement la table cible avec ON DELETE CASCADE pour les données dépendantes (ex. un utilisateur supprimé emporte ses tokens) ou ON DELETE SET NULL pour les données d'audit qui doivent être conservées (ex. audit_logs.user_id).
Timestamps
| Colonne | Sémantique |
|---|---|
created_at | Horodatage d'insertion, jamais modifié |
updated_at | Mis à jour à chaque modification via le service |
deleted_at | Présent uniquement là où un soft-delete est nécessaire (non utilisé dans le schéma core actuel) |
disabled_at | Utilisé dans remote_identities pour indiquer qu'un acteur AP distant est désactivé sans suppression |
expires_at | Présent sur toutes les tables à durée de vie limitée : refresh_token, jwt_blacklist, password_reset_tokens, email_validation_tokens, password_change_requests, totp_challenges, personal_access_tokens |
snake_case partout
Noms de tables et de colonnes en snake_case. Les entités SeaORM utilisent PascalCase en Rust et mappent automatiquement vers snake_case SQL.
Cycle de vie des tables temporelles
Plusieurs tables contiennent des données éphémères. Comprendre leur cycle de vie permet d'éviter les fuites mémoire en base.
jwt_blacklist
La table stocke le hash SHA-256 des JWT révoqués avant leur expiration naturelle (logout explicite, révocation admin, détection de réutilisation de refresh token). Chaque entrée porte un expires_at calé sur la durée de vie du JWT original.
flowchart LR
A[JWT émis] -->|révocation explicite| B[Insertion dans jwt_blacklist]
B -->|expires_at atteint| C[Suppression par background task]
A -->|expiration naturelle| D[JWT rejeté par exp claim\nsans toucher la blacklist]
La background task JwtBlacklistCleanupProcessor supprime périodiquement les entrées dont expires_at < NOW(). Les entrées expirées sont inoffensives mais consomment de l'espace inutilement.
email_queue
La file d'emails suit un cycle à trois états contraints par une CHECK constraint :
stateDiagram-v2
[*] --> pending : INSERT
pending --> sent : envoi SMTP réussi
pending --> failed : max tentatives atteint
sent --> [*]
failed --> [*]
Les colonnes clés : status (pending / sent / failed), attempts (compteur de tentatives), last_attempt_at, smtp_code, smtp_response (pour le diagnostic). La background task de mail lit les entrées pending et tente l'envoi SMTP.
password_reset_tokens
Token à usage unique avec TTL court (typiquement 30 minutes). La colonne used_at est positionnée lors de la consommation. Une fois used_at IS NOT NULL, le token est rejeté même s'il n'a pas encore expiré. Un index partiel sur (user_id) WHERE confirmed_at IS NULL garantit qu'un seul reset est actif par utilisateur à la fois.
totp_challenges
Créées lors du second facteur en cours de login, TTL de 5 minutes, maximum 5 tentatives (max_attempts). La cleanup task TotpService::cleanup_expired_challenges supprime les challenges expirés.
personal_access_tokens
Les PAT n'ont pas de TTL obligatoire (expires_at nullable). La suppression physique n'intervient que si le token est à la fois expiré et révoqué (cleanup_expired — conserver les tokens expirés mais non révoqués pour l'audit).
Migrations
Les migrations SeaORM sont définies dans crates/rustwarden-core/src/migrations/. Chaque fichier est nommé m<date>_<seq>_<description>.rs et implémente le trait MigrationTrait avec deux méthodes : up et down.
flowchart LR
A[Éditer\nm<date>_<N>_desc.rs] --> B[cargo run --bin migrate]
B --> C{Base à jour ?}
C -->|Non| D[Applique up de\nchaque migration manquante]
C -->|Oui| E[No-op]
D --> F[Schema PostgreSQL\nmis à jour]
F --> G[sea-orm-cli generate entity\noptionnel]
G --> H[Entités dans\ncrates/rustwarden-core/src/models/]
Ordre d'exécution : les migrations s'appliquent dans l'ordre lexicographique de leurs noms. La table seaql_migrations trace ce qui a déjà été appliqué.
Rollback : cargo run --bin migrate -- down applique la méthode down de la dernière migration.
Règle constitution : les migrations ne doivent contenir aucun SQL brut (clause execute_unprepared) sauf quand le DSL SeaORM ne permet pas d'exprimer l'opération — par exemple les index partiels PostgreSQL (WHERE ...), les contraintes CHECK complexes, ou la modification de colonnes existantes. Dans ces cas précis, du SQL brut est acceptable et documenté dans le code source.
Patterns SeaORM utilisés
Entités et ActiveModel
Chaque table correspond à une entité dans crates/rustwarden-core/src/models/. Le pattern de base pour une insertion :
let model = user_totp::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_id), secret_encrypted: Set(secret_encrypted), enabled: Set(false), // ... }; model.insert(db).await?;
Pour une mise à jour partielle, on convertit le modèle existant en ActiveModel et on ne positionne que les champs modifiés :
let mut active: user_totp::ActiveModel = record.into(); active.enabled = Set(true); active.enabled_at = Set(Some(now)); active.update(db).await?;
Relations via l'enum Relation
Les relations FK sont déclarées dans l'entité via l'enum Relation et le trait RelationTrait. Cela permet les requêtes find_with_related sans JOIN manuel.
Pagination via paginate()
let paginator = User::find().paginate(db, 20); let total_pages = paginator.num_pages().await?; let page = paginator.fetch_page(0).await?;
Transactions
Les opérations multi-tables qui doivent être atomiques utilisent les transactions SeaORM :
db.transaction::<_, (), AppError>(|txn| { Box::pin(async move { // toutes les opérations utilisent `txn` au lieu de `db` Ok(()) }) }).await?;
Query DSL vs find_by_id vs raw SQL
| Méthode | Usage |
|---|---|
Entity::find_by_id(id) | Lookup par PK — le cas le plus fréquent |
Entity::find().filter(Column::Foo.eq(val)) | Requêtes avec conditions |
Entity::find().filter(...).order_by_desc(...) | Tri + filtre combinés |
| SQL brut | Interdit par la constitution. Uniquement dans les migrations pour les opérations non exprimables en DSL |
Index et performances
Les index critiques pour les chemins chauds sont listés ci-dessous :
| Table | Colonne(s) | Type | Raison |
|---|---|---|---|
users | email | UNIQUE | Login par email — requête systématique |
users | username | UNIQUE | Login par username |
users | email_verified | BTREE | Filtrage des comptes non vérifiés |
jwt_blacklist | token_hash | UNIQUE | Vérification de révocation à chaque requête |
jwt_blacklist | expires_at | BTREE | Cleanup de la background task |
refresh_token | token | UNIQUE | Échange de refresh token |
refresh_token | expires_at | BTREE | Cleanup |
personal_access_tokens | token_hash | UNIQUE | Vérification PAT à chaque requête API |
personal_access_tokens | (user_id, name) | UNIQUE | Unicité du nom par utilisateur |
personal_access_tokens | expires_at | BTREE | Cleanup |
password_reset_tokens | token_hash | UNIQUE | Lookup lors de la réinitialisation |
password_reset_tokens | expires_at | BTREE | Cleanup |
oauth_accounts | (provider, provider_user_id) | UNIQUE | Lookup lors du callback OAuth |
oauth_accounts | user_id | BTREE | Liste des comptes liés pour un utilisateur |
app_settings | key | UNIQUE | Lookup par clé de paramètre |
remote_identities | ap_id | UNIQUE | Résolution de l'acteur AP par URL |
remote_identities | (username, domain) | UNIQUE | Résolution par handle @user@domain |
team_members | remote_identity_id | PARTIEL (NOT NULL) | Index fediverse sur la colonne nullable |
resource_shares | shared_with_remote_identity_id | PARTIEL (NOT NULL) | Index fediverse sur la colonne nullable |
Backup et restore
La base PostgreSQL de développement est définie dans database/ via un docker-compose.yml. Pour les environnements de production, la stratégie de backup recommandée est pg_dump avec restauration via pg_restore.
# Backup logique pg_dump -Fc -U rustwarden rustwarden > backup_$(date +%Y%m%d).dump # Restore pg_restore -U rustwarden -d rustwarden backup_20260517.dump # Après restore, rejouer les migrations si la base vient d'un schéma antérieur cargo run --bin migrate
Les credentials PostgreSQL de développement sont dans database/.env. Ne jamais versionner ces credentials en production.
GitRust