use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, TransactionTrait}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use models::space::space_join_rules::{self, Entity as JoinRules}; use models::space::{SpaceJoinTrigger, space_member_roles, space_roles}; use crate::authority; use crate::errors::SpaceError; use crate::services::service::SpaceService; use crate::services::types::JoinRuleSummary; use crate::share::authz::{MANAGE_ROLES, require_perm}; use crate::share::lookup; pub const MAX_JOIN_RULE_MATCHER_LEN: usize = 64; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct JoinRuleDto { pub id: Uuid, pub role_id: Uuid, pub trigger: SpaceJoinTrigger, pub matcher: String, pub enabled: bool, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl From for JoinRuleDto { fn from(row: JoinRuleSummary) -> Self { Self { id: row.id, role_id: row.role_id, trigger: SpaceJoinTrigger::from_str(&row.trigger), matcher: row.matcher, enabled: row.enabled, created_at: authority::to_time(row.created_at), updated_at: authority::to_time(row.updated_at), } } } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateJoinRuleCmd { pub role_id: Uuid, #[serde(default)] pub trigger: SpaceJoinTrigger, #[serde(default)] pub matcher: String, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateJoinRuleCmd { pub enabled: Option, pub matcher: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct JoinRulePath { pub space_name: String, pub rule_id: Uuid, } fn normalize_matcher(raw: &str) -> Result { let matcher = raw.trim().to_string(); if matcher.chars().count() > MAX_JOIN_RULE_MATCHER_LEN { return Err(SpaceError::BadRequest(format!( "join rule matcher must be at most {MAX_JOIN_RULE_MATCHER_LEN} characters" ))); } Ok(matcher) } /// Public entry point consumed by action sources (e.g. lib/issues reactions). /// Grants the configured role for every enabled, matching join rule of the space. /// Idempotent: an already bound role is left untouched. pub async fn apply_space_join_rules( access: &crate::Access, space_id: Uuid, user_id: i64, trigger: SpaceJoinTrigger, matcher: &str, ) -> Result<(), SpaceError> { let matcher = matcher.trim(); let rules = JoinRules::find() .filter(space_join_rules::Column::OrganizationId.eq(space_id)) .filter(space_join_rules::Column::Trigger.eq(trigger.as_str())) .filter(space_join_rules::Column::Enabled.eq(true)) .filter( Condition::any() .add(space_join_rules::Column::Matcher.eq("")) .add(space_join_rules::Column::Matcher.eq(matcher)), ) .all(access.database.read()) .await?; if rules.is_empty() { return Ok(()); } let Some(member) = crate::services::member::find_any(access.database.read(), space_id, user_id).await? else { return Ok(()); }; if member.status() != models::space::MemberStatus::Active { return Ok(()); } let txn = access.database.write().begin().await?; let mut bound = false; for rule in &rules { let role = space_roles::Entity::find_by_id(rule.role_id) .filter(space_roles::Column::OrganizationId.eq(space_id)) .one(&txn) .await?; let Some(role) = role else { continue; }; let existing = space_member_roles::Entity::find() .filter(space_member_roles::Column::MemberId.eq(member.id)) .filter(space_member_roles::Column::RoleId.eq(role.id)) .one(&txn) .await?; if existing.is_some() { continue; } crate::services::role::bind_role(&txn, member.id, role.id, user_id).await?; bound = true; } if bound { crate::services::service::bump_version(&txn, space_id).await?; } txn.commit().await?; if bound { crate::services::permission::invalidate(&access.cache, space_id).await?; } Ok(()) } impl SpaceService { #[tracing::instrument(name = "space.create_join_rule", skip_all, fields(caller = %caller, space = %space_name))] pub async fn create_join_rule( &self, caller: i64, space_name: &str, cmd: CreateJoinRuleCmd, ) -> Result { let space = lookup::lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let matcher = normalize_matcher(&cmd.matcher)?; let rule = self .admin_create_join_rule( caller, space.id, cmd.role_id, cmd.trigger.as_str().to_string(), matcher, true, None, ) .await?; Ok(rule.into()) } #[tracing::instrument(name = "space.update_join_rule", skip_all, fields(caller = %caller, space = %space_name, rule_id = %rule_id))] pub async fn update_join_rule( &self, caller: i64, space_name: &str, rule_id: Uuid, cmd: UpdateJoinRuleCmd, ) -> Result { let space = lookup::lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let matcher = match cmd.matcher { Some(raw) => Some(normalize_matcher(&raw)?), None => None, }; let rule = self .admin_update_join_rule( caller, space.id, rule_id, None, None, matcher, cmd.enabled, None, ) .await?; Ok(rule.into()) } #[tracing::instrument(name = "space.delete_join_rule", skip_all, fields(caller = %caller, space = %space_name, rule_id = %rule_id))] pub async fn delete_join_rule( &self, caller: i64, space_name: &str, rule_id: Uuid, ) -> Result<(), SpaceError> { let space = lookup::lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; if !self .admin_delete_join_rule(caller, space.id, rule_id, None) .await? { return Err(SpaceError::NotFound); } Ok(()) } pub async fn list_join_rules( &self, caller: i64, space_name: &str, ) -> Result, SpaceError> { let space = lookup::lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let rows = self.admin_list_join_rules(caller, space.id, None).await?; Ok(rows.into_iter().map(JoinRuleDto::from).collect()) } }