omnigraph_server/
identity.rs1use std::fmt;
17use std::sync::Arc;
18use std::sync::OnceLock;
19
20use color_eyre::eyre::{Result, bail};
21use regex::Regex;
22use serde::{Deserialize, Serialize};
23
24use crate::graph_id::GraphId;
25
26pub const TENANT_ID_MAX_LEN: usize = 64;
28
29#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
36#[serde(transparent)]
37pub struct TenantId(String);
38
39impl TenantId {
40 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43}
44
45impl fmt::Display for TenantId {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.write_str(&self.0)
48 }
49}
50
51impl AsRef<str> for TenantId {
52 fn as_ref(&self) -> &str {
53 &self.0
54 }
55}
56
57impl TryFrom<String> for TenantId {
58 type Error = color_eyre::eyre::Error;
59
60 fn try_from(value: String) -> Result<Self> {
61 validate_tenant_id(value.as_str())?;
62 Ok(Self(value))
63 }
64}
65
66impl TryFrom<&str> for TenantId {
67 type Error = color_eyre::eyre::Error;
68
69 fn try_from(value: &str) -> Result<Self> {
70 validate_tenant_id(value)?;
71 Ok(Self(value.to_string()))
72 }
73}
74
75impl<'de> Deserialize<'de> for TenantId {
76 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
77 where
78 D: serde::Deserializer<'de>,
79 {
80 let s = String::deserialize(deserializer)?;
81 Self::try_from(s).map_err(serde::de::Error::custom)
82 }
83}
84
85fn validate_tenant_id(value: &str) -> Result<()> {
86 if value.is_empty() {
87 bail!("tenant_id must not be empty");
88 }
89 if value.len() > TENANT_ID_MAX_LEN {
90 bail!(
91 "tenant_id '{}' is {} chars; max {}",
92 value,
93 value.len(),
94 TENANT_ID_MAX_LEN
95 );
96 }
97 if !tenant_id_regex().is_match(value) {
98 bail!("tenant_id '{}' must match ^[a-zA-Z0-9-]{{1,64}}$", value);
99 }
100 Ok(())
101}
102
103fn tenant_id_regex() -> &'static Regex {
104 static RE: OnceLock<Regex> = OnceLock::new();
105 RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9-]{1,64}$").expect("regex literal"))
106}
107
108#[derive(Debug, Clone, Eq, PartialEq, Hash)]
116pub struct GraphKey {
117 pub tenant_id: Option<TenantId>,
118 pub graph_id: GraphId,
119}
120
121impl GraphKey {
122 pub fn cluster(graph_id: GraphId) -> Self {
124 Self {
125 tenant_id: None,
126 graph_id,
127 }
128 }
129
130 pub fn cloud(tenant_id: TenantId, graph_id: GraphId) -> Self {
133 Self {
134 tenant_id: Some(tenant_id),
135 graph_id,
136 }
137 }
138}
139
140impl fmt::Display for GraphKey {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 match &self.tenant_id {
143 Some(t) => write!(f, "{}/{}", t, self.graph_id),
144 None => write!(f, "{}", self.graph_id),
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
156#[non_exhaustive]
157pub enum Scope {
158 Full,
161}
162
163#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
170#[non_exhaustive]
171pub enum AuthSource {
172 Static,
174}
175
176#[derive(Debug, Clone)]
187pub struct ResolvedActor {
188 pub actor_id: Arc<str>,
189 pub tenant_id: Option<TenantId>,
190 pub scopes: Vec<Scope>,
191 pub source: AuthSource,
192}
193
194impl ResolvedActor {
195 pub fn cluster_static(actor_id: Arc<str>) -> Self {
198 Self {
199 actor_id,
200 tenant_id: None,
201 scopes: vec![Scope::Full],
202 source: AuthSource::Static,
203 }
204 }
205
206 pub fn actor_id_str(&self) -> &str {
209 &self.actor_id
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn tenant_id_accepts_simple_values() {
219 for ok in ["alpha", "tenant-001", "X", "01HZWA0KT0H0V0V0V0V0V0V0V0"] {
220 TenantId::try_from(ok).unwrap_or_else(|_| panic!("expected accept: {ok}"));
221 }
222 }
223
224 #[test]
225 fn tenant_id_rejects_empty_and_over_max() {
226 assert!(TenantId::try_from("").is_err());
227 let too_long = "a".repeat(65);
228 assert!(TenantId::try_from(too_long.as_str()).is_err());
229 }
230
231 #[test]
232 fn tenant_id_rejects_path_traversal() {
233 assert!(TenantId::try_from("../etc").is_err());
234 assert!(TenantId::try_from("alpha/beta").is_err());
235 }
236
237 #[test]
238 fn tenant_id_deserialize_runs_validation() {
239 let bad: Result<TenantId, _> = serde_json::from_str("\"../evil\"");
240 assert!(bad.is_err());
241 }
242
243 #[test]
244 fn graph_key_cluster_constructor_sets_no_tenant() {
245 let id = GraphId::try_from("alpha").unwrap();
246 let key = GraphKey::cluster(id.clone());
247 assert!(key.tenant_id.is_none());
248 assert_eq!(key.graph_id, id);
249 }
250
251 #[test]
252 fn graph_key_cloud_constructor_sets_tenant() {
253 let tenant = TenantId::try_from("acme").unwrap();
254 let id = GraphId::try_from("alpha").unwrap();
255 let key = GraphKey::cloud(tenant.clone(), id.clone());
256 assert_eq!(key.tenant_id.as_ref(), Some(&tenant));
257 assert_eq!(key.graph_id, id);
258 }
259
260 #[test]
261 fn graph_key_displays_with_or_without_tenant() {
262 let id = GraphId::try_from("alpha").unwrap();
263 let cluster_key = GraphKey::cluster(id.clone());
264 assert_eq!(format!("{cluster_key}"), "alpha");
265
266 let tenant = TenantId::try_from("acme").unwrap();
267 let cloud_key = GraphKey::cloud(tenant, id);
268 assert_eq!(format!("{cloud_key}"), "acme/alpha");
269 }
270
271 #[test]
272 fn graph_key_is_hashable_for_map_use() {
273 use std::collections::HashMap;
274 let a = GraphKey::cluster(GraphId::try_from("alpha").unwrap());
275 let b = GraphKey::cluster(GraphId::try_from("alpha").unwrap());
276 let mut m: HashMap<GraphKey, u32> = HashMap::new();
277 m.insert(a, 1);
278 assert_eq!(m.get(&b), Some(&1));
279 }
280
281 #[test]
282 fn graph_key_distinguishes_tenants() {
283 let id = GraphId::try_from("alpha").unwrap();
284 let t1 = TenantId::try_from("acme").unwrap();
285 let t2 = TenantId::try_from("globex").unwrap();
286 let k1 = GraphKey::cloud(t1, id.clone());
287 let k2 = GraphKey::cloud(t2, id);
288 assert_ne!(k1, k2);
289 }
290
291 #[test]
292 fn resolved_actor_cluster_defaults() {
293 let actor = ResolvedActor::cluster_static(Arc::<str>::from("act-alice"));
294 assert_eq!(actor.actor_id_str(), "act-alice");
295 assert!(actor.tenant_id.is_none());
296 assert_eq!(actor.scopes, vec![Scope::Full]);
297 assert_eq!(actor.source, AuthSource::Static);
298 }
299
300 #[test]
301 fn scope_and_auth_source_are_non_exhaustive() {
302 let _scope = Scope::Full;
306 let _src = AuthSource::Static;
307 }
308}