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 pub fn new(value: impl AsRef<str>) -> Result<Self> {
41 validate_simple_name(value.as_ref(), $kind).map(Self)
42 }
43
44 pub fn from_string(value: String) -> Self {
46 Self(value)
47 }
48
49 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 TenantId,
92 "tenant id"
93);
94define_id_type!(
95 PrincipalId,
97 "principal id"
98);
99define_id_type!(
100 RoleId,
102 "role id"
103);
104define_id_type!(
105 GlobalRoleId,
107 "global role id"
108);
109
110impl PrincipalId {
111 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#[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 pub fn new(value: impl AsRef<str>) -> Result<Self> {
132 validate_simple_name(value.as_ref(), "resource name").map(Self)
133 }
134
135 pub fn from_string(value: String) -> Self {
137 Self(value)
138 }
139
140 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#[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 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 pub fn from_string(value: String) -> Self {
218 Self(value)
219 }
220
221 pub fn as_str(&self) -> &str {
223 &self.0
224 }
225
226 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 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}