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}