Skip to main content

authx_plugins/organization/
service.rs

1use chrono::Utc;
2use tracing::instrument;
3use uuid::Uuid;
4
5use authx_core::{
6    crypto::sha256_hex,
7    error::{AuthError, Result},
8    events::{AuthEvent, EventBus},
9    models::{CreateInvite, CreateOrg, Invite, Membership, Organization, Role},
10    validation::validate_slug,
11};
12use authx_storage::ports::{
13    AuditLogRepository, InviteRepository, OrgRepository, SessionRepository,
14};
15
16/// Returned by `invite_member`. Caller sends the raw token in the invite email.
17#[derive(Debug)]
18pub struct InviteDetails {
19    pub invite: Invite,
20    pub raw_token: String,
21}
22
23/// High-level org management service.
24///
25/// Wraps [`OrgRepository`] + [`InviteRepository`] + [`AuditLogRepository`] with
26/// business logic for creating orgs, managing members, and the invite flow.
27pub struct OrgService<S> {
28    storage: S,
29    events: EventBus,
30}
31
32impl<S> OrgService<S>
33where
34    S: OrgRepository
35        + InviteRepository
36        + SessionRepository
37        + AuditLogRepository
38        + Clone
39        + Send
40        + Sync
41        + 'static,
42{
43    pub fn new(storage: S, events: EventBus) -> Self {
44        Self { storage, events }
45    }
46
47    /// Create a new org with an initial "owner" role.
48    /// The `owner_id` is added as a member with that role.
49    #[instrument(skip(self), fields(owner_id = %owner_id, slug = %slug))]
50    pub async fn create(
51        &self,
52        owner_id: Uuid,
53        name: String,
54        slug: String,
55        metadata: Option<serde_json::Value>,
56    ) -> Result<(Organization, Membership)> {
57        validate_slug(&slug)?;
58        let org = OrgRepository::create(
59            &self.storage,
60            CreateOrg {
61                name,
62                slug: slug.clone(),
63                metadata,
64            },
65        )
66        .await?;
67
68        let role =
69            OrgRepository::create_role(&self.storage, org.id, "owner".into(), vec!["*".into()])
70                .await?;
71
72        let membership =
73            OrgRepository::add_member(&self.storage, org.id, owner_id, role.id).await?;
74
75        tracing::info!(org_id = %org.id, owner_id = %owner_id, slug = %slug, "org created");
76        Ok((org, membership))
77    }
78
79    pub async fn get(&self, org_id: Uuid) -> Result<Organization> {
80        OrgRepository::find_by_id(&self.storage, org_id)
81            .await?
82            .ok_or(AuthError::Storage(
83                authx_core::error::StorageError::NotFound,
84            ))
85    }
86
87    pub async fn list_members(&self, org_id: Uuid) -> Result<Vec<Membership>> {
88        OrgRepository::get_members(&self.storage, org_id).await
89    }
90
91    pub async fn create_role(
92        &self,
93        org_id: Uuid,
94        name: String,
95        permissions: Vec<String>,
96    ) -> Result<Role> {
97        let role = OrgRepository::create_role(&self.storage, org_id, name, permissions).await?;
98        tracing::info!(org_id = %org_id, role_id = %role.id, "role created");
99        Ok(role)
100    }
101
102    pub async fn set_member_role(
103        &self,
104        org_id: Uuid,
105        user_id: Uuid,
106        role_id: Uuid,
107    ) -> Result<Membership> {
108        let m = OrgRepository::update_member_role(&self.storage, org_id, user_id, role_id).await?;
109        tracing::info!(org_id = %org_id, user_id = %user_id, role_id = %role_id, "member role updated");
110        Ok(m)
111    }
112
113    #[instrument(skip(self), fields(org_id = %org_id, user_id = %user_id))]
114    pub async fn remove_member(&self, org_id: Uuid, user_id: Uuid, actor_id: Uuid) -> Result<()> {
115        OrgRepository::remove_member(&self.storage, org_id, user_id).await?;
116        use authx_core::models::CreateAuditLog;
117        AuditLogRepository::append(
118            &self.storage,
119            CreateAuditLog {
120                user_id: Some(actor_id),
121                org_id: Some(org_id),
122                action: "org.remove_member".into(),
123                resource_type: "membership".into(),
124                resource_id: Some(user_id.to_string()),
125                ip_address: None,
126                metadata: None,
127            },
128        )
129        .await?;
130        tracing::info!(org_id = %org_id, user_id = %user_id, actor_id = %actor_id, "member removed");
131        Ok(())
132    }
133
134    /// Create an org invite. Returns the `Invite` row and the raw token.
135    /// The caller is responsible for emailing the raw token to the invitee.
136    #[instrument(skip(self), fields(org_id = %org_id, email = %email))]
137    pub async fn invite_member(
138        &self,
139        org_id: Uuid,
140        email: String,
141        role_id: Uuid,
142        _actor_id: Uuid,
143    ) -> Result<InviteDetails> {
144        let raw: [u8; 32] = rand::Rng::r#gen(&mut rand::thread_rng());
145        let raw_str = hex::encode(raw);
146        let token_hash = sha256_hex(raw_str.as_bytes());
147
148        let invite = InviteRepository::create(
149            &self.storage,
150            CreateInvite {
151                org_id,
152                email,
153                role_id,
154                token_hash,
155                expires_at: Utc::now() + chrono::Duration::hours(48),
156            },
157        )
158        .await?;
159
160        tracing::info!(org_id = %org_id, invite_id = %invite.id, "invite created");
161        Ok(InviteDetails {
162            invite,
163            raw_token: raw_str,
164        })
165    }
166
167    /// Accept an invite. Looks up the invite by raw token, verifies it hasn't
168    /// expired or been accepted, marks it accepted, then adds the user as a member.
169    #[instrument(skip(self, raw_token), fields(user_id = %user_id))]
170    pub async fn accept_invite(&self, raw_token: &str, user_id: Uuid) -> Result<Membership> {
171        let token_hash = sha256_hex(raw_token.as_bytes());
172        let invite = InviteRepository::find_by_token_hash(&self.storage, &token_hash)
173            .await?
174            .ok_or(AuthError::InvalidToken)?;
175
176        if invite.accepted_at.is_some() {
177            return Err(AuthError::InvalidToken);
178        }
179        if invite.expires_at < Utc::now() {
180            return Err(AuthError::InvalidToken);
181        }
182
183        InviteRepository::accept(&self.storage, invite.id).await?;
184        if let Some(existing) = OrgRepository::get_members(&self.storage, invite.org_id)
185            .await?
186            .into_iter()
187            .find(|membership| membership.user_id == user_id)
188        {
189            tracing::info!(
190                org_id = %invite.org_id,
191                user_id = %user_id,
192                membership_id = %existing.id,
193                "invite accepted for existing org member"
194            );
195            return Ok(existing);
196        }
197        let membership =
198            OrgRepository::add_member(&self.storage, invite.org_id, user_id, invite.role_id)
199                .await?;
200
201        self.events.emit(AuthEvent::InviteAccepted {
202            membership: membership.clone(),
203        });
204        tracing::info!(org_id = %invite.org_id, user_id = %user_id, "invite accepted");
205        Ok(membership)
206    }
207
208    /// Switch the active org on a session.
209    #[instrument(skip(self), fields(session_id = %session_id, org_id = ?org_id))]
210    pub async fn switch_org(
211        &self,
212        session_id: Uuid,
213        org_id: Option<Uuid>,
214    ) -> Result<authx_core::models::Session> {
215        let session = SessionRepository::set_org(&self.storage, session_id, org_id).await?;
216        tracing::info!(session_id = %session_id, org_id = ?org_id, "org switched");
217        Ok(session)
218    }
219}