Skip to main content

teamcraft/domain/
roles.rs

1//! Roles and project group visibility.
2
3use serde::{Deserialize, Serialize};
4
5/// Global user role. Ordering matters: `Viewer < Editor < Admin`.
6///
7/// What a CAD editor may change is further limited to the project groups the
8/// user is a member of (see `Actor::can_edit_project`).
9#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Role {
12    /// Read-only access to internal project groups and to restricted groups
13    /// the user is a member of.
14    Viewer,
15    /// "CAD editor": edits the groups they are a member of (create and modify
16    /// product data, author changes, review changes); reads like a viewer.
17    Editor,
18    /// Everything, including restricted groups, plus user/project
19    /// administration and lock overrides.
20    Admin,
21}
22
23impl Role {
24    pub const ALL: [Role; 3] = [Role::Viewer, Role::Editor, Role::Admin];
25
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Role::Viewer => "viewer",
29            Role::Editor => "editor",
30            Role::Admin => "admin",
31        }
32    }
33
34    pub fn label(self) -> &'static str {
35        match self {
36            Role::Viewer => "Viewer",
37            Role::Editor => "CAD editor",
38            Role::Admin => "Admin",
39        }
40    }
41
42    /// Accepts the stored values plus a few spellings (`cad-editor`, and the
43    /// former name `engineer`).
44    pub fn parse(s: &str) -> Option<Role> {
45        match s
46            .trim()
47            .to_ascii_lowercase()
48            .replace([' ', '_'], "-")
49            .as_str()
50        {
51            "viewer" => Some(Role::Viewer),
52            "editor" | "cad-editor" | "engineer" => Some(Role::Editor),
53            "admin" => Some(Role::Admin),
54            _ => None,
55        }
56    }
57}
58
59/// How administrators who are not members read a restricted group (the
60/// need-to-know policy, `project_policies.admin_read`).
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum AdminRead {
64    /// Administrators read every restricted group (the default).
65    Implicit,
66    /// The group is hidden from an administrator until they open a
67    /// time-limited, audited elevation with a written reason.
68    WithReason,
69    /// Administrators manage the group but read nothing in it unless they
70    /// are members.
71    None,
72}
73
74impl AdminRead {
75    pub fn as_str(self) -> &'static str {
76        match self {
77            AdminRead::Implicit => "implicit",
78            AdminRead::WithReason => "with_reason",
79            AdminRead::None => "none",
80        }
81    }
82
83    pub fn label(self) -> &'static str {
84        match self {
85            AdminRead::Implicit => "Administrators read the group",
86            AdminRead::WithReason => "Administrators need a reason (time-limited, audited)",
87            AdminRead::None => "Administrators manage but cannot read",
88        }
89    }
90
91    pub fn parse(s: &str) -> Option<AdminRead> {
92        match s.trim().to_ascii_lowercase().as_str() {
93            "implicit" => Some(AdminRead::Implicit),
94            "with_reason" => Some(AdminRead::WithReason),
95            "none" => Some(AdminRead::None),
96            _ => None,
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Project group visibility
103// ---------------------------------------------------------------------------
104
105/// Who may read the data of a project group.
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ProjectVisibility {
109    /// Every signed-in user reads; members edit (the default).
110    Internal,
111    /// Only members (of any role) and administrators read anything in it;
112    /// everybody else gets "not found".
113    Restricted,
114}
115
116impl ProjectVisibility {
117    pub fn as_str(self) -> &'static str {
118        match self {
119            ProjectVisibility::Internal => "internal",
120            ProjectVisibility::Restricted => "restricted",
121        }
122    }
123
124    pub fn label(self) -> &'static str {
125        match self {
126            ProjectVisibility::Internal => "Internal",
127            ProjectVisibility::Restricted => "Restricted",
128        }
129    }
130
131    pub fn parse(s: &str) -> Option<ProjectVisibility> {
132        match s.trim().to_ascii_lowercase().as_str() {
133            "internal" => Some(ProjectVisibility::Internal),
134            "restricted" => Some(ProjectVisibility::Restricted),
135            _ => None,
136        }
137    }
138}
139
140/// Found by cargo mutants: the stored values, labels and aliases of roles,
141/// admin read policies and visibilities were not pinned.
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn roles() {
148        let roles: Vec<(&str, &str)> = Role::ALL.iter().map(|r| (r.as_str(), r.label())).collect();
149        assert_eq!(
150            roles,
151            [
152                ("viewer", "Viewer"),
153                ("editor", "CAD editor"),
154                ("admin", "Admin")
155            ]
156        );
157        for r in Role::ALL {
158            assert_eq!(Role::parse(r.as_str()), Some(r));
159        }
160        for alias in ["CAD editor", "cad_editor", "Engineer", " EDITOR "] {
161            assert_eq!(Role::parse(alias), Some(Role::Editor), "{alias}");
162        }
163        assert_eq!(Role::parse("owner"), None);
164    }
165
166    #[test]
167    fn admin_read_policies() {
168        let all = [AdminRead::Implicit, AdminRead::WithReason, AdminRead::None];
169        let stored: Vec<&str> = all.iter().map(|a| a.as_str()).collect();
170        assert_eq!(stored, ["implicit", "with_reason", "none"]);
171        for a in all {
172            assert_eq!(AdminRead::parse(a.as_str()), Some(a));
173        }
174        assert_eq!(AdminRead::parse(" NONE "), Some(AdminRead::None));
175        assert_eq!(AdminRead::parse("never"), None);
176        assert_eq!(AdminRead::Implicit.label(), "Administrators read the group");
177        assert_eq!(
178            AdminRead::WithReason.label(),
179            "Administrators need a reason (time-limited, audited)"
180        );
181        assert_eq!(
182            AdminRead::None.label(),
183            "Administrators manage but cannot read"
184        );
185    }
186
187    #[test]
188    fn visibilities() {
189        let all = [ProjectVisibility::Internal, ProjectVisibility::Restricted];
190        let v: Vec<(&str, &str)> = all.iter().map(|v| (v.as_str(), v.label())).collect();
191        assert_eq!(v, [("internal", "Internal"), ("restricted", "Restricted")]);
192        for v in all {
193            assert_eq!(ProjectVisibility::parse(v.as_str()), Some(v));
194        }
195        assert_eq!(ProjectVisibility::parse("secret"), None);
196    }
197}