1mod callbacks;
2mod fields;
3pub use callbacks::OrganizationCallbacks;
4pub mod handlers;
5pub mod hooks;
6mod input;
7mod native_json;
8mod policy;
9mod request;
10mod server_api;
11pub use better_auth_core::organization_fields::OrganizationFields;
12pub use server_api::{AddMemberInput, OrganizationApi};
13pub mod rbac;
14pub mod types;
15
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use async_trait::async_trait;
20use better_auth_core::entity::AuthSession;
21use better_auth_core::error::AuthResult;
22use better_auth_core::plugin::{AuthContext, AuthPlugin, AuthRoute};
23use better_auth_core::types::{AuthRequest, AuthResponse, HttpMethod};
24
25#[derive(Clone)]
27pub struct InvitationEmail {
28 pub invitation: better_auth_core::wire::InvitationView,
30 pub organization: types::OrganizationResponse,
32 pub member: better_auth_core::types::Member,
34 pub inviter: better_auth_core::wire::UserView,
36 pub request: Option<AuthRequest>,
38}
39
40#[async_trait]
42pub trait SendInvitationEmail: Send + Sync {
43 async fn send(&self, email: &InvitationEmail) -> AuthResult<()>;
45}
46
47#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
49#[serde(default)]
50pub struct RolePermissions {
51 pub organization: Vec<String>,
52 pub member: Vec<String>,
53 pub invitation: Vec<String>,
54 pub api_key: Vec<String>,
58 pub team: Vec<String>,
59 pub ac: Vec<String>,
60 #[serde(flatten)]
61 pub additional: HashMap<String, Vec<String>>,
62}
63
64#[derive(Clone)]
66pub struct OrganizationTeamsConfig {
67 pub enabled: bool,
68 pub default_team: bool,
69 pub allow_removing_all_teams: bool,
70 pub maximum_teams: Option<usize>,
71 pub maximum_members_per_team: Option<usize>,
72 pub maximum_members_per_team_callback: Option<Arc<dyn hooks::TeamMemberLimitPolicy>>,
74}
75
76impl std::fmt::Debug for OrganizationTeamsConfig {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.debug_struct("OrganizationTeamsConfig")
79 .field("enabled", &self.enabled)
80 .field("default_team", &self.default_team)
81 .field("allow_removing_all_teams", &self.allow_removing_all_teams)
82 .field("maximum_teams", &self.maximum_teams)
83 .field("maximum_members_per_team", &self.maximum_members_per_team)
84 .field(
85 "maximum_members_per_team_callback",
86 &self
87 .maximum_members_per_team_callback
88 .as_ref()
89 .map(|_| "custom"),
90 )
91 .finish()
92 }
93}
94
95impl Default for OrganizationTeamsConfig {
96 fn default() -> Self {
97 Self {
98 enabled: false,
99 default_team: true,
100 allow_removing_all_teams: false,
101 maximum_teams: None,
102 maximum_members_per_team: None,
103 maximum_members_per_team_callback: None,
104 }
105 }
106}
107
108#[derive(Clone, better_auth_core::PluginConfig)]
110#[plugin(name = "OrganizationPlugin")]
111pub struct OrganizationConfig {
112 #[config(default = better_auth_core::organization_fields::OrganizationFields::default(), skip)]
114 pub schema: OrganizationFields,
115
116 #[config(default = None, skip)]
118 pub hooks: Option<Arc<dyn hooks::OrganizationHooks>>,
119 #[config(default = None, skip)]
121 pub policy: Option<Arc<dyn hooks::OrganizationPolicy>>,
122 #[config(default = OrganizationTeamsConfig::default(), skip)]
124 pub teams: OrganizationTeamsConfig,
125 #[config(default = false)]
127 pub dynamic_access_control: bool,
128 #[config(default = None)]
130 pub maximum_roles_per_organization: Option<usize>,
131 #[config(default = None, skip)]
133 pub ac: Option<HashMap<String, Vec<String>>>,
134 #[config(default = true)]
136 pub allow_user_to_create_organization: bool,
137 #[config(default = None)]
139 pub organization_limit: Option<usize>,
140 #[config(default = Some(100))]
142 pub membership_limit: Option<usize>,
143 #[config(default = None, skip)]
145 pub membership_limit_callback: Option<Arc<dyn hooks::MembershipLimitPolicy>>,
146 #[config(default = "owner".to_string())]
148 pub creator_role: String,
149 #[config(default = 172800.0)]
151 pub invitation_expires_in: f64,
152 #[config(default = Some(100))]
154 pub invitation_limit: Option<usize>,
155 #[config(default = false)]
157 pub cancel_pending_invitations_on_re_invite: bool,
158 #[config(default = false)]
160 pub require_email_verification_on_invitation: bool,
161 #[config(default = false)]
163 pub disable_organization_deletion: bool,
164 #[config(default = None, skip)]
166 pub roles: Option<HashMap<String, RolePermissions>>,
167 #[config(default = None, skip)]
169 pub send_invitation_email: Option<Arc<dyn SendInvitationEmail>>,
170}
171
172impl std::fmt::Debug for OrganizationConfig {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.debug_struct("OrganizationConfig")
175 .field(
176 "allow_user_to_create_organization",
177 &self.allow_user_to_create_organization,
178 )
179 .field("organization_limit", &self.organization_limit)
180 .field("membership_limit", &self.membership_limit)
181 .field("creator_role", &self.creator_role)
182 .field("invitation_expires_in", &self.invitation_expires_in)
183 .field("invitation_limit", &self.invitation_limit)
184 .field(
185 "cancel_pending_invitations_on_re_invite",
186 &self.cancel_pending_invitations_on_re_invite,
187 )
188 .field(
189 "require_email_verification_on_invitation",
190 &self.require_email_verification_on_invitation,
191 )
192 .field(
193 "disable_organization_deletion",
194 &self.disable_organization_deletion,
195 )
196 .field("roles", &self.roles)
197 .field("teams", &self.teams)
198 .field("dynamic_access_control", &self.dynamic_access_control)
199 .field(
200 "maximum_roles_per_organization",
201 &self.maximum_roles_per_organization,
202 )
203 .field("ac", &self.ac)
204 .field(
205 "send_invitation_email",
206 &self.send_invitation_email.as_ref().map(|_| "custom"),
207 )
208 .finish()
209 }
210}
211
212#[derive(Clone)]
214pub struct OrganizationPlugin {
215 config: OrganizationConfig,
216}
217
218impl OrganizationPlugin {
219 pub fn membership_limit_callback(
221 mut self,
222 callback: Arc<dyn hooks::MembershipLimitPolicy>,
223 ) -> Self {
224 self.config.membership_limit_callback = Some(callback);
225 self
226 }
227 pub fn hooks(mut self, hooks: Arc<dyn hooks::OrganizationHooks>) -> Self {
229 self.config.hooks = Some(hooks);
230 self
231 }
232 pub fn policy(mut self, policy: Arc<dyn hooks::OrganizationPolicy>) -> Self {
234 self.config.policy = Some(policy);
235 self
236 }
237 pub fn teams(mut self, teams: OrganizationTeamsConfig) -> Self {
239 self.config.teams = teams;
240 self
241 }
242 pub fn access_control(mut self, ac: HashMap<String, Vec<String>>) -> Self {
244 self.config.ac = Some(ac);
245 self
246 }
247 pub fn custom_send_invitation_email(mut self, sender: Arc<dyn SendInvitationEmail>) -> Self {
249 self.config.send_invitation_email = Some(sender);
250 self
251 }
252}
253
254pub(crate) const METADATA_DYNAMIC_ACCESS_CONTROL: &str = "organization.dynamic_access_control";
256pub(crate) const METADATA_AC: &str = "organization.ac";
257pub(crate) const METADATA_ENABLED: &str = "organization.enabled";
258pub(crate) const METADATA_ROLES: &str = "organization.roles";
261pub(crate) const METADATA_CREATOR_ROLE: &str = "organization.creator_role";
263
264#[async_trait]
265impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for OrganizationPlugin {
266 fn name(&self) -> &'static str {
267 "organization"
268 }
269
270 fn openapi(
271 &self,
272 ) -> better_auth_core::AuthResult<better_auth_core::openapi::OpenApiPluginMetadata> {
273 better_auth_core::openapi::OpenApiPluginMetadata::from_routes(
274 <Self as better_auth_core::AuthPlugin<S>>::name(self),
275 <Self as better_auth_core::AuthPlugin<S>>::routes(self),
276 )?
277 .organization_fields(&self.config.schema)
278 }
279
280 async fn on_init(
281 &self,
282 ctx: &mut better_auth_core::AuthInitContext<S>,
283 ) -> better_auth_core::AuthResult<()> {
284 ctx.register_organization_schema(&self.config.schema, self.config.teams.enabled);
285 ctx.extensions.insert(self.config.schema.clone());
286 ctx.extensions.insert(self.clone());
287
288 S::Session::require_plugin_fields("organization", &["active_organization_id"])?;
289 if self.config.teams.enabled {
290 S::Session::require_plugin_fields("organization", &["active_team_id"])?;
291 }
292 ctx.set_metadata(
293 METADATA_DYNAMIC_ACCESS_CONTROL,
294 serde_json::json!(self.config.dynamic_access_control),
295 );
296 ctx.set_metadata(METADATA_AC, serde_json::json!(self.config.ac));
297 ctx.set_metadata(
298 "organization.teams_enabled",
299 serde_json::json!(self.config.teams.enabled),
300 );
301 ctx.set_metadata(METADATA_ENABLED, serde_json::Value::Bool(true));
302 ctx.set_metadata(
303 METADATA_ROLES,
304 serde_json::to_value(&self.config.roles).unwrap_or_default(),
305 );
306 ctx.set_metadata(
307 METADATA_CREATOR_ROLE,
308 serde_json::Value::String(self.config.creator_role.clone()),
309 );
310 Ok(())
311 }
312
313 fn routes(&self) -> Vec<AuthRoute> {
314 let mut routes = vec![
315 AuthRoute::post("/organization/create", "createOrganization"),
317 AuthRoute::post("/organization/update", "updateOrganization").require_headers(true),
318 AuthRoute::post("/organization/delete", "deleteOrganization").require_headers(true),
319 AuthRoute::get("/organization/list", "listOrganizations").require_headers(true),
320 AuthRoute::get("/organization/get-organization", "getOrganization")
321 .require_headers(true)
322 .query_validator(crate::plugins::query_input::organization),
323 AuthRoute::get("/organization/get-full-organization", "getFullOrganization")
324 .require_headers(true)
325 .query_validator(crate::plugins::query_input::full_organization),
326 AuthRoute::post("/organization/check-slug", "checkOrganizationSlug"),
327 AuthRoute::post("/organization/set-active", "setActiveOrganization")
328 .require_headers(true),
329 AuthRoute::post("/organization/leave", "leaveOrganization").require_headers(true),
330 AuthRoute::get("/organization/get-active-member", "getActiveMember")
332 .require_headers(true),
333 AuthRoute::get(
334 "/organization/get-active-member-role",
335 "getActiveMemberRole",
336 )
337 .require_headers(true)
338 .query_validator(crate::plugins::query_input::active_member_role),
339 AuthRoute::get("/organization/list-members", "listMembers")
340 .require_headers(true)
341 .query_validator(crate::plugins::query_input::list_members),
342 AuthRoute::post("/organization/remove-member", "removeMember").require_headers(true),
343 AuthRoute::post(
344 "/organization/update-member-role",
345 "updateOrganizationMemberRole",
346 )
347 .require_headers(true),
348 AuthRoute::post(
350 "/organization/invite-member",
351 "createOrganizationInvitation",
352 )
353 .require_headers(true),
354 AuthRoute::get("/organization/get-invitation", "getInvitation")
355 .require_headers(true)
356 .query_validator(crate::plugins::query_input::invitation),
357 AuthRoute::get("/organization/list-invitations", "listInvitations")
358 .require_headers(true)
359 .query_validator(crate::plugins::query_input::organization_id),
360 AuthRoute::get("/organization/list-user-invitations", "listUserInvitations")
361 .query_validator(crate::plugins::query_input::user_invitations),
362 AuthRoute::post("/organization/accept-invitation", "acceptInvitation")
363 .require_headers(true),
364 AuthRoute::post("/organization/reject-invitation", "rejectInvitation")
365 .require_headers(true),
366 AuthRoute::post("/organization/cancel-invitation", "cancelInvitation")
367 .require_headers(true),
368 AuthRoute::post("/organization/has-permission", "hasPermission").require_headers(true),
370 ];
371 if self.config.teams.enabled {
372 routes.extend(handlers::team::routes());
373 }
374 if self.config.dynamic_access_control {
375 for (path, method) in [
376 ("create-role", HttpMethod::Post),
377 ("get-role", HttpMethod::Get),
378 ("list-roles", HttpMethod::Get),
379 ("update-role", HttpMethod::Post),
380 ("delete-role", HttpMethod::Post),
381 ] {
382 routes.push(
383 match method {
384 HttpMethod::Get => AuthRoute::get(format!("/organization/{path}"), path)
385 .query_validator(if path == "get-role" {
386 crate::plugins::query_input::organization_role
387 } else {
388 crate::plugins::query_input::organization_id
389 }),
390 _ => AuthRoute::post(format!("/organization/{path}"), path),
391 }
392 .require_headers(true),
393 );
394 }
395 }
396 routes
397 .into_iter()
398 .map(|route| {
399 if route.method == HttpMethod::Post {
400 let schema = request::prepared_schema(&route.path, &self.config.schema);
401 route.body_validator(move |req| request::validate(req, &schema))
402 } else {
403 route
404 }
405 })
406 .collect()
407 }
408
409 async fn after_request(
410 &self,
411 req: &AuthRequest,
412 response: &mut AuthResponse,
413 _ctx: &AuthContext<S>,
414 ) -> AuthResult<()> {
415 if !response
416 .headers
417 .get("content-type")
418 .is_some_and(|value| value.starts_with("application/json"))
419 || response.body.is_empty()
420 {
421 return Ok(());
422 }
423 if let better_auth_core::ResponseBody::Native(value) = &mut response.body {
424 fields::shape_native_session_teams(value, self.config.teams.enabled);
425 if response.status < 400 && req.path().starts_with("/organization/") {
426 fields::filter_native_response(req.path(), value, &self.config.schema);
427 }
428 return Ok(());
429 }
430 let mut value = serde_json::from_slice(&response.body.bytes()?)?;
431 let changed = fields::shape_session_teams(&mut value, self.config.teams.enabled)?;
432 if response.status < 400 && req.path().starts_with("/organization/") {
433 let mut value = serde_json::from_str(value.get())?;
434 fields::filter_response(req.path(), &mut value, &self.config.schema);
435 response.body = better_auth_core::ResponseBody::Bytes(serde_json::to_vec(&value)?);
436 } else if changed {
437 response.body = better_auth_core::ResponseBody::Bytes(serde_json::to_vec(&value)?);
438 }
439 Ok(())
440 }
441
442 async fn on_request(
443 &self,
444 req: &AuthRequest,
445 ctx: &AuthContext<S>,
446 ) -> AuthResult<Option<AuthResponse>> {
447 if self.config.teams.enabled
448 && handlers::team::routes()
449 .iter()
450 .any(|route| route.path == req.path())
451 {
452 return handlers::team::handle_team_request(req, ctx, &self.config).await;
453 }
454 if self.config.dynamic_access_control
455 && matches!(
456 req.path(),
457 "/organization/create-role"
458 | "/organization/get-role"
459 | "/organization/list-roles"
460 | "/organization/update-role"
461 | "/organization/delete-role"
462 )
463 {
464 return handlers::roles::handle_role_request(req, ctx, &self.config).await;
465 }
466 let response: AuthResult<Option<AuthResponse>> = match (req.method(), req.path()) {
467 (HttpMethod::Post, "/organization/create") => Ok(Some(
469 handlers::org::handle_create_organization(req, ctx, &self.config).await?,
470 )),
471 (HttpMethod::Post, "/organization/update") => Ok(Some(
472 handlers::org::handle_update_organization(req, ctx, &self.config).await?,
473 )),
474 (HttpMethod::Post, "/organization/delete") => Ok(Some(
475 handlers::org::handle_delete_organization(req, ctx, &self.config).await?,
476 )),
477 (HttpMethod::Get, "/organization/list") => Ok(Some(
478 handlers::org::handle_list_organizations(req, ctx).await?,
479 )),
480 (HttpMethod::Get, "/organization/get-organization") => Ok(Some(
481 handlers::org::handle_get_organization(req, ctx).await?,
482 )),
483 (HttpMethod::Get, "/organization/get-full-organization") => Ok(Some(
484 handlers::org::handle_get_full_organization(req, ctx, &self.config).await?,
485 )),
486 (HttpMethod::Post, "/organization/check-slug") => {
487 Ok(Some(handlers::org::handle_check_slug(req, ctx).await?))
488 }
489 (HttpMethod::Post, "/organization/set-active") => Ok(Some(
490 handlers::org::handle_set_active_organization(req, ctx).await?,
491 )),
492 (HttpMethod::Post, "/organization/leave") => Ok(Some(
493 handlers::org::handle_leave_organization(req, ctx, &self.config).await?,
494 )),
495 (HttpMethod::Get, "/organization/get-active-member") => Ok(Some(
497 handlers::member::handle_get_active_member(req, ctx).await?,
498 )),
499 (HttpMethod::Get, "/organization/get-active-member-role") => Ok(Some(
500 handlers::member::handle_get_active_member_role(req, ctx).await?,
501 )),
502 (HttpMethod::Get, "/organization/list-members") => Ok(Some(
503 handlers::member::handle_list_members(req, &self.config, ctx).await?,
504 )),
505 (HttpMethod::Post, "/organization/remove-member") => Ok(Some(
506 handlers::member::handle_remove_member(req, ctx, &self.config).await?,
507 )),
508 (HttpMethod::Post, "/organization/update-member-role") => Ok(Some(
509 handlers::member::handle_update_member_role(req, ctx, &self.config).await?,
510 )),
511 (HttpMethod::Post, "/organization/invite-member") => Ok(Some(
513 handlers::invitation::handle_invite_member(req, ctx, &self.config).await?,
514 )),
515 (HttpMethod::Get, "/organization/get-invitation") => Ok(Some(
516 handlers::invitation::handle_get_invitation(req, ctx, &self.config).await?,
517 )),
518 (HttpMethod::Get, "/organization/list-invitations") => Ok(Some(
519 handlers::invitation::handle_list_invitations(req, ctx).await?,
520 )),
521 (HttpMethod::Get, "/organization/list-user-invitations") => Ok(Some(
522 handlers::invitation::handle_list_user_invitations(req, ctx).await?,
523 )),
524 (HttpMethod::Post, "/organization/accept-invitation") => Ok(Some(
525 handlers::invitation::handle_accept_invitation(req, ctx, &self.config).await?,
526 )),
527 (HttpMethod::Post, "/organization/reject-invitation") => Ok(Some(
528 handlers::invitation::handle_reject_invitation(req, ctx, &self.config).await?,
529 )),
530 (HttpMethod::Post, "/organization/cancel-invitation") => Ok(Some(
531 handlers::invitation::handle_cancel_invitation(req, ctx, &self.config).await?,
532 )),
533 (HttpMethod::Post, "/organization/has-permission") => Ok(Some(
535 handlers::handle_has_permission(req, ctx, &self.config).await?,
536 )),
537 _ => Ok(None),
538 };
539 response?
540 .map(|mut response| {
541 shape_invitation_output(req.path(), &mut response, &self.config)?;
542 Ok(response)
543 })
544 .transpose()
545 }
546}
547
548fn shape_invitation_output(
549 path: &str,
550 response: &mut AuthResponse,
551 config: &OrganizationConfig,
552) -> AuthResult<()> {
553 if response.status >= 400
554 || !matches!(
555 path,
556 "/organization/invite-member"
557 | "/organization/get-invitation"
558 | "/organization/list-invitations"
559 | "/organization/list-user-invitations"
560 | "/organization/accept-invitation"
561 | "/organization/reject-invitation"
562 | "/organization/cancel-invitation"
563 | "/organization/get-full-organization"
564 )
565 {
566 return Ok(());
567 }
568 let mut value: serde_json::Value = serde_json::from_slice(&response.body.bytes()?)?;
569 let shape = |invitation: &mut serde_json::Value| {
570 if !fields::invitation_team_id_is_declared(config)
571 && let Some(object) = invitation.as_object_mut()
572 {
573 let _ = object.remove("teamId");
574 }
575 };
576 match path {
577 "/organization/list-invitations" | "/organization/list-user-invitations" => {
578 if let Some(invitations) = value.as_array_mut() {
579 invitations.iter_mut().for_each(shape);
580 }
581 }
582 "/organization/get-full-organization" => {
583 if let Some(invitations) = value
584 .get_mut("invitations")
585 .and_then(serde_json::Value::as_array_mut)
586 {
587 invitations.iter_mut().for_each(shape);
588 }
589 }
590 "/organization/accept-invitation" | "/organization/reject-invitation" => {
591 if let Some(invitation) = value.get_mut("invitation") {
592 shape(invitation);
593 }
594 }
595 _ => shape(&mut value),
596 }
597 response.body = better_auth_core::ResponseBody::Bytes(serde_json::to_vec(&value)?);
598 Ok(())
599}