Skip to main content

omnigraph_server/
identity.rs

1//! Identity types for the multi-graph server (MR-668) + forward-compatible
2//! shapes for Cloud mode (RFC 0003) and OAuth provider (RFC 0004).
3//!
4//! Per decision 13 in the implementation plan: ship the type shapes that
5//! Cloud mode will consume, without committing to any trait shape
6//! (`TokenVerifier` stays draft in RFC 0001). Every Cluster-mode call site
7//! constructs these types with their Cluster-mode-specific values:
8//!
9//! - `tenant_id: None` (Cloud will set `Some(...)` from the OAuth `org_id` claim)
10//! - `scopes: vec![Scope::Full]` (Cloud will populate from the OAuth `scope` claim)
11//! - `source: AuthSource::Static` (Cloud / OIDC will set `AuthSource::Oidc`)
12//!
13//! The enums use `#[non_exhaustive]` so RFC 0001 step 1 / RFC 0004 can
14//! add variants without breaking exhaustive matches in callers.
15
16use 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
26/// Maximum length of a `TenantId` value.
27pub const TENANT_ID_MAX_LEN: usize = 64;
28
29/// Cloud-mode tenant identifier. Validated with the same regex as
30/// `GraphId` so the two interchange syntactically.
31///
32/// `None` in Cluster mode; Cloud mode (RFC 0003) sets `Some(...)` from
33/// the OAuth `org_id` claim. Constructed only via `try_from` so callers
34/// cannot bypass validation.
35#[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/// Registry HashMap key. Cluster mode populates `tenant_id: None`;
109/// Cloud mode (RFC 0003) populates `tenant_id: Some(...)`.
110///
111/// The `Option<TenantId>` field is the **single forward-compatibility seam**
112/// between Cluster and Cloud modes. Every handler reaches the engine via
113/// `state.registry.get(&key)` — the key shape stays stable, so handlers
114/// don't get re-touched when Cloud mode lands.
115#[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    /// Cluster-mode constructor (`tenant_id: None`).
123    pub fn cluster(graph_id: GraphId) -> Self {
124        Self {
125            tenant_id: None,
126            graph_id,
127        }
128    }
129
130    /// Cloud-mode constructor — reserved for RFC 0003; included here so
131    /// the seam is visible even though no Cluster-mode code path calls it.
132    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/// Authorization scope. Cluster mode: every authenticated actor gets
150/// `Scope::Full`. Cloud mode (RFC 0004) adds OAuth-style scopes via the
151/// dashboard-configured `graph:read`, `graph:write`, `graph:admin`,
152/// `graph:*` set; those become additional variants here.
153///
154/// `#[non_exhaustive]` so RFC 0004 can extend without breaking matches.
155#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
156#[non_exhaustive]
157pub enum Scope {
158    /// Full access. The Cluster-mode default — every authenticated actor
159    /// has unrestricted access subject to Cedar policy.
160    Full,
161}
162
163/// How the actor was authenticated. Cluster mode: every actor authenticates
164/// via the existing SHA-256 hash compare against a static token set, so
165/// `AuthSource::Static`. RFC 0001 step 1 adds `AuthSource::Oidc` when the
166/// `OidcJwtVerifier` ships.
167///
168/// `#[non_exhaustive]` so RFC 0001 can extend without breaking matches.
169#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
170#[non_exhaustive]
171pub enum AuthSource {
172    /// Authenticated via the static bearer-token hash table.
173    Static,
174}
175
176/// Server-resolved actor identity. Replaces the previous
177/// `AuthenticatedActor(Arc<str>)` from `lib.rs`.
178///
179/// The fields are populated by `authenticate_bearer_token` after a successful
180/// constant-time hash match. **Clients cannot set any of these fields directly**
181/// — this is the MR-731 invariant. See `authorize_request` in `lib.rs` for the
182/// chokepoint that overwrites any client-supplied actor identity.
183///
184/// Cluster mode constructs this with `tenant_id: None`, `scopes: vec![Scope::Full]`,
185/// `source: AuthSource::Static` via the convenience constructor below.
186#[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    /// Cluster-mode constructor — Static auth, no tenant, Full scope.
196    /// Used by `authenticate_bearer_token` after a successful hash match.
197    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    /// View the actor identifier as `&str`. Stable across the Cluster/Cloud
207    /// boundary — Cedar always sees this value as the principal.
208    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        // Regression: keep the `#[non_exhaustive]` annotation. If someone
303        // removes it, this test still passes (matches are still legal); it's
304        // the cross-crate compile that catches it. Document the contract here.
305        let _scope = Scope::Full;
306        let _src = AuthSource::Static;
307    }
308}