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/// `read` for a page, else the handler's first word, on the longest aggregate name its other words spell, or else its last word.
95/// A write never needs `read`, which a read-only member holds: `read_notifications` marks them, so it needs `update`.
96#[must_use]
97pub fn route_permission(module: &Module, route: &Route) -> String {
98    let handler = route.handler.last().map_or("", String::as_str);
99    let words: Vec<&str> = handler.split('_').filter(|word| !word.is_empty()).collect();
100    let (action, subject) = match (route.method, words.split_first()) {
101        (Method::Get, _) => ("read", words.as_slice()),
102        (_, None) => ("update", words.as_slice()),
103        (_, Some((&"read", rest))) => ("update", rest),
104        (_, Some((verb, rest))) => (*verb, rest),
105    };
106    let models: Vec<String> = module
107        .code
108        .aggregates
109        .iter()
110        .map(|aggregate| snake_case(&aggregate.name))
111        .collect();
112    let model = subject_model(&models, subject, handler);
113    format!(
114        "{}.{model}.{WHOLE_RECORD}.{action}",
115        module.krate.crate_name
116    )
117}
118
119/// A resource's list handler spells its record's plural, as `list_opening_hours`.
120fn subject_model(models: &[String], words: &[&str], handler: &str) -> String {
121    (1..=words.len())
122        .rev()
123        .find_map(|length| {
124            words.windows(length).find_map(|run| {
125                let joined = run.join("_");
126                models
127                    .iter()
128                    .find(|model| {
129                        **model == joined
130                            || **model == singular_run(run)
131                            || sofie_core::plural(model) == joined
132                    })
133                    .cloned()
134            })
135        })
136        .unwrap_or_else(|| singular(words.last().copied().unwrap_or(handler)))
137}
138
139fn singular_run(run: &[&str]) -> String {
140    let mut words: Vec<String> = run.iter().map(|word| (*word).to_owned()).collect();
141    if let Some(last) = words.last_mut() {
142        *last = singular(last);
143    }
144    words.join("_")
145}
146
147/// Naive, but enough for record names, which is all a handler's words are matched against.
148fn singular(word: &str) -> String {
149    if let Some(stem) = word.strip_suffix("ies") {
150        return format!("{stem}y");
151    }
152    if ["ss", "us", "is"]
153        .iter()
154        .any(|ending| word.ends_with(ending))
155    {
156        return word.to_owned();
157    }
158    match word.strip_suffix('s') {
159        Some(stem) if !stem.is_empty() => stem.to_owned(),
160        _ => word.to_owned(),
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::{permission_constant, singular, subject_model};
167
168    #[test]
169    fn a_permission_s_constant_names_its_model_field_and_action() {
170        assert_eq!(permission_constant("order.order.*.cancel"), "ORDER_CANCEL");
171        assert_eq!(
172            permission_constant("order.order_line.total.read"),
173            "ORDER_LINE_TOTAL_READ"
174        );
175    }
176
177    #[test]
178    fn plural_record_names_become_singular() {
179        assert_eq!(singular("orders"), "order");
180        assert_eq!(singular("categories"), "category");
181        assert_eq!(singular("order"), "order");
182        assert_eq!(singular("address"), "address");
183        assert_eq!(singular("status"), "status");
184        assert_eq!(singular("campus"), "campus");
185    }
186
187    #[test]
188    fn a_list_handler_names_its_record_by_the_record_s_plural() {
189        let models = ["opening_hours", "category", "status"].map(str::to_owned);
190        let model = |handler: &str| {
191            let words: Vec<&str> = handler.split('_').collect();
192            subject_model(&models, &words[1..], handler)
193        };
194
195        assert_eq!(model("list_opening_hours"), "opening_hours");
196        assert_eq!(model("list_categories"), "category");
197        assert_eq!(model("list_statuses"), "status");
198        assert_eq!(model("list_hourses"), "hourse");
199    }
200}