1use 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#[derive(Debug, Clone)]
12pub struct Role {
13 pub name: String,
14 pub permissions: Vec<String>,
15}
16
17pub 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
25pub fn operator_role() -> Role {
27 Role {
28 name: "operator".to_string(),
29 permissions: vec!["read".to_string(), "write".to_string()],
30 }
31}
32
33pub fn viewer_role() -> Role {
35 Role {
36 name: "viewer".to_string(),
37 permissions: vec!["read".to_string()],
38 }
39}
40
41pub 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#[derive(Deserialize)]
55pub struct UserConfig {
56 pub users: HashMap<String, UserEntry>,
57}
58
59#[derive(Deserialize, Debug)]
69pub struct UserEntry {
70 pub password_hash: String,
72 pub role: Option<String>,
74 pub permissions: Option<Vec<String>>,
77}
78
79pub 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
97pub struct UserRecord {
99 pub credentials: ScramCredentials,
100 pub permissions: Vec<String>,
101}
102
103pub 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
136pub 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
156pub 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
163pub 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 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 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}