Skip to main content

haystack_server/auth/
users.rs

1//! TOML-based user store for SCRAM authentication.
2
3use base64::Engine;
4use base64::engine::general_purpose::STANDARD as BASE64;
5use serde::Deserialize;
6use std::collections::HashMap;
7
8use haystack_core::auth::{DEFAULT_ITERATIONS, ScramCredentials, derive_credentials};
9
10/// A named role with a fixed set of permissions.
11#[derive(Debug, Clone)]
12pub struct Role {
13    pub name: String,
14    pub permissions: Vec<String>,
15}
16
17/// Return the built-in `admin` role (read, write, admin).
18pub fn admin_role() -> Role {
19    Role {
20        name: "admin".to_string(),
21        permissions: vec!["read".to_string(), "write".to_string(), "admin".to_string()],
22    }
23}
24
25/// Return the built-in `operator` role (read, write).
26pub fn operator_role() -> Role {
27    Role {
28        name: "operator".to_string(),
29        permissions: vec!["read".to_string(), "write".to_string()],
30    }
31}
32
33/// Return the built-in `viewer` role (read).
34pub fn viewer_role() -> Role {
35    Role {
36        name: "viewer".to_string(),
37        permissions: vec!["read".to_string()],
38    }
39}
40
41/// Look up a built-in role by name.
42///
43/// Returns `None` if the name does not match any built-in role.
44pub fn builtin_role(name: &str) -> Option<Role> {
45    match name {
46        "admin" => Some(admin_role()),
47        "operator" => Some(operator_role()),
48        "viewer" => Some(viewer_role()),
49        _ => None,
50    }
51}
52
53/// Top-level TOML user configuration.
54#[derive(Deserialize)]
55pub struct UserConfig {
56    pub users: HashMap<String, UserEntry>,
57}
58
59/// A single user entry in the TOML config.
60///
61/// Supports two modes:
62/// - **Role-based:** set `role` to a built-in role name (`"admin"`,
63///   `"operator"`, `"viewer"`).
64/// - **Direct permissions:** set `permissions` to an explicit list.
65///
66/// If both `role` and `permissions` are provided, `permissions` takes
67/// precedence.
68#[derive(Deserialize, Debug)]
69pub struct UserEntry {
70    /// Password hash in the format: `"base64(salt):iterations:base64(stored_key):base64(server_key)"`.
71    pub password_hash: String,
72    /// Optional role name that maps to a built-in role's permissions.
73    pub role: Option<String>,
74    /// Explicit list of permissions: `"read"`, `"write"`, `"admin"`.
75    /// Takes precedence over `role` when both are present.
76    pub permissions: Option<Vec<String>>,
77}
78
79/// Resolve the effective permissions for a user entry.
80///
81/// Resolution order:
82/// 1. If `permissions` is set, use it directly.
83/// 2. If `role` is set, look up the built-in role.
84/// 3. Otherwise return an empty list.
85pub fn resolve_permissions(entry: &UserEntry) -> Vec<String> {
86    if let Some(ref perms) = entry.permissions {
87        return perms.clone();
88    }
89    if let Some(ref role_name) = entry.role
90        && let Some(role) = builtin_role(role_name)
91    {
92        return role.permissions;
93    }
94    Vec::new()
95}
96
97/// Parsed user record ready for authentication.
98pub struct UserRecord {
99    pub credentials: ScramCredentials,
100    pub permissions: Vec<String>,
101}
102
103/// Parse a password hash string into SCRAM credentials.
104///
105/// Format: `"base64(salt):iterations:base64(stored_key):base64(server_key)"`.
106pub fn parse_password_hash(hash: &str) -> Result<ScramCredentials, String> {
107    let parts: Vec<&str> = hash.split(':').collect();
108    if parts.len() != 4 {
109        return Err(format!(
110            "expected 4 colon-separated fields, got {}",
111            parts.len()
112        ));
113    }
114
115    let salt = BASE64
116        .decode(parts[0])
117        .map_err(|e| format!("invalid base64 salt: {e}"))?;
118    let iterations: u32 = parts[1]
119        .parse()
120        .map_err(|e| format!("invalid iterations: {e}"))?;
121    let stored_key = BASE64
122        .decode(parts[2])
123        .map_err(|e| format!("invalid base64 stored_key: {e}"))?;
124    let server_key = BASE64
125        .decode(parts[3])
126        .map_err(|e| format!("invalid base64 server_key: {e}"))?;
127
128    Ok(ScramCredentials {
129        salt,
130        iterations,
131        stored_key,
132        server_key,
133    })
134}
135
136/// Create a password hash string from a plaintext password.
137///
138/// Generates a random 16-byte salt and uses `DEFAULT_ITERATIONS`.
139/// Returns the hash in the format accepted by [`parse_password_hash`].
140pub fn hash_password(password: &str) -> String {
141    let mut salt = [0u8; 16];
142    use rand::RngExt;
143    rand::rng().fill(&mut salt);
144
145    let creds = derive_credentials(password, &salt, DEFAULT_ITERATIONS);
146
147    format!(
148        "{}:{}:{}:{}",
149        BASE64.encode(&creds.salt),
150        creds.iterations,
151        BASE64.encode(&creds.stored_key),
152        BASE64.encode(&creds.server_key),
153    )
154}
155
156/// Load user records from a TOML configuration file path.
157pub fn load_users_from_toml(path: &str) -> Result<HashMap<String, UserRecord>, String> {
158    let content =
159        std::fs::read_to_string(path).map_err(|e| format!("failed to read {path}: {e}"))?;
160    load_users_from_str(&content)
161}
162
163/// Load user records from TOML content string.
164pub fn load_users_from_str(content: &str) -> Result<HashMap<String, UserRecord>, String> {
165    let config: UserConfig =
166        toml::from_str(content).map_err(|e| format!("TOML parse error: {e}"))?;
167
168    let mut records = HashMap::new();
169    for (username, entry) in config.users {
170        let credentials = parse_password_hash(&entry.password_hash)?;
171        let permissions = resolve_permissions(&entry);
172        records.insert(
173            username,
174            UserRecord {
175                credentials,
176                permissions,
177            },
178        );
179    }
180    Ok(records)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn hash_and_parse_roundtrip() {
189        let hash_str = hash_password("testpassword");
190        let creds = parse_password_hash(&hash_str).unwrap();
191        assert_eq!(creds.iterations, DEFAULT_ITERATIONS);
192        assert_eq!(creds.stored_key.len(), 32);
193        assert_eq!(creds.server_key.len(), 32);
194    }
195
196    #[test]
197    fn parse_invalid_format() {
198        assert!(parse_password_hash("not:enough:parts").is_err());
199        assert!(parse_password_hash("").is_err());
200    }
201
202    #[test]
203    fn load_users_direct_permissions() {
204        let hash = hash_password("s3cret");
205        let toml_str = format!(
206            r#"
207[users.admin]
208password_hash = "{hash}"
209permissions = ["read", "write", "admin"]
210
211[users.viewer]
212password_hash = "{hash}"
213permissions = ["read"]
214"#
215        );
216
217        let records = load_users_from_str(&toml_str).unwrap();
218        assert_eq!(records.len(), 2);
219        assert!(records.contains_key("admin"));
220        assert!(records.contains_key("viewer"));
221        assert_eq!(records["admin"].permissions, vec!["read", "write", "admin"]);
222        assert_eq!(records["viewer"].permissions, vec!["read"]);
223    }
224
225    #[test]
226    fn load_users_role_based() {
227        let hash = hash_password("s3cret");
228        let toml_str = format!(
229            r#"
230[users.alice]
231password_hash = "{hash}"
232role = "admin"
233
234[users.bob]
235password_hash = "{hash}"
236role = "operator"
237
238[users.carol]
239password_hash = "{hash}"
240role = "viewer"
241"#
242        );
243
244        let records = load_users_from_str(&toml_str).unwrap();
245        assert_eq!(records.len(), 3);
246        assert_eq!(records["alice"].permissions, vec!["read", "write", "admin"]);
247        assert_eq!(records["bob"].permissions, vec!["read", "write"]);
248        assert_eq!(records["carol"].permissions, vec!["read"]);
249    }
250
251    #[test]
252    fn load_users_mixed_role_and_permissions() {
253        let hash = hash_password("s3cret");
254        let toml_str = format!(
255            r#"
256[users.role_user]
257password_hash = "{hash}"
258role = "viewer"
259
260[users.perm_user]
261password_hash = "{hash}"
262permissions = ["read", "write"]
263"#
264        );
265
266        let records = load_users_from_str(&toml_str).unwrap();
267        assert_eq!(records["role_user"].permissions, vec!["read"]);
268        assert_eq!(records["perm_user"].permissions, vec!["read", "write"]);
269    }
270
271    #[test]
272    fn permissions_override_role() {
273        let hash = hash_password("s3cret");
274        let toml_str = format!(
275            r#"
276[users.override_user]
277password_hash = "{hash}"
278role = "viewer"
279permissions = ["read", "write", "admin"]
280"#
281        );
282
283        let records = load_users_from_str(&toml_str).unwrap();
284        // permissions takes precedence over role
285        assert_eq!(
286            records["override_user"].permissions,
287            vec!["read", "write", "admin"]
288        );
289    }
290
291    #[test]
292    fn unknown_role_gives_empty_permissions() {
293        let hash = hash_password("s3cret");
294        let toml_str = format!(
295            r#"
296[users.mystery]
297password_hash = "{hash}"
298role = "superuser"
299"#
300        );
301
302        let records = load_users_from_str(&toml_str).unwrap();
303        assert!(records["mystery"].permissions.is_empty());
304    }
305
306    #[test]
307    fn no_role_no_permissions_gives_empty() {
308        let hash = hash_password("s3cret");
309        let toml_str = format!(
310            r#"
311[users.bare]
312password_hash = "{hash}"
313"#
314        );
315
316        let records = load_users_from_str(&toml_str).unwrap();
317        assert!(records["bare"].permissions.is_empty());
318    }
319
320    #[test]
321    fn resolve_permissions_direct() {
322        let entry = UserEntry {
323            password_hash: String::new(),
324            role: None,
325            permissions: Some(vec!["read".to_string(), "write".to_string()]),
326        };
327        assert_eq!(resolve_permissions(&entry), vec!["read", "write"]);
328    }
329
330    #[test]
331    fn resolve_permissions_role() {
332        let entry = UserEntry {
333            password_hash: String::new(),
334            role: Some("operator".to_string()),
335            permissions: None,
336        };
337        assert_eq!(resolve_permissions(&entry), vec!["read", "write"]);
338    }
339
340    #[test]
341    fn resolve_permissions_both_prefers_permissions() {
342        let entry = UserEntry {
343            password_hash: String::new(),
344            role: Some("admin".to_string()),
345            permissions: Some(vec!["read".to_string()]),
346        };
347        // explicit permissions win
348        assert_eq!(resolve_permissions(&entry), vec!["read"]);
349    }
350
351    #[test]
352    fn builtin_roles_exist() {
353        assert!(builtin_role("admin").is_some());
354        assert!(builtin_role("operator").is_some());
355        assert!(builtin_role("viewer").is_some());
356        assert!(builtin_role("nonexistent").is_none());
357    }
358}