Skip to main content

typesec_macro/
lib.rs

1//! # typesec-macro
2//!
3//! Procedural macros for the typesec ecosystem.
4//!
5//! ## `#[derive(TypesecRole)]`
6//!
7//! Derive the [`Role`][typesec_core::role::Role] trait for a struct, pulling
8//! permissions and resource patterns from the `#[role(...)]` attribute:
9//!
10//! ```rust,ignore
11//! use typesec_macro::TypesecRole;
12//!
13//! #[derive(TypesecRole)]
14//! #[role(permissions = "read,write", resources = "code/*,infra/*")]
15//! pub struct Engineer;
16//! ```
17//!
18//! Expands to:
19//!
20//! ```rust,ignore
21//! impl typesec_core::role::Role for Engineer {
22//!     fn name() -> &'static str { "Engineer" }
23//!     fn permission_names() -> &'static [&'static str] { &["read", "write"] }
24//!     fn resource_patterns() -> &'static [&'static str] { &["code/*", "infra/*"] }
25//! }
26//! ```
27//!
28//! ## `policy!` macro
29//!
30//! Inline role definitions without a YAML file:
31//!
32//! ```rust,ignore
33//! use typesec_macro::policy;
34//!
35//! policy! {
36//!     role Analyst {
37//!         can [read, read_sensitive] on ["reports/*", "metrics/*"];
38//!     }
39//! }
40//! ```
41
42use proc_macro::TokenStream;
43use proc_macro2::Span;
44use quote::quote;
45use syn::{DeriveInput, LitStr, parse_macro_input};
46
47/// Permission names defined in `typesec-core` (`Permission::name()` values).
48///
49/// Both macros validate against this list so a typo like `raed` fails at
50/// compile time instead of becoming a permission string that never matches.
51const KNOWN_PERMISSIONS: &[&str] = &[
52    "read",
53    "write",
54    "delete",
55    "execute",
56    "delegate",
57    "read_sensitive",
58    "write_sensitive",
59    "declassify",
60    "ai:infer",
61    "ai:train",
62    "ai:exfiltrate",
63];
64
65fn check_permission(name: &str, span: Span) -> Result<(), syn::Error> {
66    if KNOWN_PERMISSIONS.contains(&name) {
67        Ok(())
68    } else {
69        Err(syn::Error::new(
70            span,
71            format!(
72                "unknown permission '{name}' (expected one of: {})",
73                KNOWN_PERMISSIONS.join(", ")
74            ),
75        ))
76    }
77}
78
79/// Derive the `typesec_core::role::Role` trait.
80///
81/// Requires a `#[role(permissions = "...", resources = "...")]` attribute.
82#[proc_macro_derive(TypesecRole, attributes(role))]
83pub fn derive_typesec_role(input: TokenStream) -> TokenStream {
84    let input = parse_macro_input!(input as DeriveInput);
85    match derive_typesec_role_impl(input) {
86        Ok(ts) => ts.into(),
87        Err(e) => e.to_compile_error().into(),
88    }
89}
90
91fn derive_typesec_role_impl(input: DeriveInput) -> Result<proc_macro2::TokenStream, syn::Error> {
92    let struct_name = &input.ident;
93    let struct_name_str = struct_name.to_string().to_lowercase();
94
95    // Find the #[role(...)] attribute.
96    let role_attr = input
97        .attrs
98        .iter()
99        .find(|a| a.path().is_ident("role"))
100        .ok_or_else(|| {
101            syn::Error::new(
102                Span::call_site(),
103                "TypesecRole requires a #[role(permissions = \"...\", resources = \"...\")] attribute",
104            )
105        })?;
106
107    // Parse the key=value pairs inside the attribute.
108    let mut permissions: Vec<String> = Vec::new();
109    let mut resources: Vec<String> = Vec::new();
110
111    role_attr.parse_nested_meta(|meta| {
112        if meta.path.is_ident("permissions") {
113            let value: LitStr = meta.value()?.parse()?;
114            permissions = value
115                .value()
116                .split(',')
117                .map(|s| s.trim().to_owned())
118                .filter(|s| !s.is_empty())
119                .collect();
120            for permission in &permissions {
121                check_permission(permission, value.span())?;
122            }
123            Ok(())
124        } else if meta.path.is_ident("resources") {
125            let value: LitStr = meta.value()?.parse()?;
126            resources = value
127                .value()
128                .split(',')
129                .map(|s| s.trim().to_owned())
130                .filter(|s| !s.is_empty())
131                .collect();
132            Ok(())
133        } else {
134            Err(meta.error("unknown role attribute key (expected 'permissions' or 'resources')"))
135        }
136    })?;
137
138    let perm_lits: Vec<LitStr> = permissions
139        .iter()
140        .map(|p| LitStr::new(p, Span::call_site()))
141        .collect();
142
143    let resource_lits: Vec<LitStr> = resources
144        .iter()
145        .map(|r| LitStr::new(r, Span::call_site()))
146        .collect();
147
148    let name_lit = LitStr::new(&struct_name_str, Span::call_site());
149
150    Ok(quote! {
151        impl typesec_core::role::Role for #struct_name {
152            fn name() -> &'static str {
153                #name_lit
154            }
155            fn permission_names() -> &'static [&'static str] {
156                &[#(#perm_lits),*]
157            }
158            fn resource_patterns() -> &'static [&'static str] {
159                &[#(#resource_lits),*]
160            }
161        }
162    })
163}
164
165/// Inline policy macro.
166///
167/// ```rust,ignore
168/// policy! {
169///     role Analyst {
170///         can [read, read_sensitive] on ["reports/*"];
171///     }
172///     role Engineer {
173///         can [read, write, execute] on ["code/*"];
174///     }
175/// }
176/// ```
177///
178/// Expands each `role X { ... }` block to a struct + `Role` impl.
179#[proc_macro]
180pub fn policy(input: TokenStream) -> TokenStream {
181    match policy_impl(input.into()) {
182        Ok(ts) => ts.into(),
183        Err(e) => e.to_compile_error().into(),
184    }
185}
186
187fn policy_impl(input: proc_macro2::TokenStream) -> Result<proc_macro2::TokenStream, syn::Error> {
188    use syn::{
189        Ident, Token, braced,
190        parse::{Parse, ParseStream},
191        punctuated::Punctuated,
192    };
193
194    // Mini-DSL parser for `role Name { can [perms] on ["resources"]; }` blocks.
195    struct PolicyParser(Vec<(Ident, Vec<Ident>, Vec<LitStr>)>);
196
197    impl Parse for PolicyParser {
198        fn parse(input: ParseStream) -> syn::Result<Self> {
199            let mut roles = Vec::new();
200
201            while !input.is_empty() {
202                // `role` — parse as a plain Ident (it's not a Rust keyword).
203                let kw: Ident = input.parse()?;
204                if kw != "role" {
205                    return Err(syn::Error::new(kw.span(), "expected `role`"));
206                }
207
208                // Role name
209                let name: Ident = input.parse()?;
210
211                // `{ can [perms] on ["resources"]; }`
212                let content;
213                braced!(content in input);
214
215                // `can`
216                let can_kw: Ident = content.parse()?;
217                if can_kw != "can" {
218                    return Err(syn::Error::new(can_kw.span(), "expected `can`"));
219                }
220
221                // `[perm1, perm2, ...]`
222                let perm_content;
223                syn::bracketed!(perm_content in content);
224                let perms: Punctuated<Ident, Token![,]> =
225                    perm_content.parse_terminated(Ident::parse, Token![,])?;
226
227                // `on`
228                let on_kw: Ident = content.parse()?;
229                if on_kw != "on" {
230                    return Err(syn::Error::new(on_kw.span(), "expected `on`"));
231                }
232
233                // `["resource1", ...]`
234                let res_content;
235                syn::bracketed!(res_content in content);
236                let resources: Punctuated<LitStr, Token![,]> =
237                    res_content.parse_terminated(Parse::parse, Token![,])?;
238
239                // Optional semicolon
240                let _ = content.parse::<Token![;]>();
241
242                roles.push((
243                    name,
244                    perms.into_iter().collect(),
245                    resources.into_iter().collect(),
246                ));
247            }
248
249            Ok(PolicyParser(roles))
250        }
251    }
252
253    let parsed: PolicyParser = syn::parse2(input)?;
254    let mut output = proc_macro2::TokenStream::new();
255
256    for (name, perms, resources) in parsed.0 {
257        let name_str = name.to_string().to_lowercase();
258        for perm in &perms {
259            check_permission(&perm.to_string(), perm.span())?;
260        }
261        let perm_strs: Vec<String> = perms.iter().map(|p| p.to_string()).collect();
262        let perm_lits: Vec<LitStr> = perm_strs
263            .iter()
264            .map(|s| LitStr::new(s, Span::call_site()))
265            .collect();
266
267        let name_lit = LitStr::new(&name_str, Span::call_site());
268
269        output.extend(quote! {
270            #[derive(Debug, Clone, Copy)]
271            pub struct #name;
272
273            impl typesec_core::role::Role for #name {
274                fn name() -> &'static str { #name_lit }
275                fn permission_names() -> &'static [&'static str] { &[#(#perm_lits),*] }
276                fn resource_patterns() -> &'static [&'static str] { &[#(#resources),*] }
277            }
278        });
279    }
280
281    Ok(output)
282}