use std::collections::HashMap; use models::space::space_group_permissions; use models::space::space_groups; use models::space::space_member_groups; use models::space::space_members; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::SpaceError; use crate::services::service::SpaceService; use crate::services::spaces::{PageQuery, UserDto}; use crate::share::authz::{ ALL_PERMISSIONS, MANAGE_ROLES, VIEW_SPACE, effective_permissions, require_perm, }; use crate::share::lookup::lookup_space_by_name; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct GroupDto { pub id: Uuid, pub space_id: Uuid, pub name: String, pub position: i64, pub color: String, pub unicode_emoji: Option, pub icon_hash: Option, pub version: i32, pub created_at: OffsetDateTime, pub updated_at: Option, pub created_by: Option, pub permissions: Vec, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateGroupCmd { pub name: String, #[serde(default)] pub color: Option, #[serde(default)] pub unicode_emoji: Option, #[serde(default)] pub icon_hash: Option, #[serde(default)] pub position: Option, #[serde(default)] pub permissions: Vec, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateGroupCmd { #[serde(default)] pub name: Option, #[serde(default)] pub color: Option, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub unicode_emoji: Option>, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub icon_hash: Option>, #[serde(default)] pub position: Option, #[serde(default)] pub permissions: Option>, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct AssignGroupCmd { pub group_id: Uuid, } pub type GroupPage = export::pagination::Page; async fn replace_permissions( db: &impl ConnectionTrait, group_id: Uuid, permissions: &[String], ) -> Result<(), SpaceError> { let mut perms: Vec<&str> = permissions.iter().map(String::as_str).collect(); perms.sort(); perms.dedup(); for perm in &perms { if !ALL_PERMISSIONS.contains(perm) { return Err(SpaceError::BadRequest(format!( "unknown permission: {perm}" ))); } } space_group_permissions::Entity::delete_many() .filter(space_group_permissions::Column::GroupId.eq(group_id)) .exec(db) .await?; for perm in perms { let row = space_group_permissions::ActiveModel { group_id: Set(group_id), permission: Set(perm.to_string()), }; row.insert(db).await?; } Ok(()) } fn org_permissions(permissions: &[String]) -> Vec { let mut out = Vec::new(); for value in permissions { out.push(format!("space.{value}")); match value.as_str() { "manage_members" => out.extend( ["member.view", "member.add", "member.remove"] .into_iter() .map(String::from), ), "manage_roles" => out.extend( [ "role.view", "role.create", "role.update", "role.delete", "role.permission.update", "role.assign", ] .into_iter() .map(String::from), ), "manage_invites" => out.extend( ["invite.view", "invite.create", "invite.revoke"] .into_iter() .map(String::from), ), "manage_bans" => out.push("member.ban".to_string()), _ => {} } } out.sort(); out.dedup(); out } impl SpaceService { #[tracing::instrument(name = "space.create_group", skip_all, fields(caller = %caller, space = %space_name))] pub async fn create_group( &self, caller: i64, space_name: &str, cmd: CreateGroupCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > self.config().space.max_group_name_len { return Err(SpaceError::BadRequest( "group name is empty or too long".to_string(), )); } let existing = space_groups::Entity::find() .filter(space_groups::Column::SpaceId.eq(space.id)) .filter(space_groups::Column::Name.eq(name)) .one(self.db().read()) .await?; if existing.is_some() { return Err(SpaceError::Conflict(format!( "group name {name} already exists in this space" ))); } let permissions = cmd.permissions.clone(); let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; let role = client .create_role( caller, &space.id.to_string(), &format!("g-{}", Uuid::now_v7()), name, "", cmd.position.unwrap_or(0) as i32, self.config().space.allow_self_join_groups, org_permissions(&permissions), ) .await?; let group_id = Uuid::parse_str(&role.id).map_err(|_| { SpaceError::Internal("kenoauth returned a non-uuid role".to_string()) })?; let now = OffsetDateTime::now_utc(); let group_row = space_groups::ActiveModel { id: Set(group_id), space_id: Set(space.id), name: Set(name.to_string()), position: Set(cmd.position.unwrap_or(0)), color: Set(cmd.color.unwrap_or_default()), unicode_emoji: Set(cmd.unicode_emoji), icon_hash: Set(cmd.icon_hash), version: Set(1), created_at: Set(now), updated_at: Set(None), created_by: Set(caller), }; let permissions = cmd.permissions; let perms_clone = permissions.clone(); let inserted = self .db() .primary() .transaction::<_, space_groups::Model, SpaceError>(|txn| { Box::pin(async move { let g = group_row.insert(txn).await?; replace_permissions(txn, group_id, &perms_clone).await?; Ok(g) }) }) .await?; let user_map: HashMap = self.load_user_briefs(&[caller]).await?; let creator_brief = user_map.get(&caller).cloned(); Ok(GroupDto { id: inserted.id, space_id: inserted.space_id, name: inserted.name, position: inserted.position, color: inserted.color, unicode_emoji: inserted.unicode_emoji, icon_hash: inserted.icon_hash, version: inserted.version, created_at: inserted.created_at, updated_at: inserted.updated_at, created_by: creator_brief, permissions, }) } pub async fn get_group( &self, caller: Option, space_name: &str, group_id: Uuid, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; if !space.is_active { return Err(SpaceError::NotFound); } let group = space_groups::Entity::find_by_id(group_id) .filter(space_groups::Column::SpaceId.eq(space.id)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; let Some(uid) = caller else { return Err(SpaceError::NotFound); }; if space.is_private { if require_perm(self, &space, uid, MANAGE_ROLES).await.is_err() { return Err(SpaceError::NotFound); } } else if require_perm(self, &space, uid, VIEW_SPACE).await.is_err() { return Err(SpaceError::Forbidden); } let perms = space_group_permissions::Entity::find() .filter(space_group_permissions::Column::GroupId.eq(group.id)) .all(self.db().read()) .await?; let permissions = perms.into_iter().map(|p| p.permission).collect(); let user_map: HashMap = self.load_user_briefs(&[group.created_by]).await?; let creator_brief = user_map.get(&group.created_by).cloned(); Ok(GroupDto { id: group.id, space_id: group.space_id, name: group.name, position: group.position, color: group.color, unicode_emoji: group.unicode_emoji, icon_hash: group.icon_hash, version: group.version, created_at: group.created_at, updated_at: group.updated_at, created_by: creator_brief, permissions, }) } #[tracing::instrument(name = "space.update_group", skip_all, fields(caller = %caller, space = %space_name, group_id = %group_id))] pub async fn update_group( &self, caller: i64, space_name: &str, group_id: Uuid, cmd: UpdateGroupCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let group = space_groups::Entity::find_by_id(group_id) .filter(space_groups::Column::SpaceId.eq(space.id)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; let mut am: space_groups::ActiveModel = group.into(); if let Some(new_name) = cmd.name { let name = new_name.trim(); if name.is_empty() || name.chars().count() > self.config().space.max_group_name_len { return Err(SpaceError::BadRequest( "group name is empty or too long".to_string(), )); } let existing = space_groups::Entity::find() .filter(space_groups::Column::SpaceId.eq(space.id)) .filter(space_groups::Column::Name.eq(name)) .filter(space_groups::Column::Id.ne(group_id)) .one(self.db().read()) .await?; if existing.is_some() { return Err(SpaceError::Conflict(format!( "group name {name} already exists in this space" ))); } am.name = Set(name.to_string()); } if let Some(color) = cmd.color { am.color = Set(color); } if let Some(unicode_emoji) = cmd.unicode_emoji { am.unicode_emoji = Set(unicode_emoji); } if let Some(icon_hash) = cmd.icon_hash { am.icon_hash = Set(icon_hash); } if let Some(position) = cmd.position { am.position = Set(position); } let curr_ver = am.version.clone().unwrap(); let next_name = match &am.name { sea_orm::ActiveValue::Set(value) | sea_orm::ActiveValue::Unchanged(value) => { Some(value.clone()) } sea_orm::ActiveValue::NotSet => None, }; let next_position = match &am.position { sea_orm::ActiveValue::Set(value) | sea_orm::ActiveValue::Unchanged(value) => { Some(*value as i32) } sea_orm::ActiveValue::NotSet => None, }; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .update_role( caller, &space.id.to_string(), &group_id.to_string(), next_name, None, next_position, None, curr_ver, ) .await?; if let Some(permissions) = cmd.permissions.as_ref() { client .set_role_permissions( caller, &space.id.to_string(), &group_id.to_string(), org_permissions(permissions), ) .await?; } am.version = Set(curr_ver + 1); let now = OffsetDateTime::now_utc(); am.updated_at = Set(Some(now)); let perms_to_update = cmd.permissions.clone(); let updated = self .db() .primary() .transaction::<_, space_groups::Model, SpaceError>(|txn| { Box::pin(async move { let g = am.update(txn).await?; if let Some(ref perms) = perms_to_update { replace_permissions(txn, group_id, perms).await?; } Ok(g) }) }) .await?; let perms = space_group_permissions::Entity::find() .filter(space_group_permissions::Column::GroupId.eq(updated.id)) .all(self.db().read()) .await?; let permissions = perms.into_iter().map(|p| p.permission).collect(); let user_map: HashMap = self.load_user_briefs(&[updated.created_by]).await?; let creator_brief = user_map.get(&updated.created_by).cloned(); Ok(GroupDto { id: updated.id, space_id: updated.space_id, name: updated.name, position: updated.position, color: updated.color, unicode_emoji: updated.unicode_emoji, icon_hash: updated.icon_hash, version: updated.version, created_at: updated.created_at, updated_at: updated.updated_at, created_by: creator_brief, permissions, }) } #[tracing::instrument(name = "space.delete_group", skip_all, fields(caller = %caller, space = %space_name, group_id = %group_id))] pub async fn delete_group( &self, caller: i64, space_name: &str, group_id: Uuid, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let group = space_groups::Entity::find_by_id(group_id) .filter(space_groups::Column::SpaceId.eq(space.id)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .delete_role(caller, &space.id.to_string(), &group.id.to_string()) .await?; self.db() .primary() .transaction::<_, (), SpaceError>(|txn| { Box::pin(async move { space_member_groups::Entity::delete_many() .filter(space_member_groups::Column::GroupId.eq(group.id)) .exec(txn) .await?; space_group_permissions::Entity::delete_many() .filter(space_group_permissions::Column::GroupId.eq(group.id)) .exec(txn) .await?; space_groups::Entity::delete_by_id(group.id) .exec(txn) .await?; Ok(()) }) }) .await?; Ok(()) } pub async fn list_groups( &self, caller: i64, space_name: &str, q: PageQuery, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let max_pagination = self.config().space.max_pagination; let default_pagination = self.config().space.default_pagination; let limit = q .limit .unwrap_or(default_pagination) .clamp(1, max_pagination); let offset = q .offset .unwrap_or(0) .clamp(0, export::pagination::MAX_OFFSET); let select = space_groups::Entity::find() .filter(space_groups::Column::SpaceId.eq(space.id)) .order_by_asc(space_groups::Column::Position); let total = select.clone().count(self.db().read()).await? as i64; let rows = select .limit(limit) .offset(offset) .all(self.db().read()) .await?; let group_ids: Vec = rows.iter().map(|g| g.id).collect(); let creator_uids: Vec = rows.iter().map(|g| g.created_by).collect(); let all_perms = space_group_permissions::Entity::find() .filter(space_group_permissions::Column::GroupId.is_in(group_ids)) .all(self.db().read()) .await?; let mut perms_map: HashMap> = HashMap::new(); for p in all_perms { perms_map.entry(p.group_id).or_default().push(p.permission); } let user_map: HashMap = self.load_user_briefs(&creator_uids).await?; let items = rows .into_iter() .map(|g| { let creator_brief = user_map.get(&g.created_by).cloned(); let permissions = perms_map.remove(&g.id).unwrap_or_default(); GroupDto { id: g.id, space_id: g.space_id, name: g.name, position: g.position, color: g.color, unicode_emoji: g.unicode_emoji, icon_hash: g.icon_hash, version: g.version, created_at: g.created_at, updated_at: g.updated_at, created_by: creator_brief, permissions, } }) .collect(); Ok(GroupPage { items, total, limit, offset, }) } #[tracing::instrument(name = "space.assign_group", skip_all, fields(caller = %caller, space = %space_name, uid = %uid))] pub async fn assign_group( &self, caller: i64, space_name: &str, uid: i64, cmd: AssignGroupCmd, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; space_groups::Entity::find_by_id(cmd.group_id) .filter(space_groups::Column::SpaceId.eq(space.id)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; space_members::Entity::find_by_id((space.id, uid)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .assign_role(caller, &space.id.to_string(), uid, &cmd.group_id.to_string()) .await?; let now = OffsetDateTime::now_utc(); let row = space_member_groups::ActiveModel { space_id: Set(space.id), user_id: Set(uid), group_id: Set(cmd.group_id), created_at: Set(now), }; row.insert(self.db().write()).await?; Ok(()) } #[tracing::instrument(name = "space.unassign_group", skip_all, fields(caller = %caller, space = %space_name, uid = %uid, group_id = %group_id))] pub async fn unassign_group( &self, caller: i64, space_name: &str, uid: i64, group_id: Uuid, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .unassign_role(caller, &space.id.to_string(), uid, &group_id.to_string()) .await?; space_member_groups::Entity::delete_many() .filter(space_member_groups::Column::SpaceId.eq(space.id)) .filter(space_member_groups::Column::UserId.eq(uid)) .filter(space_member_groups::Column::GroupId.eq(group_id)) .exec(self.db().write()) .await?; Ok(()) } #[tracing::instrument(name = "space.join_group_self", skip_all, fields(caller = %caller, space = %space_name, group_id = %group_id))] pub async fn join_group_self( &self, caller: i64, space_name: &str, group_id: Uuid, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; effective_permissions(&self.state, &space, caller).await?; if !self.config().space.allow_self_join_groups { return Err(SpaceError::Forbidden); } space_groups::Entity::find_by_id(group_id) .filter(space_groups::Column::SpaceId.eq(space.id)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; let existing = space_member_groups::Entity::find_by_id((space.id, caller, group_id)) .one(self.db().read()) .await?; if existing.is_some() { return Err(SpaceError::Conflict("already in group".to_string())); } let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .assign_role( caller, &space.id.to_string(), caller, &group_id.to_string(), ) .await?; let now = OffsetDateTime::now_utc(); let row = space_member_groups::ActiveModel { space_id: Set(space.id), user_id: Set(caller), group_id: Set(group_id), created_at: Set(now), }; row.insert(self.db().write()).await?; Ok(()) } #[tracing::instrument(name = "space.leave_group_self", skip_all, fields(caller = %caller, space = %space_name, group_id = %group_id))] pub async fn leave_group_self( &self, caller: i64, space_name: &str, group_id: Uuid, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; effective_permissions(&self.state, &space, caller).await?; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .unassign_role( caller, &space.id.to_string(), caller, &group_id.to_string(), ) .await?; let res = space_member_groups::Entity::delete_many() .filter(space_member_groups::Column::SpaceId.eq(space.id)) .filter(space_member_groups::Column::UserId.eq(caller)) .filter(space_member_groups::Column::GroupId.eq(group_id)) .exec(self.db().write()) .await?; if res.rows_affected == 0 { return Err(SpaceError::NotFound); } Ok(()) } }