Skip to main content

lex_store/
users.rs

1//! `<store>/users.json` — actor identity (lex-tea v3d, #172).
2//!
3//! Tightens the v3a–v3c `LEX_TEA_USER` env var auth: the env var
4//! (and the `--actor` flag) still nominate who took an action,
5//! but when `users.json` exists the nominated name must be in
6//! the file. Anonymous overrides aren't a regression we want.
7//! When the file is absent the surfaces fall back to the v3a–v3c
8//! "anyone with LEX_TEA_USER" behaviour so existing dev setups
9//! keep working.
10//!
11//! File schema (deliberately minimal):
12//!
13//! ```json
14//! {
15//!   "users": [
16//!     {"name": "alice", "role": "human"},
17//!     {"name": "lexbot", "role": "agent"}
18//!   ]
19//! }
20//! ```
21//!
22//! `role` is recorded but not enforced in v3d — it gives later
23//! slices a place to gate "only humans can pin" or similar
24//! without a schema migration.
25
26use serde::{Deserialize, Serialize};
27use std::fs;
28use std::io;
29use std::path::Path;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum UserRole {
34    Human,
35    Agent,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct User {
40    pub name: String,
41    pub role: UserRole,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub struct UsersFile {
46    #[serde(default)]
47    pub users: Vec<User>,
48}
49
50/// Load `<root>/users.json`. Returns `Ok(None)` when the file is
51/// absent (the v3a–v3c "no auth wired up, use env-var fallback"
52/// regime); `Ok(Some(_))` when present, even if the user list is
53/// empty (an empty file is "auth wired up, nobody allowed").
54pub fn load(root: &Path) -> io::Result<Option<UsersFile>> {
55    let path = root.join("users.json");
56    if !path.exists() {
57        return Ok(None);
58    }
59    let bytes = fs::read(&path)?;
60    let file: UsersFile = serde_json::from_slice(&bytes)
61        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData,
62            format!("parsing {}: {e}", path.display())))?;
63    Ok(Some(file))
64}
65
66impl UsersFile {
67    /// Look up a user by name. Names are case-sensitive — the file
68    /// is the spec.
69    pub fn find(&self, name: &str) -> Option<&User> {
70        self.users.iter().find(|u| u.name == name)
71    }
72
73    /// Whether this name is recognized. Convenience over `find` for
74    /// callers that just want a yes/no gate.
75    pub fn knows(&self, name: &str) -> bool {
76        self.find(name).is_some()
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use tempfile::tempdir;
84
85    #[test]
86    fn load_absent_returns_none() {
87        let tmp = tempdir().unwrap();
88        let got = load(tmp.path()).unwrap();
89        assert!(got.is_none());
90    }
91
92    #[test]
93    fn load_empty_file_returns_some_empty() {
94        let tmp = tempdir().unwrap();
95        std::fs::write(tmp.path().join("users.json"), r#"{"users":[]}"#).unwrap();
96        let got = load(tmp.path()).unwrap().unwrap();
97        assert_eq!(got.users.len(), 0);
98        assert!(!got.knows("alice"));
99    }
100
101    #[test]
102    fn round_trip_through_disk() {
103        let tmp = tempdir().unwrap();
104        let f = UsersFile {
105            users: vec![
106                User { name: "alice".into(), role: UserRole::Human },
107                User { name: "lexbot".into(), role: UserRole::Agent },
108            ],
109        };
110        std::fs::write(
111            tmp.path().join("users.json"),
112            serde_json::to_vec_pretty(&f).unwrap(),
113        ).unwrap();
114        let got = load(tmp.path()).unwrap().unwrap();
115        assert_eq!(got, f);
116        assert_eq!(got.find("alice").unwrap().role, UserRole::Human);
117        assert_eq!(got.find("lexbot").unwrap().role, UserRole::Agent);
118        assert!(!got.knows("eve"));
119    }
120
121    #[test]
122    fn malformed_json_is_an_error() {
123        let tmp = tempdir().unwrap();
124        std::fs::write(tmp.path().join("users.json"), "not json").unwrap();
125        let err = load(tmp.path()).unwrap_err();
126        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
127    }
128}