use std::collections::HashMap;
use crate::plugins::organization::request::{from_fields, object, take};
use better_auth_core::types::{
CreateOrganizationRole, HttpMethod, OrganizationRole, UpdateOrganizationRole,
};
use better_auth_core::{
AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, FieldMap,
FieldValue, FromFieldMap, SchemaField,
};
use serde::Deserialize;
use serde_json::json;
use validator::Validate;
use super::require_session;
use crate::plugins::organization::{OrganizationConfig, rbac::check_permission};
type Permissions = HashMap<String, Vec<String>>;
#[derive(Clone)]
struct RequestedPermissions(FieldMap);
impl RequestedPermissions {
fn dynamic(value: &FieldValue) -> Self {
let units = match value {
FieldValue::String(value) => Some(value.encode_utf16().collect::<Vec<_>>()),
FieldValue::Utf16String(value) => Some(value.as_utf16().to_vec()),
_ => None,
};
Self(match value {
FieldValue::Object(fields) => (**fields).clone(),
FieldValue::Array(values) => values
.iter()
.enumerate()
.map(|(index, value)| (index.to_string(), value.clone()))
.collect(),
_ => units
.into_iter()
.flatten()
.enumerate()
.map(|(index, unit)| {
(
index.to_string(),
better_auth_core::Utf16String::from_units(vec![unit]).into(),
)
})
.collect(),
})
}
}
impl SchemaField for RequestedPermissions {
fn from_field(value: FieldValue) -> Result<Self, FieldValue> {
match value {
FieldValue::Object(fields) => Ok(Self((*fields).clone())),
value => Err(value),
}
}
fn into_field(self) -> FieldValue {
self.0.into()
}
}
#[derive(Clone, Validate)]
pub(in crate::plugins::organization) struct CreateRole {
additional_fields: OptionalField<FieldMap>,
organization_id: Option<String>,
role: String,
permission: RequestedPermissions,
}
#[derive(Clone, Default, Deserialize, Validate)]
#[serde(rename_all = "camelCase")]
pub(in crate::plugins::organization) struct RoleSelector {
organization_id: Option<String>,
role_name: Option<String>,
role_id: Option<String>,
}
#[derive(Clone, Validate)]
pub(in crate::plugins::organization) struct UpdateRole {
selector: RoleSelector,
data: RoleUpdate,
configured_fields: FieldMap,
}
#[derive(Clone)]
pub(in crate::plugins::organization) struct RoleUpdate {
role_name: OptionalField<String>,
permission: OptionalField<RequestedPermissions>,
}
#[derive(Clone, Default)]
enum OptionalField<T> {
#[default]
Missing,
Null,
Value(T),
}
impl<T: SchemaField> SchemaField for OptionalField<T> {
fn from_field(value: FieldValue) -> Result<Self, FieldValue> {
match value {
FieldValue::Undefined => Ok(Self::Missing),
FieldValue::Null => Ok(Self::Null),
value => T::from_field(value).map(Self::Value),
}
}
fn into_field(self) -> FieldValue {
match self {
Self::Missing => FieldValue::Undefined,
Self::Null => FieldValue::Null,
Self::Value(value) => value.into_field(),
}
}
}
impl<T> OptionalField<T> {
fn into_option(self) -> Option<T> {
match self {
Self::Value(value) => Some(value),
_ => None,
}
}
}
impl FromFieldMap for CreateRole {
fn from_field_values(mut fields: FieldMap) -> AuthResult<Self> {
let additional_fields = match fields.remove("additionalFields") {
None | Some(FieldValue::Undefined) => OptionalField::Missing,
Some(FieldValue::Null) => OptionalField::Null,
Some(FieldValue::Object(fields)) => OptionalField::Value((*fields).clone()),
Some(_) => {
return Err(AuthError::internal(
"Validated additionalFields is not an object",
));
}
};
Ok(Self {
additional_fields,
organization_id: take(&mut fields, "organizationId")?,
role: take(&mut fields, "role")?,
permission: take(&mut fields, "permission")?,
})
}
}
from_fields!(RoleSelector {
organization_id: "organizationId",
role_name: "roleName",
role_id: "roleId"
});
from_fields!(RoleUpdate {
role_name: "roleName",
permission: "permission"
});
impl FromFieldMap for UpdateRole {
fn from_field_values(mut fields: FieldMap) -> AuthResult<Self> {
let data = RoleUpdate::from_field_values(object(&mut fields, "data")?)?;
Ok(Self {
selector: RoleSelector::from_field_values(fields)?,
data,
configured_fields: FieldMap::new(),
})
}
}
impl UpdateRole {
pub(in crate::plugins::organization) fn validated(
mut fields: FieldMap,
schema: &better_auth_core::user_fields::UserConfig,
) -> AuthResult<Self> {
let mut data = object(&mut fields, "data")?;
let configured_fields = data
.iter()
.filter(|(name, _)| {
schema
.fields()
.get(*name)
.is_some_and(|field| field.input())
})
.map(|(name, value)| (name.clone(), value.clone()))
.collect();
if schema
.fields()
.get("permission")
.is_some_and(|field| field.input())
{
let _ = data.remove("permission");
}
Ok(Self {
selector: RoleSelector::from_field_values(fields)?,
data: RoleUpdate::from_field_values(data)?,
configured_fields,
})
}
}
fn role_error(action: &str) -> AuthError {
let (code, message) = match action {
"create" => (
"YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE",
"You are not allowed to create a role",
),
"update" => (
"YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE",
"You are not allowed to update a role",
),
"delete" => (
"YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE",
"You are not allowed to delete a role",
),
"list" => (
"YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE",
"You are not allowed to list a role",
),
_ => (
"YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE",
"You are not allowed to read a role",
),
};
AuthError::Upstream {
status: 403,
code,
message,
}
}
fn require_ac(config: &OrganizationConfig) -> AuthResult<&Permissions> {
config.ac.as_ref().ok_or(AuthError::Upstream {
status: 501, code: "MISSING_AC_INSTANCE",
message: "Dynamic Access Control requires a pre-defined ac instance on the server auth plugin. Read server logs for more information",
})
}
fn predefined(name: &str, config: &OrganizationConfig) -> bool {
config.roles.as_ref().map_or_else(
|| ["owner", "admin", "member"].contains(&name),
|roles| roles.contains_key(name),
)
}
async fn unused_name(
name: &str,
organization_id: &str,
config: &OrganizationConfig,
ctx: &AuthContext<impl AuthSchema>,
) -> AuthResult<()> {
if predefined(name, config)
|| ctx
.database
.find_organization_role(
organization_id,
better_auth_core::store::OrganizationRoleKey::Name(name),
)
.await?
.is_some()
{
return Err(AuthError::Upstream {
status: 400,
code: "ROLE_NAME_IS_ALREADY_TAKEN",
message: "That role name is already taken",
});
}
Ok(())
}
async fn authorize_member(
req: &AuthRequest,
organization_id: Option<&str>,
action: &str,
config: &OrganizationConfig,
ctx: &AuthContext<impl AuthSchema>,
) -> AuthResult<(String, String)> {
let (user, session) = require_session(req, ctx).await?;
let organization_id = organization_id
.or(session.active_organization_id.as_deref())
.filter(|id| !id.is_empty())
.ok_or_else(|| {
if action == "create" {
AuthError::Upstream {
status: 400,
code: "YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE",
message: "You must be in an organization to create a role",
}
} else {
AuthError::bad_request("No active organization")
}
})?;
let member = ctx
.database
.get_member(organization_id, user.id.typed()?)
.await?
.ok_or_else(|| AuthError::forbidden("You are not a member of this organization"))?;
let permission = if action == "list" { "read" } else { action };
if !check_permission(
member.role.typed()?,
organization_id,
"ac",
&[permission],
config,
ctx,
)
.await?
{
return Err(role_error(action));
}
Ok((organization_id.to_owned(), member.role.typed()?.clone()))
}
async fn select_role(
selector: &RoleSelector,
organization_id: &str,
ctx: &AuthContext<impl AuthSchema>,
) -> AuthResult<OrganizationRole> {
use better_auth_core::store::OrganizationRoleKey;
let missing = || AuthError::Upstream {
status: 400,
code: "ROLE_NOT_FOUND",
message: "Role not found",
};
let key = if let Some(name) = selector
.role_name
.as_deref()
.filter(|name| !name.is_empty())
{
OrganizationRoleKey::Name(name)
} else if let Some(id) = selector.role_id.as_deref() {
OrganizationRoleKey::Id(id)
} else {
return Err(missing());
};
ctx.database
.find_organization_role(organization_id, key)
.await?
.ok_or_else(missing)
}
async fn validate_permissions(
permission: &RequestedPermissions,
member_role: &str,
organization_id: &str,
action: &str,
config: &OrganizationConfig,
ctx: &AuthContext<impl AuthSchema>,
) -> AuthResult<Option<AuthResponse>> {
let ac = require_ac(config)?;
if permission
.0
.iter()
.any(|(resource, _)| !ac.contains_key(resource))
{
return Err(AuthError::Upstream {
status: 400,
code: "INVALID_RESOURCE",
message: "The provided permission includes an invalid resource",
});
}
let mut missing = Vec::new();
for (resource, actions) in &permission.0 {
let actions = match actions {
FieldValue::Array(actions) => actions.to_vec(),
FieldValue::String(actions) => actions
.chars()
.map(|value| FieldValue::from(value.to_string()))
.collect(),
FieldValue::Utf16String(actions) => {
char::decode_utf16(actions.as_utf16().iter().copied())
.map(|character| match character {
Ok(character) => FieldValue::from(character.to_string()),
Err(error) => better_auth_core::Utf16String::from_units(vec![
error.unpaired_surrogate(),
])
.into(),
})
.collect()
}
_ => {
return Err(AuthError::internal(
"Role permission actions are not iterable",
));
}
};
for requested in actions {
let allowed = if let Some(requested) = requested.as_str() {
check_permission(
member_role,
organization_id,
resource,
&[requested],
config,
ctx,
)
.await?
} else {
false
};
if !allowed {
let requested = better_auth_core::SchemaValue::<FieldValue>::Dynamic(requested)
.display_string()?;
missing.push(format!("{resource}:{requested}"));
}
}
}
if missing.is_empty() {
return Ok(None);
}
let (status, code, message) = role_error(action).error_payload();
Ok(Some(AuthResponse::json(
status,
&json!({"code":code,"message":message,"missingPermissions":missing}),
)?))
}
pub async fn handle_role_request(
req: &AuthRequest,
ctx: &AuthContext<impl AuthSchema>,
config: &OrganizationConfig,
) -> AuthResult<Option<AuthResponse>> {
let (_, session) = require_session(req, ctx).await?;
let response = match (req.method(), req.path()) {
(HttpMethod::Post, "/organization/create-role") => {
let body: CreateRole = super::super::request::read(req, &config.schema)?;
let additional_fields = body.additional_fields.into_option().unwrap_or_default();
let _ = require_ac(config)?;
if body
.organization_id
.as_deref()
.or(session.active_organization_id.as_deref())
.is_none_or(str::is_empty)
{
return Err(AuthError::Upstream {
status: 400,
code: "YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE",
message: "You must be in an organization to create a role",
});
}
let name = body.role.to_lowercase();
if predefined(&name, config) {
return Err(AuthError::Upstream {
status: 400,
code: "ROLE_NAME_IS_ALREADY_TAKEN",
message: "That role name is already taken",
});
}
let (organization_id, member_role) =
authorize_member(req, body.organization_id.as_deref(), "create", config, ctx)
.await?;
let maximum = config.role_limit(&organization_id).await?;
let count = ctx
.database
.count_organization_roles(&organization_id)
.await?;
if maximum.is_some_and(|limit| count >= limit as u64) {
return Err(AuthError::Upstream {
status: 400,
code: "TOO_MANY_ROLES",
message: "This organization has too many roles",
});
}
if let Some(response) = validate_permissions(
&body.permission,
&member_role,
&organization_id,
"create",
config,
ctx,
)
.await?
{
return Ok(Some(response));
}
unused_name(&name, &organization_id, config, ctx).await?;
let permission = body.permission.into_field();
let mut role = ctx
.database
.create_organization_role(CreateOrganizationRole {
additional_fields,
organization_id,
role: name,
permission: permission.clone(),
})
.await?;
role.permission = permission.clone().into();
AuthResponse::json(
200,
&json!({"success":true,"roleData":role,"statements":better_auth_core::SchemaValue::Typed(permission)}),
)?
}
(HttpMethod::Get, "/organization/list-roles" | "/organization/get-role") => {
let selector: RoleSelector =
serde_json::from_value(req.query.clone().unwrap_or_else(|| serde_json::json!({})))?;
let action = if req.path().ends_with("list-roles") {
"list"
} else {
"read"
};
let (organization_id, _) = authorize_member(
req,
selector.organization_id.as_deref(),
action,
config,
ctx,
)
.await?;
if action == "list" {
let roles = ctx
.database
.list_organization_roles(&organization_id)
.await?
.into_iter()
.map(|mut role| {
role.permission =
super::super::native_json::permission(&role.permission)?.into();
Ok(role)
})
.collect::<AuthResult<Vec<_>>>()?;
AuthResponse::json(200, &roles)?
} else {
if req.query.is_none() {
return Err(AuthError::internal("Role lookup requires a query object"));
}
let mut role = select_role(&selector, &organization_id, ctx).await?;
role.permission = super::super::native_json::permission(&role.permission)?.into();
AuthResponse::json(200, &role)?
}
}
(HttpMethod::Post, "/organization/delete-role") => {
let selector: RoleSelector = super::super::request::read(req, &config.schema)?;
let (organization_id, _) = authorize_member(
req,
selector.organization_id.as_deref(),
"delete",
config,
ctx,
)
.await?;
if selector
.role_name
.as_deref()
.is_some_and(|name| predefined(name, config))
{
return Err(AuthError::Upstream {
status: 400,
code: "CANNOT_DELETE_A_PRE_DEFINED_ROLE",
message: "Cannot delete a pre-defined role",
});
}
let role = select_role(&selector, &organization_id, ctx).await?;
let _ = super::super::native_json::permission(&role.permission)?;
if ctx
.database
.list_organization_members(&organization_id)
.await?
.iter()
.map(|member| member.role.typed())
.collect::<AuthResult<Vec<_>>>()?
.iter()
.any(|member_role| member_role.split(',').any(|name| role.role == name.trim()))
{
return Err(AuthError::Upstream {
status: 400,
code: "ROLE_IS_ASSIGNED_TO_MEMBERS",
message: "Cannot delete a role that is assigned to members. Please reassign the members to a different role first",
});
}
ctx.database
.delete_organization_role(role.id.typed()?)
.await?;
AuthResponse::json(200, &json!({"success":true}))?
}
(HttpMethod::Post, "/organization/update-role") => {
let body: UpdateRole = super::super::request::read(req, &config.schema)?;
let overrides_permission = config
.schema
.organization_role
.fields()
.get("permission")
.is_some_and(|field| field.input());
let mut fields = body.configured_fields;
let requested = body.data.permission.into_option();
let permission = if overrides_permission {
fields.remove("permission")
} else {
requested.clone().map(SchemaField::into_field)
}
.filter(FieldValue::is_truthy);
let requested = requested.unwrap_or_else(|| {
RequestedPermissions::dynamic(permission.as_ref().unwrap_or(&FieldValue::Null))
});
let _ = require_ac(config)?;
let (organization_id, member_role) = authorize_member(
req,
body.selector.organization_id.as_deref(),
"update",
config,
ctx,
)
.await?;
let mut role = select_role(&body.selector, &organization_id, ctx).await?;
role.permission = if role.permission.is_truthy()? {
super::super::native_json::permission(&role.permission)?.into()
} else {
better_auth_core::SchemaValue::Undefined
};
if permission.is_some()
&& let Some(response) = validate_permissions(
&requested,
&member_role,
&organization_id,
"update",
config,
ctx,
)
.await?
{
return Ok(Some(response));
}
let name = body
.data
.role_name
.into_option()
.filter(|name| !name.is_empty())
.map(|name| name.to_lowercase());
if let Some(name) = &name {
unused_name(name, &organization_id, config, ctx).await?;
}
let _ = ctx
.database
.update_organization_role(
role.id.typed()?,
UpdateOrganizationRole {
additional_fields: fields.clone(),
role: name.clone(),
permission: permission.clone(),
},
)
.await?;
let mut updated = role;
if let Some(value) = fields
.remove("id")
.and_then(|value| value.as_str().map(str::to_owned))
{
updated.id = value.into();
}
if let Some(value) = fields.remove("organizationId") {
updated.organization_id = better_auth_core::SchemaValue::Dynamic(value);
}
if let Some(value) = fields.remove("role") {
updated.role = better_auth_core::SchemaValue::Dynamic(value);
}
if let Some(value) = fields.remove("createdAt") {
updated.created_at = better_auth_core::SchemaValue::Dynamic(value);
}
if let Some(value) = fields.remove("updatedAt") {
updated.updated_at = better_auth_core::SchemaValue::Dynamic(value);
}
updated.additional_fields.extend(fields);
if let Some(name) = name {
updated.role = name.into();
}
if let Some(permission) = permission {
updated.permission = permission.into();
} else if !updated.permission.is_truthy()? {
updated.permission = FieldValue::Null.into();
}
AuthResponse::json(200, &json!({"success":true,"roleData":updated}))?
}
_ => return Ok(None),
};
Ok(Some(response))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::test_helpers::{
create_auth_json_request_no_query, create_test_context, create_user_and_session,
};
use better_auth_core::{CreateMember, CreateOrganization, CreateUser};
use chrono::Duration;
#[tokio::test]
async fn persisted_roles_authorize_only_their_tenant_and_cannot_escalate() {
let ctx = create_test_context().await;
let (user, session) = create_user_and_session(
&ctx,
CreateUser {
name: Some("Fixture".into()).into(),
email: Some("role-admin@example.com".into()),
..Default::default()
},
Duration::hours(1),
)
.await;
let org = ctx
.database
.create_organization(CreateOrganization {
additional_fields: Default::default(),
id: None,
name: "Roles".into(),
slug: "roles".into(),
logo: None.into(),
metadata: None.into(),
})
.await
.unwrap();
ctx.database
.create_member(CreateMember {
additional_fields: Default::default(),
organization_id: org.id.clone(),
user_id: user.id.clone(),
role: "admin".into(),
})
.await
.unwrap();
let config = OrganizationConfig {
dynamic_access_control: true,
ac: Some(HashMap::from([(
"organization".into(),
vec!["update".into(), "delete".into()],
)])),
..Default::default()
};
let request = |permission| {
create_auth_json_request_no_query(
HttpMethod::Post,
"/organization/create-role",
Some(&session.token),
Some(json!({"organizationId":org.id,"role":"Editor","permission":permission})),
)
};
let denied =
handle_role_request(&request(json!({"organization":["delete"]})), &ctx, &config)
.await
.unwrap()
.unwrap();
assert_eq!(denied.status, 403);
let denied: serde_json::Value =
serde_json::from_slice(&denied.body.bytes().unwrap()).unwrap();
assert_eq!(denied["missingPermissions"], json!(["organization:delete"]));
assert!(
ctx.database
.list_organization_roles(org.id.typed().unwrap())
.await
.unwrap()
.is_empty()
);
let created =
handle_role_request(&request(json!({"organization":["update"]})), &ctx, &config)
.await
.unwrap()
.unwrap();
assert_eq!(created.status, 200);
let rows = ctx
.database
.list_organization_roles(org.id.typed().unwrap())
.await
.unwrap();
assert_eq!(rows[0].role, "editor");
assert!(
check_permission(
"editor",
org.id.typed().unwrap(),
"organization",
&["update"],
&config,
&ctx
)
.await
.unwrap()
);
assert!(
!check_permission(
"editor",
"other-tenant",
"organization",
&["update"],
&config,
&ctx
)
.await
.unwrap()
);
let duplicate =
handle_role_request(&request(json!({"organization":["update"]})), &ctx, &config)
.await
.unwrap_err();
assert_eq!(
duplicate.error_payload().1.as_deref(),
Some("ROLE_NAME_IS_ALREADY_TAKEN")
);
}
}