Skip to main content

permkit_permission_macros/
lib.rs

1//! Derive macro for permission enums.
2//!
3//! Annotate a unit-only enum with `#[derive(Permission)]` and use the
4//! `#[permission(...)]` helper attribute to declare the permission string for
5//! each variant and the roles that should hold it by default.
6//!
7//! ```ignore
8//! use permkit::Permission;
9//!
10//! #[derive(Permission)]
11//! #[permission(roles = ["owner", "operator"])]
12//! pub enum CompanyPermission {
13//!     #[permission(name = "Companies.List")]
14//!     List,
15//!     #[permission(name = "Companies.Create", roles = ["owner"])]
16//!     Create,
17//! }
18//! ```
19
20use std::collections::HashMap;
21
22use proc_macro::TokenStream;
23use quote::quote;
24use syn::spanned::Spanned as _;
25use syn::{
26    Attribute,
27    Data,
28    DeriveInput,
29    Expr,
30    ExprArray,
31    ExprLit,
32    Fields,
33    Lit,
34    LitStr,
35    Variant,
36    parse_macro_input,
37};
38
39#[proc_macro_derive(Permission, attributes(permission))]
40pub fn derive_permission(input: TokenStream) -> TokenStream {
41    let input = parse_macro_input!(input as DeriveInput);
42    expand(&input)
43        .unwrap_or_else(|err| err.to_compile_error())
44        .into()
45}
46
47#[derive(Default)]
48struct EnumAttrs {
49    default_roles: Vec<String>,
50}
51
52struct VariantInfo {
53    ident: syn::Ident,
54    name: String,
55    roles: Vec<String>,
56}
57
58fn expand(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
59    let enum_ident = &input.ident;
60
61    let Data::Enum(data) = &input.data else {
62        return Err(syn::Error::new_spanned(
63            enum_ident,
64            "Permission can only be derived for enums",
65        ));
66    };
67
68    let enum_attrs = parse_enum_attrs(&input.attrs)?;
69
70    let mut variants: Vec<VariantInfo> = Vec::with_capacity(data.variants.len());
71    let mut seen_names = HashMap::<String, proc_macro2::Span>::new();
72
73    for variant in &data.variants {
74        if !matches!(variant.fields, Fields::Unit) {
75            return Err(syn::Error::new(
76                variant.span(),
77                "Permission only supports unit variants (no fields)",
78            ));
79        }
80
81        let info = parse_variant_info(variant, &enum_attrs)?;
82
83        if let Some(prev_span) = seen_names.insert(info.name.clone(), variant.span()) {
84            let mut err = syn::Error::new(
85                variant.span(),
86                format!("duplicate permission name {:?}", info.name),
87            );
88
89            err.combine(syn::Error::new(
90                prev_span,
91                format!("note: previous use of {:?}", info.name),
92            ));
93
94            return Err(err);
95        }
96
97        variants.push(info);
98    }
99
100    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
101
102    let as_ref_arms = variants.iter().map(|variant| {
103        let ident = &variant.ident;
104        let name = &variant.name;
105        quote! { Self::#ident => #name }
106    });
107
108    let enum_name_str = enum_ident.to_string();
109
110    let utoipa_impls = if cfg!(feature = "utoipa") {
111        let all_names = variants.iter().map(|variant| variant.name.as_str());
112
113        quote! {
114            impl #impl_generics ::permkit::utoipa::PartialSchema for #enum_ident #ty_generics #where_clause {
115                fn schema() -> ::permkit::utoipa::openapi::RefOr<::permkit::utoipa::openapi::schema::Schema> {
116                    ::permkit::utoipa::openapi::RefOr::T(::permkit::utoipa::openapi::schema::Schema::Object(
117                        ::permkit::utoipa::openapi::schema::ObjectBuilder::new()
118                            .schema_type(::permkit::utoipa::openapi::schema::SchemaType::Type(
119                                ::permkit::utoipa::openapi::schema::Type::String,
120                            ))
121                            .enum_values(::core::option::Option::Some([
122                                #(#all_names),*
123                            ]))
124                            .build(),
125                    ))
126                }
127            }
128
129            impl #impl_generics ::permkit::utoipa::ToSchema for #enum_ident #ty_generics #where_clause {
130                fn name() -> ::std::borrow::Cow<'static, str> {
131                    ::std::borrow::Cow::Borrowed(#enum_name_str)
132                }
133            }
134        }
135    } else {
136        quote! {}
137    };
138
139    let inventory_submits = variants
140        .iter()
141        .map(|variant| {
142            let name = &variant.name;
143            let roles = variant.roles.iter();
144            quote! {
145                ::permkit::inventory::submit! {
146                    ::permkit::PermissionEntry {
147                        name: ::std::borrow::Cow::Borrowed(#name),
148                        enum_name: #enum_name_str,
149                        roles: &[#(#roles),*],
150                    }
151                }
152            }
153        })
154        .collect::<Vec<_>>();
155
156    Ok(quote! {
157        impl #impl_generics ::core::convert::AsRef<str> for #enum_ident #ty_generics #where_clause {
158            #[inline]
159            fn as_ref(&self) -> &str {
160                match self {
161                    #(#as_ref_arms),*
162                }
163            }
164        }
165
166        impl #impl_generics ::permkit::serde::Serialize for #enum_ident #ty_generics #where_clause {
167            fn serialize<__S>(&self, serializer: __S) -> ::core::result::Result<__S::Ok, __S::Error>
168            where
169                __S: ::permkit::serde::Serializer,
170            {
171                serializer.serialize_str(::core::convert::AsRef::<str>::as_ref(self))
172            }
173        }
174
175        #utoipa_impls
176
177        #(#inventory_submits)*
178    })
179}
180
181fn parse_enum_attrs(attrs: &[Attribute]) -> syn::Result<EnumAttrs> {
182    let mut out = EnumAttrs::default();
183
184    for attr in attrs {
185        if !attr.path().is_ident("permission") {
186            continue;
187        }
188
189        attr.parse_nested_meta(|meta| {
190            if meta.path.is_ident("roles") {
191                let expr: Expr = meta.value()?.parse()?;
192                out.default_roles = parse_string_array(&expr)?;
193                Ok(())
194            } else {
195                Err(meta.error(
196                    "unsupported `#[permission(...)]` key on enum (expected `roles = [..]`)",
197                ))
198            }
199        })?;
200    }
201
202    Ok(out)
203}
204
205fn parse_variant_info(variant: &Variant, enum_attrs: &EnumAttrs) -> syn::Result<VariantInfo> {
206    let mut name: Option<String> = None;
207    let mut roles: Option<Vec<String>> = None;
208    let mut saw_permission_attr = false;
209
210    for attr in &variant.attrs {
211        if !attr.path().is_ident("permission") {
212            continue;
213        }
214        saw_permission_attr = true;
215
216        attr.parse_nested_meta(|meta| {
217            if meta.path.is_ident("name") {
218                let lit: LitStr = meta.value()?.parse()?;
219                name = Some(lit.value());
220                Ok(())
221            } else if meta.path.is_ident("roles") {
222                let expr: Expr = meta.value()?.parse()?;
223                roles = Some(parse_string_array(&expr)?);
224                Ok(())
225            } else {
226                Err(meta.error(
227                    "unsupported `#[permission(...)]` key on variant (expected `name = \"..\"` or `roles = [..]`)",
228                ))
229            }
230        })?;
231    }
232
233    let Some(name) = name else {
234        return Err(syn::Error::new(
235            variant.span(),
236            if saw_permission_attr {
237                "missing `name = \"..\"` in `#[permission(...)]`"
238            } else {
239                "expected `#[permission(name = \"..\")]` on variant"
240            },
241        ));
242    };
243
244    let roles = roles.unwrap_or_else(|| enum_attrs.default_roles.clone());
245
246    Ok(VariantInfo {
247        ident: variant.ident.clone(),
248        name,
249        roles,
250    })
251}
252
253fn parse_string_array(expr: &Expr) -> syn::Result<Vec<String>> {
254    let Expr::Array(ExprArray { elems, .. }) = expr else {
255        return Err(syn::Error::new(
256            expr.span(),
257            "expected a string array like `[\"owner\", \"operator\"]`",
258        ));
259    };
260
261    elems
262        .iter()
263        .map(|expr| match expr {
264            Expr::Lit(ExprLit {
265                lit: Lit::Str(s), ..
266            }) => Ok(s.value()),
267            other => Err(syn::Error::new(
268                other.span(),
269                "expected a string literal inside the role array",
270            )),
271        })
272        .collect()
273}
274
275#[cfg(test)]
276mod tests {
277    use proc_macro2::TokenStream;
278    use quote::quote;
279
280    fn try_expand(input: TokenStream) -> syn::Result<TokenStream> {
281        let parsed: syn::DeriveInput = syn::parse2(input)?;
282        super::expand(&parsed)
283    }
284
285    fn expand_ok(input: TokenStream) -> String {
286        try_expand(input).expect("should expand").to_string()
287    }
288
289    #[test]
290    fn expands_basic_enum_with_default_roles() {
291        let input = quote! {
292            #[permission(roles = ["owner", "operator"])]
293            pub enum CompanyPermission {
294                #[permission(name = "Companies.List")]
295                List,
296                #[permission(name = "Companies.Create", roles = ["owner"])]
297                Create,
298            }
299        };
300
301        let expanded = expand_ok(input);
302
303        assert!(expanded.contains("Self :: List => \"Companies.List\""));
304        assert!(expanded.contains("Self :: Create => \"Companies.Create\""));
305        assert!(expanded.contains("roles : & [\"owner\" , \"operator\"]"));
306        assert!(expanded.contains("roles : & [\"owner\"]"));
307        assert_eq!(
308            expanded.contains(":: permkit :: utoipa :: ToSchema for CompanyPermission"),
309            cfg!(feature = "utoipa")
310        );
311        assert!(expanded.contains(":: permkit :: serde :: Serialize for CompanyPermission"));
312        assert!(expanded.contains(":: core :: convert :: AsRef < str > for CompanyPermission"));
313        assert!(expanded.contains(":: permkit :: inventory :: submit"));
314        assert!(expanded.contains(":: permkit :: PermissionEntry"));
315        assert!(expanded.contains("Cow :: Borrowed (\"Companies.List\")"));
316        assert!(expanded.contains("enum_name : \"CompanyPermission\""));
317    }
318
319    #[test]
320    fn variant_without_roles_inherits_default() {
321        let input = quote! {
322            #[permission(roles = ["owner"])]
323            pub enum P {
324                #[permission(name = "A.B")]
325                Variant,
326            }
327        };
328
329        let expanded = expand_ok(input);
330        assert!(expanded.contains("roles : & [\"owner\"]"));
331    }
332
333    #[test]
334    fn no_default_roles_means_empty_slice() {
335        let input = quote! {
336            pub enum P {
337                #[permission(name = "A.B")]
338                Variant,
339            }
340        };
341
342        let expanded = expand_ok(input);
343        assert!(expanded.contains("roles : & []"));
344    }
345
346    #[test]
347    fn rejects_non_enums() {
348        let input = quote! {
349            pub struct Foo;
350        };
351        let err = try_expand(input).expect_err("should fail");
352        assert!(err.to_string().contains("can only be derived for enums"));
353    }
354
355    #[test]
356    fn rejects_non_unit_variants() {
357        let input = quote! {
358            pub enum P {
359                #[permission(name = "A.B")]
360                Variant(String),
361            }
362        };
363        let err = try_expand(input).expect_err("should fail");
364        assert!(err.to_string().contains("unit variants"));
365    }
366
367    #[test]
368    fn rejects_missing_name() {
369        let input = quote! {
370            pub enum P {
371                Variant,
372            }
373        };
374        let err = try_expand(input).expect_err("should fail");
375        assert!(err.to_string().contains("permission(name"));
376    }
377
378    #[test]
379    fn rejects_duplicate_names() {
380        let input = quote! {
381            pub enum P {
382                #[permission(name = "A.B")]
383                X,
384                #[permission(name = "A.B")]
385                Y,
386            }
387        };
388        let err = try_expand(input).expect_err("should fail");
389        assert!(err.to_string().contains("duplicate permission name"));
390    }
391
392    #[test]
393    fn rejects_unknown_keys_on_variant() {
394        let input = quote! {
395            pub enum P {
396                #[permission(name = "A.B", description = "nope")]
397                X,
398            }
399        };
400        let err = try_expand(input).expect_err("should fail");
401        assert!(err.to_string().contains("unsupported"));
402    }
403}