Skip to main content

nythos_core/
rbac.rs

1//! Tenant-scoped RBAC concepts.
2//!
3//! This module contains role, permission, and assignment models used for
4//! Tenant-scoped authorization.
5
6use std::collections::BTreeSet;
7
8use crate::{AuthError, NythosResult, RoleId, TenantId, UserId};
9
10/// Concrete authorization capability within a tenant scope.
11#[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/// Tenant-scoped role with an explicit permission set.
61#[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/// User-to-role relation inside one tenant boundary.
148#[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/// Tenant-scoped role registry used to load current RBAC state.
181#[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}