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, AuthRecordFields, AuthRequest, AuthResponse, AuthResult, AuthSchema,
FieldMap, FieldValue, FromFieldMap, SchemaField,
};
use serde::Deserialize;
use serde_json::json;
use validator::Validate;
use super::require_native_session;
use crate::plugins::organization::{OrganizationConfig, rbac::check_permission};
use better_auth_core::session::NativeSessionData;
type Permissions = HashMap<String, Vec<String>>;
#[derive(Clone)]
struct RequestedPermissions(better_auth_core::FieldObject);
impl RequestedPermissions {
fn dynamic(value: &FieldValue) -> AuthResult<Self> {
Ok(Self(match value {
FieldValue::Object(fields) => fields.clone(),
_ => value.enumerable_fields()?.into(),
}))
}
}
impl SchemaField for RequestedPermissions {
fn from_field(value: FieldValue) -> Result<Self, FieldValue> {
match value {
FieldValue::Object(fields) => Ok(Self(fields)),
value => Err(value),
}
}
fn into_field(self) -> FieldValue {
FieldValue::Object(self.0)
}
}
#[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.snapshot_fields()?),
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_denial(action: &str) -> (&'static str, &'static str) {
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",
),
}
}
fn require_ac(config: &OrganizationConfig) -> AuthResult<&Permissions> {
config.ac.as_ref().ok_or(AuthError::from_code("NOT_IMPLEMENTED", "MISSING_AC_INSTANCE", "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: &FieldValue,
config: &OrganizationConfig,
ctx: &AuthContext<impl AuthSchema>,
) -> AuthResult<()> {
if predefined(name, config)
|| ctx
.database
.find_organization_role_value(
organization_id,
better_auth_core::store::OrganizationRoleKey::Name(name),
)
.await?
.is_some()
{
return Err(AuthError::from_code(
"BAD_REQUEST",
"ROLE_NAME_IS_ALREADY_TAKEN",
"That role name is already taken",
));
}
Ok(())
}
async fn authorize_member(
session: &NativeSessionData,
organization_id: Option<&str>,
action: &str,
config: &OrganizationConfig,
ctx: &AuthContext<impl AuthSchema>,
) -> AuthResult<(FieldValue, String)> {
let organization_id = organization_id
.map(FieldValue::from)
.map(Ok)
.unwrap_or_else(|| session.session_property("activeOrganizationId"))?;
let organization_id = organization_id
.is_truthy()
.then_some(organization_id)
.ok_or_else(|| {
if action == "create" {
AuthError::from_code(
"BAD_REQUEST",
"YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE",
"You must be in an organization to create a role",
)
} else {
AuthError::from_code(
"BAD_REQUEST",
"NO_ACTIVE_ORGANIZATION",
"No active organization",
)
}
})?;
let member = ctx
.database
.get_member_value(&organization_id, &session.user_property("id")?)
.await?
.ok_or_else(|| {
AuthError::from_code(
"FORBIDDEN",
"YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION",
"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?
{
let (code, message) = role_denial(action);
return Err(AuthError::from_code("FORBIDDEN", code, message));
}
Ok((organization_id, member.role.typed()?.clone()))
}
async fn select_role(
selector: &RoleSelector,
organization_id: &FieldValue,
ctx: &AuthContext<impl AuthSchema>,
) -> AuthResult<OrganizationRole> {
ctx.database
.find_organization_role_by_fields(&role_selectors(selector, organization_id)?)
.await?
.ok_or_else(role_not_found)
}
fn role_not_found() -> AuthError {
AuthError::from_code("BAD_REQUEST", "ROLE_NOT_FOUND", "Role not found")
}
fn role_selectors(selector: &RoleSelector, organization_id: &FieldValue) -> AuthResult<FieldMap> {
let (field, value) = if let Some(name) = selector
.role_name
.as_deref()
.filter(|name| !name.is_empty())
{
("role", name)
} else if let Some(id) = selector.role_id.as_deref().filter(|id| !id.is_empty()) {
("id", id)
} else {
return Err(role_not_found());
};
Ok([
("organizationId".into(), organization_id.clone()),
(field.into(), value.into()),
]
.into())
}
async fn validate_permissions(
permission: &RequestedPermissions,
member_role: &str,
organization_id: &FieldValue,
action: &str,
config: &OrganizationConfig,
ctx: &AuthContext<impl AuthSchema>,
) -> AuthResult<()> {
let ac = require_ac(config)?;
let permission = permission.0.snapshot_fields()?;
if permission
.iter()
.any(|(resource, _)| !ac.contains_key(resource))
{
return Err(AuthError::from_code(
"BAD_REQUEST",
"INVALID_RESOURCE",
"The provided permission includes an invalid resource",
));
}
let mut missing = Vec::new();
for (resource, actions) in &permission {
let actions: Box<dyn Iterator<Item = AuthResult<FieldValue>> + Send + '_> = match actions {
FieldValue::Array(actions) => Box::new(actions.iter()?),
FieldValue::String(actions) => Box::new(
actions
.chars()
.map(|value| Ok(FieldValue::from(value.to_string()))),
),
FieldValue::Utf16String(actions) => Box::new(
char::decode_utf16(actions.as_utf16().iter().copied()).map(|character| {
Ok(match character {
Ok(character) => FieldValue::from(character.to_string()),
Err(error) => better_auth_core::Utf16String::from_units(vec![
error.unpaired_surrogate(),
])
.into(),
})
}),
),
_ => {
return Err(AuthError::internal(
"Role permission actions are not iterable",
));
}
};
for requested in actions {
let requested = requested?;
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(());
}
let (code, message) = role_denial(action);
Err(AuthError::from_status(
"FORBIDDEN",
FieldMap::from([
("message".into(), message.into()),
("code".into(), code.into()),
(
"missingPermissions".into(),
missing
.into_iter()
.map(FieldValue::from)
.collect::<Vec<_>>()
.into(),
),
])
.into(),
))
}
pub async fn handle_role_request(
req: &AuthRequest,
ctx: &AuthContext<impl AuthSchema>,
config: &OrganizationConfig,
) -> AuthResult<Option<AuthResponse>> {
let session = require_native_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()
.map(FieldValue::from)
.map(Ok)
.unwrap_or_else(|| session.session_property("activeOrganizationId"))?
.is_truthy()
{
return Err(AuthError::from_code(
"BAD_REQUEST",
"YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE",
"You must be in an organization to create a role",
));
}
let name = body.role.to_lowercase();
if predefined(&name, config) {
return Err(AuthError::from_code(
"BAD_REQUEST",
"ROLE_NAME_IS_ALREADY_TAKEN",
"That role name is already taken",
));
}
let (organization_id, member_role) = authorize_member(
&session,
body.organization_id.as_deref(),
"create",
config,
ctx,
)
.await?;
let maximum = config.role_limit(&organization_id).await?;
let count = ctx
.database
.count_organization_roles_value(&organization_id)
.await?;
if maximum.is_some_and(|limit| count >= limit as u64) {
return Err(AuthError::from_code(
"BAD_REQUEST",
"TOO_MANY_ROLES",
"This organization has too many roles",
));
}
validate_permissions(
&body.permission,
&member_role,
&organization_id,
"create",
config,
ctx,
)
.await?;
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: better_auth_core::SchemaValue::from_field(organization_id),
role: name,
permission: permission.clone(),
})
.await?;
role.permission = permission.clone().into();
AuthResponse::native(
None,
FieldMap::from_iter([
("success".into(), true.into()),
("roleData".into(), role.field_values()?.into()),
("statements".into(), permission),
])
.into(),
)
}
(HttpMethod::Get, "/organization/list-roles" | "/organization/get-role") => {
let selector: RoleSelector = serde_json::from_value(
req.input_query()?.unwrap_or_else(|| serde_json::json!({})),
)?;
let action = if req.path().ends_with("list-roles") {
"list"
} else {
"read"
};
let (organization_id, _) = authorize_member(
&session,
selector.organization_id.as_deref(),
action,
config,
ctx,
)
.await?;
if action == "list" {
let roles = ctx
.database
.list_organization_roles_value(&organization_id)
.await?
.into_iter()
.map(|mut role| {
role.permission =
super::super::native_json::permission(&role.permission)?.into();
Ok(role.field_values()?.into())
})
.collect::<AuthResult<Vec<FieldValue>>>()?;
AuthResponse::native(None, roles.into())
} else {
if req.input_query_field_value()?.is_undefined() {
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::native(None, role.field_values()?.into())
}
}
(HttpMethod::Post, "/organization/delete-role") => {
let selector: RoleSelector = super::super::request::read(req, &config.schema)?;
let (organization_id, _) = authorize_member(
&session,
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::from_code(
"BAD_REQUEST",
"CANNOT_DELETE_A_PRE_DEFINED_ROLE",
"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_value(&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::from_code(
"BAD_REQUEST",
"ROLE_IS_ASSIGNED_TO_MEMBERS",
"Cannot delete a role that is assigned to members. Please reassign the members to a different role first",
));
}
ctx.database
.delete_organization_role_by_fields(&role_selectors(&selector, &organization_id)?)
.await?;
AuthResponse::json(None, &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 = match requested {
Some(requested) => requested,
None => {
RequestedPermissions::dynamic(permission.as_ref().unwrap_or(&FieldValue::Null))?
}
};
let _ = require_ac(config)?;
let (organization_id, member_role) = authorize_member(
&session,
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() {
validate_permissions(
&requested,
&member_role,
&organization_id,
"update",
config,
ctx,
)
.await?;
}
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_roles(
&role_selectors(&body.selector, &organization_id)?,
UpdateOrganizationRole {
additional_fields: fields.clone(),
role: name.clone(),
permission: permission.clone(),
},
)
.await?;
let mut updated = role.field_values()?;
updated.extend(fields);
if let Some(name) = name {
let _ = updated.insert("role".into(), name.into());
}
let permission = permission.unwrap_or_else(|| {
let permission = role.permission.field_value();
if permission.is_truthy() {
permission
} else {
FieldValue::Null
}
});
let _ = updated.insert("permission".into(), permission);
AuthResponse::native(
None,
FieldMap::from_iter([
("success".into(), true.into()),
("roleData".into(), updated.into()),
])
.into(),
)
}
_ => return Ok(None),
};
Ok(Some(response))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::organization::OrganizationPlugin;
use crate::plugins::test_helpers::{
create_auth_json_request_no_query, create_test_config, create_test_context_with_plugins,
create_user_and_session,
};
use better_auth_core::{CreateMember, CreateOrganization, CreateUser};
use chrono::Duration;
#[tokio::test]
async fn update_role_returns_native_raw_fields_without_projecting_the_write() {
use better_auth_core::user_fields::{
FieldTransforms, UserConfig, UserFieldConfig, UserFieldTransform, UserFieldType,
};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let outputs = Arc::new(AtomicUsize::new(0));
let counter = outputs.clone();
let config = OrganizationConfig {
dynamic_access_control: true,
ac: Some(HashMap::from([(
"organization".into(),
vec!["update".into()],
)])),
schema: better_auth_core::organization_fields::OrganizationFields {
organization_role: UserConfig {
additional_fields: Some(
[
(
"id".into(),
UserFieldConfig {
field_type: UserFieldType::Number,
..Default::default()
},
),
(
"permission".into(),
UserFieldConfig {
transform: Some(FieldTransforms {
input: None,
output: Some(UserFieldTransform::new(move |value| {
counter.fetch_add(1, Ordering::SeqCst);
Ok(value)
})),
}),
..Default::default()
},
),
]
.into(),
),
},
..Default::default()
},
..Default::default()
};
let ctx = super::super::native_tests::context(
crate::plugins::test_helpers::create_test_config(),
&config,
)
.await;
let user = ctx
.database
.create_user(CreateUser::new().with_email("role-native@example.test"))
.await
.unwrap();
let session = super::super::native_tests::session(&ctx, &user).await;
let org = ctx
.database
.create_organization(CreateOrganization::new("Roles", "native-roles"))
.await
.unwrap();
let _ = ctx
.database
.create_member(CreateMember {
additional_fields: FieldMap::new(),
organization_id: org.id.clone(),
user_id: user.id.clone(),
role: "owner".into(),
})
.await
.unwrap();
let role = ctx
.database
.create_organization_role(CreateOrganizationRole {
additional_fields: FieldMap::new(),
organization_id: org.id.clone(),
role: "editor".into(),
permission: FieldMap::new().into(),
})
.await
.unwrap();
outputs.store(0, Ordering::SeqCst);
let request = create_auth_json_request_no_query(
HttpMethod::Post,
"/organization/update-role",
Some(session.session_view().unwrap().token.typed().unwrap()),
Some(json!({
"organizationId": org.id, "roleId": role.id, "data": { "id": 7 }
})),
);
let response = handle_role_request(&request, &ctx, &config)
.await
.unwrap()
.unwrap();
assert_eq!(response.status, 200);
assert_eq!(outputs.load(Ordering::SeqCst), 2);
let FieldValue::Object(body) = response.body.field_value().unwrap() else {
panic!("Expected native role response");
};
let Some(FieldValue::Object(returned)) = body.get("roleData").unwrap() else {
panic!("Expected native role data");
};
assert_eq!(returned.get("id").unwrap(), Some(FieldValue::Number(7.0)));
assert!(matches!(
returned.get("createdAt").unwrap(),
Some(FieldValue::Date(_))
));
assert!(matches!(
returned.get("permission").unwrap(),
Some(FieldValue::Object(_))
));
assert!(
ctx.database
.find_organization_role_by_fields(
&[
("organizationId".into(), org.id.field_value()),
("id".into(), FieldValue::Number(7.0))
]
.into()
)
.await
.unwrap()
.is_some()
);
assert!(
ctx.database
.get_organization_role(role.id.typed().unwrap())
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn persisted_roles_authorize_only_their_tenant_and_cannot_escalate() {
let config = OrganizationConfig {
dynamic_access_control: true,
ac: Some(HashMap::from([(
"organization".into(),
vec!["update".into(), "delete".into()],
)])),
..Default::default()
};
let ctx = create_test_context_with_plugins(
create_test_config(),
&[&OrganizationPlugin::with_config(config.clone())],
)
.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 request = |permission| {
create_auth_json_request_no_query(
HttpMethod::Post,
"/organization/create-role",
Some(session.token.typed().unwrap()),
Some(json!({"organizationId":org.id,"role":"Editor","permission":permission})),
)
};
let denied =
handle_role_request(&request(json!({"organization":["delete"]})), &ctx, &config)
.await
.unwrap_err();
assert_eq!(
denied.status_value().unwrap(),
FieldValue::from("FORBIDDEN")
);
assert_eq!(denied.status_code().unwrap(), 403);
let denied = denied.body_value().unwrap().json().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.field_value(),
"organization",
&["update"],
&config,
&ctx
)
.await
.unwrap()
);
assert!(
!check_permission(
"editor",
&"other-tenant".into(),
"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().unwrap().1.as_deref(),
Some("ROLE_NAME_IS_ALREADY_TAKEN")
);
}
}