Skip to main content

rs_tenant/
types.rs

1use crate::error::{Error, Result};
2use std::borrow::Borrow;
3use std::fmt;
4
5const MAX_NAME_LEN: usize = 128;
6const MAX_SCOPE_PATH_LEN: usize = 256;
7
8fn validate_simple_name(value: &str, kind: &str) -> Result<String> {
9    let trimmed = value.trim();
10    if trimmed.is_empty() {
11        return Err(Error::InvalidId(format!("{kind} must not be empty")));
12    }
13    if trimmed.len() > MAX_NAME_LEN {
14        return Err(Error::InvalidId(format!(
15            "{kind} length must be <= {MAX_NAME_LEN}"
16        )));
17    }
18    if !trimmed.chars().all(is_allowed_name_char) {
19        return Err(Error::InvalidId(format!(
20            "{kind} contains invalid characters"
21        )));
22    }
23    Ok(trimmed.to_string())
24}
25
26fn is_allowed_name_char(ch: char) -> bool {
27    ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_' | '-')
28}
29
30macro_rules! define_id_type {
31    ($(#[$doc:meta])* $name:ident, $kind:expr) => {
32        $(#[$doc])*
33        #[derive(Clone, Debug, Eq, PartialEq, Hash)]
34        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35        #[cfg_attr(feature = "serde", serde(transparent))]
36        pub struct $name(String);
37
38        impl $name {
39            /// Creates a validated identifier.
40            pub fn new(value: impl AsRef<str>) -> Result<Self> {
41                validate_simple_name(value.as_ref(), $kind).map(Self)
42            }
43
44            /// Creates an identifier from a trusted string without validation.
45            pub fn from_string(value: String) -> Self {
46                Self(value)
47            }
48
49            /// Returns the underlying string slice.
50            pub fn as_str(&self) -> &str {
51                &self.0
52            }
53        }
54
55        impl fmt::Display for $name {
56            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57                f.write_str(&self.0)
58            }
59        }
60
61        impl AsRef<str> for $name {
62            fn as_ref(&self) -> &str {
63                &self.0
64            }
65        }
66
67        impl Borrow<str> for $name {
68            fn borrow(&self) -> &str {
69                &self.0
70            }
71        }
72
73        impl TryFrom<&str> for $name {
74            type Error = Error;
75
76            fn try_from(value: &str) -> Result<Self> {
77                Self::new(value)
78            }
79        }
80
81        impl From<String> for $name {
82            fn from(value: String) -> Self {
83                Self::from_string(value)
84            }
85        }
86    };
87}
88
89define_id_type!(
90    /// Tenant identifier.
91    TenantId,
92    "tenant id"
93);
94define_id_type!(
95    /// Principal identifier.
96    PrincipalId,
97    "principal id"
98);
99define_id_type!(
100    /// Role identifier.
101    RoleId,
102    "role id"
103);
104define_id_type!(
105    /// Global role identifier.
106    GlobalRoleId,
107    "global role id"
108);
109
110impl PrincipalId {
111    /// Creates a principal id from `kind` and `account_id` segments.
112    ///
113    /// Both segments are validated by [`PrincipalId::new`]. Callers should pass
114    /// semantic pieces such as `("admin", "user_1")` instead of formatting
115    /// the raw id string at call sites.
116    pub fn try_from_parts(kind: impl AsRef<str>, account_id: impl AsRef<str>) -> Result<Self> {
117        let kind = validate_simple_name(kind.as_ref(), "principal kind")?;
118        let account_id = validate_simple_name(account_id.as_ref(), "principal account id")?;
119        Self::new(format!("{kind}:{account_id}"))
120    }
121}
122
123/// Resource name used for scope checks.
124#[derive(Clone, Debug, Eq, PartialEq, Hash)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
126#[cfg_attr(feature = "serde", serde(transparent))]
127pub struct ResourceName(String);
128
129impl ResourceName {
130    /// Creates a validated resource name.
131    pub fn new(value: impl AsRef<str>) -> Result<Self> {
132        validate_simple_name(value.as_ref(), "resource name").map(Self)
133    }
134
135    /// Creates a resource name from a trusted string without validation.
136    pub fn from_string(value: String) -> Self {
137        Self(value)
138    }
139
140    /// Returns the underlying string slice.
141    pub fn as_str(&self) -> &str {
142        &self.0
143    }
144}
145
146impl fmt::Display for ResourceName {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        f.write_str(&self.0)
149    }
150}
151
152impl AsRef<str> for ResourceName {
153    fn as_ref(&self) -> &str {
154        &self.0
155    }
156}
157
158impl Borrow<str> for ResourceName {
159    fn borrow(&self) -> &str {
160        &self.0
161    }
162}
163
164impl TryFrom<&str> for ResourceName {
165    type Error = Error;
166
167    fn try_from(value: &str) -> Result<Self> {
168        Self::new(value)
169    }
170}
171
172impl From<String> for ResourceName {
173    fn from(value: String) -> Self {
174        Self::from_string(value)
175    }
176}
177
178/// Hierarchical scope path used by resource-level access checks.
179#[derive(Clone, Debug, Eq, PartialEq, Hash)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181#[cfg_attr(feature = "serde", serde(transparent))]
182pub struct ScopePath(String);
183
184impl ScopePath {
185    /// Creates a validated scope path.
186    pub fn new(value: impl AsRef<str>) -> Result<Self> {
187        let value = value.as_ref().trim();
188        if value.is_empty() {
189            return Err(Error::InvalidId("scope path must not be empty".to_string()));
190        }
191        if value.len() > MAX_SCOPE_PATH_LEN {
192            return Err(Error::InvalidId(format!(
193                "scope path length must be <= {MAX_SCOPE_PATH_LEN}"
194            )));
195        }
196
197        for segment in value.split('/') {
198            if segment.is_empty() {
199                return Err(Error::InvalidId(
200                    "scope path contains empty segment".to_string(),
201                ));
202            }
203            if !segment
204                .chars()
205                .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
206            {
207                return Err(Error::InvalidId(
208                    "scope path contains invalid characters".to_string(),
209                ));
210            }
211        }
212
213        Ok(Self(value.to_string()))
214    }
215
216    /// Creates a scope path from a trusted string without validation.
217    pub fn from_string(value: String) -> Self {
218        Self(value)
219    }
220
221    /// Returns the underlying string slice.
222    pub fn as_str(&self) -> &str {
223        &self.0
224    }
225
226    /// Returns whether `self` is an ancestor scope of `other`.
227    pub fn is_ancestor_of(&self, other: &ScopePath) -> bool {
228        if self == other {
229            return false;
230        }
231
232        let self_prefix = format!("{}/", self.0);
233        other.0.starts_with(&self_prefix)
234    }
235
236    /// Returns whether `self` can access `other` under ancestor rules.
237    pub fn allows(&self, other: &ScopePath) -> bool {
238        self == other || self.is_ancestor_of(other)
239    }
240}
241
242impl fmt::Display for ScopePath {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.write_str(&self.0)
245    }
246}
247
248impl AsRef<str> for ScopePath {
249    fn as_ref(&self) -> &str {
250        &self.0
251    }
252}
253
254impl Borrow<str> for ScopePath {
255    fn borrow(&self) -> &str {
256        &self.0
257    }
258}
259
260impl TryFrom<&str> for ScopePath {
261    type Error = Error;
262
263    fn try_from(value: &str) -> Result<Self> {
264        Self::new(value)
265    }
266}
267
268impl From<String> for ScopePath {
269    fn from(value: String) -> Self {
270        Self::from_string(value)
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::{PrincipalId, ScopePath};
277
278    #[test]
279    fn principal_id_try_from_parts_success() {
280        let principal = PrincipalId::try_from_parts("admin", "user_1").expect("principal id");
281        assert_eq!(principal.as_str(), "admin:user_1");
282    }
283
284    #[test]
285    fn principal_id_try_from_parts_rejects_empty_segment() {
286        let err = PrincipalId::try_from_parts("admin", "   ").expect_err("must reject");
287        assert!(err.to_string().contains("principal account id"));
288    }
289
290    #[test]
291    fn principal_id_try_from_parts_rejects_invalid_chars() {
292        let err = PrincipalId::try_from_parts("ad min", "user_1").expect_err("must reject");
293        assert!(err.to_string().contains("principal kind"));
294    }
295
296    #[test]
297    fn scope_path_should_allow_ancestor_access() {
298        let parent = ScopePath::new("agent/123").expect("scope path");
299        let child = ScopePath::new("agent/123/store/456").expect("scope path");
300
301        assert!(parent.allows(&child));
302        assert!(!child.allows(&parent));
303    }
304}