1use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use uuid::Uuid;
18
19#[derive(thiserror::Error, Debug)]
21pub enum Error {
22 #[error("password hashing failed: {0}")]
23 Hash(String),
24 #[error("invalid or expired token")]
25 Token,
26}
27
28pub const ADMIN_ROLE: &str = "admin";
36
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct OrgMembership {
40 pub org_id: Uuid,
41 pub role: Option<String>,
44 pub roles: Vec<String>,
47 pub org_class: Option<String>,
51}
52
53impl OrgMembership {
54 pub fn new(
57 org_id: Uuid,
58 role: Option<String>,
59 extra: impl IntoIterator<Item = String>,
60 ) -> Self {
61 let mut roles: Vec<String> = Vec::new();
62 for candidate in role.clone().into_iter().chain(extra) {
63 if !candidate.is_empty() && !roles.contains(&candidate) {
64 roles.push(candidate);
65 }
66 }
67 OrgMembership {
68 org_id,
69 role,
70 roles,
71 org_class: None,
72 }
73 }
74
75 pub fn in_class(mut self, class: Option<String>) -> Self {
77 self.org_class = class.filter(|c| !c.is_empty());
78 self
79 }
80
81 pub fn is_class(&self, class: &str) -> bool {
83 self.org_class.as_deref() == Some(class)
84 }
85
86 pub fn has_role(&self, role: &str) -> bool {
88 self.roles.iter().any(|held| held == role) || self.is_admin()
89 }
90
91 pub fn is_admin(&self) -> bool {
93 self.roles.iter().any(|held| held == ADMIN_ROLE)
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Principal {
100 pub user_id: Uuid,
101 pub organizations: Vec<OrgMembership>,
104}
105
106impl Principal {
107 pub fn membership(&self, org: Uuid) -> Option<&OrgMembership> {
109 self.organizations.iter().find(|m| m.org_id == org)
110 }
111
112 pub fn is_member(&self, org: Uuid) -> bool {
114 self.membership(org).is_some()
115 }
116
117 pub fn role_in(&self, org: Uuid) -> Option<&str> {
123 self.membership(org).and_then(|m| m.role.as_deref())
124 }
125
126 pub fn roles_in(&self, org: Uuid) -> &[String] {
128 self.membership(org)
129 .map(|m| m.roles.as_slice())
130 .unwrap_or(&[])
131 }
132
133 pub fn has_role_in(&self, org: Uuid, role: &str) -> bool {
136 self.membership(org).is_some_and(|m| m.has_role(role))
137 }
138
139 pub fn org_class_of(&self, org: Uuid) -> Option<&str> {
141 self.membership(org).and_then(|m| m.org_class.as_deref())
142 }
143
144 pub fn is_admin_of(&self, org: Uuid) -> bool {
146 self.membership(org).is_some_and(OrgMembership::is_admin)
147 }
148
149 pub fn org_ids(&self) -> Vec<Uuid> {
151 self.organizations.iter().map(|m| m.org_id).collect()
152 }
153
154 pub fn org_ids_with_role(&self, role: &str) -> Vec<Uuid> {
157 self.org_ids_with_role_in_class(role, None)
158 }
159
160 pub fn org_ids_with_role_in_class(&self, role: &str, class: Option<&str>) -> Vec<Uuid> {
163 self.organizations
164 .iter()
165 .filter(|m| m.has_role(role))
166 .filter(|m| class.is_none_or(|c| m.is_class(c)))
167 .map(|m| m.org_id)
168 .collect()
169 }
170
171 pub fn org_ids_in_class(&self, class: Option<&str>) -> Vec<Uuid> {
173 self.organizations
174 .iter()
175 .filter(|m| class.is_none_or(|c| m.is_class(c)))
176 .map(|m| m.org_id)
177 .collect()
178 }
179}
180
181#[derive(Debug, Serialize, Deserialize)]
184struct Claims {
185 sub: String,
187 exp: i64,
189}
190
191#[derive(Clone)]
193pub struct Authenticator {
194 secret: Vec<u8>,
195 session_ttl_secs: i64,
196}
197
198impl Authenticator {
199 pub fn new(secret: impl Into<Vec<u8>>, session_ttl_secs: u64) -> Self {
200 Authenticator {
201 secret: secret.into(),
202 session_ttl_secs: session_ttl_secs as i64,
203 }
204 }
205
206 pub fn hash_password(&self, plaintext: &str) -> Result<String, Error> {
210 Self::hash_password_with_argon2(plaintext)
211 }
212
213 pub fn hash_password_with_argon2(plaintext: &str) -> Result<String, Error> {
220 use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
221 use argon2::Argon2;
222 let salt = SaltString::generate(&mut OsRng);
223 Argon2::default()
224 .hash_password(plaintext.as_bytes(), &salt)
225 .map(|h| h.to_string())
226 .map_err(|e| Error::Hash(e.to_string()))
227 }
228
229 pub fn verify_password(&self, plaintext: &str, hash: &str) -> bool {
231 use argon2::password_hash::{PasswordHash, PasswordVerifier};
232 use argon2::Argon2;
233 match PasswordHash::new(hash) {
234 Ok(parsed) => Argon2::default()
235 .verify_password(plaintext.as_bytes(), &parsed)
236 .is_ok(),
237 Err(_) => false,
238 }
239 }
240
241 pub fn issue_token(&self, user_id: Uuid) -> Result<String, Error> {
245 use jsonwebtoken::{encode, EncodingKey, Header};
246 let exp = chrono::Utc::now().timestamp() + self.session_ttl_secs;
247 let claims = Claims {
248 sub: user_id.to_string(),
249 exp,
250 };
251 encode(
252 &Header::default(),
253 &claims,
254 &EncodingKey::from_secret(&self.secret),
255 )
256 .map_err(|_| Error::Token)
257 }
258
259 pub fn verify_token(&self, token: &str) -> Result<Uuid, Error> {
261 use jsonwebtoken::{decode, DecodingKey, Validation};
262 let data = decode::<Claims>(
263 token,
264 &DecodingKey::from_secret(&self.secret),
265 &Validation::default(),
266 )
267 .map_err(|_| Error::Token)?;
268 Uuid::parse_str(&data.claims.sub).map_err(|_| Error::Token)
269 }
270
271 pub fn generate_api_key(&self) -> (String, String) {
276 use rand::RngCore;
277 let mut bytes = [0u8; 32];
278 rand::thread_rng().fill_bytes(&mut bytes);
279 let plaintext = format!("apik_{}", hex::encode(bytes));
280 let hash = Self::hash_api_key(&plaintext);
281 (plaintext, hash)
282 }
283
284 pub fn hash_api_key(plaintext: &str) -> String {
287 let mut hasher = Sha256::new();
288 hasher.update(plaintext.as_bytes());
289 hex::encode(hasher.finalize())
290 }
291
292 pub fn generate_link_token(prefix: &str) -> (String, String) {
307 use rand::RngCore;
308 let mut bytes = [0u8; 32];
309 rand::thread_rng().fill_bytes(&mut bytes);
310 let plaintext = format!("{prefix}_{}", hex::encode(bytes));
311 let hash = Self::hash_link_token(&plaintext);
312 (plaintext, hash)
313 }
314
315 pub fn hash_link_token(plaintext: &str) -> String {
318 Self::hash_api_key(plaintext)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn password_roundtrip() {
328 let auth = Authenticator::new(b"secret".to_vec(), 3600);
329 let hash = auth.hash_password("hunter2").unwrap();
330 assert!(auth.verify_password("hunter2", &hash));
331 assert!(!auth.verify_password("wrong", &hash));
332 }
333
334 #[test]
335 fn token_roundtrip() {
336 let auth = Authenticator::new(b"secret".to_vec(), 3600);
337 let id = Uuid::new_v4();
338 let token = auth.issue_token(id).unwrap();
339 assert_eq!(auth.verify_token(&token).unwrap(), id);
340 }
341
342 #[test]
343 fn api_key_hash_is_deterministic() {
344 assert_eq!(
345 Authenticator::hash_api_key("apik_abc"),
346 Authenticator::hash_api_key("apik_abc")
347 );
348 }
349
350 #[test]
351 fn a_link_token_is_stored_only_as_its_hash() {
352 let (plaintext, hash) = Authenticator::generate_link_token("inv");
353 assert!(plaintext.starts_with("inv_"));
354 assert_ne!(plaintext, hash);
357 assert_eq!(Authenticator::hash_link_token(&plaintext), hash);
358
359 let (other, _) = Authenticator::generate_link_token("inv");
360 assert_ne!(plaintext, other);
361 }
362
363 #[test]
364 fn membership_lookup() {
365 let org = Uuid::new_v4();
366 let p = Principal {
367 user_id: Uuid::new_v4(),
368 organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
369 };
370 assert!(p.is_member(org));
371 assert_eq!(p.role_in(org), Some("support"));
372 assert!(!p.is_member(Uuid::new_v4()));
373 assert_eq!(p.org_ids_with_role("support"), vec![org]);
374 assert!(p.org_ids_with_role("billing").is_empty());
375 }
376
377 #[test]
378 fn a_member_holds_every_role_granted_to_them() {
379 let org = Uuid::new_v4();
380 let p = Principal {
381 user_id: Uuid::new_v4(),
382 organizations: vec![OrgMembership::new(
383 org,
384 Some("support".into()),
385 ["billing".to_string()],
386 )],
387 };
388
389 assert_eq!(p.roles_in(org), ["support", "billing"]);
391 assert!(p.has_role_in(org, "support"));
392 assert!(p.has_role_in(org, "billing"));
393 assert!(!p.has_role_in(org, "admin"));
394
395 assert_eq!(p.role_in(org), Some("support"));
398 }
399
400 #[test]
401 fn an_admin_holds_every_role_without_being_granted_them() {
402 let org = Uuid::new_v4();
403 let other = Uuid::new_v4();
404 let p = Principal {
405 user_id: Uuid::new_v4(),
406 organizations: vec![
407 OrgMembership::new(org, Some("admin".into()), []),
408 OrgMembership::new(other, Some("support".into()), []),
409 ],
410 };
411
412 assert!(p.is_admin_of(org));
413 assert!(p.has_role_in(org, "billing"));
414 assert!(p.has_role_in(org, "anything-at-all"));
415 assert_eq!(p.org_ids_with_role("billing"), vec![org]);
416
417 assert!(!p.has_role_in(other, "billing"));
419 assert!(!p.is_admin_of(other));
420
421 assert_eq!(p.roles_in(org), ["admin"]);
425 }
426
427 #[test]
428 fn a_role_held_twice_is_still_one_role() {
429 let org = Uuid::new_v4();
430 let membership = OrgMembership::new(
433 org,
434 Some("admin".into()),
435 ["admin".to_string(), "billing".to_string(), String::new()],
436 );
437 assert_eq!(membership.roles, ["admin", "billing"]);
438 }
439
440 #[test]
441 fn memberships_carry_the_organisations_class() {
442 let school = Uuid::new_v4();
443 let staff = Uuid::new_v4();
444 let unclassed = Uuid::new_v4();
445 let p = Principal {
446 user_id: Uuid::new_v4(),
447 organizations: vec![
448 OrgMembership::new(school, Some("admin".into()), [])
449 .in_class(Some("school".into())),
450 OrgMembership::new(staff, Some("admin".into()), []).in_class(Some("staff".into())),
451 OrgMembership::new(unclassed, Some("admin".into()), []),
452 ],
453 };
454
455 assert_eq!(p.org_class_of(school), Some("school"));
456 assert_eq!(p.org_class_of(unclassed), None);
457
458 assert_eq!(
461 p.org_ids_with_role_in_class("admin", Some("school")),
462 vec![school]
463 );
464 assert_eq!(p.org_ids_with_role("admin").len(), 3);
465 assert_eq!(p.org_ids_in_class(Some("staff")), vec![staff]);
466 assert_eq!(p.org_ids_in_class(None).len(), 3);
467
468 assert!(p
470 .org_ids_with_role_in_class("admin", Some("customer"))
471 .is_empty());
472
473 let blank = OrgMembership::new(school, None, []).in_class(Some(String::new()));
475 assert_eq!(blank.org_class, None);
476 assert!(!blank.is_class(""));
477 }
478
479 #[test]
480 fn a_member_with_no_role_holds_none() {
481 let org = Uuid::new_v4();
482 let membership = OrgMembership::new(org, None, []);
483 assert!(membership.roles.is_empty());
484 assert!(!membership.is_admin());
485 assert!(!membership.has_role("member"));
486 }
487}