06-data-model.md 18621 octets

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.

DomaineTables principalesMigration source
Auth / Utilisateursusers, user_preferencesm20260305_000001
RBACroles, permissions, role_permissions, user_rolesm20260305_000001, m20260309_000003
Sessions & tokensrefresh_token, jwt_blacklistm20260305_000001
Tokens personnelspersonal_access_tokensm20260422_000007
Mots de passepassword_reset_tokens, email_validation_tokens, password_change_requestsm20260305_000001
OAuthoauth_accountsm20260310_000005
TOTP / 2FAuser_totp, totp_challengesm20260409_000006
Équipesteams, team_membersm20260423_000008
Ressourcesresources, resource_sharesm20260309_000004
Emailemail_queue, email_delivery_statusm20260305_000001
Paramètresapp_settingsm20260306_000002
Auditaudit_logsm20260305_000001
Fediverseremote_identities + colonnes AP sur users + colonnes FK sur team_members, resource_shares, audit_logsm20260510_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

ColonneSémantique
created_atHorodatage d'insertion, jamais modifié
updated_atMis à jour à chaque modification via le service
deleted_atPrésent uniquement là où un soft-delete est nécessaire (non utilisé dans le schéma core actuel)
disabled_atUtilisé dans remote_identities pour indiquer qu'un acteur AP distant est désactivé sans suppression
expires_atPré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&lt;date&gt;_&lt;N&gt;_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éthodeUsage
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 brutInterdit 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 :

TableColonne(s)TypeRaison
usersemailUNIQUELogin par email — requête systématique
usersusernameUNIQUELogin par username
usersemail_verifiedBTREEFiltrage des comptes non vérifiés
jwt_blacklisttoken_hashUNIQUEVérification de révocation à chaque requête
jwt_blacklistexpires_atBTREECleanup de la background task
refresh_tokentokenUNIQUEÉchange de refresh token
refresh_tokenexpires_atBTREECleanup
personal_access_tokenstoken_hashUNIQUEVérification PAT à chaque requête API
personal_access_tokens(user_id, name)UNIQUEUnicité du nom par utilisateur
personal_access_tokensexpires_atBTREECleanup
password_reset_tokenstoken_hashUNIQUELookup lors de la réinitialisation
password_reset_tokensexpires_atBTREECleanup
oauth_accounts(provider, provider_user_id)UNIQUELookup lors du callback OAuth
oauth_accountsuser_idBTREEListe des comptes liés pour un utilisateur
app_settingskeyUNIQUELookup par clé de paramètre
remote_identitiesap_idUNIQUERésolution de l'acteur AP par URL
remote_identities(username, domain)UNIQUERésolution par handle @user@domain
team_membersremote_identity_idPARTIEL (NOT NULL)Index fediverse sur la colonne nullable
resource_sharesshared_with_remote_identity_idPARTIEL (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.