03 — Modèle RBAC de Rustwarden
Rustwarden implémente un contrôle d'accès basé sur les rôles (RBAC) à deux niveaux : un niveau global (rôles systèmes attribués à l'utilisateur) et un niveau team-scoped (rôle de l'utilisateur au sein d'une équipe). Ce document décrit les entités, leurs relations, le flux de vérification, et les limites actuelles du modèle.
1. Vocabulaire
| Terme | Définition |
|---|---|
| User | Compte local enregistré dans la table users. Identifié par un UUID. |
| Role | Groupe nommé de permissions (admin, user, app_manager). Un utilisateur peut avoir plusieurs rôles. |
| Permission | Capacité atomique nommée par un code pointillé (users.delete, audit.view). Toujours attachée à une catégorie. |
| TeamRole | Rôle d'un utilisateur à l'intérieur d'une équipe (owner, admin, member). Indépendant des rôles système. |
| Team | Groupe d'utilisateurs identifié par un slug unique. Possède un owner. |
| Resource | Entité métier enregistrée dans le registre générique (resource_type + resource_id). Peut être partagée avec des utilisateurs ou des équipes via des resource_shares. |
| Principal | Abstraction qui unifie un utilisateur individuel (Principal::User(uuid)) ou une équipe (Principal::Team(uuid)) comme cible de partage. Défini dans resource_service.rs. |
| Identity | Énumération Identity::Local(uuid) / Identity::Remote(uuid) qui étend le concept de Principal aux acteurs fédérés. Définie dans identity.rs. |
2. Diagramme entité-relation
Le schéma RBAC est établi par les migrations m20260305_000001, m20260309_000003, m20260309_000004 et m20260423_000008.
erDiagram
users {
uuid id PK
string username
string email
string password_hash
bool email_verified
bool must_change_password
timestamptz created_at
timestamptz updated_at
timestamptz last_login_at
string last_login_ip
}
roles {
uuid id PK
string name
string description
timestamptz created_at
timestamptz updated_at
}
permissions {
uuid id PK
string name
string description
string category
timestamptz created_at
}
user_roles {
uuid user_id FK
uuid role_id FK
}
role_permissions {
uuid role_id FK
uuid permission_id FK
}
teams {
uuid id PK
string slug
string name
uuid owner_id FK
timestamptz created_at
}
team_members {
uuid id PK
uuid team_id FK
uuid user_id FK
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 FK
uuid shared_with_team_id FK
string permission_level
uuid shared_by_user_id FK
timestamptz created_at
}
users ||--o{ user_roles : "possède"
roles ||--o{ user_roles : "attribué via"
roles ||--o{ role_permissions : "dispose de"
permissions ||--o{ role_permissions : "incluse dans"
users ||--o{ team_members : "appartient à"
teams ||--o{ team_members : "comprend"
users ||--o{ teams : "owner"
users ||--o{ resources : "propriétaire"
resources ||--o{ resource_shares : "partagée via"
users ||--o{ resource_shares : "partagée avec (user)"
teams ||--o{ resource_shares : "partagée avec (team)"
Note sur
team_members: depuis la migrationm20260423_000008, la colonneuser_idest nullable pour permettre l'accueil de membres fédérés (remote_identity_id). La contraintechk_resource_shares_principalgarantit qu'exactement un des deux colonnes (shared_with_user_id,shared_with_team_id) est non-null dansresource_shares.
3. Hiérarchie d'autorisation
flowchart TD
REQ[Requête HTTP] --> EXT[Extraction du Principal\nAuthUser via JWT ou session]
EXT --> ADMIN{is_admin == true ?}
ADMIN -->|oui| GRANT[Accès accordé]
ADMIN -->|non| PERM{Permission directe ?\nuser_roles JOIN role_permissions}
PERM -->|oui| GRANT
PERM -->|non| TEAM{Permission team-scoped ?\nteam_members.role}
TEAM -->|oui| GRANT
TEAM -->|non| DENY[403 Forbidden]
Trois chemins d'autorisation :
-
Admin global — le champ
is_adminsur l'utilisateur n'existe pas directement comme colonne, mais le rôle systèmeadminreçoit automatiquement toutes les permissions lors du seed. Un utilisateur avec le rôleadminpasse toujours la vérification de permission. -
Permission directe —
user_roles→role_permissions→permissions. C'est le chemin principal utilisé parPermissionService::get_permissions_for_user. -
Team-scoped — le
TeamRoled'un membre détermine ce qu'il peut faire dans le contexte de l'équipe (inviter, retirer des membres, supprimer l'équipe). Ce niveau est géré parTeamService.
4. Le concept Principal
L'abstraction Principal apparaît à deux endroits du codebase avec des sémantiques légèrement différentes.
Principal dans ResourceService
Défini dans resource_service.rs:17 :
pub enum Principal { User(Uuid), Team(Uuid), }
Il sert de cible de partage lors des appels à ResourceService::share_with(db, resource_id, principal, permission_level, shared_by). Un handler peut partager une ressource avec un utilisateur individuel ou avec une équipe entière.
Identity — extension fédérée
Défini dans identity.rs:29 :
pub enum Identity { Local(Uuid), // users.id — compte local Remote(Uuid), // remote_identities.id — acteur fédéré (ActivityPub) }
Identity étend Principal pour les contextes où un acteur fédéré peut agir sur le système. Les services qui acceptent des acteurs doivent utiliser Identity plutôt qu'un Uuid brut pour éviter la confusion entre identifiants locaux et fédérés (guard contre le confused-deputy pattern).
Conversion depuis les claims JWT :
// identity.rs:77 pub fn from_claims(claims: &Claims) -> Self { match claims.remote_identity_id() { Some(remote_id) => Self::Remote(remote_id), None => Self::Local(claims.sub), } }
Résolution via IdentityResolver — le trait IdentityResolver expose resolve_local(user_id) et resolve_remote(remote_identity_id) pour vérifier l'existence d'un acteur dans la base avant d'agir.
5. Flow d'une vérification de permission
sequenceDiagram
participant H as Handler Axum
participant E as AuthUser Extractor
participant PS as PermissionService
participant DB as PostgreSQL
H->>E: Extension<AuthUser>
E-->>H: AuthUser { id, roles, ... }
H->>PS: get_permissions_for_user(db, user_id)
PS->>DB: SELECT user_id, role_id FROM user_roles WHERE user_id = ?
DB-->>PS: [(user_id, role_id), ...]
PS->>DB: SELECT role_id, permission_id FROM role_permissions WHERE role_id IN (...)
DB-->>PS: [(role_id, permission_id), ...]
PS->>DB: SELECT name FROM permissions WHERE id IN (...)
DB-->>PS: ["users.list", "users.edit", ...]
PS-->>H: Vec<String>
H->>H: permissions.contains("users.delete") ?
alt contient la permission
H-->>H: traiter la requête
else ne contient pas
H-->>H: AppError::Forbidden → 403
end
Code type dans un handler :
use crate::auth::extractors::AuthUser; use crate::services::permission_service::PermissionService; use crate::error::AppError; use axum::{Extension, extract::State}; use sea_orm::DatabaseConnection; async fn delete_user_handler( State(db): State<DatabaseConnection>, Extension(auth_user): Extension<AuthUser>, ) -> Result<(), AppError> { let perms = PermissionService::get_permissions_for_user(&db, auth_user.id).await?; if !perms.contains(&"users.delete".to_string()) { return Err(AppError::Forbidden); } // ... logique de suppression Ok(()) }
6. TeamRole — rôles et matrice de permissions
Le type TeamRole est défini dans team_service.rs avec trois variantes :
pub enum TeamRole { Owner, // "owner" Admin, // "admin" Member, // "member" }
Matrice de permissions par rôle
| Action | Owner | Admin | Member |
|---|---|---|---|
| Voir les membres de l'équipe | oui | oui | oui |
| Ajouter un membre | oui | oui | non |
| Retirer un membre | oui | oui | non |
| Modifier le rôle d'un membre | oui | non | non |
| Supprimer l'équipe | oui | non | non |
| Partager une ressource avec l'équipe | oui | oui | non |
| Voir les ressources partagées avec l'équipe | oui | oui | oui |
Remarque : Il n'y a pas de rôle
Viewerdans le modèle actuel deTeamRole. Les trois variantes sontOwner,Admin, etMember. Un membre en lecture seule n'existe pas comme variante distincte — tous les membres peuvent voir les ressources partagées.
Création et insertion du owner comme premier membre
Lors de la création d'une équipe via TeamService::create, le owner est automatiquement inséré dans team_members avec le rôle owner :
team_member::ActiveModel { id: Set(Uuid::new_v4()), team_id: Set(team_id), user_id: Set(Some(owner_id)), role: Set(TeamRole::Owner.as_str().to_string()), joined_at: Set(Utc::now()), remote_identity_id: Set(None), } .insert(db) .await?;
7. Exemples concrets
Cas 1 — Admin global : accès à tout
Un utilisateur avec le rôle admin reçoit toutes les permissions lors du seed initial (migration m20260309_000003) :
INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'admin' ON CONFLICT DO NOTHING;
Dans le code, la vérification revient à :
let perms = PermissionService::get_permissions_for_user(&db, user_id).await?; // perms contient ["users.list", "users.create", "users.edit", "users.delete", // "users.reset_password", "roles.list", "roles.create", "roles.edit", // "roles.delete", "settings.view", "settings.edit", "audit.view"]
Cas 2 — Utilisateur avec rôle "editor"
Supposons un rôle editor créé via RoleService::create_role avec les permissions users.list et users.edit uniquement :
// Création du rôle let editor = RoleService::create_role(&db, "editor".into(), Some("Éditeur".into())).await?; // Attribution des permissions let list_perm = PermissionService::get_by_name(&db, "users.list").await?.unwrap(); let edit_perm = PermissionService::get_by_name(&db, "users.edit").await?.unwrap(); PermissionService::set_role_permissions(&db, editor.id, vec![list_perm.id, edit_perm.id]).await?; // Attribution du rôle à un utilisateur (via user_role::ActiveModel)
Cet utilisateur peut GET /api/v1/users et PUT /api/v1/users/{id}, mais pas DELETE /api/v1/users/{id} (permission users.delete absente).
Cas 3 — Owner d'une team : inviter et supprimer
// Créer une team let team = TeamService::create( &db, owner_id, TeamSlug::new("my-team"), "Mon équipe", Some(&hooks), ).await?; // Ajouter un membre (uniquement owner ou admin peuvent le faire) TeamService::add_member(&db, team.id, new_user_id, TeamRole::Member, Some(&hooks)).await?; // Retirer un membre TeamService::remove_member(&db, team.id, new_user_id, Some(&hooks)).await?; // Supprimer la team (uniquement le owner) TeamService::delete(&db, team.id, Some(&hooks)).await?;
Cas 4 — Personal access token avec scope limité
Les personal access tokens (migration m20260422_000007) permettent d'émettre un JWT avec un sous-ensemble de permissions. Le token porte ses propres permissions dans les claims JWT ; le middleware d'authentification les injecte dans AuthUser sans consulter user_roles.
// Création d'un PAT via PersonalTokenService let token = PersonalTokenService::create( &db, user_id, "CI token", Some(expires_at), vec!["users.list".into()], // scope restreint ).await?;
Un handler qui vérifie users.delete refusera ce token même si l'utilisateur propriétaire possède ce droit globalement.
8. Permissions par défaut
Le seed initial (via PermissionService::initialize_default_permissions ou la migration m20260309_000003) crée les permissions suivantes :
| Code | Catégorie | Description |
|---|---|---|
users.list | users | Voir la liste des utilisateurs |
users.create | users | Créer de nouveaux utilisateurs |
users.edit | users | Modifier des utilisateurs existants |
users.delete | users | Supprimer des utilisateurs |
users.reset_password | users | Réinitialiser les mots de passe |
roles.list | roles | Voir la liste des rôles |
roles.create | roles | Créer des rôles |
roles.edit | roles | Modifier des rôles |
roles.delete | roles | Supprimer des rôles |
settings.view | settings | Voir les paramètres de l'application |
settings.edit | settings | Modifier les paramètres |
audit.view | audit | Consulter les logs d'audit |
Rôles par défaut créés par RoleService::initialize_default_roles :
| Rôle | Description |
|---|---|
admin | Administrateur avec accès complet — reçoit toutes les permissions |
user | Utilisateur standard avec accès de base — aucune permission RBAC par défaut |
app_manager | Peut activer/désactiver des applications |
9. Limites actuelles et pistes V2
Limites du modèle V1
Pas de hiérarchie de rôles. Il n'existe pas de mécanisme d'héritage entre rôles (admin hérite de editor qui hérite de viewer). Chaque rôle est indépendant et ses permissions sont entièrement explicites.
Pas d'ABAC (Attribute-Based Access Control). Les vérifications de permission ne tiennent pas compte des attributs de la ressource cible (propriétaire, état, tags). Un utilisateur avec users.edit peut modifier n'importe quel utilisateur, pas seulement les siens.
Pas de permissions wildcard. Le code users.* n'est pas interprété comme "toutes les permissions de la catégorie users". Chaque permission doit être listée explicitement dans role_permissions.
Pas de deny explicite. Le modèle est purement additif : une permission est soit présente, soit absente. Il n'est pas possible d'interdire explicitement une action à un utilisateur qui aurait le droit via un autre rôle.
Permissions non portées dans le JWT par défaut. Le JWT porte les roles de l'utilisateur mais pas ses permissions. La vérification nécessite un aller-retour en base via PermissionService::get_permissions_for_user. Une évolution possible est d'inclure les permissions dans les claims à l'émission du token.
Pistes d'évolution V2
| Fonctionnalité | Description |
|---|---|
| Hiérarchie de rôles | Arbre de rôles avec héritage — admin inclut automatiquement editor |
| Permissions dans le JWT | Inclure les permissions[] dans les claims pour éviter les requêtes DB sur chaque vérification |
| ABAC light | Ajouter un contexte de ressource à la vérification : can_user_edit(user_id, resource_id) |
| Wildcard permissions | Support de users.* comme raccourci pour toutes les permissions d'une catégorie |
| Deny rules | Règles d'interdiction explicite prioritaires sur les permissions positives |
GitRust