Skip to main content

trustee_api/
identity.rs

1//! Stable identity → user_key pinning (nghr ec3e0622).
2//!
3//! ROOT DEFECT CLASS (owner ruling 2026-09-14): the per-user namespace key
4//! used to be RECOMPUTED from token claims on every request (humans:
5//! `preferred_username || sub`). Any claim mutation — a routine IdP group/
6//! role/scope change, or fallback flicker when a claim appears/disappears
7//! (the 16D migration itself) — silently rotated `users/{user_hash(key)}`
8//! and stranded config + session history in an empty new home, masked by
9//! the sibling global-token shadowing bug (92a36073).
10//!
11//! ROOT-CAUSE FIX: derive ONCE per principal, PIN it, never recompute.
12//! - first sighting of a `sub` pins the 16D-derived key;
13//! - every later request resolves through the pin — claims may gate
14//!   PERMISSIONS (Cedar) but never the namespace;
15//! - agents are unaffected (their key is already the immutable `sub`; the
16//!   carve-out lives in the auth-side resolution, not here);
17//! - THQ-materialized profiles were already claim-independent (keyed by
18//!   profile id); this registry brings the human personal lane to the same
19//!   guarantee.
20//!
21//! Storage: `~/.trustee/users/identity_map.json` — `{ "pins": { sub: key } }`.
22//! BTreeMap for deterministic serialization. Values NEVER logged (a user_key
23//! is not secret, but the file is identity data — keep it 0600).
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::Mutex;
28
29#[derive(Clone)]
30pub struct IdentityRegistry {
31    path: PathBuf,
32    map: std::sync::Arc<Mutex<BTreeMap<String, String>>>,
33}
34
35impl IdentityRegistry {
36    /// Registry at the default trustee home (`~/.trustee/users/`).
37    /// Missing/blank home → an in-memory-only registry (same posture as
38    /// every other `dirs::home_dir()` consumer in this crate).
39    pub fn from_default_home() -> Self {
40        let path = dirs::home_dir()
41            .map(|h| h.join(".trustee").join("users").join("identity_map.json"))
42            .unwrap_or_else(|| PathBuf::from("identity_map.json"));
43        Self::from_path(path)
44    }
45
46    pub fn from_path(path: PathBuf) -> Self {
47        let map = std::fs::read_to_string(&path)
48            .ok()
49            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
50            .and_then(|v| {
51                v.get("pins").and_then(|p| p.as_object()).map(|obj| {
52                    obj.iter()
53                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
54                        .collect::<BTreeMap<String, String>>()
55                })
56            })
57            .unwrap_or_default();
58        Self {
59            path,
60            map: std::sync::Arc::new(Mutex::new(map)),
61        }
62    }
63
64    /// Current pin for `sub`, if any.
65    pub fn get(&self, sub: &str) -> Option<String> {
66        self.map.lock().ok()?.get(sub).cloned()
67    }
68
69    /// Resolve the namespace key for `sub`: the pinned key when one exists,
70    /// otherwise pin `derived` (the one-time legacy derivation) and return it.
71    /// Save failures are loud but non-fatal — the in-memory pin still holds
72    /// for this process; the next boot re-pins from the same first claims.
73    pub fn pin(&self, sub: &str, derived: &str) -> String {
74        if let Ok(map) = self.map.lock() {
75            if let Some(pinned) = map.get(sub) {
76                return pinned.clone();
77            }
78        }
79        let mut map = self.map.lock().expect("identity registry poisoned");
80        // Double-check under the write lock (two first-sights racing).
81        if let Some(pinned) = map.get(sub) {
82            return pinned.clone();
83        }
84        map.insert(sub.to_string(), derived.to_string());
85        self.save(&map);
86        tracing::info!(
87            "identity registry: pinned user_key for sub {sub} (first sighting) — \
88             claims can no longer rotate this namespace"
89        );
90        derived.to_string()
91    }
92
93    fn save(&self, map: &BTreeMap<String, String>) {
94        let dir = self
95            .path
96            .parent()
97            .map(|p| p.to_path_buf())
98            .unwrap_or_default();
99        let _ = std::fs::create_dir_all(&dir);
100        let doc = serde_json::json!({ "pins": map });
101        match serde_json::to_vec_pretty(&doc) {
102            Ok(bytes) => {
103                if let Err(e) = std::fs::write(&self.path, bytes) {
104                    tracing::warn!(
105                        "identity registry: failed to persist {}: {e} — pin is process-local only",
106                        self.path.display()
107                    );
108                }
109            }
110            Err(e) => tracing::warn!("identity registry: serialize failed: {e}"),
111        }
112        #[cfg(unix)]
113        {
114            use std::os::unix::fs::PermissionsExt;
115            let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn registry(tag: &str) -> IdentityRegistry {
125        let dir = std::env::temp_dir().join(format!("trustee-ident-{tag}-{}", std::process::id()));
126        let _ = std::fs::remove_dir_all(&dir);
127        std::fs::create_dir_all(&dir).unwrap();
128        IdentityRegistry::from_path(dir.join("identity_map.json"))
129    }
130
131    #[test]
132    fn first_sight_pins_derived_key() {
133        let r = registry("pin1");
134        assert_eq!(r.pin("sub-1", "farzan"), "farzan");
135        assert_eq!(r.pin("sub-1", "anything-else"), "farzan");
136    }
137
138    #[test]
139    fn claim_mutation_cannot_rotate_the_namespace() {
140        // THE ec3e0622 PIN: preferred_username / fallback flicker must not
141        // move a pinned principal.
142        let r = registry("mut");
143        let first = r.pin("sub-1", "farzan");
144        // Simulate a claims change: derived key flips to the sub fallback.
145        assert_eq!(
146            r.pin("sub-1", "sub-1"),
147            first,
148            "claim mutation rotated the namespace"
149        );
150        // And a totally different derived value still loses to the pin.
151        assert_eq!(r.pin("sub-1", "ghost"), first);
152    }
153
154    #[test]
155    fn distinct_principals_pin_independently() {
156        let r = registry("multi");
157        assert_eq!(r.pin("sub-a", "farzan"), "farzan");
158        assert_eq!(r.pin("sub-b", "leo"), "leo");
159        assert_eq!(r.pin("sub-a", "leo"), "farzan");
160    }
161
162    #[test]
163    fn pins_persist_across_reload() {
164        let dir =
165            std::env::temp_dir().join(format!("trustee-ident-persist-{}", std::process::id()));
166        let _ = std::fs::remove_dir_all(&dir);
167        std::fs::create_dir_all(&dir).unwrap();
168        let path = dir.join("identity_map.json");
169
170        let r = IdentityRegistry::from_path(path.clone());
171        r.pin("sub-1", "farzan");
172        drop(r);
173
174        let r2 = IdentityRegistry::from_path(path);
175        assert_eq!(r2.pin("sub-1", "different-derived"), "farzan");
176        let _ = std::fs::remove_dir_all(&dir);
177    }
178
179    #[test]
180    fn missing_file_is_an_empty_registry() {
181        let r = registry("empty");
182        assert_eq!(r.get("nobody"), None);
183        assert_eq!(r.pin("nobody", "default"), "default");
184    }
185}