Skip to main content

sofie_model/
permissions.rs

1use std::collections::BTreeMap;
2
3use crate::naming::{RouteSite, snake_case};
4use crate::{Aggregate, App, AreaDefinition, KnownArea, Method, ModelError, Module, Route};
5
6const PERMITTED_PRINCIPAL: &str = "Permitted";
7
8const WHOLE_RECORD: &str = "*";
9const RECORD_ACTIONS: [&str; 4] = ["read", "create", "update", "delete"];
10const FIELD_ACTIONS: [&str; 2] = ["read", "update"];
11
12impl AreaDefinition {
13    #[must_use]
14    pub fn is_permitted(&self) -> bool {
15        self.principal
16            .last()
17            .is_some_and(|name| name == PERMITTED_PRINCIPAL)
18    }
19}
20
21impl KnownArea<'_> {
22    #[must_use]
23    pub fn is_permitted(&self) -> bool {
24        self.definition.is_some_and(AreaDefinition::is_permitted)
25    }
26}
27
28impl RouteSite<'_> {
29    #[must_use]
30    pub fn permission(&self, app: &App) -> Option<String> {
31        app.area(&self.area.area)
32            .is_some_and(|area| area.is_permitted())
33            .then(|| route_permission(self.module, self.route))
34    }
35}
36
37#[must_use]
38pub fn permission_constant(permission: &str) -> String {
39    let axes: Vec<&str> = permission
40        .split('.')
41        .skip(1)
42        .filter(|axis| *axis != WHOLE_RECORD)
43        .collect();
44    axes.join("_").to_ascii_uppercase()
45}
46
47/// The generated `permissions` module would not compile with one constant written twice.
48pub fn refuse_colliding_constants(module: &Module) -> Result<(), ModelError> {
49    let mut constants: BTreeMap<String, String> = BTreeMap::new();
50    for permission in module_permissions(module) {
51        let constant = permission_constant(&permission);
52        if let Some(earlier) = constants.insert(constant.clone(), permission.clone()) {
53            return Err(ModelError::in_file(
54                &module.krate.path.join("src").join("lib.rs"),
55                format!(
56                    "permissions `{earlier}` and `{permission}` both become the constant \
57                     `{constant}`; rename the record or field of one"
58                ),
59            ));
60        }
61    }
62    Ok(())
63}
64
65#[must_use]
66pub fn module_permissions(module: &Module) -> Vec<String> {
67    let name = &module.krate.crate_name;
68    let mut permissions: Vec<String> = module
69        .code
70        .aggregates
71        .iter()
72        .flat_map(|aggregate| aggregate_permissions(name, aggregate))
73        .collect();
74    let routed = module.areas.iter().flat_map(|area| &area.routes);
75    permissions.extend(routed.map(|route| route_permission(module, route)));
76    permissions.sort();
77    permissions.dedup();
78    permissions
79}
80
81fn aggregate_permissions(module: &str, aggregate: &Aggregate) -> Vec<String> {
82    let model = snake_case(&aggregate.name);
83    let record = RECORD_ACTIONS
84        .iter()
85        .map(|action| format!("{module}.{model}.{WHOLE_RECORD}.{action}"));
86    let fields = aggregate.fields.iter().flat_map(|field| {
87        FIELD_ACTIONS
88            .iter()
89            .map(|action| format!("{module}.{model}.{}.{action}", field.name))
90    });
91    record.chain(fields).collect()
92}
93
94/// The handler's action on the longest aggregate name its other words spell, or else its last word.
95#[must_use]
96pub fn route_permission(module: &Module, route: &Route) -> String {
97    let handler = route.handler.last().map_or("", String::as_str);
98    let words: Vec<&str> = handler.split('_').filter(|word| !word.is_empty()).collect();
99    let (action, subject) = route_action(route.method, &words);
100    let models: Vec<String> = module
101        .code
102        .aggregates
103        .iter()
104        .map(|aggregate| snake_case(&aggregate.name))
105        .collect();
106    let model = subject_model(&models, subject, handler);
107    format!(
108        "{}.{model}.{WHOLE_RECORD}.{action}",
109        module.krate.crate_name
110    )
111}
112
113/// `read` for a page, but a `new_` or `edit_` form needs the right to submit it, as sofie-http
114/// enforces; else the handler's first word. A write never needs `read`, which a read-only member
115/// holds: `read_notifications` marks them, so it needs `update`.
116fn route_action<'a>(method: Method, words: &'a [&'a str]) -> (&'a str, &'a [&'a str]) {
117    match (method, words.split_first()) {
118        (Method::Get, Some((&"new", rest))) => ("create", rest),
119        (Method::Get, Some((&"edit", rest))) => ("update", rest),
120        (Method::Get, _) => ("read", words),
121        (_, None) => ("update", words),
122        (_, Some((&"read", rest))) => ("update", rest),
123        (_, Some((verb, rest))) => (verb, rest),
124    }
125}
126
127/// A resource's list handler spells its record's plural, as `list_opening_hours`.
128fn subject_model(models: &[String], words: &[&str], handler: &str) -> String {
129    (1..=words.len())
130        .rev()
131        .find_map(|length| {
132            words.windows(length).find_map(|run| {
133                let joined = run.join("_");
134                models
135                    .iter()
136                    .find(|model| {
137                        **model == joined
138                            || **model == singular_run(run)
139                            || sofie_core::plural(model) == joined
140                    })
141                    .cloned()
142            })
143        })
144        .unwrap_or_else(|| singular(words.last().copied().unwrap_or(handler)))
145}
146
147fn singular_run(run: &[&str]) -> String {
148    let mut words: Vec<String> = run.iter().map(|word| (*word).to_owned()).collect();
149    if let Some(last) = words.last_mut() {
150        *last = singular(last);
151    }
152    words.join("_")
153}
154
155/// Naive, but enough for record names, which is all a handler's words are matched against.
156fn singular(word: &str) -> String {
157    if let Some(stem) = word.strip_suffix("ies") {
158        return format!("{stem}y");
159    }
160    if ["ss", "us", "is"]
161        .iter()
162        .any(|ending| word.ends_with(ending))
163    {
164        return word.to_owned();
165    }
166    match word.strip_suffix('s') {
167        Some(stem) if !stem.is_empty() => stem.to_owned(),
168        _ => word.to_owned(),
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::{permission_constant, route_action, singular, subject_model};
175    use crate::Method;
176
177    #[test]
178    fn a_permission_s_constant_names_its_model_field_and_action() {
179        assert_eq!(permission_constant("order.order.*.cancel"), "ORDER_CANCEL");
180        assert_eq!(
181            permission_constant("order.order_line.total.read"),
182            "ORDER_LINE_TOTAL_READ"
183        );
184    }
185
186    #[test]
187    fn a_form_page_needs_the_action_its_form_submits() {
188        assert_eq!(
189            route_action(Method::Get, &["new", "order"]),
190            ("create", &["order"][..])
191        );
192        assert_eq!(
193            route_action(Method::Get, &["edit", "order"]),
194            ("update", &["order"][..])
195        );
196        assert_eq!(route_action(Method::Get, &["show", "order"]).0, "read");
197        assert_eq!(
198            route_action(Method::Post, &["read", "notifications"]).0,
199            "update"
200        );
201    }
202
203    #[test]
204    fn plural_record_names_become_singular() {
205        assert_eq!(singular("orders"), "order");
206        assert_eq!(singular("categories"), "category");
207        assert_eq!(singular("order"), "order");
208        assert_eq!(singular("address"), "address");
209        assert_eq!(singular("status"), "status");
210        assert_eq!(singular("campus"), "campus");
211    }
212
213    #[test]
214    fn a_list_handler_names_its_record_by_the_record_s_plural() {
215        let models = ["opening_hours", "category", "status"].map(str::to_owned);
216        let model = |handler: &str| {
217            let words: Vec<&str> = handler.split('_').collect();
218            subject_model(&models, &words[1..], handler)
219        };
220
221        assert_eq!(model("list_opening_hours"), "opening_hours");
222        assert_eq!(model("list_categories"), "category");
223        assert_eq!(model("list_statuses"), "status");
224        assert_eq!(model("list_hourses"), "hourse");
225    }
226}