use crate::{
AppState,
api::models::{
organizations::{
AddMemberRequest, InviteDetailsResponse, InviteMemberRequest, InviteMemberResponse, ListOrganizationsQuery, OrganizationCreate,
OrganizationMemberResponse, OrganizationResponse, OrganizationUpdate, SetActiveOrganizationRequest,
SetActiveOrganizationResponse, UpdateMemberRoleRequest,
},
pagination::PaginatedResponse,
users::{CurrentUser, UserResponse},
},
auth::permissions::{can_manage_org_resource, can_read_all_resources, can_read_own_resource},
db::handlers::{Credits, Organizations, Repository, Users, api_keys::ApiKeys, organizations::OrganizationFilter},
db::models::organizations::{OrganizationCreateDBRequest, OrganizationUpdateDBRequest},
email::EmailService,
errors::{Error, Result},
types::{Operation, Permission, Resource, UserId, UserIdOrCurrent},
};
use chrono::Duration;
use rust_decimal::prelude::ToPrimitive;
use sha2::{Digest, Sha256};
use sqlx_pool_router::PoolProvider;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::Json,
};
const MAX_ORGS_PER_USER: i64 = 3;
fn hash_invite_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
const VALID_ROLES: [&str; 3] = ["owner", "admin", "member"];
fn validate_role(role: &str) -> Result<()> {
if !VALID_ROLES.contains(&role) {
return Err(Error::BadRequest {
message: format!("Invalid role '{}'. Must be one of: owner, admin, member", role),
});
}
Ok(())
}
async fn check_role_assignment_privilege(
current_user: &CurrentUser,
org_id: UserId,
target_role: &str,
is_platform_manager: bool,
pool_conn: &mut sqlx::PgConnection,
) -> Result<()> {
if target_role == "owner" && !is_platform_manager {
let mut repo = Organizations::new(pool_conn);
let caller_role = repo.get_user_org_role(current_user.id, org_id).await?;
if caller_role.as_deref() != Some("owner") {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::UpdateOwn),
action: Operation::UpdateOwn,
resource: "Only owners can assign the owner role".to_string(),
});
}
}
Ok(())
}
#[utoipa::path(
post,
path = "/organizations",
tag = "organizations",
summary = "Create organization",
description = "Create a new organization. The authenticated user becomes the owner.",
responses(
(status = 201, description = "Organization created", body = OrganizationResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn create_organization<P: PoolProvider>(
State(state): State<AppState<P>>,
current_user: CurrentUser,
Json(data): Json<OrganizationCreate>,
) -> Result<(StatusCode, Json<OrganizationResponse>)> {
let is_platform_manager = crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::CreateAll);
if !is_platform_manager && !crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::CreateOwn) {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::CreateOwn),
action: Operation::CreateOwn,
resource: "Organizations".to_string(),
});
}
if data.name.trim().is_empty() {
return Err(Error::BadRequest {
message: "Organization name cannot be empty".to_string(),
});
}
let owner_id = if is_platform_manager {
data.owner_id.unwrap_or(current_user.id)
} else {
if data.owner_id.is_some() {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::CreateAll),
action: Operation::CreateAll,
resource: "Organization owner assignment".to_string(),
});
}
current_user.id
};
let db_request = OrganizationCreateDBRequest {
name: data.name,
email: data.email,
display_name: data.display_name,
avatar_url: None,
created_by: owner_id,
};
let config = state.current_config();
let mut pool_conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut repo = Organizations::new(&mut pool_conn);
let org_count = repo.count_user_organizations(owner_id).await?;
if org_count >= MAX_ORGS_PER_USER {
return Err(Error::BadRequest {
message: format!("Cannot create organization: user is already a member of {MAX_ORGS_PER_USER} organizations (maximum)"),
});
}
let org = repo.create(&db_request, &config.auth.default_user_roles).await?;
let response = OrganizationResponse::from_user(UserResponse::from(org)).with_member_count(1);
Ok((StatusCode::CREATED, Json(response)))
}
#[utoipa::path(
get,
path = "/organizations",
tag = "organizations",
summary = "List organizations",
description = "List organizations. Platform managers see all organizations; standard users see only those they belong to.",
params(ListOrganizationsQuery),
responses(
(status = 200, description = "List of organizations", body = PaginatedResponse<OrganizationResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn list_organizations<P: PoolProvider>(
State(state): State<AppState<P>>,
current_user: CurrentUser,
Query(query): Query<ListOrganizationsQuery>,
) -> Result<Json<PaginatedResponse<OrganizationResponse>>> {
let can_all = can_read_all_resources(¤t_user, Resource::Organizations);
if can_all {
let skip = query.pagination.skip();
let limit = query.pagination.limit();
let filter = OrganizationFilter::new(skip, limit);
let filter = if let Some(search) = query.search {
filter.with_search(search)
} else {
filter
};
let mut pool_conn = state.db.read().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut repo = Organizations::new(&mut pool_conn);
let orgs = repo.list(&filter).await?;
let total_count = repo.count(&filter).await?;
let data = orgs
.into_iter()
.map(|o| OrganizationResponse::from_user(UserResponse::from(o)))
.collect();
Ok(Json(PaginatedResponse {
data,
total_count,
skip,
limit,
}))
} else {
let mut pool_conn = state.db.read().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut repo = Organizations::new(&mut pool_conn);
let memberships = repo.list_user_organizations(current_user.id).await?;
let mut users_repo = Users::new(&mut pool_conn);
let org_ids: Vec<UserId> = memberships.iter().map(|m| m.organization_id).collect();
let org_map = users_repo.get_bulk(org_ids).await?;
let data: Vec<OrganizationResponse> = memberships
.iter()
.filter_map(|m| {
org_map
.get(&m.organization_id)
.map(|o| OrganizationResponse::from_user(UserResponse::from(o.clone())))
})
.collect();
let total_count = data.len() as i64;
Ok(Json(PaginatedResponse {
data,
total_count,
skip: 0,
limit: total_count,
}))
}
}
#[utoipa::path(
get,
path = "/organizations/{id}",
tag = "organizations",
summary = "Get organization",
description = "Get organization details. Requires membership or platform manager access.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
),
responses(
(status = 200, description = "Organization details", body = OrganizationResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn get_organization<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(id): Path<UserId>,
current_user: CurrentUser,
) -> Result<Json<OrganizationResponse>> {
let can_all = can_read_all_resources(¤t_user, Resource::Organizations);
let mut pool_conn = state.db.read().acquire().await.map_err(|e| Error::Database(e.into()))?;
if !can_all {
let mut repo = Organizations::new(&mut pool_conn);
let role = repo.get_user_org_role(current_user.id, id).await?;
if role.is_none() {
return Err(Error::NotFound {
resource: "Organization".to_string(),
id: id.to_string(),
});
}
}
let mut users_repo = Users::new(&mut pool_conn);
let org = users_repo.get_by_id(id).await?.ok_or_else(|| Error::NotFound {
resource: "Organization".to_string(),
id: id.to_string(),
})?;
if org.user_type != "organization" {
return Err(Error::NotFound {
resource: "Organization".to_string(),
id: id.to_string(),
});
}
let mut org_repo = Organizations::new(&mut pool_conn);
let members = org_repo.list_members(id).await?;
let mut response = OrganizationResponse::from_user(UserResponse::from(org)).with_member_count(members.len() as i64);
let can_view_billing = crate::auth::permissions::has_permission(¤t_user, Resource::Credits, Operation::ReadAll)
|| crate::auth::permissions::has_permission(¤t_user, Resource::Credits, Operation::ReadOwn);
if can_view_billing {
let mut credits_repo = Credits::new(&mut pool_conn);
let balance = credits_repo.get_user_balance(id).await?.to_f64().unwrap_or(0.0);
response.user = response.user.with_credit_balance(balance);
}
Ok(Json(response))
}
#[utoipa::path(
patch,
path = "/organizations/{id}",
tag = "organizations",
summary = "Update organization",
description = "Update organization details. Requires owner/admin role or platform manager access.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
),
responses(
(status = 200, description = "Organization updated", body = OrganizationResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn update_organization<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(id): Path<UserId>,
current_user: CurrentUser,
Json(data): Json<OrganizationUpdate>,
) -> Result<Json<OrganizationResponse>> {
let can_all = crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::UpdateAll);
let mut pool_conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
if !can_all {
let can_org = can_manage_org_resource(¤t_user, id, &mut pool_conn).await?;
if !can_org {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::UpdateOwn),
action: Operation::UpdateOwn,
resource: format!("Organization {id}"),
});
}
}
let db_request = OrganizationUpdateDBRequest {
display_name: data.display_name,
avatar_url: None,
email: data.email,
batch_notifications_enabled: data.batch_notifications_enabled,
low_balance_threshold: data.low_balance_threshold,
};
let mut repo = Organizations::new(&mut pool_conn);
let org = repo.update(id, &db_request).await?;
Ok(Json(OrganizationResponse::from_user(UserResponse::from(org))))
}
#[utoipa::path(
delete,
path = "/organizations/{id}",
tag = "organizations",
summary = "Delete organization",
description = "Soft-delete an organization. Platform managers only.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
),
responses(
(status = 204, description = "Organization deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn delete_organization<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(id): Path<UserId>,
current_user: CurrentUser,
) -> Result<StatusCode> {
if !crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::DeleteAll) {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::DeleteAll),
action: Operation::DeleteAll,
resource: format!("Organization {id}"),
});
}
let mut pool_conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut repo = Organizations::new(&mut pool_conn);
let deleted = repo.delete(id).await?;
if !deleted {
return Err(Error::NotFound {
resource: "Organization".to_string(),
id: id.to_string(),
});
}
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get,
path = "/organizations/{id}/members",
tag = "organizations",
summary = "List organization members",
description = "List all members of an organization. Requires membership or platform manager access.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
),
responses(
(status = 200, description = "List of members", body = Vec<OrganizationMemberResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn list_members<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(id): Path<UserId>,
current_user: CurrentUser,
) -> Result<Json<Vec<OrganizationMemberResponse>>> {
let can_all = can_read_all_resources(¤t_user, Resource::Organizations);
let mut pool_conn = state.db.read().acquire().await.map_err(|e| Error::Database(e.into()))?;
if !can_all {
let mut repo = Organizations::new(&mut pool_conn);
let role = repo.get_user_org_role(current_user.id, id).await?;
if role.is_none() {
return Err(Error::NotFound {
resource: "Organization".to_string(),
id: id.to_string(),
});
}
}
let mut repo = Organizations::new(&mut pool_conn);
let memberships = repo.list_members(id).await?;
let user_ids: Vec<UserId> = memberships.iter().filter_map(|m| m.user_id).collect();
let mut users_repo = Users::new(&mut pool_conn);
let user_map = users_repo.get_bulk(user_ids).await?;
let members: Vec<OrganizationMemberResponse> = memberships
.iter()
.filter_map(|m| {
if let Some(uid) = m.user_id {
user_map.get(&uid).map(|u| OrganizationMemberResponse {
id: m.id,
user: Some(UserResponse::from(u.clone())),
role: m.role.clone(),
status: m.status.clone(),
created_at: m.created_at,
invite_email: m.invite_email.clone(),
})
} else {
Some(OrganizationMemberResponse {
id: m.id,
user: None,
role: m.role.clone(),
status: m.status.clone(),
created_at: m.created_at,
invite_email: m.invite_email.clone(),
})
}
})
.collect();
Ok(Json(members))
}
#[utoipa::path(
post,
path = "/organizations/{id}/members",
tag = "organizations",
summary = "Add organization member",
description = "Add a user as a member of an organization. Requires owner/admin role or platform manager access.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
),
responses(
(status = 201, description = "Member added", body = OrganizationMemberResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn add_member<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(id): Path<UserId>,
current_user: CurrentUser,
Json(data): Json<AddMemberRequest>,
) -> Result<(StatusCode, Json<OrganizationMemberResponse>)> {
let can_all = crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::UpdateAll);
let mut pool_conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
if !can_all {
let can_org = can_manage_org_resource(¤t_user, id, &mut pool_conn).await?;
if !can_org {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::UpdateOwn),
action: Operation::UpdateOwn,
resource: format!("Organization {id} members"),
});
}
}
let role = data.role.as_deref().unwrap_or("member");
validate_role(role)?;
check_role_assignment_privilege(¤t_user, id, role, can_all, &mut pool_conn).await?;
let mut repo = Organizations::new(&mut pool_conn);
let org_count = repo.count_user_organizations(data.user_id).await?;
if org_count >= MAX_ORGS_PER_USER {
return Err(Error::BadRequest {
message: format!("Cannot add member: user is already a member of {MAX_ORGS_PER_USER} organizations (maximum)"),
});
}
let membership = repo.add_member(id, data.user_id, role).await?;
let mut users_repo = Users::new(&mut pool_conn);
let user = users_repo.get_by_id(data.user_id).await?.ok_or_else(|| Error::NotFound {
resource: "User".to_string(),
id: data.user_id.to_string(),
})?;
Ok((
StatusCode::CREATED,
Json(OrganizationMemberResponse {
id: membership.id,
user: Some(UserResponse::from(user)),
role: membership.role,
status: membership.status,
created_at: membership.created_at,
invite_email: None,
}),
))
}
#[utoipa::path(
patch,
path = "/organizations/{id}/members/{user_id}",
tag = "organizations",
summary = "Update member role",
description = "Update a member's role in an organization. Requires owner/admin role or platform manager access.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
("user_id" = String, Path, description = "Member user ID (UUID)"),
),
responses(
(status = 200, description = "Role updated", body = OrganizationMemberResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn update_member_role<P: PoolProvider>(
State(state): State<AppState<P>>,
Path((id, user_id)): Path<(UserId, UserId)>,
current_user: CurrentUser,
Json(data): Json<UpdateMemberRoleRequest>,
) -> Result<Json<OrganizationMemberResponse>> {
let can_all = crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::UpdateAll);
let mut tx = state.db.write().begin().await.map_err(|e| Error::Database(e.into()))?;
if !can_all {
let can_org = can_manage_org_resource(¤t_user, id, &mut tx).await?;
if !can_org {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::UpdateOwn),
action: Operation::UpdateOwn,
resource: format!("Organization {id} members"),
});
}
}
validate_role(&data.role)?;
check_role_assignment_privilege(¤t_user, id, &data.role, can_all, &mut tx).await?;
let mut repo = Organizations::new(&mut tx);
if data.role != "owner" {
let current_role = repo.get_user_org_role(user_id, id).await?;
if current_role.as_deref() == Some("owner") {
let members = repo.list_members(id).await?;
let owner_count = members.iter().filter(|m| m.role == "owner").count();
if owner_count <= 1 {
return Err(Error::BadRequest {
message: "Cannot demote the last owner. Assign another owner first.".to_string(),
});
}
}
}
let membership = repo.update_member_role(id, user_id, &data.role).await?;
let mut users_repo = Users::new(&mut tx);
let user = users_repo.get_by_id(user_id).await?.ok_or_else(|| Error::NotFound {
resource: "User".to_string(),
id: user_id.to_string(),
})?;
tx.commit().await.map_err(|e| Error::Database(e.into()))?;
Ok(Json(OrganizationMemberResponse {
id: membership.id,
user: Some(UserResponse::from(user)),
role: membership.role,
status: membership.status,
created_at: membership.created_at,
invite_email: None,
}))
}
#[utoipa::path(
delete,
path = "/organizations/{id}/members/{user_id}",
tag = "organizations",
summary = "Remove organization member",
description = "Remove a member from an organization. Requires owner/admin role or platform manager access.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
("user_id" = String, Path, description = "Member user ID (UUID)"),
),
responses(
(status = 204, description = "Member removed"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn remove_member<P: PoolProvider>(
State(state): State<AppState<P>>,
Path((id, user_id)): Path<(UserId, UserId)>,
current_user: CurrentUser,
) -> Result<StatusCode> {
let can_all = crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::UpdateAll);
let mut tx = state.db.write().begin().await.map_err(|e| Error::Database(e.into()))?;
if !can_all {
let can_org = can_manage_org_resource(¤t_user, id, &mut tx).await?;
if !can_org {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::UpdateOwn),
action: Operation::UpdateOwn,
resource: format!("Organization {id} members"),
});
}
}
let mut repo = Organizations::new(&mut tx);
let target_role = repo.get_user_org_role(user_id, id).await?;
if let Some(ref role) = target_role
&& role == "owner"
{
let members = repo.list_members(id).await?;
let owner_count = members.iter().filter(|m| m.role == "owner").count();
if owner_count <= 1 {
return Err(Error::BadRequest {
message: "Cannot remove the last owner of an organization. Transfer ownership first.".to_string(),
});
}
}
let removed = repo.remove_member(id, user_id).await?;
if !removed {
return Err(Error::NotFound {
resource: "Organization membership".to_string(),
id: format!("{user_id} in organization {id}"),
});
}
let mut api_keys_repo = ApiKeys::new(&mut tx);
let keys_deleted = api_keys_repo.soft_delete_member_org_keys(id, user_id).await?;
if keys_deleted > 0 {
tracing::info!("Soft-deleted {keys_deleted} API key(s) for removed member {user_id} in org {id}");
}
tx.commit().await.map_err(|e| Error::Database(e.into()))?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/organizations/{id}/leave",
tag = "organizations",
summary = "Leave organization",
description = "Leave an organization voluntarily. Cannot leave if you are the last owner.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
),
responses(
(status = 204, description = "Left organization"),
(status = 400, description = "Bad request - last owner cannot leave"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found - not a member"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn leave_organization<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(id): Path<UserId>,
current_user: CurrentUser,
) -> Result<StatusCode> {
let mut tx = state.db.write().begin().await.map_err(|e| Error::Database(e.into()))?;
let mut repo = Organizations::new(&mut tx);
let role = repo.get_user_org_role(current_user.id, id).await?;
if role.is_none() {
return Err(Error::NotFound {
resource: "Organization membership".to_string(),
id: format!("{} in organization {id}", current_user.id),
});
}
if role.as_deref() == Some("owner") {
let members = repo.list_members(id).await?;
let owner_count = members.iter().filter(|m| m.role == "owner").count();
if owner_count <= 1 {
return Err(Error::BadRequest {
message: "Cannot leave as the last owner. Transfer ownership first.".to_string(),
});
}
}
repo.remove_member(id, current_user.id).await?;
let mut api_keys_repo = ApiKeys::new(&mut tx);
let keys_deleted = api_keys_repo.soft_delete_member_org_keys(id, current_user.id).await?;
if keys_deleted > 0 {
tracing::info!(
"Soft-deleted {keys_deleted} API key(s) for user {} leaving org {id}",
current_user.id
);
}
tx.commit().await.map_err(|e| Error::Database(e.into()))?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get,
path = "/users/{user_id}/organizations",
tag = "organizations",
summary = "List user's organizations",
description = "List organizations that a user belongs to.",
params(
("user_id" = String, Path, description = "User ID (UUID) or 'current' for current user"),
),
responses(
(status = 200, description = "List of organizations", body = Vec<crate::api::models::organizations::OrganizationSummary>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn list_user_organizations<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(user_id): Path<UserIdOrCurrent>,
current_user: CurrentUser,
) -> Result<Json<Vec<crate::api::models::organizations::OrganizationSummary>>> {
let target_user_id = match user_id {
UserIdOrCurrent::Current(_) => current_user.id,
UserIdOrCurrent::Id(uuid) => uuid,
};
let can_all = can_read_all_resources(¤t_user, Resource::Users);
let can_own = can_read_own_resource(¤t_user, Resource::Users, target_user_id);
if !can_all && !can_own {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Users, Operation::ReadOwn),
action: Operation::ReadOwn,
resource: format!("Organizations for user {target_user_id}"),
});
}
let mut pool_conn = state.db.read().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut repo = Organizations::new(&mut pool_conn);
let memberships = repo.list_user_organizations(target_user_id).await?;
let org_ids: Vec<UserId> = memberships.iter().map(|m| m.organization_id).collect();
let mut users_repo = Users::new(&mut pool_conn);
let org_map = users_repo.get_bulk(org_ids).await?;
let summaries: Vec<crate::api::models::organizations::OrganizationSummary> = memberships
.iter()
.filter_map(|m| {
org_map
.get(&m.organization_id)
.map(|o| crate::api::models::organizations::OrganizationSummary {
id: o.id,
name: o.username.clone(),
role: m.role.clone(),
})
})
.collect();
Ok(Json(summaries))
}
#[utoipa::path(
post,
path = "/session/organization",
tag = "organizations",
summary = "Set active organization",
description = "Validate organization membership and set a cookie for the active organization context.",
responses(
(status = 200, description = "Organization context validated", body = SetActiveOrganizationResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn set_active_organization<P: PoolProvider>(
State(state): State<AppState<P>>,
current_user: CurrentUser,
Json(data): Json<SetActiveOrganizationRequest>,
) -> Result<(HeaderMap, Json<SetActiveOrganizationResponse>)> {
if let Some(org_id) = data.organization_id {
let mut pool_conn = state.db.read().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut repo = Organizations::new(&mut pool_conn);
let role = repo.get_user_org_role(current_user.id, org_id).await?;
if role.is_none() {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::ReadOwn),
action: Operation::ReadOwn,
resource: format!("Organization {org_id}"),
});
}
}
let config = state.current_config();
let session_config = &config.auth.native.session;
let secure = if session_config.cookie_secure { "; Secure" } else { "" };
let domain = session_config
.cookie_domain
.as_ref()
.map(|d| format!("; Domain={d}"))
.unwrap_or_default();
let cookie = if let Some(org_id) = data.organization_id {
format!(
"dw_active_org={}; Path=/; HttpOnly{}{}; SameSite={}; Max-Age={}",
org_id,
secure,
domain,
session_config.cookie_same_site,
30 * 24 * 60 * 60
)
} else {
format!(
"dw_active_org=; Path=/; HttpOnly{}{}; SameSite={}; Max-Age=0",
secure, domain, session_config.cookie_same_site
)
};
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
Ok((
headers,
Json(SetActiveOrganizationResponse {
active_organization_id: data.organization_id,
}),
))
}
#[utoipa::path(
post,
path = "/organizations/{id}/invites",
tag = "organizations",
summary = "Invite member by email",
description = "Send an invitation email to join the organization. Requires owner/admin role or platform manager access.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
),
responses(
(status = 201, description = "Invite created", body = InviteMemberResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 409, description = "Conflict - already a member or pending invite"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn invite_member<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(id): Path<UserId>,
current_user: CurrentUser,
Json(data): Json<InviteMemberRequest>,
) -> Result<(StatusCode, Json<InviteMemberResponse>)> {
let can_all = crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::UpdateAll);
let mut pool_conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
if !can_all {
let can_org = can_manage_org_resource(¤t_user, id, &mut pool_conn).await?;
if !can_org {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::UpdateOwn),
action: Operation::UpdateOwn,
resource: format!("Organization {id} members"),
});
}
}
let email = data.email.trim().to_lowercase();
if !email.contains('@') || !email.contains('.') {
return Err(Error::BadRequest {
message: "Invalid email address".to_string(),
});
}
let role = data.role.as_deref().unwrap_or("member");
validate_role(role)?;
check_role_assignment_privilege(¤t_user, id, role, can_all, &mut pool_conn).await?;
let mut users_repo = Users::new(&mut pool_conn);
let existing_user = users_repo.get_user_by_email(&email).await?;
let existing_user_id = existing_user.as_ref().map(|u| u.id);
if let Some(ref user) = existing_user {
let mut org_repo = Organizations::new(&mut pool_conn);
let existing_role = org_repo.get_user_org_role(user.id, id).await?;
if existing_role.is_some() {
return Err(Error::Conflict {
message: "User is already an active member of this organization".to_string(),
conflicts: None,
});
}
}
let mut org_repo = Organizations::new(&mut pool_conn);
let token = crate::auth::password::generate_reset_token();
let token_hash = hash_invite_token(&token);
let expires_at = chrono::Utc::now() + Duration::days(7);
let invite = org_repo
.create_invite(id, existing_user_id, &email, role, current_user.id, &token_hash, expires_at)
.await?;
let mut users_repo = Users::new(&mut pool_conn);
let org_user = users_repo.get_by_id(id).await?;
let org_name = org_user
.as_ref()
.and_then(|u| u.display_name.clone())
.unwrap_or_else(|| org_user.as_ref().map(|u| u.username.clone()).unwrap_or_default());
let inviter = users_repo.get_by_id(current_user.id).await?;
let inviter_name = inviter
.as_ref()
.and_then(|u| u.display_name.clone())
.unwrap_or_else(|| inviter.as_ref().map(|u| u.username.clone()).unwrap_or_default());
let config = state.current_config();
let invite_link = format!("{}/org-invite?token={}", config.dashboard_url.trim_end_matches('/'), token);
let email_service = EmailService::new(&config)?;
if let Err(e) = email_service
.send_org_invite_email(&email, &org_name, &inviter_name, role, &invite_link)
.await
{
tracing::warn!("Failed to send invite email to {email}: {e}");
}
Ok((
StatusCode::CREATED,
Json(InviteMemberResponse {
id: invite.id,
email,
role: invite.role,
status: invite.status,
created_at: invite.created_at,
expires_at: invite.expires_at.expect("invite must have expires_at"),
}),
))
}
#[utoipa::path(
get,
path = "/organizations/invites/{token}",
tag = "organizations",
summary = "Get invite details",
description = "Look up a pending invite by its token. Returns organization name, role, and inviter info.",
params(
("token" = String, Path, description = "Invite token"),
),
responses(
(status = 200, description = "Invite details", body = InviteDetailsResponse),
(status = 401, description = "Unauthorized"),
(status = 400, description = "Bad request - invite has expired"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn get_invite_details<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(token): Path<String>,
_current_user: CurrentUser,
) -> Result<Json<InviteDetailsResponse>> {
let token_hash = hash_invite_token(&token);
let mut pool_conn = state.db.read().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut org_repo = Organizations::new(&mut pool_conn);
let invite = org_repo
.find_invite_by_token_hash(&token_hash)
.await?
.ok_or_else(|| Error::NotFound {
resource: "Invite".to_string(),
id: "invalid or expired token".to_string(),
})?;
if let Some(expires_at) = invite.expires_at
&& expires_at < chrono::Utc::now()
{
return Err(Error::BadRequest {
message: "This invite has expired".to_string(),
});
}
let mut users_repo = Users::new(&mut pool_conn);
let org_user = users_repo.get_by_id(invite.organization_id).await?;
let org_name = org_user
.as_ref()
.and_then(|u| u.display_name.clone())
.unwrap_or_else(|| org_user.as_ref().map(|u| u.username.clone()).unwrap_or_default());
let inviter_name = if let Some(invited_by) = invite.invited_by {
let inviter = users_repo.get_by_id(invited_by).await?;
inviter.and_then(|u| u.display_name.or(Some(u.username)))
} else {
None
};
Ok(Json(InviteDetailsResponse {
org_name,
role: invite.role,
inviter_name,
expires_at: invite.expires_at.expect("invite must have expires_at"),
}))
}
#[utoipa::path(
post,
path = "/organizations/invites/{token}/accept",
tag = "organizations",
summary = "Accept invite",
description = "Accept a pending organization invite. The authenticated user's email must match the invite email.",
params(
("token" = String, Path, description = "Invite token"),
),
responses(
(status = 200, description = "Invite accepted"),
(status = 400, description = "Bad request - expired or email mismatch"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn accept_invite<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(token): Path<String>,
current_user: CurrentUser,
) -> Result<Json<serde_json::Value>> {
let token_hash = hash_invite_token(&token);
let mut pool_conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut org_repo = Organizations::new(&mut pool_conn);
let invite = org_repo
.find_invite_by_token_hash(&token_hash)
.await?
.ok_or_else(|| Error::NotFound {
resource: "Invite".to_string(),
id: "invalid or expired token".to_string(),
})?;
if let Some(expires_at) = invite.expires_at
&& expires_at < chrono::Utc::now()
{
return Err(Error::BadRequest {
message: "This invite has expired".to_string(),
});
}
if let Some(ref invite_email) = invite.invite_email
&& current_user.email.to_lowercase() != invite_email.to_lowercase()
{
return Err(Error::BadRequest {
message: "Your email address does not match this invite".to_string(),
});
}
let org_count = org_repo.count_user_organizations(current_user.id).await?;
if org_count >= MAX_ORGS_PER_USER {
return Err(Error::BadRequest {
message: format!("Cannot accept invite: you are already a member of {MAX_ORGS_PER_USER} organizations (maximum)"),
});
}
org_repo.accept_invite(invite.id, current_user.id).await?;
Ok(Json(serde_json::json!({ "message": "Invite accepted" })))
}
#[utoipa::path(
post,
path = "/organizations/invites/{token}/decline",
tag = "organizations",
summary = "Decline invite",
description = "Decline a pending organization invite. The authenticated user's email must match the invite email.",
params(
("token" = String, Path, description = "Invite token"),
),
responses(
(status = 200, description = "Invite declined"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn decline_invite<P: PoolProvider>(
State(state): State<AppState<P>>,
Path(token): Path<String>,
current_user: CurrentUser,
) -> Result<Json<serde_json::Value>> {
let token_hash = hash_invite_token(&token);
let mut pool_conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
let mut org_repo = Organizations::new(&mut pool_conn);
let invite = org_repo
.find_invite_by_token_hash(&token_hash)
.await?
.ok_or_else(|| Error::NotFound {
resource: "Invite".to_string(),
id: "invalid or expired token".to_string(),
})?;
if let Some(ref invite_email) = invite.invite_email
&& current_user.email.to_lowercase() != invite_email.to_lowercase()
{
return Err(Error::BadRequest {
message: "Your email address does not match this invite".to_string(),
});
}
org_repo.cancel_invite(invite.organization_id, invite.id).await?;
Ok(Json(serde_json::json!({ "message": "Invite declined" })))
}
#[utoipa::path(
delete,
path = "/organizations/{id}/invites/{invite_id}",
tag = "organizations",
summary = "Cancel invite",
description = "Cancel a pending invite. Requires owner/admin role or platform manager access.",
params(
("id" = String, Path, description = "Organization ID (UUID)"),
("invite_id" = String, Path, description = "Invite row ID (UUID)"),
),
responses(
(status = 204, description = "Invite cancelled"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(
("BearerAuth" = []),
("CookieAuth" = []),
("X-Doubleword-User" = [])
)
)]
#[tracing::instrument(skip_all)]
pub async fn cancel_invite<P: PoolProvider>(
State(state): State<AppState<P>>,
Path((id, invite_id)): Path<(UserId, UserId)>,
current_user: CurrentUser,
) -> Result<StatusCode> {
let can_all = crate::auth::permissions::has_permission(¤t_user, Resource::Organizations, Operation::UpdateAll);
let mut pool_conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
if !can_all {
let can_org = can_manage_org_resource(¤t_user, id, &mut pool_conn).await?;
if !can_org {
return Err(Error::InsufficientPermissions {
required: Permission::Allow(Resource::Organizations, Operation::UpdateOwn),
action: Operation::UpdateOwn,
resource: format!("Organization {id} invites"),
});
}
}
let mut org_repo = Organizations::new(&mut pool_conn);
let cancelled = org_repo.cancel_invite(id, invite_id).await?;
if !cancelled {
return Err(Error::NotFound {
resource: "Pending invite".to_string(),
id: format!("{invite_id} in organization {id}"),
});
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use crate::api::models::users::Role;
use crate::test::utils::{
add_auth_headers, create_test_admin_user, create_test_app, create_test_app_with_config, create_test_config, create_test_user,
};
use serde_json::json;
use sqlx::PgPool;
#[sqlx::test]
#[test_log::test]
async fn test_standard_user_can_create_organization(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let user = create_test_user(&pool, Role::StandardUser).await;
let user_headers = add_auth_headers(&user);
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&user_headers[0].0, &user_headers[0].1)
.add_header(&user_headers[1].0, &user_headers[1].1)
.json(&json!({ "name": "my-org", "email": "contact@my-org.com" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let body = resp.json::<serde_json::Value>();
assert_eq!(body["username"].as_str().unwrap(), "my-org");
}
#[sqlx::test]
#[test_log::test]
async fn test_standard_user_becomes_owner_of_created_org(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let user = create_test_user(&pool, Role::StandardUser).await;
let user_headers = add_auth_headers(&user);
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&user_headers[0].0, &user_headers[0].1)
.add_header(&user_headers[1].0, &user_headers[1].1)
.json(&json!({ "name": "self-serve-org", "email": "contact@example.com" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.get(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&user_headers[0].0, &user_headers[0].1)
.add_header(&user_headers[1].0, &user_headers[1].1)
.await;
resp.assert_status(axum::http::StatusCode::OK);
let members = resp.json::<Vec<serde_json::Value>>();
assert_eq!(members.len(), 1);
assert_eq!(members[0]["role"].as_str().unwrap(), "owner");
assert_eq!(members[0]["user"]["id"].as_str().unwrap(), user.id.to_string());
}
#[sqlx::test]
#[test_log::test]
async fn test_standard_user_cannot_set_owner_id(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let user = create_test_user(&pool, Role::StandardUser).await;
let user_headers = add_auth_headers(&user);
let other = create_test_user(&pool, Role::StandardUser).await;
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&user_headers[0].0, &user_headers[0].1)
.add_header(&user_headers[1].0, &user_headers[1].1)
.json(&json!({ "name": "hijack-org", "email": "x@example.com", "owner_id": other.id }))
.await;
resp.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[sqlx::test]
#[test_log::test]
async fn test_cannot_create_org_when_at_limit(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let user = create_test_user(&pool, Role::StandardUser).await;
let user_headers = add_auth_headers(&user);
for i in 0..super::MAX_ORGS_PER_USER {
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&user_headers[0].0, &user_headers[0].1)
.add_header(&user_headers[1].0, &user_headers[1].1)
.json(&json!({ "name": format!("org-{i}"), "email": format!("org{i}@example.com") }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
}
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&user_headers[0].0, &user_headers[0].1)
.add_header(&user_headers[1].0, &user_headers[1].1)
.json(&json!({ "name": "one-too-many", "email": "extra@example.com" }))
.await;
resp.assert_status(axum::http::StatusCode::BAD_REQUEST);
let body = resp.text();
assert!(body.contains("maximum"));
}
#[sqlx::test]
#[test_log::test]
async fn test_member_can_leave_organization(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let member = create_test_user(&pool, Role::StandardUser).await;
let member_headers = add_auth_headers(&member);
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "leave-org", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "user_id": member.id, "role": "member" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/leave"))
.add_header(&member_headers[0].0, &member_headers[0].1)
.add_header(&member_headers[1].0, &member_headers[1].1)
.await;
resp.assert_status(axum::http::StatusCode::NO_CONTENT);
let resp = server
.get(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.await;
resp.assert_status(axum::http::StatusCode::OK);
let members = resp.json::<Vec<serde_json::Value>>();
assert_eq!(members.len(), 1); }
#[sqlx::test]
#[test_log::test]
async fn test_last_owner_cannot_leave(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let owner = create_test_user(&pool, Role::StandardUser).await;
let owner_headers = add_auth_headers(&owner);
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&owner_headers[0].0, &owner_headers[0].1)
.add_header(&owner_headers[1].0, &owner_headers[1].1)
.json(&json!({ "name": "solo-org", "email": "solo@example.com" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/leave"))
.add_header(&owner_headers[0].0, &owner_headers[0].1)
.add_header(&owner_headers[1].0, &owner_headers[1].1)
.await;
resp.assert_status(axum::http::StatusCode::BAD_REQUEST);
let body = resp.text();
assert!(body.contains("last owner"));
}
#[sqlx::test]
#[test_log::test]
async fn test_non_member_cannot_leave(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let outsider = create_test_user(&pool, Role::StandardUser).await;
let outsider_headers = add_auth_headers(&outsider);
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "private-org", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/leave"))
.add_header(&outsider_headers[0].0, &outsider_headers[0].1)
.add_header(&outsider_headers[1].0, &outsider_headers[1].1)
.await;
resp.assert_status(axum::http::StatusCode::NOT_FOUND);
}
#[sqlx::test]
#[test_log::test]
async fn test_cannot_remove_last_owner(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let admin = create_test_admin_user(&pool, Role::PlatformManager).await;
let admin_headers = add_auth_headers(&admin);
let owner = create_test_user(&pool, Role::StandardUser).await;
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&admin_headers[0].0, &admin_headers[0].1)
.add_header(&admin_headers[1].0, &admin_headers[1].1)
.json(&json!({ "name": "test-org-last-owner", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.delete(&format!("/admin/api/v1/organizations/{org_id}/members/{}", owner.id))
.add_header(&admin_headers[0].0, &admin_headers[0].1)
.add_header(&admin_headers[1].0, &admin_headers[1].1)
.await;
resp.assert_status(axum::http::StatusCode::BAD_REQUEST);
let body = resp.text();
assert!(body.contains("last owner"));
}
#[sqlx::test]
#[test_log::test]
async fn test_can_remove_owner_when_another_exists(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let admin = create_test_admin_user(&pool, Role::PlatformManager).await;
let admin_headers = add_auth_headers(&admin);
let owner1 = create_test_user(&pool, Role::StandardUser).await;
let owner2 = create_test_user(&pool, Role::StandardUser).await;
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&admin_headers[0].0, &admin_headers[0].1)
.add_header(&admin_headers[1].0, &admin_headers[1].1)
.json(&json!({ "name": "test-org-two-owners", "email": "org@example.com", "owner_id": owner1.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&admin_headers[0].0, &admin_headers[0].1)
.add_header(&admin_headers[1].0, &admin_headers[1].1)
.json(&json!({ "user_id": owner2.id, "role": "owner" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let resp = server
.delete(&format!("/admin/api/v1/organizations/{org_id}/members/{}", owner1.id))
.add_header(&admin_headers[0].0, &admin_headers[0].1)
.add_header(&admin_headers[1].0, &admin_headers[1].1)
.await;
resp.assert_status(axum::http::StatusCode::NO_CONTENT);
}
#[sqlx::test]
#[test_log::test]
async fn test_cannot_demote_last_owner(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let admin = create_test_admin_user(&pool, Role::PlatformManager).await;
let admin_headers = add_auth_headers(&admin);
let owner = create_test_user(&pool, Role::StandardUser).await;
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&admin_headers[0].0, &admin_headers[0].1)
.add_header(&admin_headers[1].0, &admin_headers[1].1)
.json(&json!({ "name": "test-org-demote", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.patch(&format!("/admin/api/v1/organizations/{org_id}/members/{}", owner.id))
.add_header(&admin_headers[0].0, &admin_headers[0].1)
.add_header(&admin_headers[1].0, &admin_headers[1].1)
.json(&json!({ "role": "member" }))
.await;
resp.assert_status(axum::http::StatusCode::BAD_REQUEST);
let body = resp.text();
assert!(body.contains("last owner"));
}
#[sqlx::test]
#[test_log::test]
async fn test_admin_cannot_assign_owner_role(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let org_admin = create_test_user(&pool, Role::StandardUser).await;
let org_admin_headers = add_auth_headers(&org_admin);
let member = create_test_user(&pool, Role::StandardUser).await;
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "test-org-priv-esc", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "user_id": org_admin.id, "role": "admin" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "user_id": member.id, "role": "member" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let resp = server
.patch(&format!("/admin/api/v1/organizations/{org_id}/members/{}", member.id))
.add_header(&org_admin_headers[0].0, &org_admin_headers[0].1)
.add_header(&org_admin_headers[1].0, &org_admin_headers[1].1)
.json(&json!({ "role": "owner" }))
.await;
resp.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[sqlx::test]
#[test_log::test]
async fn test_admin_cannot_add_member_as_owner(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let org_admin = create_test_user(&pool, Role::StandardUser).await;
let org_admin_headers = add_auth_headers(&org_admin);
let new_user = create_test_user(&pool, Role::StandardUser).await;
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "test-org-add-owner", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "user_id": org_admin.id, "role": "admin" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&org_admin_headers[0].0, &org_admin_headers[0].1)
.add_header(&org_admin_headers[1].0, &org_admin_headers[1].1)
.json(&json!({ "user_id": new_user.id, "role": "owner" }))
.await;
resp.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[sqlx::test]
#[test_log::test]
async fn test_owner_can_assign_owner_role(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let owner_headers = add_auth_headers(&owner);
let new_user = create_test_user(&pool, Role::StandardUser).await;
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "test-org-owner-assign", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&owner_headers[0].0, &owner_headers[0].1)
.add_header(&owner_headers[1].0, &owner_headers[1].1)
.json(&json!({ "user_id": new_user.id, "role": "owner" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let body = resp.json::<serde_json::Value>();
assert_eq!(body["role"].as_str().unwrap(), "owner");
}
#[sqlx::test]
#[test_log::test]
async fn test_platform_manager_can_assign_owner_role(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let new_user = create_test_user(&pool, Role::StandardUser).await;
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "test-org-pm-assign", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/admin/api/v1/organizations/{org_id}/members"))
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "user_id": new_user.id, "role": "owner" }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let body = resp.json::<serde_json::Value>();
assert_eq!(body["role"].as_str().unwrap(), "owner");
}
#[sqlx::test]
#[test_log::test]
async fn test_set_active_organization_validates_membership(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let owner_headers = add_auth_headers(&owner);
let non_member = create_test_user(&pool, Role::StandardUser).await;
let non_member_headers = add_auth_headers(&non_member);
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "test-org-session", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post("/admin/api/v1/session/organization")
.add_header(&owner_headers[0].0, &owner_headers[0].1)
.add_header(&owner_headers[1].0, &owner_headers[1].1)
.json(&json!({ "organization_id": org_id }))
.await;
resp.assert_status(axum::http::StatusCode::OK);
let body = resp.json::<serde_json::Value>();
assert_eq!(body["active_organization_id"].as_str().unwrap(), org_id);
let resp = server
.post("/admin/api/v1/session/organization")
.add_header(&non_member_headers[0].0, &non_member_headers[0].1)
.add_header(&non_member_headers[1].0, &non_member_headers[1].1)
.json(&json!({ "organization_id": org_id }))
.await;
resp.assert_status(axum::http::StatusCode::FORBIDDEN);
let resp = server
.post("/admin/api/v1/session/organization")
.add_header(&non_member_headers[0].0, &non_member_headers[0].1)
.add_header(&non_member_headers[1].0, &non_member_headers[1].1)
.json(&json!({ "organization_id": null }))
.await;
resp.assert_status(axum::http::StatusCode::OK);
}
#[sqlx::test]
#[test_log::test]
async fn test_set_active_org_cookie_includes_domain_when_configured(pool: PgPool) {
let mut config = create_test_config();
config.auth.native.session.cookie_domain = Some(".example.com".to_string());
let (server, _bg) = create_test_app_with_config(pool.clone(), config, false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let owner_headers = add_auth_headers(&owner);
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "domain-test-org", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post("/admin/api/v1/session/organization")
.add_header(&owner_headers[0].0, &owner_headers[0].1)
.add_header(&owner_headers[1].0, &owner_headers[1].1)
.json(&json!({ "organization_id": org_id }))
.await;
resp.assert_status(axum::http::StatusCode::OK);
let cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie.contains("Domain=.example.com"), "set cookie should include Domain: {cookie}");
let resp = server
.post("/admin/api/v1/session/organization")
.add_header(&owner_headers[0].0, &owner_headers[0].1)
.add_header(&owner_headers[1].0, &owner_headers[1].1)
.json(&json!({ "organization_id": null }))
.await;
resp.assert_status(axum::http::StatusCode::OK);
let cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
assert!(
cookie.contains("Domain=.example.com"),
"clear cookie should include Domain: {cookie}"
);
}
#[sqlx::test]
#[test_log::test]
async fn test_set_active_org_cookie_omits_domain_when_not_configured(pool: PgPool) {
let (server, _bg) = create_test_app(pool.clone(), false).await;
let pm = create_test_admin_user(&pool, Role::PlatformManager).await;
let pm_headers = add_auth_headers(&pm);
let owner = create_test_user(&pool, Role::StandardUser).await;
let owner_headers = add_auth_headers(&owner);
let resp = server
.post("/admin/api/v1/organizations")
.add_header(&pm_headers[0].0, &pm_headers[0].1)
.add_header(&pm_headers[1].0, &pm_headers[1].1)
.json(&json!({ "name": "no-domain-org", "email": "org@example.com", "owner_id": owner.id }))
.await;
resp.assert_status(axum::http::StatusCode::CREATED);
let org_id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let resp = server
.post("/admin/api/v1/session/organization")
.add_header(&owner_headers[0].0, &owner_headers[0].1)
.add_header(&owner_headers[1].0, &owner_headers[1].1)
.json(&json!({ "organization_id": org_id }))
.await;
resp.assert_status(axum::http::StatusCode::OK);
let cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
assert!(!cookie.contains("Domain="), "cookie should not include Domain: {cookie}");
}
}