Skip to content
Clone
1480255
225 lines 7.1 KB Rust
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<JoinRuleSummary> 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<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 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.database, &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<JoinRuleDto, SpaceError> {
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<JoinRuleDto, SpaceError> {
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<Vec<JoinRuleDto>, 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())
}
}