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(
116                &self.path,
117                std::fs::Permissions::from_mode(0o600),
118            );
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn registry(tag: &str) -> IdentityRegistry {
128        let dir = std::env::temp_dir().join(format!(
129            "trustee-ident-{tag}-{}",
130            std::process::id()
131        ));
132        let _ = std::fs::remove_dir_all(&dir);
133        std::fs::create_dir_all(&dir).unwrap();
134        IdentityRegistry::from_path(dir.join("identity_map.json"))
135    }
136
137    #[test]
138    fn first_sight_pins_derived_key() {
139        let r = registry("pin1");
140        assert_eq!(r.pin("sub-1", "farzan"), "farzan");
141        assert_eq!(r.pin("sub-1", "anything-else"), "farzan");
142    }
143
144    #[test]
145    fn claim_mutation_cannot_rotate_the_namespace() {
146        // THE ec3e0622 PIN: preferred_username / fallback flicker must not
147        // move a pinned principal.
148        let r = registry("mut");
149        let first = r.pin("sub-1", "farzan");
150        // Simulate a claims change: derived key flips to the sub fallback.
151        assert_eq!(r.pin("sub-1", "sub-1"), first, "claim mutation rotated the namespace");
152        // And a totally different derived value still loses to the pin.
153        assert_eq!(r.pin("sub-1", "ghost"), first);
154    }
155
156    #[test]
157    fn distinct_principals_pin_independently() {
158        let r = registry("multi");
159        assert_eq!(r.pin("sub-a", "farzan"), "farzan");
160        assert_eq!(r.pin("sub-b", "leo"), "leo");
161        assert_eq!(r.pin("sub-a", "leo"), "farzan");
162    }
163
164    #[test]
165    fn pins_persist_across_reload() {
166        let dir = std::env::temp_dir().join(format!(
167            "trustee-ident-persist-{}",
168            std::process::id()
169        ));
170        let _ = std::fs::remove_dir_all(&dir);
171        std::fs::create_dir_all(&dir).unwrap();
172        let path = dir.join("identity_map.json");
173
174        let r = IdentityRegistry::from_path(path.clone());
175        r.pin("sub-1", "farzan");
176        drop(r);
177
178        let r2 = IdentityRegistry::from_path(path);
179        assert_eq!(r2.pin("sub-1", "different-derived"), "farzan");
180        let _ = std::fs::remove_dir_all(&dir);
181    }
182
183    #[test]
184    fn missing_file_is_an_empty_registry() {
185        let r = registry("empty");
186        assert_eq!(r.get("nobody"), None);
187        assert_eq!(r.pin("nobody", "default"), "default");
188    }
189}