eeff292
use std::collections::HashMap;
use models::space::{
PermissionEffect, space_group_permissions, space_groups, space_member_groups, space_roles,
};
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::dto::{PageQuery, UserDto};
use crate::services::member::find_active;
use crate::services::service::SpaceService;
use crate::services::types::{CreateRoleBody, PermissionGrant, SetRolePermissionsBody};
use crate::share::authz::{
ALL_PERMISSIONS, MANAGE_ROLES, VIEW_SPACE, effective_permissions, require_perm,
};
use crate::share::lookup;
#[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 = "api::serde_patch::deserialize_opt_opt")]
pub unicode_emoji: Option<Option<String>>,
#[serde(default, deserialize_with = "api::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 = api::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
}
fn allow_grants(permissions: Vec<String>) -> Vec<PermissionGrant> {
permissions
.into_iter()
.map(|permission| PermissionGrant {
permission,
effect: PermissionEffect::Allow,
})
.collect()
}
impl SpaceService {
async fn group_role_key(
&self,
organization_id: Uuid,
group_id: Uuid,
) -> Result<String, SpaceError> {
space_roles::Entity::find_by_id(group_id)
.filter(space_roles::Column::OrganizationId.eq(organization_id))
.one(self.access.database.read())
.await?
.map(|row| row.key)
.ok_or(SpaceError::SpaceRoleNotFound)
}
#[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::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.policy.settings.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.access.database.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 role = self
.create_role(
caller,
space.id,
CreateRoleBody {
key: format!("g-{}", Uuid::now_v7()),
name: name.to_string(),
description: None,
rank: cmd.position.unwrap_or(0) as i32,
default_role: false,
self_assignable: self.policy.settings.space.allow_self_join_groups,
permissions: allow_grants(org_permissions(&permissions)),
},
None,
)
.await?;
let group_id = role.id;
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
.access
.database
.write()
.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::lookup_space_by_name(self, space_name).await?;
if !authority::space_is_active(&space) {
return Err(SpaceError::NotFound);
}
let group = space_groups::Entity::find_by_id(group_id)
.filter(space_groups::Column::SpaceId.eq(space.id))
.one(self.access.database.read())
.await?
.ok_or(SpaceError::NotFound)?;
let Some(uid) = caller else {
return Err(SpaceError::NotFound);
};
if space.join_policy == models::space::SpaceJoinPolicy::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.access.database.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::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.access.database.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.policy.settings.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.access.database.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,
};
self.update_role(
caller,
space.id,
group_id,
next_name,
None,
next_position,
None,
None,
curr_ver,
None,
)
.await?;
if let Some(permissions) = cmd.permissions.as_ref() {
self.set_role_permissions(
caller,
space.id,
group_id,
SetRolePermissionsBody {
permissions: allow_grants(org_permissions(permissions)),
},
None,
)
.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
.access
.database
.write()
.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.access.database.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::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.access.database.read())
.await?
.ok_or(SpaceError::NotFound)?;
self.delete_role(caller, space.id, group.id, None).await?;
self.access
.database
.write()
.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::lookup_space_by_name(self, space_name).await?;
require_perm(self, &space, caller, MANAGE_ROLES).await?;
let (limit, offset) = api::pagination::page_bounds(
q.limit,
q.offset,
self.policy.settings.space.default_pagination,
self.policy.settings.space.max_pagination,
)?;
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.access.database.read()).await?;
let rows = select
.limit(limit)
.offset(offset)
.all(self.access.database.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.access.database.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::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.access.database.read())
.await?
.ok_or(SpaceError::NotFound)?;
let member = find_active(self.access.database.read(), space.id, uid)
.await?
.ok_or(SpaceError::NotFound)?;
let role_key = self.group_role_key(space.id, cmd.group_id).await?;
self.assign_role(caller, space.id, member.id, &role_key, None)
.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.access.database.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::lookup_space_by_name(self, space_name).await?;
require_perm(self, &space, caller, MANAGE_ROLES).await?;
let member = find_active(self.access.database.read(), space.id, uid)
.await?
.ok_or(SpaceError::NotFound)?;
let role_key = self.group_role_key(space.id, group_id).await?;
self.unassign_role(caller, space.id, member.id, &role_key, None)
.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.access.database.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::lookup_space_by_name(self, space_name).await?;
effective_permissions(self, &space, caller).await?;
if !self.policy.settings.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.access.database.read())
.await?
.ok_or(SpaceError::NotFound)?;
let existing = space_member_groups::Entity::find_by_id((space.id, caller, group_id))
.one(self.access.database.read())
.await?;
if existing.is_some() {
return Err(SpaceError::Conflict("already in group".to_string()));
}
let member = find_active(self.access.database.read(), space.id, caller)
.await?
.ok_or(SpaceError::Forbidden)?;
let role_key = self.group_role_key(space.id, group_id).await?;
self.assign_role(caller, space.id, member.id, &role_key, None)
.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.access.database.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::lookup_space_by_name(self, space_name).await?;
effective_permissions(self, &space, caller).await?;
let member = find_active(self.access.database.read(), space.id, caller)
.await?
.ok_or(SpaceError::Forbidden)?;
let role_key = self.group_role_key(space.id, group_id).await?;
self.unassign_role(caller, space.id, member.id, &role_key, None)
.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.access.database.write())
.await?;
if res.rows_affected == 0 {
return Err(SpaceError::NotFound);
}
Ok(())
}
}