hodei_derive/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use serde_json;
4use syn::{Data, DeriveInput, Fields, Lit, Meta, parse_macro_input};
5
6/// Extrae el tipo de entidad desde los atributos del campo
7/// Busca atributos como: #[entity_type = "MyNamespace::MyEntity"]
8fn extract_entity_type_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
9    for attr in attrs {
10        if attr.path().is_ident("entity_type") {
11            if let Meta::NameValue(meta) = &attr.meta {
12                if let syn::Expr::Lit(expr_lit) = &meta.value {
13                    if let Lit::Str(lit_str) = &expr_lit.lit {
14                        return Some(lit_str.value());
15                    }
16                }
17            }
18        }
19    }
20    None
21}
22
23// Función de inferencia eliminada - ahora el atributo #[entity_type] es OBLIGATORIO
24// para todos los campos de tipo Hrn. Esto hace el sistema completamente escalable.
25
26#[proc_macro_derive(HodeiEntity, attributes(hodei, entity_type))]
27pub fn hodei_entity_derive(input: TokenStream) -> TokenStream {
28    let ast = parse_macro_input!(input as DeriveInput);
29    let struct_name = &ast.ident;
30
31    let mut entity_type_str: Option<String> = None;
32    for attr in &ast.attrs {
33        if attr.path().is_ident("hodei") {
34            let _ = attr.parse_nested_meta(|meta| {
35                if meta.path.is_ident("entity_type") {
36                    if let Ok(Lit::Str(s)) = meta.value()?.parse() {
37                        entity_type_str = Some(s.value());
38                    }
39                }
40                Ok(())
41            });
42        }
43    }
44    let entity_type_str =
45        entity_type_str.expect("#[derive(HodeiEntity)] requiere #[hodei(entity_type = \"...\")]");
46
47    let mut attributes = serde_json::Map::new();
48    let mut attr_map_assignments: Vec<proc_macro2::TokenStream> = Vec::new();
49
50    if let Data::Struct(data_struct) = &ast.data {
51        if let Fields::Named(fields) = &data_struct.fields {
52            for field in &fields.named {
53                let field_ident = field.ident.as_ref().unwrap();
54                let field_name = field_ident.to_string();
55                if field_name == "id" {
56                    continue;
57                }
58
59                let type_ident = match &field.ty {
60                    syn::Type::Path(tp) => tp
61                        .path
62                        .segments
63                        .last()
64                        .map(|s| s.ident.to_string())
65                        .unwrap_or_else(|| "Complex".to_string()),
66                    _ => "Complex".to_string(),
67                };
68
69                // Determinar el tipo Cedar basado en el tipo Rust
70                let schema_type = if type_ident == "Hrn" {
71                    // Hrn se convierte a Entity en Cedar
72                    // El atributo #[entity_type = "Namespace::EntityType"] es OBLIGATORIO
73                    let entity_type = extract_entity_type_from_attrs(&field.attrs)
74                        .unwrap_or_else(|| {
75                            panic!(
76                                "Campo '{}' de tipo Hrn requiere el atributo #[entity_type = \"Namespace::EntityType\"]\n\
77                                Ejemplo: #[entity_type = \"MyApp::User\"]",
78                                field_name
79                            )
80                        });
81                    serde_json::json!({
82                        "type": "Entity",
83                        "name": entity_type,
84                        "required": true
85                    })
86                } else {
87                    let cedar_type = match type_ident.as_str() {
88                        "String" => "String",
89                        "i64" | "u64" | "i32" | "u32" | "usize" => "Long",
90                        "bool" => "Boolean",
91                        _ => "String",
92                    };
93                    serde_json::json!({ "type": cedar_type, "required": true })
94                };
95
96                attributes.insert(field_name.clone(), schema_type);
97
98                let value_expr = if type_ident == "Hrn" {
99                    // Convertir Hrn a EntityUid en Cedar
100                    // El atributo #[entity_type = "Namespace::EntityType"] es OBLIGATORIO
101                    let entity_type = extract_entity_type_from_attrs(&field.attrs)
102                        .unwrap_or_else(|| {
103                            panic!(
104                                "Campo '{}' de tipo Hrn requiere el atributo #[entity_type = \"Namespace::EntityType\"]\n\
105                                Ejemplo: #[entity_type = \"MyApp::User\"]",
106                                field_name
107                            )
108                        });
109                    let entity_type_lit =
110                        syn::LitStr::new(&entity_type, proc_macro2::Span::call_site());
111                    quote! {
112                        {
113                            let euid = cedar_policy::EntityUid::from_type_name_and_id(
114                                #entity_type_lit.parse().unwrap(),
115                                self.#field_ident.to_string().parse().unwrap(),
116                            );
117                            cedar_policy::RestrictedExpression::new_entity_uid(euid)
118                        }
119                    }
120                } else if type_ident == "String" {
121                    quote! { cedar_policy::RestrictedExpression::new_string(self.#field_ident.clone()) }
122                } else if type_ident == "bool" {
123                    quote! { cedar_policy::RestrictedExpression::new_bool(self.#field_ident) }
124                } else {
125                    quote! { cedar_policy::RestrictedExpression::new_long(self.#field_ident as i64) }
126                };
127
128                attr_map_assignments.push(quote! {
129                    attrs.insert(#field_name.into(), #value_expr);
130                });
131            }
132        }
133    }
134
135    attributes.insert(
136        "tenant_id".to_string(),
137        serde_json::json!({ "type": "String" }),
138    );
139    attributes.insert(
140        "service".to_string(),
141        serde_json::json!({ "type": "String" }),
142    );
143
144    // Cedar 4.7 usa "shape" con "type": "Record" y "attributes" dentro
145    let schema_fragment_json = serde_json::json!({
146        "memberOfTypes": [],
147        "shape": {
148            "type": "Record",
149            "attributes": attributes
150        }
151    });
152    let schema_fragment_str = serde_json::to_string(&schema_fragment_json).unwrap();
153
154    let expanded = quote! {
155        impl hodei_authz::RuntimeHodeiEntityMapper for #struct_name {
156            fn hodei_type_name(&self) -> &'static str { #entity_type_str }
157            fn hodei_id(&self) -> String { self.id.resource_id.clone() }
158            fn hodei_hrn(&self) -> &hodei_hrn::api::Hrn { &self.id }
159            fn to_cedar_entity(&self) -> cedar_policy::Entity {
160                let euid = self.to_cedar_euid();
161                let mut attrs = std::collections::HashMap::new();
162                #(#attr_map_assignments)*
163                attrs.insert("tenant_id".into(), cedar_policy::RestrictedExpression::new_string(self.id.tenant_id.clone()));
164                attrs.insert("service".into(), cedar_policy::RestrictedExpression::new_string(self.id.service.clone()));
165                cedar_policy::Entity::new(euid, attrs, std::collections::HashSet::new()).unwrap()
166            }
167        }
168        #[cfg(feature = "schema-discovery")]
169        hodei_authz::inventory::submit! {
170            hodei_authz::EntitySchemaFragment { entity_type: #entity_type_str, fragment_json: #schema_fragment_str, }
171        }
172    };
173    expanded.into()
174}
175
176#[proc_macro_derive(HodeiAction, attributes(hodei))]
177pub fn hodei_action_derive(input: TokenStream) -> TokenStream {
178    let ast = parse_macro_input!(input as DeriveInput);
179    let enum_name = &ast.ident;
180
181    let mut namespace: Option<String> = None;
182    for attr in &ast.attrs {
183        if attr.path().is_ident("hodei") {
184            let _ = attr.parse_nested_meta(|meta| {
185                if meta.path.is_ident("namespace") {
186                    if let Ok(Lit::Str(s)) = meta.value()?.parse() {
187                        namespace = Some(s.value());
188                    }
189                }
190                Ok(())
191            });
192        }
193    }
194    let _namespace =
195        namespace.expect("#[derive(HodeiAction)] requiere #[hodei(namespace = \"...\")]");
196
197    let data_enum = match &ast.data {
198        Data::Enum(de) => de,
199        _ => panic!("HodeiAction solo se puede derivar en enums"),
200    };
201
202    let mut inventory_submissions: Vec<proc_macro2::TokenStream> = Vec::new();
203    let mut euid_match_arms: Vec<proc_macro2::TokenStream> = Vec::new();
204    let mut creates_resource_match_arms: Vec<proc_macro2::TokenStream> = Vec::new();
205    let mut virtual_entity_match_arms: Vec<proc_macro2::TokenStream> = Vec::new();
206
207    for variant in &data_enum.variants {
208        let variant_name = &variant.ident;
209        let action_name_str = variant_name.to_string();
210        let mut principal_types: Vec<String> = Vec::new();
211        let mut resource_types: Vec<String> = Vec::new();
212        let mut is_create_action = false;
213
214        for attr in &variant.attrs {
215            if attr.path().is_ident("hodei") {
216                let _ = attr.parse_nested_meta(|meta| {
217                    if meta.path.is_ident("principal") {
218                        if let Ok(Lit::Str(s)) = meta.value()?.parse() {
219                            principal_types.push(s.value());
220                        }
221                    } else if meta.path.is_ident("resource") {
222                        if let Ok(Lit::Str(s)) = meta.value()?.parse() {
223                            resource_types.push(s.value());
224                        }
225                    } else if meta.path.is_ident("creates_resource") {
226                        is_create_action = true;
227                    }
228                    Ok(())
229                });
230            }
231        }
232
233        // Generar nombre de acción con formato ResourceType::ActionName
234        // Ej: "Document::Create", "Artifact::Read"
235        let resource_type = resource_types
236            .first()
237            .expect("Action must have at least one resource type");
238        let full_action_name = format!("{}::{}", resource_type, action_name_str);
239        let action_euid_str = format!("Action::\"{}\"", full_action_name);
240
241        let action_schema_json = serde_json::json!({
242            "appliesTo": {
243                "principalTypes": principal_types,
244                "resourceTypes": resource_types
245            }
246        });
247        let action_schema_str = serde_json::to_string(&action_schema_json).unwrap();
248
249        inventory_submissions.push(quote! {
250            #[cfg(feature = "schema-discovery")]
251            hodei_authz::inventory::submit! {
252                hodei_authz::ActionSchemaFragment {
253                    name: #full_action_name,
254                    fragment_json: #action_schema_str
255                }
256            }
257        });
258
259        let fields_pattern = match &variant.fields {
260            Fields::Named(_) => quote! { {..} },
261            Fields::Unnamed(_) => quote! { (..) },
262            Fields::Unit => quote! {},
263        };
264
265        euid_match_arms.push(
266            quote! { Self::#variant_name #fields_pattern => #action_euid_str.parse().unwrap() },
267        );
268
269        if is_create_action {
270            creates_resource_match_arms
271                .push(quote! { Self::#variant_name #fields_pattern => true });
272            match &variant.fields {
273                Fields::Unnamed(f) if f.unnamed.len() == 1 => {
274                    virtual_entity_match_arms.push(quote! {
275                        Self::#variant_name(payload) => {
276                            let ctx = context.downcast_ref::<crate::RequestContext>().expect("Invalid context type");
277                            Some(payload.to_virtual_entity(ctx))
278                        }
279                    });
280                }
281                _ => {
282                    virtual_entity_match_arms
283                        .push(quote! { Self::#variant_name #fields_pattern => None });
284                }
285            }
286        } else {
287            creates_resource_match_arms
288                .push(quote! { Self::#variant_name #fields_pattern => false });
289        }
290    }
291
292    virtual_entity_match_arms.push(quote! { _ => None });
293
294    let expanded = quote! {
295        #(#inventory_submissions)*
296        impl hodei_authz::RuntimeHodeiActionMapper for #enum_name {
297            fn to_cedar_action_euid(&self) -> cedar_policy::EntityUid {
298                match self { #(#euid_match_arms,)* }
299            }
300            fn creates_resource_from_payload(&self) -> bool {
301                match self { #(#creates_resource_match_arms,)* }
302            }
303            fn get_payload_as_virtual_entity(&self, context: &dyn std::any::Any) -> Option<cedar_policy::Entity> {
304                match self { #(#virtual_entity_match_arms,)* }
305            }
306        }
307    };
308    expanded.into()
309}