use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, EntityTrait, QueryFilter, Set, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use models::space::space_bans; use models::space::space_groups; use models::space::space_join_rule::{self, SpaceJoinTrigger}; use models::space::space_member_groups; use models::space::space_members::{self, MemberStatus}; use crate::errors::{SpaceError, is_unique_violation}; use crate::services::service::SpaceService; use crate::share::authz::{MANAGE_ROLES, effective_permissions, require_perm}; use crate::share::lookup::lookup_space_by_name; pub const MAX_JOIN_RULE_MATCHER_LEN: usize = 64; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct JoinRuleDto { pub id: Uuid, pub group_id: Uuid, pub trigger: SpaceJoinTrigger, pub matcher: String, pub enabled: bool, pub created_at: OffsetDateTime, pub updated_at: Option, } impl From for JoinRuleDto { fn from(m: space_join_rule::Model) -> Self { Self { id: m.id, group_id: m.group_id, trigger: m.trigger, matcher: m.matcher, enabled: m.enabled, created_at: m.created_at, updated_at: m.updated_at, } } } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateJoinRuleCmd { pub group_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 group membership for every enabled, matching join rule of the space. /// Idempotent: membership rows are unique per (space, user, group) and races /// resolve to a no-op via the unique-violation path. pub async fn apply_space_join_rules( db: &impl ConnectionTrait, space_id: Uuid, user_id: i64, trigger: SpaceJoinTrigger, matcher: &str, ) -> Result<(), SpaceError> { let now = OffsetDateTime::now_utc(); let matcher = matcher.trim(); let rules = space_join_rule::Entity::find() .filter(space_join_rule::Column::SpaceId.eq(space_id)) .filter(space_join_rule::Column::Trigger.eq(trigger)) .filter(space_join_rule::Column::Enabled.eq(true)) .filter( Condition::any() .add(space_join_rule::Column::Matcher.eq("")) .add(space_join_rule::Column::Matcher.eq(matcher)), ) .all(db) .await?; if rules.is_empty() { return Ok(()); } let Some(member) = space_members::Entity::find_by_id((space_id, user_id)) .one(db) .await? else { return Ok(()); }; if member.status != MemberStatus::Active { return Ok(()); } if let Some(ban) = space_bans::Entity::find_by_id((space_id, user_id)) .one(db) .await? && (ban.expires_at.is_none() || ban.expires_at > Some(now)) { return Ok(()); } for rule in &rules { let group_exists = space_groups::Entity::find_by_id(rule.group_id) .filter(space_groups::Column::SpaceId.eq(space_id)) .one(db) .await? .is_some(); if !group_exists { continue; } let existing = space_member_groups::Entity::find_by_id((space_id, user_id, rule.group_id)) .one(db) .await?; if existing.is_some() { continue; } if let Some(client) = export::oauth_client::client() { if let Err(error) = client .ensure_bot_member( &space_id.to_string(), user_id, Vec::new(), vec![rule.group_id.to_string()], ) .await { tracing::warn!( %error, %space_id, %user_id, group_id = %rule.group_id, "kenoauth join-rule grant failed" ); continue; } } let row = space_member_groups::ActiveModel { space_id: Set(space_id), user_id: Set(user_id), group_id: Set(rule.group_id), created_at: Set(now), }; match row.insert(db).await { Ok(_) => {} Err(err) if is_unique_violation(&err) => {} Err(err) => return Err(SpaceError::from(err)), } } Ok(()) } fn trigger_name(value: SpaceJoinTrigger) -> &'static str { match value { SpaceJoinTrigger::IssueReaction => "issue_reaction", SpaceJoinTrigger::IssueCommentReaction => "issue_comment_reaction", } } 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_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let matcher = normalize_matcher(&cmd.matcher)?; 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)?; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; let rule = client .create_join_rule( caller, &space.id.to_string(), &cmd.group_id.to_string(), trigger_name(cmd.trigger), &matcher, true, ) .await?; let rule_id = Uuid::parse_str(&rule.id) .map_err(|_| SpaceError::Internal("kenoauth returned a non-uuid rule".to_string()))?; let now = OffsetDateTime::now_utc(); let row = space_join_rule::ActiveModel { id: Set(rule_id), space_id: Set(space.id), group_id: Set(cmd.group_id), trigger: Set(cmd.trigger), matcher: Set(matcher), enabled: Set(true), created_at: Set(now), updated_at: Set(None), }; let inserted = row.insert(self.db().write()).await?; Ok(JoinRuleDto::from(inserted)) } #[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_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_ROLES).await?; let Some(row) = space_join_rule::Entity::find_by_id(rule_id) .filter(space_join_rule::Column::SpaceId.eq(space.id)) .one(self.db().read()) .await? else { return Err(SpaceError::NotFound); }; let matcher = match cmd.matcher { Some(raw) => Some(normalize_matcher(&raw)?), None => None, }; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .update_join_rule( caller, &space.id.to_string(), &rule_id.to_string(), None, None, matcher.clone(), cmd.enabled, ) .await?; let now = OffsetDateTime::now_utc(); let mut active: space_join_rule::ActiveModel = row.into(); if let Some(enabled) = cmd.enabled { active.enabled = Set(enabled); } if let Some(matcher) = matcher { active.matcher = Set(matcher); } active.updated_at = Set(Some(now)); let updated = active.update(self.db().write()).await?; Ok(JoinRuleDto::from(updated)) } #[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_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 .delete_join_rule(caller, &space.id.to_string(), &rule_id.to_string()) .await?; let res = space_join_rule::Entity::delete_many() .filter(space_join_rule::Column::Id.eq(rule_id)) .filter(space_join_rule::Column::SpaceId.eq(space.id)) .exec(self.db().write()) .await?; if res.rows_affected == 0 { return Err(SpaceError::NotFound); } Ok(()) } pub async fn list_join_rules( &self, caller: i64, space_name: &str, ) -> Result, SpaceError> { let space = lookup_space_by_name(self, space_name).await?; effective_permissions(&self.state, &space, caller).await?; let rows = space_join_rule::Entity::find() .filter(space_join_rule::Column::SpaceId.eq(space.id)) .all(self.db().read()) .await?; Ok(rows.into_iter().map(JoinRuleDto::from).collect()) } }