1use std::collections::BTreeSet;
7
8use crate::{AuthError, NythosResult, RoleId, TenantId, UserId};
9
10#[derive(
12 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
13)]
14pub struct Permission(String);
15
16impl Permission {
17 pub fn new(value: impl AsRef<str>) -> NythosResult<Self> {
18 let value = value.as_ref().trim();
19
20 if value.is_empty() {
21 return Err(AuthError::ValidationError(
22 "permission cannot be empty".to_owned(),
23 ));
24 }
25
26 if value.starts_with('.') || value.ends_with('.') || !value.contains('.') {
27 return Err(AuthError::ValidationError(
28 "permission must contain a namespace separator '.'".to_owned(),
29 ));
30 }
31
32 if !value
33 .chars()
34 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_')
35 {
36 return Err(AuthError::ValidationError(
37 "permission must contain only lowercase ASCII letters, digits, '_' or '.'"
38 .to_owned(),
39 ));
40 }
41
42 Ok(Self(value.to_owned()))
43 }
44
45 pub fn as_str(&self) -> &str {
46 &self.0
47 }
48
49 pub fn into_inner(self) -> String {
50 self.0
51 }
52}
53
54impl AsRef<str> for Permission {
55 fn as_ref(&self) -> &str {
56 self.as_str()
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub struct Role {
63 id: RoleId,
64 tenant_id: TenantId,
65 name: String,
66 permissions: BTreeSet<Permission>,
67}
68
69impl Role {
70 const MAX_NAME_LEN: usize = 64;
71
72 pub fn new(
73 id: RoleId,
74 tenant_id: TenantId,
75 name: impl AsRef<str>,
76 permissions: impl IntoIterator<Item = Permission>,
77 ) -> NythosResult<Self> {
78 let name = Self::validate_name(name.as_ref())?;
79 let permissions = permissions.into_iter().collect();
80
81 Ok(Self {
82 id,
83 tenant_id,
84 name,
85 permissions,
86 })
87 }
88
89 pub const fn id(&self) -> RoleId {
90 self.id
91 }
92
93 pub const fn tenant_id(&self) -> TenantId {
94 self.tenant_id
95 }
96
97 pub fn name(&self) -> &str {
98 &self.name
99 }
100
101 pub fn permissions(&self) -> &BTreeSet<Permission> {
102 &self.permissions
103 }
104
105 pub fn has_permission(&self, permission: &Permission) -> bool {
106 self.permissions.contains(permission)
107 }
108
109 pub fn add_permission(&mut self, permission: Permission) {
110 self.permissions.insert(permission);
111 }
112
113 pub fn remove_permission(&mut self, permission: &Permission) {
114 self.permissions.remove(permission);
115 }
116
117 fn validate_name(input: &str) -> NythosResult<String> {
118 let name = input.trim();
119
120 if name.is_empty() {
121 return Err(AuthError::ValidationError(
122 "role name cannot be empty".to_owned(),
123 ));
124 }
125
126 if name.len() > Self::MAX_NAME_LEN {
127 return Err(AuthError::ValidationError(format!(
128 "role name must be at most {} characters",
129 Self::MAX_NAME_LEN
130 )));
131 }
132
133 if !name
134 .chars()
135 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
136 {
137 return Err(AuthError::ValidationError(
138 "role name must contain only lowercase ASCII letters, digits, '_' or '-'"
139 .to_owned(),
140 ));
141 }
142
143 Ok(name.to_owned())
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
149pub struct RoleAssignment {
150 tenant_id: TenantId,
151 user_id: UserId,
152 role_id: RoleId,
153}
154
155impl RoleAssignment {
156 pub const fn new(tenant_id: TenantId, user_id: UserId, role_id: RoleId) -> Self {
157 Self {
158 tenant_id,
159 user_id,
160 role_id,
161 }
162 }
163
164 pub const fn tenant_id(&self) -> TenantId {
165 self.tenant_id
166 }
167
168 pub const fn user_id(&self) -> UserId {
169 self.user_id
170 }
171
172 pub const fn role_id(&self) -> RoleId {
173 self.role_id
174 }
175
176 pub fn matches_tenant(&self, tenant_id: TenantId) -> bool {
177 self.tenant_id == tenant_id
178 }
179}
180#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
182pub struct RoleRegistry {
183 tenant_id: TenantId,
184 roles: Vec<Role>,
185}
186
187impl RoleRegistry {
188 pub fn new(tenant_id: TenantId, roles: Vec<Role>) -> NythosResult<Self> {
189 if roles.iter().any(|role| role.tenant_id() != tenant_id) {
190 return Err(AuthError::ValidationError(
191 "all roles in registry must belong to the same tenant".to_owned(),
192 ));
193 }
194
195 Ok(Self { tenant_id, roles })
196 }
197
198 pub const fn tenant_id(&self) -> TenantId {
199 self.tenant_id
200 }
201
202 pub fn roles(&self) -> &[Role] {
203 &self.roles
204 }
205
206 pub fn find_role(&self, role_id: RoleId) -> Option<&Role> {
207 self.roles.iter().find(|role| role.id() == role_id)
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::{Permission, Role, RoleAssignment, RoleRegistry};
214 use crate::{AuthError, RoleId, TenantId, UserId};
215
216 #[test]
217 fn permission_accepts_namespaced_values() {
218 let perm = Permission::new("shipments.read").unwrap();
219
220 assert_eq!(perm.as_str(), "shipments.read")
221 }
222
223 #[test]
224 fn permission_rejects_invalid_shapes() {
225 assert!(matches!(
226 Permission::new(""),
227 Err(AuthError::ValidationError(_))
228 ));
229 assert!(matches!(
230 Permission::new("shipments"),
231 Err(AuthError::ValidationError(_))
232 ));
233 assert!(matches!(
234 Permission::new(".read"),
235 Err(AuthError::ValidationError(_))
236 ));
237 assert!(matches!(
238 Permission::new("read."),
239 Err(AuthError::ValidationError(_))
240 ));
241 assert!(matches!(
242 Permission::new("Shipments.Read"),
243 Err(AuthError::ValidationError(_))
244 ));
245 }
246
247 #[test]
248 fn role_is_tenant_scoped_and_holds_permissions() {
249 let tenant_id = TenantId::generate();
250 let read = Permission::new("shipments.read").unwrap();
251 let write = Permission::new("shipments.write").unwrap();
252
253 let role = Role::new(
254 RoleId::generate(),
255 tenant_id,
256 "shipment_manager",
257 vec![read.clone(), write.clone()],
258 )
259 .unwrap();
260
261 assert_eq!(role.tenant_id(), tenant_id);
262 assert!(role.has_permission(&read));
263 assert!(role.has_permission(&write));
264 }
265
266 #[test]
267 fn role_name_rejects_invalid_shapes() {
268 let tenant_id = TenantId::generate();
269
270 assert!(matches!(
271 Role::new(RoleId::generate(), tenant_id, "", vec![]),
272 Err(AuthError::ValidationError(_))
273 ));
274 assert!(matches!(
275 Role::new(
276 RoleId::generate(),
277 tenant_id,
278 "ThisIsAVeryLongRoleNameThatExceedsTheMaximumAllowedLength",
279 vec![]
280 ),
281 Err(AuthError::ValidationError(_))
282 ));
283 assert!(matches!(
284 Role::new(RoleId::generate(), tenant_id, "Global Admin", vec![]),
285 Err(AuthError::ValidationError(_))
286 ));
287 }
288
289 #[test]
290 fn role_assignment_is_explicitly_tenant_scoped() {
291 let tenant_id = TenantId::generate();
292 let assignment = RoleAssignment::new(tenant_id, UserId::generate(), RoleId::generate());
293
294 assert!(assignment.matches_tenant(tenant_id));
295 assert!(!assignment.matches_tenant(TenantId::generate()));
296 }
297
298 #[test]
299 fn role_registry_rejects_cross_tenant_roles() {
300 let tenant_a = TenantId::generate();
301 let tenant_b = TenantId::generate();
302
303 let role_a = Role::new(
304 RoleId::generate(),
305 tenant_a,
306 "operator",
307 [Permission::new("shipments.read").unwrap()],
308 )
309 .unwrap();
310
311 let role_b = Role::new(
312 RoleId::generate(),
313 tenant_b,
314 "operator",
315 [Permission::new("shipments.read").unwrap()],
316 )
317 .unwrap();
318
319 let result = RoleRegistry::new(tenant_a, vec![role_a.clone(), role_b.clone()]);
320
321 assert!(matches!(result, Err(AuthError::ValidationError(_))));
322 }
323}