Skip to main content

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}