mod callbacks;
mod fields;
pub use callbacks::OrganizationCallbacks;
pub mod handlers;
pub mod hooks;
mod input;
mod native_json;
mod policy;
mod request;
mod server_api;
pub use better_auth_core::organization_fields::OrganizationFields;
pub use server_api::{AddMemberInput, OrganizationApi};
pub mod rbac;
pub mod types;
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use better_auth_core::entity::AuthSession;
use better_auth_core::error::AuthResult;
use better_auth_core::plugin::{AuthContext, AuthPlugin, AuthRoute};
use better_auth_core::types::{AuthRequest, AuthResponse, HttpMethod};
#[derive(Clone)]
pub struct InvitationEmail {
pub invitation: better_auth_core::wire::InvitationView,
pub organization: types::OrganizationResponse,
pub member: better_auth_core::types::Member,
pub inviter: better_auth_core::FieldValue,
pub request: Option<AuthRequest>,
}
#[async_trait]
pub trait SendInvitationEmail: Send + Sync {
async fn send(&self, email: &InvitationEmail) -> AuthResult<()>;
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct RolePermissions {
pub organization: Vec<String>,
pub member: Vec<String>,
pub invitation: Vec<String>,
#[serde(rename = "apiKey", alias = "api_key")]
pub api_key: Vec<String>,
pub team: Vec<String>,
pub ac: Vec<String>,
#[serde(flatten)]
pub additional: HashMap<String, Vec<String>>,
}
#[derive(Clone)]
pub struct OrganizationTeamsConfig {
pub enabled: bool,
pub default_team: bool,
pub allow_removing_all_teams: bool,
pub maximum_teams: Option<usize>,
pub maximum_members_per_team: Option<usize>,
pub maximum_members_per_team_callback: Option<Arc<dyn hooks::TeamMemberLimitPolicy>>,
}
impl std::fmt::Debug for OrganizationTeamsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OrganizationTeamsConfig")
.field("enabled", &self.enabled)
.field("default_team", &self.default_team)
.field("allow_removing_all_teams", &self.allow_removing_all_teams)
.field("maximum_teams", &self.maximum_teams)
.field("maximum_members_per_team", &self.maximum_members_per_team)
.field(
"maximum_members_per_team_callback",
&self
.maximum_members_per_team_callback
.as_ref()
.map(|_| "custom"),
)
.finish()
}
}
impl Default for OrganizationTeamsConfig {
fn default() -> Self {
Self {
enabled: false,
default_team: true,
allow_removing_all_teams: false,
maximum_teams: None,
maximum_members_per_team: None,
maximum_members_per_team_callback: None,
}
}
}
#[derive(Clone, better_auth_core::PluginConfig)]
#[plugin(name = "OrganizationPlugin")]
pub struct OrganizationConfig {
#[config(default = better_auth_core::organization_fields::OrganizationFields::default(), skip)]
pub schema: OrganizationFields,
#[config(default = None, skip)]
pub hooks: Option<Arc<dyn hooks::OrganizationHooks>>,
#[config(default = None, skip)]
pub policy: Option<Arc<dyn hooks::OrganizationPolicy>>,
#[config(default = OrganizationTeamsConfig::default(), skip)]
pub teams: OrganizationTeamsConfig,
#[config(default = false)]
pub dynamic_access_control: bool,
#[config(default = None)]
pub maximum_roles_per_organization: Option<usize>,
#[config(default = None, skip)]
pub ac: Option<HashMap<String, Vec<String>>>,
#[config(default = true)]
pub allow_user_to_create_organization: bool,
#[config(default = None)]
pub organization_limit: Option<usize>,
#[config(default = Some(100))]
pub membership_limit: Option<usize>,
#[config(default = None, skip)]
pub membership_limit_callback: Option<Arc<dyn hooks::MembershipLimitPolicy>>,
#[config(default = "owner".to_string())]
pub creator_role: String,
#[config(default = 172800.0)]
pub invitation_expires_in: f64,
#[config(default = Some(100))]
pub invitation_limit: Option<usize>,
#[config(default = false)]
pub cancel_pending_invitations_on_re_invite: bool,
#[config(default = false)]
pub require_email_verification_on_invitation: bool,
#[config(default = false)]
pub disable_organization_deletion: bool,
#[config(default = None, skip)]
pub roles: Option<HashMap<String, RolePermissions>>,
#[config(default = None, skip)]
pub send_invitation_email: Option<Arc<dyn SendInvitationEmail>>,
}
impl std::fmt::Debug for OrganizationConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OrganizationConfig")
.field(
"allow_user_to_create_organization",
&self.allow_user_to_create_organization,
)
.field("organization_limit", &self.organization_limit)
.field("membership_limit", &self.membership_limit)
.field("creator_role", &self.creator_role)
.field("invitation_expires_in", &self.invitation_expires_in)
.field("invitation_limit", &self.invitation_limit)
.field(
"cancel_pending_invitations_on_re_invite",
&self.cancel_pending_invitations_on_re_invite,
)
.field(
"require_email_verification_on_invitation",
&self.require_email_verification_on_invitation,
)
.field(
"disable_organization_deletion",
&self.disable_organization_deletion,
)
.field("roles", &self.roles)
.field("teams", &self.teams)
.field("dynamic_access_control", &self.dynamic_access_control)
.field(
"maximum_roles_per_organization",
&self.maximum_roles_per_organization,
)
.field("ac", &self.ac)
.field(
"send_invitation_email",
&self.send_invitation_email.as_ref().map(|_| "custom"),
)
.finish()
}
}
#[derive(Clone)]
pub struct OrganizationPlugin {
config: OrganizationConfig,
}
impl OrganizationPlugin {
pub fn membership_limit_callback(
mut self,
callback: Arc<dyn hooks::MembershipLimitPolicy>,
) -> Self {
self.config.membership_limit_callback = Some(callback);
self
}
pub fn hooks(mut self, hooks: Arc<dyn hooks::OrganizationHooks>) -> Self {
self.config.hooks = Some(hooks);
self
}
pub fn policy(mut self, policy: Arc<dyn hooks::OrganizationPolicy>) -> Self {
self.config.policy = Some(policy);
self
}
pub fn teams(mut self, teams: OrganizationTeamsConfig) -> Self {
self.config.teams = teams;
self
}
pub fn access_control(mut self, ac: HashMap<String, Vec<String>>) -> Self {
self.config.ac = Some(ac);
self
}
pub fn custom_send_invitation_email(mut self, sender: Arc<dyn SendInvitationEmail>) -> Self {
self.config.send_invitation_email = Some(sender);
self
}
}
pub(crate) const METADATA_DYNAMIC_ACCESS_CONTROL: &str = "organization.dynamic_access_control";
pub(crate) const METADATA_AC: &str = "organization.ac";
pub(crate) const METADATA_ENABLED: &str = "organization.enabled";
pub(crate) const METADATA_ROLES: &str = "organization.roles";
pub(crate) const METADATA_CREATOR_ROLE: &str = "organization.creator_role";
#[async_trait]
impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for OrganizationPlugin {
fn name(&self) -> &'static str {
"organization"
}
fn openapi(
&self,
) -> better_auth_core::AuthResult<better_auth_core::openapi::OpenApiPluginMetadata> {
better_auth_core::openapi::OpenApiPluginMetadata::from_routes(
<Self as better_auth_core::AuthPlugin<S>>::name(self),
<Self as better_auth_core::AuthPlugin<S>>::routes(self),
)?
.organization_fields(&self.config.schema)
}
fn schema(
&self,
fields: &mut better_auth_core::plugin_runtime::ModelFields,
) -> better_auth_core::AuthResult<()> {
fields.register_organization_schema(
&self.config.schema,
self.config.teams.enabled,
self.config.dynamic_access_control,
);
fields.register_native_session_fields(METADATA_ENABLED)?;
if self.config.teams.enabled {
fields.register_native_session_fields("organization.teams_enabled")?;
}
Ok(())
}
async fn on_init(
&self,
ctx: &mut better_auth_core::AuthInitContext<S>,
) -> better_auth_core::AuthResult<()> {
ctx.extensions
.insert(self.config.schema.initialized_options());
ctx.extensions.insert(self.clone());
S::Session::require_plugin_fields("organization", &["active_organization_id"])?;
if self.config.teams.enabled {
S::Session::require_plugin_fields("organization", &["active_team_id"])?;
}
ctx.set_metadata(
METADATA_DYNAMIC_ACCESS_CONTROL,
serde_json::json!(self.config.dynamic_access_control),
);
ctx.set_metadata(METADATA_AC, serde_json::json!(self.config.ac));
ctx.set_metadata(
"organization.teams_enabled",
serde_json::json!(self.config.teams.enabled),
);
ctx.set_metadata(METADATA_ENABLED, serde_json::Value::Bool(true));
ctx.set_metadata(
METADATA_ROLES,
serde_json::to_value(&self.config.roles).unwrap_or_default(),
);
ctx.set_metadata(
METADATA_CREATOR_ROLE,
serde_json::Value::String(self.config.creator_role.clone()),
);
Ok(())
}
fn routes(&self) -> Vec<AuthRoute> {
let mut routes = vec![
AuthRoute::post("/organization/create", "createOrganization"),
AuthRoute::post("/organization/update", "updateOrganization").require_headers(true),
AuthRoute::post("/organization/delete", "deleteOrganization").require_headers(true),
AuthRoute::get("/organization/list", "listOrganizations").require_headers(true),
AuthRoute::get("/organization/get-organization", "getOrganization")
.require_headers(true)
.query_validator(crate::plugins::query_input::organization),
AuthRoute::get("/organization/get-full-organization", "getFullOrganization")
.require_headers(true)
.query_validator(crate::plugins::query_input::full_organization),
AuthRoute::post("/organization/check-slug", "checkOrganizationSlug"),
AuthRoute::post("/organization/set-active", "setActiveOrganization")
.require_headers(true),
AuthRoute::post("/organization/leave", "leaveOrganization").require_headers(true),
AuthRoute::get("/organization/get-active-member", "getActiveMember")
.require_headers(true),
AuthRoute::get(
"/organization/get-active-member-role",
"getActiveMemberRole",
)
.require_headers(true)
.query_validator(crate::plugins::query_input::active_member_role),
AuthRoute::get("/organization/list-members", "listMembers")
.require_headers(true)
.query_validator(crate::plugins::query_input::list_members),
AuthRoute::post("/organization/remove-member", "removeMember").require_headers(true),
AuthRoute::post(
"/organization/update-member-role",
"updateOrganizationMemberRole",
)
.require_headers(true),
AuthRoute::post(
"/organization/invite-member",
"createOrganizationInvitation",
)
.require_headers(true),
AuthRoute::get("/organization/get-invitation", "getInvitation")
.require_headers(true)
.query_validator(crate::plugins::query_input::invitation),
AuthRoute::get("/organization/list-invitations", "listInvitations")
.require_headers(true)
.query_validator(crate::plugins::query_input::organization_id),
AuthRoute::get("/organization/list-user-invitations", "listUserInvitations")
.query_validator(crate::plugins::query_input::user_invitations),
AuthRoute::post("/organization/accept-invitation", "acceptInvitation")
.require_headers(true),
AuthRoute::post("/organization/reject-invitation", "rejectInvitation")
.require_headers(true),
AuthRoute::post("/organization/cancel-invitation", "cancelInvitation")
.require_headers(true),
AuthRoute::post("/organization/has-permission", "hasPermission").require_headers(true),
];
if self.config.teams.enabled {
routes.extend(handlers::team::routes());
}
if self.config.dynamic_access_control {
for (path, method) in [
("create-role", HttpMethod::Post),
("get-role", HttpMethod::Get),
("list-roles", HttpMethod::Get),
("update-role", HttpMethod::Post),
("delete-role", HttpMethod::Post),
] {
routes.push(
match method {
HttpMethod::Get => AuthRoute::get(format!("/organization/{path}"), path)
.query_validator(if path == "get-role" {
crate::plugins::query_input::organization_role
} else {
crate::plugins::query_input::organization_id
}),
_ => AuthRoute::post(format!("/organization/{path}"), path),
}
.require_headers(true),
);
}
}
routes
.into_iter()
.map(|route| {
if route.method == HttpMethod::Post {
let schema = request::prepared_schema(&route.path, &self.config.schema);
route.body_validator(move |req| request::validate(req, &schema))
} else {
route
}
})
.collect()
}
async fn on_request(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
if self.config.teams.enabled
&& handlers::team::routes()
.iter()
.any(|route| route.path == req.path())
{
return handlers::team::handle_team_request(req, ctx, &self.config).await;
}
if self.config.dynamic_access_control
&& matches!(
req.path(),
"/organization/create-role"
| "/organization/get-role"
| "/organization/list-roles"
| "/organization/update-role"
| "/organization/delete-role"
)
{
return handlers::roles::handle_role_request(req, ctx, &self.config).await;
}
let response: AuthResult<Option<AuthResponse>> = match (req.method(), req.path()) {
(HttpMethod::Post, "/organization/create") => Ok(Some(
handlers::org::handle_create_organization(req, ctx, &self.config).await?,
)),
(HttpMethod::Post, "/organization/update") => Ok(Some(
handlers::org::handle_update_organization(req, ctx, &self.config).await?,
)),
(HttpMethod::Post, "/organization/delete") => Ok(Some(
handlers::org::handle_delete_organization(req, ctx, &self.config).await?,
)),
(HttpMethod::Get, "/organization/list") => Ok(Some(
handlers::org::handle_list_organizations(req, ctx).await?,
)),
(HttpMethod::Get, "/organization/get-organization") => Ok(Some(
handlers::org::handle_get_organization(req, ctx).await?,
)),
(HttpMethod::Get, "/organization/get-full-organization") => Ok(Some(
handlers::org::handle_get_full_organization(req, ctx, &self.config).await?,
)),
(HttpMethod::Post, "/organization/check-slug") => {
Ok(Some(handlers::org::handle_check_slug(req, ctx).await?))
}
(HttpMethod::Post, "/organization/set-active") => Ok(Some(
handlers::org::handle_set_active_organization(req, ctx).await?,
)),
(HttpMethod::Post, "/organization/leave") => Ok(Some(
handlers::org::handle_leave_organization(req, ctx, &self.config).await?,
)),
(HttpMethod::Get, "/organization/get-active-member") => Ok(Some(
handlers::member::handle_get_active_member(req, ctx).await?,
)),
(HttpMethod::Get, "/organization/get-active-member-role") => Ok(Some(
handlers::member::handle_get_active_member_role(req, ctx).await?,
)),
(HttpMethod::Get, "/organization/list-members") => Ok(Some(
handlers::member::handle_list_members(req, &self.config, ctx).await?,
)),
(HttpMethod::Post, "/organization/remove-member") => Ok(Some(
handlers::member::handle_remove_member(req, ctx, &self.config).await?,
)),
(HttpMethod::Post, "/organization/update-member-role") => Ok(Some(
handlers::member::handle_update_member_role(req, ctx, &self.config).await?,
)),
(HttpMethod::Post, "/organization/invite-member") => Ok(Some(
handlers::invitation::handle_invite_member(req, ctx, &self.config).await?,
)),
(HttpMethod::Get, "/organization/get-invitation") => Ok(Some(
handlers::invitation::handle_get_invitation(req, ctx, &self.config).await?,
)),
(HttpMethod::Get, "/organization/list-invitations") => Ok(Some(
handlers::invitation::handle_list_invitations(req, ctx).await?,
)),
(HttpMethod::Get, "/organization/list-user-invitations") => Ok(Some(
handlers::invitation::handle_list_user_invitations(req, ctx).await?,
)),
(HttpMethod::Post, "/organization/accept-invitation") => Ok(Some(
handlers::invitation::handle_accept_invitation(req, ctx, &self.config).await?,
)),
(HttpMethod::Post, "/organization/reject-invitation") => Ok(Some(
handlers::invitation::handle_reject_invitation(req, ctx, &self.config).await?,
)),
(HttpMethod::Post, "/organization/cancel-invitation") => Ok(Some(
handlers::invitation::handle_cancel_invitation(req, ctx, &self.config).await?,
)),
(HttpMethod::Post, "/organization/has-permission") => Ok(Some(
handlers::handle_has_permission(req, ctx, &self.config).await?,
)),
_ => Ok(None),
};
response?
.map(|mut response| {
shape_invitation_output(req.path(), &mut response, &self.config)?;
Ok(response)
})
.transpose()
}
}
fn shape_invitation_output(
path: &str,
response: &mut AuthResponse,
config: &OrganizationConfig,
) -> AuthResult<()> {
if response.status >= 400
|| fields::invitation_team_id_is_declared(config)
|| !matches!(
path,
"/organization/invite-member"
| "/organization/get-invitation"
| "/organization/list-invitations"
| "/organization/list-user-invitations"
| "/organization/accept-invitation"
| "/organization/reject-invitation"
| "/organization/cancel-invitation"
| "/organization/get-full-organization"
)
{
return Ok(());
}
fn shape(invitation: &better_auth_core::FieldValue) -> AuthResult<()> {
if let Some(object) = invitation.as_object() {
let _ = object.delete("teamId")?;
}
Ok(())
}
fn shape_list(value: &better_auth_core::FieldValue) -> AuthResult<()> {
if let Some(invitations) = value.as_array() {
for invitation in invitations.iter()? {
shape(&invitation?)?;
}
}
Ok(())
}
let value = response.body.field_value()?;
match path {
"/organization/list-invitations" | "/organization/list-user-invitations" => {
shape_list(&value)?;
}
"/organization/get-full-organization" => {
if let Some(object) = value.as_object()
&& let Some(invitations) = object.get("invitations")?
{
shape_list(&invitations)?;
}
}
"/organization/accept-invitation" | "/organization/reject-invitation" => {
if let Some(object) = value.as_object()
&& let Some(invitation) = object.get("invitation")?
{
shape(&invitation)?;
}
}
_ => shape(&value)?,
}
if matches!(response.body, better_auth_core::ResponseBody::Bytes(_)) {
response.body = better_auth_core::ResponseBody::Bytes(
value.stringify()?.unwrap_or_default().into_bytes(),
);
}
Ok(())
}