Skip to main content

miryad_core/
resource.rs

1use sea_orm::EntityTrait;
2use serde::Serialize;
3
4use crate::auth::AuthPrincipal;
5
6/// Erreur métier retournée par un hook applicatif (`MiryadResource::before_create`) — jamais une
7/// erreur *de* miryad-core, donc jamais de code `MRD-XXX-NNN` (cette convention identifie un
8/// problème dans le framework, pas une règle métier qui rejette une requête). Le code est libre,
9/// à la charge de l'app ; `None` si elle n'en a pas.
10#[derive(Debug, Clone)]
11pub struct HookError {
12    pub code: Option<String>,
13    pub message: String,
14}
15
16impl HookError {
17    pub fn new(message: impl Into<String>) -> Self {
18        Self {
19            code: None,
20            message: message.into(),
21        }
22    }
23
24    pub fn with_code(code: impl Into<String>, message: impl Into<String>) -> Self {
25        Self {
26            code: Some(code.into()),
27            message: message.into(),
28        }
29    }
30}
31
32/// Politique d'accès à une entité exposée par miryad-core.
33/// Read et write sont évalués séparément — une entité peut être publique en
34/// lecture et restreinte en écriture (cas "recettes partagées, modifiables
35/// par leur auteur uniquement").
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
37pub enum AccessPolicy {
38    /// Tout utilisateur authentifié (JWT ou token API valide)
39    Public,
40    /// Uniquement l'utilisateur référencé par `owner_column` (+ les membres
41    /// du groupe admin)
42    OwnerOnly,
43    /// Membres du groupe nommé (+ admin)
44    Group(&'static str),
45    /// Membres du groupe admin uniquement
46    AdminOnly,
47}
48
49/// Contrat qu'implémente toute entité SeaORM exposée par miryad-core.
50/// Une seule implémentation par entité — REST, GraphQL et MCP la lisent
51/// telle quelle, aucune n'a sa propre déclaration de politique.
52pub trait MiryadResource: EntityTrait {
53    /// Nom exposé côté API (ex: "recipes") — utilisé pour les chemins REST,
54    /// le type GraphQL, et le nom des tools MCP.
55    fn resource_name() -> &'static str;
56
57    fn read_policy() -> AccessPolicy;
58    fn write_policy() -> AccessPolicy;
59
60    /// Colonne portant l'identifiant du propriétaire. `None` si l'entité
61    /// n'a pas de notion de propriétaire (ex: référentiel partagé comme la
62    /// liste des ingrédients dans l'exemple recette).
63    /// Doit être `Some` si `read_policy()` ou `write_policy()` retourne
64    /// `AccessPolicy::OwnerOnly` — comportement non défini sinon (vérifié
65    /// par test, pas par le compilateur à ce stade).
66    fn owner_column() -> Option<<Self as EntityTrait>::Column>;
67
68    /// Colonne texte sur laquelle la liste REST/GraphQL/MCP peut être filtrée
69    /// (`?filter=valeur`, égalité exacte) — feature 4. `None` par défaut : pas
70    /// de filtre pour cette entité. Une entité qui veut un filtre de liste
71    /// (ex. "recettes par catégorie") le déclare explicitement.
72    fn filter_column() -> Option<<Self as EntityTrait>::Column> {
73        None
74    }
75
76    /// Colonne à afficher comme libellé humain de l'entité (liste, select) — feature 8, IR
77    /// frontend. `None` par défaut : le générateur retombe sur la clé primaire.
78    fn label_column() -> Option<<Self as EntityTrait>::Column> {
79        None
80    }
81
82    /// Hook métier exécuté après RBAC (`can_create`), avant l'insertion — peut muter
83    /// l'`ActiveModel` (champ dérivé, valeur calculée) ou rejeter l'opération avec une erreur
84    /// métier. Miroir direct de `before_active_model_save` (Seaography, feature 5) : create only,
85    /// car Seaography ne déclenche ce hook que sur un insert pour l'instant — un hook qui ne se
86    /// comporterait pas à l'identique sur les 3 surfaces (REST/GraphQL/MCP) n'a pas sa place ici.
87    /// Défaut : no-op.
88    fn before_create(
89        active: Self::ActiveModel,
90        principal: &AuthPrincipal,
91    ) -> Result<Self::ActiveModel, HookError> {
92        let _ = principal;
93        Ok(active)
94    }
95}