Skip to main content

better_auth_api/plugins/organization/
mod.rs

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/// Data supplied when an invitation is created or resent.
26#[derive(Clone)]
27pub struct InvitationEmail {
28    /// The projected invitation, including its current expiration and declared fields.
29    pub invitation: better_auth_core::wire::InvitationView,
30    /// The organization receiving the invited member.
31    pub organization: types::OrganizationResponse,
32    /// The member sending the invitation.
33    pub member: better_auth_core::types::Member,
34    /// The user sending the invitation.
35    pub inviter: better_auth_core::wire::UserView,
36    /// HTTP request that initiated delivery, absent for requestless server calls.
37    pub request: Option<AuthRequest>,
38}
39
40/// Application callback for invitation email delivery.
41#[async_trait]
42pub trait SendInvitationEmail: Send + Sync {
43    /// Send the invitation email. The plugin logs delivery errors without failing the request.
44    async fn send(&self, email: &InvitationEmail) -> AuthResult<()>;
45}
46
47/// Permission definitions for a role
48#[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    /// Actions this role may perform on organization-owned API keys. Upstream's
55    /// default statements define none, so only the creator role can manage them
56    /// until an application grants this explicitly.
57    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/// Team creation and membership limits.
65#[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    /// Dynamic capacity. Trusted server calls require a session when this callback is configured.
73    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/// Configuration for the Organization plugin
109#[derive(Clone, better_auth_core::PluginConfig)]
110#[plugin(name = "OrganizationPlugin")]
111pub struct OrganizationConfig {
112    /// Application fields for organization entities.
113    #[config(default = better_auth_core::organization_fields::OrganizationFields::default(), skip)]
114    pub schema: OrganizationFields,
115
116    /// Application lifecycle hooks.
117    #[config(default = None, skip)]
118    pub hooks: Option<Arc<dyn hooks::OrganizationHooks>>,
119    /// Asynchronous organization configuration.
120    #[config(default = None, skip)]
121    pub policy: Option<Arc<dyn hooks::OrganizationPolicy>>,
122    /// Enable teams and configure team limits.
123    #[config(default = OrganizationTeamsConfig::default(), skip)]
124    pub teams: OrganizationTeamsConfig,
125    /// Enable persisted organization roles.
126    #[config(default = false)]
127    pub dynamic_access_control: bool,
128    /// Maximum persisted roles per organization.
129    #[config(default = None)]
130    pub maximum_roles_per_organization: Option<usize>,
131    /// Resource statements for dynamic access control.
132    #[config(default = None, skip)]
133    pub ac: Option<HashMap<String, Vec<String>>>,
134    /// Allow users to create organizations (default: true)
135    #[config(default = true)]
136    pub allow_user_to_create_organization: bool,
137    /// Maximum organizations per user (None = unlimited)
138    #[config(default = None)]
139    pub organization_limit: Option<usize>,
140    /// Maximum members per organization (None or zero uses upstream default 100)
141    #[config(default = Some(100))]
142    pub membership_limit: Option<usize>,
143    /// Dynamic membership capacity, replacing the static admission limit.
144    #[config(default = None, skip)]
145    pub membership_limit_callback: Option<Arc<dyn hooks::MembershipLimitPolicy>>,
146    /// Role assigned to organization creator (default: "owner")
147    #[config(default = "owner".to_string())]
148    pub creator_role: String,
149    /// Invitation expiration in seconds, including fractions (default: 48 hours).
150    #[config(default = 172800.0)]
151    pub invitation_expires_in: f64,
152    /// Maximum pending invitations per organization (None uses upstream default 100)
153    #[config(default = Some(100))]
154    pub invitation_limit: Option<usize>,
155    /// Cancel the previous pending invitation before creating a replacement.
156    #[config(default = false)]
157    pub cancel_pending_invitations_on_re_invite: bool,
158    /// Require a verified recipient email for invitation lookup, acceptance, and rejection.
159    #[config(default = false)]
160    pub require_email_verification_on_invitation: bool,
161    /// Disable organization deletion (default: false)
162    #[config(default = false)]
163    pub disable_organization_deletion: bool,
164    /// Static role definitions. An explicit map, including an empty map, replaces defaults.
165    #[config(default = None, skip)]
166    pub roles: Option<HashMap<String, RolePermissions>>,
167    /// Optional delivery callback used for new and resent invitations.
168    #[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/// Organization plugin for multi-tenancy support
213#[derive(Clone)]
214pub struct OrganizationPlugin {
215    config: OrganizationConfig,
216}
217
218impl OrganizationPlugin {
219    /// Configure dynamic organization capacity. Listing retains the upstream default page size.
220    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    /// Configure application lifecycle hooks.
228    pub fn hooks(mut self, hooks: Arc<dyn hooks::OrganizationHooks>) -> Self {
229        self.config.hooks = Some(hooks);
230        self
231    }
232    /// Configure asynchronous organization options.
233    pub fn policy(mut self, policy: Arc<dyn hooks::OrganizationPolicy>) -> Self {
234        self.config.policy = Some(policy);
235        self
236    }
237    /// Configure organization teams.
238    pub fn teams(mut self, teams: OrganizationTeamsConfig) -> Self {
239        self.config.teams = teams;
240        self
241    }
242    /// Configure access-control resource statements.
243    pub fn access_control(mut self, ac: HashMap<String, Vec<String>>) -> Self {
244        self.config.ac = Some(ac);
245        self
246    }
247    /// Configure delivery for new and resent invitations.
248    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
254/// Metadata key announcing that the organization plugin is installed.
255pub(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";
258/// Metadata key carrying the configured custom roles, so other plugins can run
259/// the organization's access control without depending on this plugin's config.
260pub(crate) const METADATA_ROLES: &str = "organization.roles";
261/// Metadata key carrying the creator role, which is allowed every action.
262pub(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            // Organization CRUD
316            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            // Member management
331            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            // Invitations
349            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            // Permission check
369            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            // Organization CRUD
468            (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            // Member management
496            (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            // Invitations
512            (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            // Permission check
534            (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}