e1df092
use sea_orm::{ActiveModelTrait, ColumnTrait, Condition, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use models::space::space_join_rule::{self, SpaceJoinTrigger};
use models::space::space_members::MemberStatus;
use models::space::{space_bans, space_groups, space_member_groups};
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;
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<OffsetDateTime>,
}
impl From<space_join_rule::Model> 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<bool>,
pub matcher: Option<String>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct JoinRulePath {
pub space_name: String,
pub rule_id: Uuid,
}
fn normalize_matcher(raw: &str) -> Result<String, SpaceError> {
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(
state: &export::AppState,
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(state.database.read())
.await?;
if rules.is_empty() {
return Ok(());
}
if let Some(member) = crate::authority::member(state, user_id, space_id, user_id).await?
&& member.status == MemberStatus::Active
{
// continue below
} else {
return Ok(());
}
if let Some(ban) = space_bans::Entity::find_by_id((space_id, user_id))
.one(state.database.read())
.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(state.database.read())
.await?
.is_some();
if !group_exists {
continue;
}
let existing = space_member_groups::Entity::find_by_id((space_id, user_id, rule.group_id))
.one(state.database.read())
.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(state.database.write()).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<JoinRuleDto, SpaceError> {
let space = lookup::lookup_space_by_name(self, caller, 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<JoinRuleDto, SpaceError> {
let space = lookup::lookup_space_by_name(self, caller, 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::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
.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<Vec<JoinRuleDto>, SpaceError> {
let space = lookup::lookup_space_by_name(self, caller, 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())
}
}