authx_plugins/organization/
service.rs1use 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#[derive(Debug)]
18pub struct InviteDetails {
19 pub invite: Invite,
20 pub raw_token: String,
21}
22
23pub 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 #[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 #[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 #[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 #[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}