03-rbac-model.md 16817 octets

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

TermeDéfinition
UserCompte local enregistré dans la table users. Identifié par un UUID.
RoleGroupe nommé de permissions (admin, user, app_manager). Un utilisateur peut avoir plusieurs rôles.
PermissionCapacité atomique nommée par un code pointillé (users.delete, audit.view). Toujours attachée à une catégorie.
TeamRoleRôle d'un utilisateur à l'intérieur d'une équipe (owner, admin, member). Indépendant des rôles système.
TeamGroupe d'utilisateurs identifié par un slug unique. Possède un owner.
ResourceEntité 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.
PrincipalAbstraction 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 migration m20260423_000008, la colonne user_id est nullable pour permettre l'accueil de membres fédérés (remote_identity_id). La contrainte chk_resource_shares_principal garantit qu'exactement un des deux colonnes (shared_with_user_id, shared_with_team_id) est non-null dans resource_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 :

  1. Admin global — le champ is_admin sur l'utilisateur n'existe pas directement comme colonne, mais le rôle système admin reçoit automatiquement toutes les permissions lors du seed. Un utilisateur avec le rôle admin passe toujours la vérification de permission.

  2. Permission directe — user_roles → role_permissions → permissions. C'est le chemin principal utilisé par PermissionService::get_permissions_for_user.

  3. Team-scoped — le TeamRole d'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é par TeamService.


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

ActionOwnerAdminMember
Voir les membres de l'équipeouiouioui
Ajouter un membreouiouinon
Retirer un membreouiouinon
Modifier le rôle d'un membreouinonnon
Supprimer l'équipeouinonnon
Partager une ressource avec l'équipeouiouinon
Voir les ressources partagées avec l'équipeouiouioui

Remarque : Il n'y a pas de rôle Viewer dans le modèle actuel de TeamRole. Les trois variantes sont Owner, Admin, et Member. 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 :

CodeCatégorieDescription
users.listusersVoir la liste des utilisateurs
users.createusersCréer de nouveaux utilisateurs
users.editusersModifier des utilisateurs existants
users.deleteusersSupprimer des utilisateurs
users.reset_passwordusersRéinitialiser les mots de passe
roles.listrolesVoir la liste des rôles
roles.createrolesCréer des rôles
roles.editrolesModifier des rôles
roles.deleterolesSupprimer des rôles
settings.viewsettingsVoir les paramètres de l'application
settings.editsettingsModifier les paramètres
audit.viewauditConsulter les logs d'audit

Rôles par défaut créés par RoleService::initialize_default_roles :

RôleDescription
adminAdministrateur avec accès complet — reçoit toutes les permissions
userUtilisateur standard avec accès de base — aucune permission RBAC par défaut
app_managerPeut 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ôlesArbre de rôles avec héritage — admin inclut automatiquement editor
Permissions dans le JWTInclure les permissions[] dans les claims pour éviter les requêtes DB sur chaque vérification
ABAC lightAjouter un contexte de ressource à la vérification : can_user_edit(user_id, resource_id)
Wildcard permissionsSupport de users.* comme raccourci pour toutes les permissions d'une catégorie
Deny rulesRègles d'interdiction explicite prioritaires sur les permissions positives