use super::super::OrganizationConfig;
use super::extension_common::{
cached_has_permissions, org_error, organization_roles, role_has_permissions,
};
use super::validation;
use alibi_core::entity::AuthUser;
use alibi_core::error::codes;
use alibi_core::types::{
CreateOrganizationRole, OrganizationPermissions, OrganizationRole, OrganizationRoleSelector,
UpdateOrganizationRole,
};
use alibi_core::{
AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, HttpMethod,
};
struct RoleUpdates {
role_name: Option<String>,
permission: Option<OrganizationPermissions>,
}
fn role_object(role: &OrganizationRole) -> AuthResult<serde_json::Map<String, serde_json::Value>> {
match serde_json::to_value(role)? {
serde_json::Value::Object(value) => Ok(value),
_ => Err(AuthError::internal(
"Organization role must serialize as an object",
)),
}
}
fn parsed_role(role: &OrganizationRole) -> AuthResult<serde_json::Map<String, serde_json::Value>> {
let permission: serde_json::Value = serde_json::from_str(role.permission.as_str())
.map_err(|error| AuthError::CallbackFailure(Box::new(error.into())))?;
let mut value = role_object(role)?;
_ = value.insert("permission".to_owned(), permission);
Ok(value)
}
fn selector(
name: Option<&str>,
id: Option<&str>,
query: bool,
) -> Result<OrganizationRoleSelector, validation::Issue> {
if let Some(name) = name.filter(|name| !name.is_empty()) {
return Ok(OrganizationRoleSelector::Name(name.to_owned()));
}
if let Some(id) = id.filter(|id| !id.is_empty()) {
return Ok(OrganizationRoleSelector::Id(id.to_owned()));
}
Err(validation::issue(match (query, name, id) {
(true, Some(""), None) => {
"[query.roleName] Too small: expected string to have >=1 characters"
}
(true, None, Some("")) => {
"[query.roleId] Too small: expected string to have >=1 characters"
}
(false, Some(""), None) => {
"[body.roleName] Too small: expected string to have >=1 characters"
}
(false, None, Some("")) => {
"[body.roleId] Too small: expected string to have >=1 characters"
}
(true, _, _) => "[query] Invalid input",
(false, _, _) => "[body] Invalid input",
}))
}
fn check_predefined_name(name: &str, config: &OrganizationConfig) -> AuthResult<()> {
if (config.roles.is_none() && ["owner", "admin", "member"].contains(&name))
|| config
.roles
.as_ref()
.is_some_and(|configured_roles| configured_roles.contains_key(name))
{
return Err(org_error(400, codes::ROLE_NAME_IS_ALREADY_TAKEN));
}
Ok(())
}
async fn check_name<S: AuthSchema>(
name: &str,
org_id: &str,
config: &OrganizationConfig,
ctx: &AuthContext<S>,
) -> AuthResult<()> {
check_predefined_name(name, config)?;
if ctx
.database
.get_organization_role(org_id, &OrganizationRoleSelector::Name(name.to_owned()))
.await?
.is_some()
{
return Err(org_error(400, codes::ROLE_NAME_IS_ALREADY_TAKEN));
}
Ok(())
}
fn missing_permissions(
role: &str,
permissions: &OrganizationPermissions,
config: &OrganizationConfig,
organization_id: &str,
) -> AuthResult<Vec<String>> {
let ac = config
.access_control
.as_ref()
.ok_or_else(|| org_error(501, codes::MISSING_AC_INSTANCE))?;
if permissions.keys().any(|key| !ac.contains_key(key)) {
return Err(org_error(400, codes::INVALID_RESOURCE));
}
let mut missing = Vec::new();
for (resource, actions) in permissions {
for action in actions {
if !cached_has_permissions(
role,
&[(resource.clone(), vec![action.clone()])].into(),
config,
organization_id,
)? {
missing.push(format!("{resource}:{action}"));
}
}
}
Ok(missing)
}
pub(crate) async fn handle_role_request<S: AuthSchema>(
req: &AuthRequest,
ctx: &AuthContext<S>,
config: &OrganizationConfig,
) -> AuthResult<Option<AuthResponse>> {
let known = matches!(
(req.method(), req.path()),
(
HttpMethod::Post,
"/organization/create-role" | "/organization/update-role" | "/organization/delete-role"
) | (
HttpMethod::Get,
"/organization/get-role" | "/organization/list-roles"
)
);
if !config.dynamic_access_control.enabled || !known {
return Ok(None);
}
let body = if *req.method() == HttpMethod::Post {
match validation::body_object(req) {
Ok(body) => body,
Err(response) => return Ok(Some(response)),
}
} else {
serde_json::Map::default()
};
role_core(req.method(), req.path(), body, &req.query, req, ctx, config).await
}
#[expect(
clippy::too_many_lines,
reason = "Keep role endpoint dispatch and each permission check adjacent to its persistence operation"
)]
#[expect(
clippy::cast_precision_loss,
reason = "Source compares stored counts as ECMAScript Numbers"
)]
pub(in crate::organization) async fn role_core<
S: AuthSchema,
R: alibi_core::session::SessionRequest + Sync,
>(
method: &HttpMethod,
path: &str,
body: serde_json::Map<String, serde_json::Value>,
query: &std::collections::HashMap<String, String>,
principal: &R,
ctx: &AuthContext<S>,
config: &OrganizationConfig,
) -> AuthResult<Option<AuthResponse>> {
if !config.dynamic_access_control.enabled {
return Err(org_error(404, codes::DYNAMIC_ACCESS_CONTROL_DISABLED));
}
let mut issues = validation::Issues::default();
let (explicit, chosen, create, updates) = match (method, path) {
(HttpMethod::Post, "/organization/create-role") => {
let organization_id = issues
.take(validation::optional_string(
&body,
"organizationId",
"body.organizationId",
))
.flatten();
let create = issues
.take(validation::required_string(&body, "role", "body.role"))
.zip(issues.take(validation::permissions(
body.get("permission"),
"body.permission",
)));
if let Some(additional) = body.get("additionalFields") {
_ = issues.take(validation::object(
Some(additional),
"body.additionalFields",
));
}
(organization_id, None, create, None)
}
(HttpMethod::Post, "/organization/update-role") => {
let organization_id = issues
.take(validation::optional_string(
&body,
"organizationId",
"body.organizationId",
))
.flatten();
let updates = issues
.take(validation::object(body.get("data"), "body.data"))
.map(|data| {
let permission = data.get("permission").and_then(|value| {
issues.take(validation::permissions(Some(value), "body.data.permission"))
});
let role_name = issues
.take(validation::optional_string(
data,
"roleName",
"body.data.roleName",
))
.flatten();
RoleUpdates {
role_name,
permission,
}
});
let chosen = issues.take(selector(
body.get("roleName").and_then(serde_json::Value::as_str),
body.get("roleId").and_then(serde_json::Value::as_str),
false,
));
(organization_id, chosen, None, updates)
}
(HttpMethod::Post, "/organization/delete-role") => {
let organization_id = issues
.take(validation::optional_string(
&body,
"organizationId",
"body.organizationId",
))
.flatten();
let chosen = issues.take(selector(
body.get("roleName").and_then(serde_json::Value::as_str),
body.get("roleId").and_then(serde_json::Value::as_str),
false,
));
(organization_id, chosen, None, None)
}
(HttpMethod::Get, "/organization/get-role") => (
query.get("organizationId").cloned(),
issues.take(selector(
query.get("roleName").map(String::as_str),
query.get("roleId").map(String::as_str),
true,
)),
None,
None,
),
_ => (query.get("organizationId").cloned(), None, None, None),
};
if let Some(response) = issues.response() {
return Ok(Some(response));
}
let (user, current) = ctx
.require_cached_session(principal)
.await
.map_err(|error| {
if matches!(
error,
AuthError::Unauthenticated | AuthError::SessionNotFound
) {
org_error(401, codes::UNAUTHORIZED)
} else {
error
}
})?;
let action = match path {
"/organization/create-role" => "create",
"/organization/update-role" => "update",
"/organization/delete-role" => "delete",
_ => "read",
};
let error_code = match path {
"/organization/create-role" => codes::YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE,
"/organization/update-role" => codes::YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE,
"/organization/delete-role" => codes::YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE,
"/organization/list-roles" => codes::YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE,
_ => codes::YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE,
};
if (action == "create" || action == "update") && config.access_control.is_none() {
return Err(org_error(501, codes::MISSING_AC_INSTANCE));
}
let org = explicit
.or(current.active_organization_id)
.filter(|id| !id.is_empty())
.ok_or_else(|| {
org_error(
400,
if action == "create" {
codes::YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE
} else {
codes::NO_ACTIVE_ORGANIZATION
},
)
})?;
if let Some((name, _)) = &create {
check_predefined_name(&name.to_lowercase(), config)?;
}
let member = ctx
.database
.get_member(&org, user.id().as_ref())
.await?
.ok_or_else(|| org_error(403, codes::YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION))?;
let roles = organization_roles(config, ctx, &org).await?;
if !role_has_permissions(
&member.role,
&[("ac".to_owned(), vec![action.to_owned()])].into(),
&roles,
) {
return Err(org_error(403, error_code));
}
let response = if let Some((name, permission)) = create {
let maximum = match &config.dynamic_access_control.limit_resolver {
Some(resolver) => resolver.maximum_roles(&org).await?,
None => config.dynamic_access_control.maximum_roles_per_organization,
}
.unwrap_or(f64::INFINITY);
if ctx.database.count_organization_roles(&org).await? as f64 >= maximum {
return Err(org_error(400, codes::TOO_MANY_ROLES));
}
let missing = missing_permissions(&member.role, &permission, config, &org)?;
if !missing.is_empty() {
return Ok(Some(AuthResponse::json(
403,
&serde_json::json!({"message":"You are not allowed to create a role","code":error_code,"missingPermissions":missing}),
)?));
}
let name = name.to_lowercase();
check_name(&name, &org, config, ctx).await?;
let role = ctx
.database
.create_organization_role(CreateOrganizationRole {
organization_id: org,
role: name,
permission: permission.clone(),
})
.await?;
AuthResponse::json(
200,
&serde_json::json!({"success":true,"roleData":parsed_role(&role)?,"statements":permission}),
)?
} else if path == "/organization/list-roles" {
let roles = ctx.database.list_organization_roles(&org).await?;
let parsed = roles
.iter()
.map(parsed_role)
.collect::<AuthResult<Vec<_>>>()?;
AuthResponse::json(200, &parsed)?
} else {
let chosen = chosen.ok_or_else(|| org_error(400, codes::ROLE_NOT_FOUND))?;
if action == "delete"
&& matches!(&chosen,OrganizationRoleSelector::Name(name) if (config.roles.is_none() && ["owner","admin","member"].contains(&name.as_str())) || config.roles.as_ref().is_some_and(|configured_roles| configured_roles.contains_key(name)))
{
return Err(org_error(400, codes::CANNOT_DELETE_A_PRE_DEFINED_ROLE));
}
let role = ctx
.database
.get_organization_role(&org, &chosen)
.await?
.ok_or_else(|| org_error(400, codes::ROLE_NOT_FOUND))?;
let mut role_data = if updates.is_some() && role.permission.as_str().is_empty() {
let mut value = role_object(&role)?;
_ = value.insert("permission".to_owned(), serde_json::Value::Null);
value
} else {
parsed_role(&role)?
};
if action == "delete" {
if ctx
.database
.has_organization_role_members(&org, &role.role)
.await?
{
return Err(org_error(400, codes::ROLE_IS_ASSIGNED_TO_MEMBERS));
}
let _ignored_delete_organization_role =
ctx.database.delete_organization_role(&org, &chosen).await?;
AuthResponse::json(200, &serde_json::json!({"success":true}))?
} else if let Some(updates) = updates {
let permission = role_data.get("permission");
if matches!(permission, Some(serde_json::Value::Bool(false)))
|| permission.and_then(serde_json::Value::as_f64) == Some(0.0)
|| permission.and_then(serde_json::Value::as_str) == Some("")
{
_ = role_data.insert("permission".to_owned(), serde_json::Value::Null);
}
let mut update = UpdateOrganizationRole::default();
if let Some(permission) = &updates.permission {
let missing = missing_permissions(&member.role, permission, config, &org)?;
if !missing.is_empty() {
return Ok(Some(AuthResponse::json(
403,
&serde_json::json!({"message":"You are not allowed to update a role","code":error_code,"missingPermissions":missing}),
)?));
}
_ = role_data.insert("permission".to_owned(), serde_json::to_value(permission)?);
update.permission = Some(permission.clone());
}
if let Some(name) = updates.role_name.filter(|name| !name.is_empty()) {
let name = name.to_lowercase();
check_name(&name, &org, config, ctx).await?;
update.role = Some(name.clone());
_ = role_data.insert("role".to_owned(), serde_json::Value::String(name));
}
_ = ctx
.database
.update_organization_role(&org, &chosen, update)
.await?;
AuthResponse::json(
200,
&serde_json::json!({"success":true,"roleData":role_data}),
)?
} else {
AuthResponse::json(200, &role_data)?
}
};
Ok(Some(response))
}