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}
127
128pub struct InMemoryOrgBackend {
129 orgs: Mutex<HashMap<String, Org>>,
130 memberships: Mutex<HashMap<(String, String), Membership>>,
131 invites: Mutex<HashMap<String, Invite>>,
132}
133
134impl Default for InMemoryOrgBackend {
135 fn default() -> Self {
136 Self {
137 orgs: Mutex::new(HashMap::new()),
138 memberships: Mutex::new(HashMap::new()),
139 invites: Mutex::new(HashMap::new()),
140 }
141 }
142}
143
144impl OrgBackend for InMemoryOrgBackend {
145 fn put_org(&self, org: &Org) {
146 self.orgs.lock().unwrap().insert(org.id.clone(), org.clone());
147 }
148 fn get_org(&self, id: &str) -> Option<Org> {
149 self.orgs.lock().unwrap().get(id).cloned()
150 }
151 fn delete_org(&self, id: &str) -> bool {
152 let removed = self.orgs.lock().unwrap().remove(id).is_some();
153 if removed {
154 self.memberships
155 .lock()
156 .unwrap()
157 .retain(|(o, _), _| o != id);
158 self.invites
159 .lock()
160 .unwrap()
161 .retain(|_, inv| inv.org_id != id);
162 }
163 removed
164 }
165 fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
166 let m = self.memberships.lock().unwrap();
167 let o = self.orgs.lock().unwrap();
168 m.values()
169 .filter(|mem| mem.user_id == user_id)
170 .filter_map(|mem| o.get(&mem.org_id).map(|org| (org.clone(), mem.role)))
171 .collect()
172 }
173
174 fn put_membership(&self, m: &Membership) {
175 self.memberships
176 .lock()
177 .unwrap()
178 .insert((m.org_id.clone(), m.user_id.clone()), m.clone());
179 }
180 fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership> {
181 self.memberships
182 .lock()
183 .unwrap()
184 .get(&(org_id.to_string(), user_id.to_string()))
185 .cloned()
186 }
187 fn delete_membership(&self, org_id: &str, user_id: &str) -> bool {
188 self.memberships
189 .lock()
190 .unwrap()
191 .remove(&(org_id.to_string(), user_id.to_string()))
192 .is_some()
193 }
194 fn list_members(&self, org_id: &str) -> Vec<Membership> {
195 self.memberships
196 .lock()
197 .unwrap()
198 .values()
199 .filter(|m| m.org_id == org_id)
200 .cloned()
201 .collect()
202 }
203
204 fn put_invite(&self, inv: &Invite) {
205 self.invites
206 .lock()
207 .unwrap()
208 .insert(inv.id.clone(), inv.clone());
209 }
210 fn get_invite(&self, id: &str) -> Option<Invite> {
211 self.invites.lock().unwrap().get(id).cloned()
212 }
213 fn list_invites(&self, org_id: &str) -> Vec<Invite> {
214 self.invites
215 .lock()
216 .unwrap()
217 .values()
218 .filter(|i| i.org_id == org_id && i.accepted_at.is_none())
219 .cloned()
220 .collect()
221 }
222 fn delete_invite(&self, id: &str) -> bool {
223 self.invites.lock().unwrap().remove(id).is_some()
224 }
225 fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite> {
226 self.invites
230 .lock()
231 .unwrap()
232 .values()
233 .filter(|i| i.token_prefix == prefix)
234 .cloned()
235 .collect()
236 }
237}
238
239pub struct OrgStore {
240 backend: Box<dyn OrgBackend>,
241}
242
243impl Default for OrgStore {
244 fn default() -> Self {
245 Self::new()
246 }
247}
248
249#[derive(Debug, Clone)]
250pub struct InviteWithToken {
251 pub invite: Invite,
252 pub token: String,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum AcceptError {
259 NotFound,
263 Expired,
265 AlreadyAccepted,
271 EmailMismatch,
276 AlreadyMember,
281}
282
283impl std::fmt::Display for AcceptError {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.write_str(match self {
286 Self::NotFound => "invite not found",
287 Self::Expired => "invite expired",
288 Self::AlreadyAccepted => "invite already accepted",
289 Self::EmailMismatch => "invite email doesn't match this account",
290 Self::AlreadyMember => "user is already a member of this org",
291 })
292 }
293}
294
295impl OrgStore {
296 pub fn new() -> Self {
297 Self::with_backend(Box::new(InMemoryOrgBackend::default()))
298 }
299
300 pub fn with_backend(backend: Box<dyn OrgBackend>) -> Self {
301 Self { backend }
302 }
303
304 pub fn create(&self, name: &str, creator_id: &str) -> Org {
306 let id = format!("org_{}", random_token(20));
307 let org = Org {
308 id: id.clone(),
309 name: name.to_string(),
310 created_by: creator_id.to_string(),
311 created_at: now_secs(),
312 };
313 self.backend.put_org(&org);
314 self.backend.put_membership(&Membership {
315 org_id: id,
316 user_id: creator_id.to_string(),
317 role: OrgRole::Owner,
318 joined_at: now_secs(),
319 });
320 org
321 }
322
323 pub fn get(&self, org_id: &str) -> Option<Org> {
324 self.backend.get_org(org_id)
325 }
326
327 pub fn list_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
328 self.backend.list_orgs_for_user(user_id)
329 }
330
331 pub fn list_members(&self, org_id: &str) -> Vec<Membership> {
332 self.backend.list_members(org_id)
333 }
334
335 pub fn role_of(&self, org_id: &str, user_id: &str) -> Option<OrgRole> {
336 self.backend.get_membership(org_id, user_id).map(|m| m.role)
337 }
338
339 pub fn set_role(&self, org_id: &str, user_id: &str, role: OrgRole) -> bool {
340 if let Some(mut m) = self.backend.get_membership(org_id, user_id) {
341 m.role = role;
342 self.backend.put_membership(&m);
343 true
344 } else {
345 false
346 }
347 }
348
349 pub fn remove_member(&self, org_id: &str, user_id: &str) -> bool {
350 self.backend.delete_membership(org_id, user_id)
351 }
352
353 pub fn delete(&self, org_id: &str) -> bool {
355 self.backend.delete_org(org_id)
356 }
357
358 pub fn create_invite(
364 &self,
365 org_id: &str,
366 email: &str,
367 role: OrgRole,
368 invited_by: &str,
369 ) -> InviteWithToken {
370 let id = format!("inv_{}", random_token(20));
371 let token = random_token(24);
372 let token_hash = crate::password::hash_password(&token);
373 let token_prefix: String = token.chars().take(8).collect();
374 let expires_at = now_secs() + 7 * 24 * 60 * 60; let invite = Invite {
376 id,
377 org_id: org_id.to_string(),
378 email: email.to_lowercase(),
379 role,
380 invited_by: invited_by.to_string(),
381 token_hash,
382 token_prefix,
383 created_at: now_secs(),
384 expires_at,
385 accepted_at: None,
386 };
387 self.backend.put_invite(&invite);
388 InviteWithToken { invite, token }
389 }
390
391 pub fn list_invites(&self, org_id: &str) -> Vec<Invite> {
392 self.backend.list_invites(org_id)
393 }
394
395 pub fn revoke_invite(&self, invite_id: &str) -> bool {
396 self.backend.delete_invite(invite_id)
397 }
398
399 pub fn accept_invite(
406 &self,
407 token: &str,
408 accepting_user_id: &str,
409 accepting_email: &str,
410 ) -> Result<Membership, AcceptError> {
411 let invite = self
417 .find_invite_by_plaintext(token)
418 .ok_or(AcceptError::NotFound)?;
419 if invite.accepted_at.is_some() {
420 return Err(AcceptError::AlreadyAccepted);
421 }
422 if invite.expires_at <= now_secs() {
423 return Err(AcceptError::Expired);
424 }
425 if invite.email != accepting_email.to_lowercase() {
426 return Err(AcceptError::EmailMismatch);
427 }
428 if self
429 .backend
430 .get_membership(&invite.org_id, accepting_user_id)
431 .is_some()
432 {
433 return Err(AcceptError::AlreadyMember);
434 }
435 let membership = Membership {
436 org_id: invite.org_id.clone(),
437 user_id: accepting_user_id.to_string(),
438 role: invite.role,
439 joined_at: now_secs(),
440 };
441 self.backend.put_membership(&membership);
442 let mut updated = invite;
444 updated.accepted_at = Some(now_secs());
445 self.backend.put_invite(&updated);
446 Ok(membership)
447 }
448
449 fn find_invite_by_plaintext(&self, token: &str) -> Option<Invite> {
456 let prefix: String = token.chars().take(8).collect();
457 for inv in self.backend.invites_by_prefix(&prefix) {
458 if crate::password::verify_password(token, &inv.token_hash) {
459 return Some(inv);
460 }
461 }
462 None
463 }
464}
465
466fn random_token(n_bytes: usize) -> String {
467 use rand::RngCore;
468 let mut bytes = vec![0u8; n_bytes];
469 rand::thread_rng().fill_bytes(&mut bytes);
470 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
471 URL_SAFE_NO_PAD.encode(bytes)
472}
473
474fn now_secs() -> u64 {
475 use std::time::{SystemTime, UNIX_EPOCH};
476 SystemTime::now()
477 .duration_since(UNIX_EPOCH)
478 .unwrap_or_default()
479 .as_secs()
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 #[test]
487 fn create_org_makes_creator_owner() {
488 let store = OrgStore::new();
489 let org = store.create("Acme", "user-1");
490 assert!(org.id.starts_with("org_"));
491 assert_eq!(org.name, "Acme");
492 assert_eq!(store.role_of(&org.id, "user-1"), Some(OrgRole::Owner));
493 }
494
495 #[test]
496 fn list_for_user_returns_all_orgs() {
497 let store = OrgStore::new();
498 let a = store.create("A", "u1");
499 let _b = store.create("B", "u2");
500 let c = store.create("C", "u3");
501 store.set_role(&c.id, "u1", OrgRole::Member);
502 store
505 .backend
506 .put_membership(&Membership {
507 org_id: c.id.clone(),
508 user_id: "u1".into(),
509 role: OrgRole::Member,
510 joined_at: 1,
511 });
512 let list = store.list_for_user("u1");
513 assert_eq!(list.len(), 2);
514 let names: Vec<_> = list.iter().map(|(o, _)| o.name.clone()).collect();
515 assert!(names.contains(&"A".to_string()));
516 assert!(names.contains(&"C".to_string()));
517 assert!(!names.contains(&"B".to_string()));
518 }
519
520 #[test]
521 fn role_helpers() {
522 assert!(OrgRole::Owner.can_manage_members());
523 assert!(OrgRole::Owner.can_delete_org());
524 assert!(OrgRole::Admin.can_manage_members());
525 assert!(!OrgRole::Admin.can_delete_org());
526 assert!(!OrgRole::Member.can_manage_members());
527 }
528
529 #[test]
530 fn delete_cascades_memberships_and_invites() {
531 let store = OrgStore::new();
532 let org = store.create("A", "owner-1");
533 let _inv = store.create_invite(&org.id, "x@example.com", OrgRole::Member, "owner-1");
534 assert_eq!(store.list_invites(&org.id).len(), 1);
535 assert_eq!(store.list_members(&org.id).len(), 1);
536 assert!(store.delete(&org.id));
537 assert!(store.get(&org.id).is_none());
538 assert!(store.list_members(&org.id).is_empty());
539 assert!(store.list_invites(&org.id).is_empty());
540 }
541
542 #[test]
543 fn accept_invite_creates_membership() {
544 let store = OrgStore::new();
545 let org = store.create("Acme", "owner-1");
546 let invited = store.create_invite(
547 &org.id,
548 "newbie@example.com",
549 OrgRole::Admin,
550 "owner-1",
551 );
552 let m = store
553 .accept_invite(&invited.token, "user-2", "newbie@example.com")
554 .expect("accept");
555 assert_eq!(m.role, OrgRole::Admin);
556 assert_eq!(store.role_of(&org.id, "user-2"), Some(OrgRole::Admin));
557 let stored = store.backend.get_invite(&invited.invite.id).unwrap();
559 assert!(stored.accepted_at.is_some());
560 }
561
562 #[test]
563 fn accept_invite_rejects_wrong_email() {
564 let store = OrgStore::new();
565 let org = store.create("Acme", "owner-1");
566 let invited =
567 store.create_invite(&org.id, "alice@example.com", OrgRole::Member, "owner-1");
568 let err = store
569 .accept_invite(&invited.token, "user-2", "bob@example.com")
570 .unwrap_err();
571 assert_eq!(err, AcceptError::EmailMismatch);
572 }
573
574 #[test]
575 fn accept_invite_rejects_replay() {
576 let store = OrgStore::new();
577 let org = store.create("A", "owner");
578 let invited = store.create_invite(&org.id, "a@b.com", OrgRole::Member, "owner");
579 store
580 .accept_invite(&invited.token, "user-2", "a@b.com")
581 .unwrap();
582 let second = store.accept_invite(&invited.token, "user-2", "a@b.com");
583 assert_eq!(second.unwrap_err(), AcceptError::AlreadyAccepted);
584 }
585
586 #[test]
587 fn accept_invite_rejects_unknown_token() {
588 let store = OrgStore::new();
589 let _org = store.create("A", "owner");
590 let err = store
591 .accept_invite("not-a-real-token", "user-2", "x@y.com")
592 .unwrap_err();
593 assert_eq!(err, AcceptError::NotFound);
594 }
595
596 #[test]
597 fn invite_email_lowercased() {
598 let store = OrgStore::new();
599 let org = store.create("A", "owner");
600 let inv = store.create_invite(&org.id, "Mixed@CASE.com", OrgRole::Member, "owner");
601 assert_eq!(inv.invite.email, "mixed@case.com");
602 }
603
604 #[test]
605 fn revoke_invite() {
606 let store = OrgStore::new();
607 let org = store.create("A", "owner");
608 let inv = store.create_invite(&org.id, "x@y.com", OrgRole::Member, "owner");
609 assert!(store.revoke_invite(&inv.invite.id));
610 assert!(store.list_invites(&org.id).is_empty());
611 }
612
613 #[test]
614 fn remove_member() {
615 let store = OrgStore::new();
616 let org = store.create("A", "owner");
617 store.backend.put_membership(&Membership {
618 org_id: org.id.clone(),
619 user_id: "u2".into(),
620 role: OrgRole::Member,
621 joined_at: 1,
622 });
623 assert!(store.remove_member(&org.id, "u2"));
624 assert!(store.role_of(&org.id, "u2").is_none());
625 }
626}