1use crate::error::{Error, Result};
2use crate::ids::TenantId;
3use std::borrow::Borrow;
4use std::collections::BTreeSet;
5use std::fmt;
6
7const MAX_SCOPE_PATH_LEN: usize = 256;
8
9#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
11pub struct ScopePath(String);
12
13impl ScopePath {
14 pub fn parse(value: impl AsRef<str>) -> Result<Self> {
16 let trimmed = value.as_ref().trim();
17 if trimmed.is_empty() {
18 return Err(Error::InvalidScope(
19 "scope path must not be empty".to_string(),
20 ));
21 }
22 if trimmed.len() > MAX_SCOPE_PATH_LEN {
23 return Err(Error::InvalidScope(format!(
24 "scope path length must be <= {MAX_SCOPE_PATH_LEN}"
25 )));
26 }
27 for segment in trimmed.split('/') {
28 if segment.is_empty() {
29 return Err(Error::InvalidScope(
30 "scope path contains empty segment".to_string(),
31 ));
32 }
33 if !segment
34 .chars()
35 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
36 {
37 return Err(Error::InvalidScope(
38 "scope path contains invalid characters".to_string(),
39 ));
40 }
41 }
42 Ok(Self(trimmed.to_string()))
43 }
44
45 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49
50 pub fn is_ancestor_of(&self, other: &ScopePath) -> bool {
52 self != other && other.0.starts_with(&format!("{}/", self.0))
53 }
54
55 pub fn allows(&self, target: &ScopePath) -> bool {
57 self == target || self.is_ancestor_of(target)
58 }
59}
60
61impl fmt::Display for ScopePath {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.write_str(self.as_str())
64 }
65}
66
67impl AsRef<str> for ScopePath {
68 fn as_ref(&self) -> &str {
69 self.as_str()
70 }
71}
72
73impl Borrow<str> for ScopePath {
74 fn borrow(&self) -> &str {
75 self.as_str()
76 }
77}
78
79impl TryFrom<&str> for ScopePath {
80 type Error = Error;
81
82 fn try_from(value: &str) -> Result<Self> {
83 Self::parse(value)
84 }
85}
86
87#[cfg(feature = "serde")]
88impl serde::Serialize for ScopePath {
89 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
90 where
91 S: serde::Serializer,
92 {
93 serializer.serialize_str(self.as_str())
94 }
95}
96
97#[cfg(feature = "serde")]
98impl<'de> serde::Deserialize<'de> for ScopePath {
99 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
100 where
101 D: serde::Deserializer<'de>,
102 {
103 let value = String::deserialize(deserializer)?;
104 Self::parse(value).map_err(serde::de::Error::custom)
105 }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq, Hash)]
110pub enum GrantScope {
111 Tenant,
113 Paths(ScopeRoots),
115}
116
117#[derive(Clone, Debug, Eq, PartialEq, Hash)]
119pub struct ScopeRoots {
120 roots: Vec<ScopePath>,
121}
122
123impl ScopeRoots {
124 pub fn new(roots: Vec<ScopePath>) -> Result<Self> {
126 if roots.is_empty() {
127 return Err(Error::InvalidScope(
128 "grant scope paths must not be empty".to_string(),
129 ));
130 }
131 Ok(Self {
132 roots: compact_paths(roots),
133 })
134 }
135
136 pub fn as_slice(&self) -> &[ScopePath] {
138 &self.roots
139 }
140
141 pub fn into_vec(self) -> Vec<ScopePath> {
143 self.roots
144 }
145}
146
147impl GrantScope {
148 pub fn tenant() -> Self {
150 Self::Tenant
151 }
152
153 pub fn paths(roots: Vec<ScopePath>) -> Result<Self> {
155 ScopeRoots::new(roots).map(Self::Paths)
156 }
157
158 pub fn is_tenant(&self) -> bool {
160 matches!(self, Self::Tenant)
161 }
162
163 pub fn roots(&self) -> &[ScopePath] {
165 match self {
166 Self::Tenant => &[],
167 Self::Paths(roots) => roots.as_slice(),
168 }
169 }
170}
171
172#[cfg(feature = "serde")]
173impl serde::Serialize for ScopeRoots {
174 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
175 where
176 S: serde::Serializer,
177 {
178 self.roots.serialize(serializer)
179 }
180}
181
182#[cfg(feature = "serde")]
183impl<'de> serde::Deserialize<'de> for ScopeRoots {
184 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
185 where
186 D: serde::Deserializer<'de>,
187 {
188 let roots = Vec::<ScopePath>::deserialize(deserializer)?;
189 Self::new(roots).map_err(serde::de::Error::custom)
190 }
191}
192
193#[cfg(feature = "serde")]
194impl serde::Serialize for GrantScope {
195 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
196 where
197 S: serde::Serializer,
198 {
199 #[derive(serde::Serialize)]
200 #[serde(tag = "type", rename_all = "snake_case")]
201 enum GrantScopeWire<'a> {
202 Tenant,
203 Paths { roots: &'a [ScopePath] },
204 }
205 match self {
206 Self::Tenant => GrantScopeWire::Tenant.serialize(serializer),
207 Self::Paths(roots) => GrantScopeWire::Paths {
208 roots: roots.as_slice(),
209 }
210 .serialize(serializer),
211 }
212 }
213}
214
215#[cfg(feature = "serde")]
216impl<'de> serde::Deserialize<'de> for GrantScope {
217 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
218 where
219 D: serde::Deserializer<'de>,
220 {
221 #[derive(serde::Deserialize)]
222 #[serde(tag = "type", rename_all = "snake_case")]
223 enum GrantScopeWire {
224 Tenant,
225 Paths { roots: Vec<ScopePath> },
226 }
227 match GrantScopeWire::deserialize(deserializer)? {
228 GrantScopeWire::Tenant => Ok(Self::Tenant),
229 GrantScopeWire::Paths { roots } => Self::paths(roots).map_err(serde::de::Error::custom),
230 }
231 }
232}
233
234#[derive(Clone, Debug, Eq, PartialEq)]
236pub enum AccessScope {
237 None,
239 Tenant { tenant: TenantId },
241 Paths {
243 tenant: TenantId,
245 roots: Vec<ScopePath>,
247 },
248}
249
250impl AccessScope {
251 pub fn merge(tenant: TenantId, grants: impl IntoIterator<Item = GrantScope>) -> Self {
253 let mut roots = Vec::new();
254 for grant in grants {
255 match grant {
256 GrantScope::Tenant => return Self::Tenant { tenant },
257 GrantScope::Paths(grant_roots) => roots.extend(grant_roots.into_vec()),
258 }
259 }
260 if roots.is_empty() {
261 Self::None
262 } else {
263 Self::Paths {
264 tenant,
265 roots: compact_paths(roots),
266 }
267 }
268 }
269
270 pub fn allows_path(&self, target: &ScopePath) -> bool {
272 match self {
273 Self::None => false,
274 Self::Tenant { .. } => true,
275 Self::Paths { roots, .. } => roots.iter().any(|root| root.allows(target)),
276 }
277 }
278}
279
280fn compact_paths(roots: Vec<ScopePath>) -> Vec<ScopePath> {
282 let ordered: BTreeSet<_> = roots.into_iter().collect();
283 let mut compacted: Vec<ScopePath> = Vec::new();
284 for path in ordered {
285 if compacted.iter().any(|root| root.allows(&path)) {
286 continue;
287 }
288 compacted.push(path);
289 }
290 compacted
291}
292
293#[cfg(test)]
294mod tests {
295 use super::{AccessScope, GrantScope, MAX_SCOPE_PATH_LEN, ScopePath};
296 use crate::TenantId;
297
298 #[test]
299 fn scope_path_should_allow_descendant() {
300 let root = ScopePath::parse("agent/123").expect("scope path");
301 let target = ScopePath::parse("agent/123/store/456").expect("scope path");
302
303 assert!(root.allows(&target));
304 assert!(!target.allows(&root));
305 }
306
307 #[test]
308 fn scope_path_should_not_allow_prefix_sibling() {
309 let root = ScopePath::parse("agent/1").expect("scope path");
310 let sibling = ScopePath::parse("agent/10/store/456").expect("scope path");
311
312 assert!(!root.allows(&sibling));
313 }
314
315 #[test]
316 fn scope_path_should_reject_invalid_boundaries() {
317 for value in ["", "agent//1", "agent/中文"] {
318 let err = ScopePath::parse(value).expect_err("must reject");
319 assert!(err.to_string().contains("scope path"));
320 }
321
322 let oversized = "a".repeat(MAX_SCOPE_PATH_LEN + 1);
323 let err = ScopePath::parse(oversized).expect_err("must reject");
324 assert!(err.to_string().contains("length must be"));
325 }
326
327 #[test]
328 fn grant_scope_paths_should_reject_empty_roots() {
329 let err = GrantScope::paths(Vec::new()).expect_err("must reject");
330 assert!(err.to_string().contains("must not be empty"));
331 }
332
333 #[test]
334 fn access_scope_should_compact_child_paths() {
335 let tenant = TenantId::parse("tenant_1").expect("tenant");
336 let parent = ScopePath::parse("agent/123").expect("scope path");
337 let child = ScopePath::parse("agent/123/store/456").expect("scope path");
338
339 let scope = AccessScope::merge(
340 tenant,
341 [GrantScope::paths(vec![child, parent]).expect("grant scope")],
342 );
343
344 let AccessScope::Paths { roots, .. } = scope else {
345 panic!("expected paths");
346 };
347 assert_eq!(roots.len(), 1);
348 assert_eq!(roots[0].as_str(), "agent/123");
349 }
350
351 #[cfg(feature = "serde")]
352 #[test]
353 fn serde_should_reject_empty_grant_paths() {
354 let err = serde_json::from_str::<GrantScope>(r#"{"type":"paths","roots":[]}"#)
355 .expect_err("must reject");
356 assert!(err.to_string().contains("must not be empty"));
357 }
358}