qm_role_build/lib.rs
1#![deny(missing_docs)]
2
3//! Role builder from markdown tables.
4//!
5//! This crate provides utilities to generate Rust code for role-based access control (RBAC)
6//! from markdown tables defined in documentation. It parses markdown files containing user
7//! groups and role mappings, then generates Rust code that can be used in your application.
8//!
9//! ## Input Format
10//!
11//! The input markdown file should contain two tables:
12//!
13//! 1. **User Groups Table** - defines available user groups and their paths
14//! 2. **Role Mappings Table** - maps user groups to roles
15//!
16//! ### Example Input
17//!
18//! \`\`\`markdown
19//! # User Groups `user_groups`
20//!
21//! | Name | Path | Display Name | Allowed Types |
22//! | --------------------- | --------------------- | -------------------- | ------------- |
23//! | Admin | /administration_owner | Admin | none |
24//! | CustomerOwner | /customer_owner | Owner of Customer | none |
25//!
26//! # Role Mappings `roles`
27//!
28//! | Roles | Admin | InstitutionOwner | Reader |
29//! | --------------- | ------- | ---------------- | ------ |
30//! | administration | x | | |
31//! | user:list | | x | |
32//! \`\`\`
33//!
34//! ## Usage
35//!
36//! ```ignore
37//! use qm_role_build::generate;
38//!
39//! fn main() -> anyhow::Result<()> {
40//! generate("path/to/roles.md")?;
41//! Ok(())
42//! }
43//! ```
44//!
45//! ## Output
46//!
47//! The generated code creates a `RoleMapping` struct containing the user group
48//! to roles mapping that can be used for authorization decisions.
49
50use std::path::{Path, PathBuf};
51
52mod model;
53mod parser;
54mod reader;
55mod writer;
56
57/// Generate role mapping code from a markdown file.
58///
59/// Reads the markdown file at the given path, parses the user groups and role
60/// mappings, and writes the generated Rust code to `OUT_DIR`.
61///
62/// The output filename is derived from the input filename with the `.rs` extension.
63pub fn generate(input_file_path: &Path) -> anyhow::Result<()> {
64 let out = input_file_path.with_extension("rs");
65 let file_name = out
66 .file_name()
67 .ok_or(anyhow::anyhow!("invalid input filename"))?;
68 let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
69 let out_file_path = out_dir.join(file_name);
70
71 let tables = reader::Reader::from_file(input_file_path)?.read()?;
72 let parse_result = crate::parser::parse(tables)?;
73
74 writer::Writer::from_file(out_file_path)?.write(parse_result)?;
75
76 Ok(())
77}
78
79/// Generate role mapping code to a custom writer.
80///
81/// Similar to [`generate`], but writes to a provided writer instead of `OUT_DIR`.
82/// This is useful for testing or capturing the generated output in memory.
83pub fn generate_to_writer<W: std::io::Write>(
84 input_file_path: &Path,
85 writer: W,
86) -> anyhow::Result<()> {
87 let tables = reader::Reader::from_file(input_file_path)?.read()?;
88 let parse_result = crate::parser::parse(tables)?;
89
90 writer::Writer::from_writer(writer).write(parse_result)?;
91
92 Ok(())
93}
94
95#[cfg(test)]
96mod test {
97 use crate::{
98 model::{RoleMapping, Table},
99 reader::Reader,
100 };
101 use std::rc::Rc;
102
103 const TEST_INPUT: &str = r#"# User Groups `user_groups`
104
105| Name | Path | Display Name | Allowed Types |
106| --------------------- | --------------------- | -------------------- | ------------- |
107| Admin | /administration_owner | Admin | none |
108| CustomerOwner | /customer_owner | Owner of Customer | none |
109| InstitutionOwner | /institution_owner | Owner of Institution | eco,state |
110| Reader | /employee_reader | Reader | eco |
111
112# Role Mappings `roles`
113
114| Roles | Admin | InstitutionOwner | Reader |
115| --------------- | ------- | ---------------- | ------ |
116| administration | x | | |
117| user:list | | x | |
118| user:view | | x | |
119| user:update | | x | |
120| user:create | | x | |
121| user:delete | | x | |
122| entity:list | | x | x |
123| entity:view | | x | x |
124| entity:update | | x | |
125| entity:create | | x | |
126| entity:delete | | x | |"#;
127
128 #[test]
129 fn test_md_table_reader() -> anyhow::Result<()> {
130 let result = Reader::from_str(TEST_INPUT).read()?;
131 assert_eq!(
132 result.user_groups,
133 Table {
134 headers: vec![
135 "Name".to_string(),
136 "Path".to_string(),
137 "Display Name".to_string(),
138 "Allowed Types".to_string(),
139 ],
140 rows: vec![
141 vec![
142 "Admin".to_string(),
143 "/administration_owner".to_string(),
144 "Admin".to_string(),
145 "none".to_string(),
146 ],
147 vec![
148 "CustomerOwner".to_string(),
149 "/customer_owner".to_string(),
150 "Owner of Customer".to_string(),
151 "none".to_string(),
152 ],
153 vec![
154 "InstitutionOwner".to_string(),
155 "/institution_owner".to_string(),
156 "Owner of Institution".to_string(),
157 "eco,state".to_string(),
158 ],
159 vec![
160 "Reader".to_string(),
161 "/employee_reader".to_string(),
162 "Reader".to_string(),
163 "eco".to_string(),
164 ],
165 ],
166 }
167 );
168 assert_eq!(
169 result.roles,
170 Table {
171 headers: vec![
172 "Roles".to_string(),
173 "Admin".to_string(),
174 "InstitutionOwner".to_string(),
175 "Reader".to_string()
176 ],
177 rows: vec![
178 vec![
179 "administration".to_string(),
180 "x".to_string(),
181 "".to_string(),
182 "".to_string()
183 ],
184 vec![
185 "user:list".to_string(),
186 "".to_string(),
187 "x".to_string(),
188 "".to_string()
189 ],
190 vec![
191 "user:view".to_string(),
192 "".to_string(),
193 "x".to_string(),
194 "".to_string()
195 ],
196 vec![
197 "user:update".to_string(),
198 "".to_string(),
199 "x".to_string(),
200 "".to_string()
201 ],
202 vec![
203 "user:create".to_string(),
204 "".to_string(),
205 "x".to_string(),
206 "".to_string()
207 ],
208 vec![
209 "user:delete".to_string(),
210 "".to_string(),
211 "x".to_string(),
212 "".to_string()
213 ],
214 vec![
215 "entity:list".to_string(),
216 "".to_string(),
217 "x".to_string(),
218 "x".to_string()
219 ],
220 vec![
221 "entity:view".to_string(),
222 "".to_string(),
223 "x".to_string(),
224 "x".to_string()
225 ],
226 vec![
227 "entity:update".to_string(),
228 "".to_string(),
229 "x".to_string(),
230 "".to_string()
231 ],
232 vec![
233 "entity:create".to_string(),
234 "".to_string(),
235 "x".to_string(),
236 "".to_string()
237 ],
238 vec![
239 "entity:delete".to_string(),
240 "".to_string(),
241 "x".to_string(),
242 "".to_string()
243 ],
244 ],
245 },
246 );
247 Ok(())
248 }
249
250 #[test]
251 fn test_md_table_parser() -> anyhow::Result<()> {
252 let result = crate::parser::parse(Reader::from_str(TEST_INPUT).read()?)?;
253 assert_eq!(
254 &RoleMapping {
255 user_group: Rc::from("Admin"),
256 roles: Rc::from([Rc::from("administration")]),
257 },
258 &result.role_mappings[0]
259 );
260 assert_eq!(
261 &RoleMapping {
262 user_group: Rc::from("InstitutionOwner"),
263 roles: Rc::from([
264 Rc::from("user:list"),
265 Rc::from("user:view"),
266 Rc::from("user:update"),
267 Rc::from("user:create"),
268 Rc::from("user:delete"),
269 Rc::from("entity:list"),
270 Rc::from("entity:view"),
271 Rc::from("entity:update"),
272 Rc::from("entity:create"),
273 Rc::from("entity:delete"),
274 ]),
275 },
276 &result.role_mappings[1]
277 );
278 assert_eq!(
279 &RoleMapping {
280 user_group: Rc::from("Reader"),
281 roles: Rc::from([Rc::from("entity:list"), Rc::from("entity:view"),]),
282 },
283 &result.role_mappings[2]
284 );
285 Ok(())
286 }
287
288 #[test]
289 fn test_roles_writer() -> anyhow::Result<()> {
290 let result = crate::parser::parse(Reader::from_str(TEST_INPUT).read()?)?;
291 let code = crate::writer::Writer::in_memory()
292 .write(result)?
293 .into_inner();
294 eprintln!("{code}");
295 Ok(())
296 }
297}