Skip to main content

pylon_auth/
org.rs

1//! Organizations + memberships + invites — multi-tenant team management.
2//!
3//! Sits alongside the existing in-memory `OrganizationsPlugin` in
4//! `pylon_plugin::builtin::organizations` but with:
5//!   - Pluggable [`OrgBackend`] trait (in-memory default, SQLite + PG
6//!     backends in pylon-runtime so orgs survive a restart)
7//!   - Email invite flow with token + expiry + accept endpoint
8//!   - Role enforcement helpers
9//!
10//! The HTTP endpoints in `routes/auth.rs` use this directly. Apps
11//! that want their own org model can ignore the store and roll their
12//! own — pylon doesn't force the schema, only ships the backend +
13//! endpoints when you opt in.
14
15use std::collections::HashMap;
16use std::sync::Mutex;
17
18use serde::{Deserialize, Serialize};
19
20/// Role within an organization.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum OrgRole {
24    /// Can do everything, including deleting the org and reassigning
25    /// ownership. Multiple owners allowed (pass an existing owner's
26    /// successor before they leave).
27    Owner,
28    /// Manage members + invites + most settings, but cannot delete
29    /// the org or transfer ownership.
30    Admin,
31    /// Default role for invited members.
32    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    /// User id of whoever created the org. Distinct from "owner" —
67    /// ownership can be transferred but creator is immutable.
68    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    /// Stable id — `inv_<24-char-base64url>`. What you reference in
83    /// management UIs (revoke, resend).
84    pub id: String,
85    pub org_id: String,
86    /// Email of the invitee. Lowercased before storage so case-only
87    /// duplicates collapse.
88    pub email: String,
89    /// Role the invitee will receive on accept.
90    pub role: OrgRole,
91    /// User id of whoever sent the invite. Used in the email body
92    /// ("Alice invited you to Acme Corp").
93    pub invited_by: String,
94    /// Single-use random token — what the invitee clicks. Stored
95    /// hashed (Argon2) so a DB read doesn't leak active invites.
96    /// The plaintext is sent in the email and never persisted.
97    pub token_hash: String,
98    /// First 8 chars of the plaintext token — display in management
99    /// UIs so the inviter can identify which link they sent.
100    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    /// All non-accepted invites whose plaintext starts with `prefix`.
122    /// SQL backends use a `WHERE token_prefix = $1 AND accepted_at IS NULL`
123    /// SELECT; the in-memory backend scans all invites. Argon2 verify
124    /// then runs against the candidate set in `accept_invite`.
125    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        // Include accepted invites in the candidate set so the
227        // accept path can return `AlreadyAccepted` (good UX) instead
228        // of `NotFound` (confusing — looks like a typo in the link).
229        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    /// Plaintext token — show in `accept_url`, never persist. Lost
253    /// after this method returns.
254    pub token: String,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum AcceptError {
259    /// Token doesn't match any stored invite (typo, never sent,
260    /// or revoked by an admin). Frontend should ask the user to
261    /// request a fresh invite.
262    NotFound,
263    /// Invite is past `expires_at`. Frontend should ask for a resend.
264    Expired,
265    /// Invite was already redeemed by SOMEONE (possibly the same
266    /// user, possibly a different account that shared the email).
267    /// **Frontends should treat this as success** for UX — the user
268    /// is effectively in the org via that prior accept; surface as
269    /// "you're already a member" not as an error.
270    AlreadyAccepted,
271    /// The accepting user's email doesn't match the invite's
272    /// addressee. This is the security gate — surface as a real
273    /// error ("this invite was sent to <other-email>; sign in
274    /// with that account to accept").
275    EmailMismatch,
276    /// User is already a member of this org via a DIFFERENT path
277    /// (e.g. they created the org themselves, or accepted an earlier
278    /// invite). **Frontends should treat this as success** — the
279    /// invite was redundant.
280    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    /// Create an org. Creator becomes Owner.
305    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    /// Delete an org + all its memberships + all pending invites.
354    pub fn delete(&self, org_id: &str) -> bool {
355        self.backend.delete_org(org_id)
356    }
357
358    /// Mint an invite. Returns the plaintext token alongside the
359    /// stored record — caller is responsible for emailing the
360    /// plaintext to the invitee. The token is single-use, expires
361    /// in 7 days, and is rejected for any account whose email
362    /// doesn't match the invite's `email` field.
363    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; // 7 days
375        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    /// Accept an invite. Verifies the token (Argon2 hash compare),
400    /// checks expiry + accepted-at, ensures the accepting user's
401    /// email matches the invite, and either creates the membership
402    /// or returns the right error variant. The invite row is
403    /// updated with `accepted_at` (not deleted) so the audit trail
404    /// stays intact.
405    pub fn accept_invite(
406        &self,
407        token: &str,
408        accepting_user_id: &str,
409        accepting_email: &str,
410    ) -> Result<Membership, AcceptError> {
411        // Linear scan for the matching token hash. At org-management
412        // scale (handfuls of pending invites per org) this is fine;
413        // an index by token-hash-prefix would help if it ever wasn't.
414        // We can't store the token directly because that would let a
415        // DB read hand attackers active invite links.
416        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        // Stamp the invite as accepted (audit).
443        let mut updated = invite;
444        updated.accepted_at = Some(now_secs());
445        self.backend.put_invite(&updated);
446        Ok(membership)
447    }
448
449    /// Resolve a plaintext invite token to its stored record.
450    /// Narrows by `token_prefix` (cheap SQL index lookup) then
451    /// Argon2-verifies the candidate set. Argon2 is non-deterministic
452    /// so we can't direct-lookup by hash — but invitations live for
453    /// 7 days max and prefix collisions are 64 bits → effectively 1
454    /// candidate per query in practice.
455    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        // u1 owns A and isn't in C yet — set_role only updates an
503        // existing membership, so add it via the backend.
504        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        // Audit: invite stamped accepted, not deleted.
558        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}