Skip to content
Clone
b4afb4b
716 lines 24.0 KB Rust
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::authority;
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<String>,
pub icon_hash: Option<String>,
pub version: i32,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
pub created_by: Option<UserDto>,
pub permissions: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateGroupCmd {
pub name: String,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub unicode_emoji: Option<String>,
#[serde(default)]
pub icon_hash: Option<String>,
#[serde(default)]
pub position: Option<i64>,
#[serde(default)]
pub permissions: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateGroupCmd {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub unicode_emoji: Option<Option<String>>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub icon_hash: Option<Option<String>>,
#[serde(default)]
pub position: Option<i64>,
#[serde(default)]
pub permissions: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct AssignGroupCmd {
pub group_id: Uuid,
}
pub type GroupPage = export::pagination::Page<GroupDto>;
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<String> {
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<GroupDto, SpaceError> {
let space = lookup_space_by_name(self, caller, 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<i64, UserDto> = 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<i64>,
space_name: &str,
group_id: Uuid,
) -> Result<GroupDto, SpaceError> {
let space = lookup_space_by_name(self, caller.unwrap_or(0), 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<i64, UserDto> = 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<GroupDto, SpaceError> {
let space = lookup_space_by_name(self, caller, 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<i64, UserDto> = 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, caller, 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<GroupPage, SpaceError> {
let space = lookup_space_by_name(self, caller, 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<Uuid> = rows.iter().map(|g| g.id).collect();
let creator_uids: Vec<i64> = 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<Uuid, Vec<String>> = HashMap::new();
for p in all_perms {
perms_map.entry(p.group_id).or_default().push(p.permission);
}
let user_map: HashMap<i64, UserDto> = 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, caller, 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)?;
authority::require_member(&self.state, space.id, uid)
.await
.map_err(|_| 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, caller, 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, caller, 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, caller, 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(())
}
}