1use std::collections::HashMap;
16use std::sync::Mutex;
17
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum OrgRole {
24 Owner,
28 Admin,
31 Member,
33}
34
35impl OrgRole {
36 pub fn from_str(s: &str) -> Option<Self> {
37 match s {
38 "owner" => Some(Self::Owner),
39 "admin" => Some(Self::Admin),
40 "member" => Some(Self::Member),
41 _ => None,
42 }
43 }
44 pub fn as_str(&self) -> &'static str {
45 match self {
46 Self::Owner => "owner",
47 Self::Admin => "admin",
48 Self::Member => "member",
49 }
50 }
51 pub fn can_manage_members(&self) -> bool {
52 matches!(self, Self::Owner | Self::Admin)
53 }
54 pub fn can_delete_org(&self) -> bool {
55 matches!(self, Self::Owner)
56 }
57 pub fn can_transfer_ownership(&self) -> bool {
58 matches!(self, Self::Owner)
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Org {
64 pub id: String,
65 pub name: String,
66 pub created_by: String,
69 pub created_at: u64,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Membership {
74 pub org_id: String,
75 pub user_id: String,
76 pub role: OrgRole,
77 pub joined_at: u64,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Invite {
82 pub id: String,
85 pub org_id: String,
86 pub email: String,
89 pub role: OrgRole,
91 pub invited_by: String,
94 pub token_hash: String,
98 pub token_prefix: String,
101 pub created_at: u64,
102 pub expires_at: u64,
103 pub accepted_at: Option<u64>,
104}
105
106pub trait OrgBackend: Send + Sync {
107 fn put_org(&self, org: &Org);
108 fn get_org(&self, id: &str) -> Option<Org>;
109 fn delete_org(&self, id: &str) -> bool;
110 fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)>;
111
112 fn put_membership(&self, m: &Membership);
113 fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership>;
114 fn delete_membership(&self, org_id: &str, user_id: &str) -> bool;
115 fn list_members(&self, org_id: &str) -> Vec<Membership>;
116
117 fn put_invite(&self, inv: &Invite);
118 fn get_invite(&self, id: &str) -> Option<Invite>;
119 fn list_invites(&self, org_id: &str) -> Vec<Invite>;
120 fn delete_invite(&self, id: &str) -> bool;
121 fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite>;
126 fn mark_invite_accepted(&self, id: &str, now: u64) -> bool;
132}
133
134pub struct InMemoryOrgBackend {
135 orgs: Mutex<HashMap<String, Org>>,
136 memberships: Mutex<HashMap<(String, String), Membership>>,
137 invites: Mutex<HashMap<String, Invite>>,
138}
139
140impl Default for InMemoryOrgBackend {
141 fn default() -> Self {
142 Self {
143 orgs: Mutex::new(HashMap::new()),
144 memberships: Mutex::new(HashMap::new()),
145 invites: Mutex::new(HashMap::new()),
146 }
147 }
148}
149
150impl OrgBackend for InMemoryOrgBackend {
151 fn put_org(&self, org: &Org) {
152 self.orgs
153 .lock()
154 .unwrap()
155 .insert(org.id.clone(), org.clone());
156 }
157 fn get_org(&self, id: &str) -> Option<Org> {
158 self.orgs.lock().unwrap().get(id).cloned()
159 }
160 fn delete_org(&self, id: &str) -> bool {
161 let removed = self.orgs.lock().unwrap().remove(id).is_some();
162 if removed {
163 self.memberships.lock().unwrap().retain(|(o, _), _| o != id);
164 self.invites
165 .lock()
166 .unwrap()
167 .retain(|_, inv| inv.org_id != id);
168 }
169 removed
170 }
171 fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
172 let m = self.memberships.lock().unwrap();
173 let o = self.orgs.lock().unwrap();
174 m.values()
175 .filter(|mem| mem.user_id == user_id)
176 .filter_map(|mem| o.get(&mem.org_id).map(|org| (org.clone(), mem.role)))
177 .collect()
178 }
179
180 fn put_membership(&self, m: &Membership) {
181 self.memberships
182 .lock()
183 .unwrap()
184 .insert((m.org_id.clone(), m.user_id.clone()), m.clone());
185 }
186 fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership> {
187 self.memberships
188 .lock()
189 .unwrap()
190 .get(&(org_id.to_string(), user_id.to_string()))
191 .cloned()
192 }
193 fn delete_membership(&self, org_id: &str, user_id: &str) -> bool {
194 self.memberships
195 .lock()
196 .unwrap()
197 .remove(&(org_id.to_string(), user_id.to_string()))
198 .is_some()
199 }
200 fn list_members(&self, org_id: &str) -> Vec<Membership> {
201 self.memberships
202 .lock()
203 .unwrap()
204 .values()
205 .filter(|m| m.org_id == org_id)
206 .cloned()
207 .collect()
208 }
209
210 fn put_invite(&self, inv: &Invite) {
211 self.invites
212 .lock()
213 .unwrap()
214 .insert(inv.id.clone(), inv.clone());
215 }
216 fn get_invite(&self, id: &str) -> Option<Invite> {
217 self.invites.lock().unwrap().get(id).cloned()
218 }
219 fn list_invites(&self, org_id: &str) -> Vec<Invite> {
220 self.invites
221 .lock()
222 .unwrap()
223 .values()
224 .filter(|i| i.org_id == org_id && i.accepted_at.is_none())
225 .cloned()
226 .collect()
227 }
228 fn delete_invite(&self, id: &str) -> bool {
229 self.invites.lock().unwrap().remove(id).is_some()
230 }
231 fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite> {
232 self.invites
236 .lock()
237 .unwrap()
238 .values()
239 .filter(|i| i.token_prefix == prefix)
240 .cloned()
241 .collect()
242 }
243 fn mark_invite_accepted(&self, id: &str, now: u64) -> bool {
244 let mut g = self.invites.lock().unwrap();
245 let Some(inv) = g.get_mut(id) else {
246 return false;
247 };
248 if inv.accepted_at.is_some() {
249 return false;
250 }
251 inv.accepted_at = Some(now);
252 true
253 }
254}
255
256pub struct OrgStore {
257 backend: Box<dyn OrgBackend>,
258}
259
260impl Default for OrgStore {
261 fn default() -> Self {
262 Self::new()
263 }
264}
265
266#[derive(Debug, Clone)]
267pub struct InviteWithToken {
268 pub invite: Invite,
269 pub token: String,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum AcceptError {
276 NotFound,
280 Expired,
282 AlreadyAccepted,
288 EmailMismatch,
293 AlreadyMember,
298}
299
300impl std::fmt::Display for AcceptError {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 f.write_str(match self {
303 Self::NotFound => "invite not found",
304 Self::Expired => "invite expired",
305 Self::AlreadyAccepted => "invite already accepted",
306 Self::EmailMismatch => "invite email doesn't match this account",
307 Self::AlreadyMember => "user is already a member of this org",
308 })
309 }
310}
311
312impl OrgStore {
313 pub fn new() -> Self {
314 Self::with_backend(Box::new(InMemoryOrgBackend::default()))
315 }
316
317 pub fn with_backend(backend: Box<dyn OrgBackend>) -> Self {
318 Self { backend }
319 }
320
321 pub fn create(&self, name: &str, creator_id: &str) -> Org {
323 let id = format!("org_{}", random_token(20));
324 let org = Org {
325 id: id.clone(),
326 name: name.to_string(),
327 created_by: creator_id.to_string(),
328 created_at: now_secs(),
329 };
330 self.backend.put_org(&org);
331 self.backend.put_membership(&Membership {
332 org_id: id,
333 user_id: creator_id.to_string(),
334 role: OrgRole::Owner,
335 joined_at: now_secs(),
336 });
337 org
338 }
339
340 pub fn get(&self, org_id: &str) -> Option<Org> {
341 self.backend.get_org(org_id)
342 }
343
344 pub fn list_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
345 self.backend.list_orgs_for_user(user_id)
346 }
347
348 pub fn list_members(&self, org_id: &str) -> Vec<Membership> {
349 self.backend.list_members(org_id)
350 }
351
352 pub fn role_of(&self, org_id: &str, user_id: &str) -> Option<OrgRole> {
353 self.backend.get_membership(org_id, user_id).map(|m| m.role)
354 }
355
356 pub fn set_role(&self, org_id: &str, user_id: &str, role: OrgRole) -> bool {
357 if let Some(mut m) = self.backend.get_membership(org_id, user_id) {
358 m.role = role;
359 self.backend.put_membership(&m);
360 true
361 } else {
362 false
363 }
364 }
365
366 pub fn remove_member(&self, org_id: &str, user_id: &str) -> bool {
367 self.backend.delete_membership(org_id, user_id)
368 }
369
370 pub fn delete(&self, org_id: &str) -> bool {
372 self.backend.delete_org(org_id)
373 }
374
375 pub fn create_invite(
381 &self,
382 org_id: &str,
383 email: &str,
384 role: OrgRole,
385 invited_by: &str,
386 ) -> InviteWithToken {
387 let id = format!("inv_{}", random_token(20));
388 let token = random_token(24);
389 let token_hash = crate::password::hash_password(&token);
390 let token_prefix: String = token.chars().take(8).collect();
391 let expires_at = now_secs() + 7 * 24 * 60 * 60; let invite = Invite {
393 id,
394 org_id: org_id.to_string(),
395 email: email.to_lowercase(),
396 role,
397 invited_by: invited_by.to_string(),
398 token_hash,
399 token_prefix,
400 created_at: now_secs(),
401 expires_at,
402 accepted_at: None,
403 };
404 self.backend.put_invite(&invite);
405 InviteWithToken { invite, token }
406 }
407
408 pub fn list_invites(&self, org_id: &str) -> Vec<Invite> {
409 self.backend.list_invites(org_id)
410 }
411
412 pub fn revoke_invite(&self, invite_id: &str) -> bool {
413 self.backend.delete_invite(invite_id)
414 }
415
416 pub fn accept_invite(
423 &self,
424 token: &str,
425 accepting_user_id: &str,
426 accepting_email: &str,
427 ) -> Result<Membership, AcceptError> {
428 let invite = self
434 .find_invite_by_plaintext(token)
435 .ok_or(AcceptError::NotFound)?;
436 if invite.accepted_at.is_some() {
437 return Err(AcceptError::AlreadyAccepted);
438 }
439 if invite.expires_at <= now_secs() {
440 return Err(AcceptError::Expired);
441 }
442 if invite.email != accepting_email.to_lowercase() {
443 return Err(AcceptError::EmailMismatch);
444 }
445 if self
446 .backend
447 .get_membership(&invite.org_id, accepting_user_id)
448 .is_some()
449 {
450 return Err(AcceptError::AlreadyMember);
451 }
452 if !self.backend.mark_invite_accepted(&invite.id, now_secs()) {
458 return Err(AcceptError::AlreadyAccepted);
459 }
460 let membership = Membership {
461 org_id: invite.org_id.clone(),
462 user_id: accepting_user_id.to_string(),
463 role: invite.role,
464 joined_at: now_secs(),
465 };
466 self.backend.put_membership(&membership);
467 Ok(membership)
468 }
469
470 fn find_invite_by_plaintext(&self, token: &str) -> Option<Invite> {
477 let prefix: String = token.chars().take(8).collect();
478 for inv in self.backend.invites_by_prefix(&prefix) {
479 if crate::password::verify_password(token, &inv.token_hash) {
480 return Some(inv);
481 }
482 }
483 None
484 }
485}
486
487fn random_token(n_bytes: usize) -> String {
488 use rand::RngCore;
489 let mut bytes = vec![0u8; n_bytes];
490 rand::thread_rng().fill_bytes(&mut bytes);
491 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
492 URL_SAFE_NO_PAD.encode(bytes)
493}
494
495fn now_secs() -> u64 {
496 use std::time::{SystemTime, UNIX_EPOCH};
497 SystemTime::now()
498 .duration_since(UNIX_EPOCH)
499 .unwrap_or_default()
500 .as_secs()
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn create_org_makes_creator_owner() {
509 let store = OrgStore::new();
510 let org = store.create("Acme", "user-1");
511 assert!(org.id.starts_with("org_"));
512 assert_eq!(org.name, "Acme");
513 assert_eq!(store.role_of(&org.id, "user-1"), Some(OrgRole::Owner));
514 }
515
516 #[test]
517 fn list_for_user_returns_all_orgs() {
518 let store = OrgStore::new();
519 let a = store.create("A", "u1");
520 let _b = store.create("B", "u2");
521 let c = store.create("C", "u3");
522 store.set_role(&c.id, "u1", OrgRole::Member);
523 store.backend.put_membership(&Membership {
526 org_id: c.id.clone(),
527 user_id: "u1".into(),
528 role: OrgRole::Member,
529 joined_at: 1,
530 });
531 let list = store.list_for_user("u1");
532 assert_eq!(list.len(), 2);
533 let names: Vec<_> = list.iter().map(|(o, _)| o.name.clone()).collect();
534 assert!(names.contains(&"A".to_string()));
535 assert!(names.contains(&"C".to_string()));
536 assert!(!names.contains(&"B".to_string()));
537 }
538
539 #[test]
540 fn role_helpers() {
541 assert!(OrgRole::Owner.can_manage_members());
542 assert!(OrgRole::Owner.can_delete_org());
543 assert!(OrgRole::Admin.can_manage_members());
544 assert!(!OrgRole::Admin.can_delete_org());
545 assert!(!OrgRole::Member.can_manage_members());
546 }
547
548 #[test]
549 fn delete_cascades_memberships_and_invites() {
550 let store = OrgStore::new();
551 let org = store.create("A", "owner-1");
552 let _inv = store.create_invite(&org.id, "x@example.com", OrgRole::Member, "owner-1");
553 assert_eq!(store.list_invites(&org.id).len(), 1);
554 assert_eq!(store.list_members(&org.id).len(), 1);
555 assert!(store.delete(&org.id));
556 assert!(store.get(&org.id).is_none());
557 assert!(store.list_members(&org.id).is_empty());
558 assert!(store.list_invites(&org.id).is_empty());
559 }
560
561 #[test]
562 fn accept_invite_creates_membership() {
563 let store = OrgStore::new();
564 let org = store.create("Acme", "owner-1");
565 let invited = store.create_invite(&org.id, "newbie@example.com", OrgRole::Admin, "owner-1");
566 let m = store
567 .accept_invite(&invited.token, "user-2", "newbie@example.com")
568 .expect("accept");
569 assert_eq!(m.role, OrgRole::Admin);
570 assert_eq!(store.role_of(&org.id, "user-2"), Some(OrgRole::Admin));
571 let stored = store.backend.get_invite(&invited.invite.id).unwrap();
573 assert!(stored.accepted_at.is_some());
574 }
575
576 #[test]
577 fn accept_invite_rejects_wrong_email() {
578 let store = OrgStore::new();
579 let org = store.create("Acme", "owner-1");
580 let invited = store.create_invite(&org.id, "alice@example.com", OrgRole::Member, "owner-1");
581 let err = store
582 .accept_invite(&invited.token, "user-2", "bob@example.com")
583 .unwrap_err();
584 assert_eq!(err, AcceptError::EmailMismatch);
585 }
586
587 #[test]
588 fn accept_invite_rejects_replay() {
589 let store = OrgStore::new();
590 let org = store.create("A", "owner");
591 let invited = store.create_invite(&org.id, "a@b.com", OrgRole::Member, "owner");
592 store
593 .accept_invite(&invited.token, "user-2", "a@b.com")
594 .unwrap();
595 let second = store.accept_invite(&invited.token, "user-2", "a@b.com");
596 assert_eq!(second.unwrap_err(), AcceptError::AlreadyAccepted);
597 }
598
599 #[test]
605 fn accept_invite_cas_blocks_concurrent_winners() {
606 let store = OrgStore::new();
607 let org = store.create("A", "owner");
608 let invited = store.create_invite(&org.id, "a@b.com", OrgRole::Member, "owner");
609
610 let won_first = store.backend.mark_invite_accepted(&invited.invite.id, 100);
613 assert!(won_first);
614 let won_second = store.backend.mark_invite_accepted(&invited.invite.id, 101);
616 assert!(!won_second);
617 let result = store.accept_invite(&invited.token, "user-x", "a@b.com");
620 assert_eq!(result.unwrap_err(), AcceptError::AlreadyAccepted);
621 }
622
623 #[test]
624 fn accept_invite_rejects_unknown_token() {
625 let store = OrgStore::new();
626 let _org = store.create("A", "owner");
627 let err = store
628 .accept_invite("not-a-real-token", "user-2", "x@y.com")
629 .unwrap_err();
630 assert_eq!(err, AcceptError::NotFound);
631 }
632
633 #[test]
634 fn invite_email_lowercased() {
635 let store = OrgStore::new();
636 let org = store.create("A", "owner");
637 let inv = store.create_invite(&org.id, "Mixed@CASE.com", OrgRole::Member, "owner");
638 assert_eq!(inv.invite.email, "mixed@case.com");
639 }
640
641 #[test]
642 fn revoke_invite() {
643 let store = OrgStore::new();
644 let org = store.create("A", "owner");
645 let inv = store.create_invite(&org.id, "x@y.com", OrgRole::Member, "owner");
646 assert!(store.revoke_invite(&inv.invite.id));
647 assert!(store.list_invites(&org.id).is_empty());
648 }
649
650 #[test]
651 fn remove_member() {
652 let store = OrgStore::new();
653 let org = store.create("A", "owner");
654 store.backend.put_membership(&Membership {
655 org_id: org.id.clone(),
656 user_id: "u2".into(),
657 role: OrgRole::Member,
658 joined_at: 1,
659 });
660 assert!(store.remove_member(&org.id, "u2"));
661 assert!(store.role_of(&org.id, "u2").is_none());
662 }
663}