use std::collections::BTreeMap;
use chrono::{DateTime, Duration, Utc};
use super::{
ActorFacts, InviteAbuseAction, InviteAbuseReason, InviteAbuseSubjectKind, InviteActivityFacts, InviteCommitPlan,
InviteCommitUsage, InviteInput, InviteOperation, InvitePlan, InvitePolicyConfig, QuotaFacts, RateLimitDenialReason,
RateLimitPolicyError, RateLimitScope, TargetDomain, WorkspaceFacts, high_risk_domain, normalize_domain, scope,
source_cohort_subject_key, source_prefix, subject_hash, workspace_subject_key,
};
use crate::access_control::Deployment;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct InviteAbuseDecision {
pub(super) reason: InviteAbuseReason,
pub(super) action: InviteAbuseAction,
pub(super) subject_kind: InviteAbuseSubjectKind,
pub(super) subject_key: String,
}
fn quota_subject(workspace_id: &str, quota: &QuotaFacts) -> (String, i32) {
if quota.uses_owner_quota || !quota.plan.is_team() {
(
format!("owner:{}", quota.owner_user_id.as_deref().unwrap_or(workspace_id)),
quota.seat_limit,
)
} else {
(format!("workspace:{workspace_id}"), quota.seat_limit)
}
}
fn checked_mul(left: i32, right: i32) -> Result<i32, RateLimitPolicyError> {
left.checked_mul(right).ok_or(RateLimitPolicyError::ArithmeticOverflow)
}
pub(super) fn validate_counts(input: &InviteInput) -> Result<(), RateLimitPolicyError> {
if input.target_count < 0 {
return Err(RateLimitPolicyError::NegativeCount);
}
if input.target_domains.iter().any(|target| target.count < 0) {
return Err(RateLimitPolicyError::NegativeCount);
}
let target_domain_count = input.target_domains.iter().try_fold(0_i32, |total, target| {
total
.checked_add(target.count)
.ok_or(RateLimitPolicyError::ArithmeticOverflow)
})?;
match input.operation {
InviteOperation::InviteMembers if input.target_count == 0 => Err(RateLimitPolicyError::InvalidTargetCount),
InviteOperation::InviteMembers
if input.target_domains.iter().any(|target| target.count == 0) || target_domain_count != input.target_count =>
{
Err(RateLimitPolicyError::InconsistentTargetCount)
}
InviteOperation::CreateInviteLink if input.target_count != 0 || !input.target_domains.is_empty() => {
Err(RateLimitPolicyError::InvalidTargetCount)
}
_ => Ok(()),
}
}
fn ceil_half(value: i32) -> Result<i32, RateLimitPolicyError> {
value
.checked_add(1)
.map(|value| value / 2)
.ok_or(RateLimitPolicyError::ArithmeticOverflow)
}
fn plan_ceiling_7d(plan: InvitePlan, seat_limit: i32) -> Result<i32, RateLimitPolicyError> {
if seat_limit < 0 {
return Err(RateLimitPolicyError::NegativeCount);
}
if plan.uses_team_ceiling() {
checked_mul(seat_limit, 2)
} else if plan.is_pro() {
Ok(20)
} else {
Ok(10)
}
}
fn base_invite_limits(
now: DateTime<Utc>,
actor: &ActorFacts,
workspace: &WorkspaceFacts,
quota: &QuotaFacts,
activity: &InviteActivityFacts,
) -> Result<(i32, i32, i32, i32), RateLimitPolicyError> {
if quota.seat_limit < 0
|| quota.member_count < 0
|| activity.actor_created_7d < 0
|| activity.actor_accepted_7d < 0
|| activity.workspace_pending < 0
|| activity.workspace_created_7d < 0
|| activity.workspace_accepted_7d < 0
{
return Err(RateLimitPolicyError::NegativeCount);
}
if actor.disabled || !actor.registered {
return Ok((0, 0, 0, 0));
}
let account_age = now - actor.created_at;
let workspace_age = now - workspace.created_at;
let mut single = 5;
let mut per_hour = 5;
let mut per_day = 15;
let mut per_week = 30;
if !actor.email_verified {
single = 1;
per_hour = 1;
per_day = 3;
per_week = 5;
} else if account_age < Duration::days(7) {
single = 3;
per_hour = 3;
per_day = 5;
per_week = 10;
} else if account_age < Duration::days(30) {
single = 5;
per_hour = 5;
per_day = 15;
per_week = 30;
} else {
if quota.plan.is_paid_team() {
single = quota.seat_limit.min(20);
per_hour = 20;
per_day = 50;
} else if quota.plan.is_pro() {
single = 8;
per_hour = 8;
per_day = 20;
per_week = 50;
}
}
if workspace_age < Duration::hours(24) || quota.member_count <= 1 {
single = single.min(3);
per_day = per_day.min(10);
} else if workspace_age < Duration::days(7) || quota.member_count <= 5 {
single = single.min(ceil_half(single)?).max(1);
per_hour = per_hour.min(ceil_half(per_hour)?).max(1);
per_day = per_day.min(ceil_half(per_day)?).max(1);
}
if activity.workspace_pending > checked_mul(quota.seat_limit, 2)?.max(6) {
single = single.min(2);
per_day = per_day.min(5);
}
if activity.actor_created_7d >= 5 && checked_mul(activity.actor_accepted_7d, 10)? < activity.actor_created_7d {
single = single.min(2);
per_hour = per_hour.min(2);
per_day = per_day.min(5);
per_week = per_week.min(10);
}
if activity.workspace_created_7d >= 10
&& checked_mul(activity.workspace_accepted_7d, 10)? < activity.workspace_created_7d
{
per_day = per_day.min(10);
per_week = per_week.min(20);
}
let seat_7d = plan_ceiling_7d(quota.plan, quota.seat_limit)?.min(checked_mul(quota.seat_limit, 2)?);
Ok((single, per_hour, per_day, per_week.min(seat_7d)))
}
pub(super) fn evaluate_quota_projection(quota: &QuotaFacts, now: DateTime<Utc>) -> Option<RateLimitDenialReason> {
if !quota.known {
return Some(RateLimitDenialReason::QuotaStateUnavailable);
}
if quota.stale || quota.stale_after.is_some_and(|stale_after| stale_after <= now) {
return Some(RateLimitDenialReason::QuotaProjectionStale);
}
None
}
pub(super) fn invite_action_retry_after(
deployment: Deployment,
config: &InvitePolicyConfig<'_>,
actor: &ActorFacts,
workspace_quota: Option<&QuotaFacts>,
now: DateTime<Utc>,
) -> Option<i32> {
if deployment == Deployment::SelfHosted || config.new_account_action_delay_seconds <= 0 {
return None;
}
if workspace_quota.is_some_and(|quota| evaluate_quota_projection(quota, now).is_some()) {
return None;
}
if actor.quota_plan.is_some_and(InvitePlan::exempts_individual_delay)
|| workspace_quota.is_some_and(|quota| quota.plan.exempts_workspace_delay())
{
return None;
}
let remaining = config.new_account_action_delay_seconds - (now - actor.created_at).num_seconds();
(remaining > 0).then(|| remaining.min(i64::from(i32::MAX)) as i32)
}
pub(super) fn build_invite_scopes(
input: &InviteInput,
actor: &ActorFacts,
workspace: &WorkspaceFacts,
quota: &QuotaFacts,
activity: &InviteActivityFacts,
config: &InvitePolicyConfig<'_>,
now: DateTime<Utc>,
) -> Result<Vec<RateLimitScope>, RateLimitPolicyError> {
validate_counts(input)?;
let (single, per_hour, per_day, per_week) = base_invite_limits(now, actor, workspace, quota, activity)?;
if input.target_count > single {
return Ok(vec![scope(
format!("invite:single_request:{}", input.actor_user_id),
60,
single,
input.target_count,
)]);
}
let actor_subject = subject_hash(&actor.email, config);
let (quota_subject, seat_limit) = quota_subject(&input.workspace_id, quota);
let quota_subject_7d = plan_ceiling_7d(quota.plan, seat_limit)?.min(checked_mul(seat_limit, 2)?);
let mut scopes = vec![
scope(
format!("invite:user:{}", input.actor_user_id),
3600,
per_hour,
input.target_count,
),
scope(
format!("invite:user:{}", input.actor_user_id),
86_400,
per_day,
input.target_count,
),
scope(
format!("invite:user:{}", input.actor_user_id),
604_800,
per_week,
input.target_count,
),
scope(
format!("invite:actor_subject:{actor_subject}"),
604_800,
per_week,
input.target_count,
),
scope(
format!("invite:workspace:{}", input.workspace_id),
3600,
per_hour.max(5),
input.target_count,
),
scope(
format!("invite:workspace:{}", input.workspace_id),
86_400,
per_day.max(10),
input.target_count,
),
scope(
format!("invite:quota_subject:{quota_subject}"),
604_800,
quota_subject_7d,
input.target_count,
),
];
for target in &input.target_domains {
let domain = normalize_domain(&target.domain);
let high_risk = high_risk_domain(&domain);
let limit_7d = if high_risk {
quota_subject_7d.min((seat_limit / 2).max(2))
} else {
quota_subject_7d
};
scopes.push(scope(
format!("invite:user_domain:{}:{domain}", input.actor_user_id),
86_400,
per_day.min(limit_7d).max(1),
target.count,
));
scopes.push(scope(
format!("invite:workspace_domain:{}:{domain}", input.workspace_id),
86_400,
per_day.max(10).min(limit_7d).max(1),
target.count,
));
scopes.push(scope(
format!("invite:quota_subject_domain:{quota_subject}:{domain}"),
604_800,
limit_7d,
target.count,
));
scopes.push(scope(
format!("invite:target_domain_global:{domain}"),
60,
if high_risk { 30 } else { 100 },
target.count,
));
scopes.push(scope(
format!("invite:target_domain_global:{domain}"),
86_400,
if high_risk { 500 } else { 2000 },
target.count,
));
}
if let Some(prefix) = source_prefix(input.source.as_ref()) {
scopes.push(scope(
format!("invite:source_prefix:{prefix}"),
3600,
30,
input.target_count,
));
scopes.push(scope(
format!("invite:source_prefix:{prefix}"),
86_400,
100,
input.target_count,
));
for target in &input.target_domains {
let domain = normalize_domain(&target.domain);
let high_risk = high_risk_domain(&domain);
scopes.push(scope(
format!("invite:source_prefix_domain:{prefix}:{domain}"),
3600,
if high_risk { 5 } else { 15 },
target.count,
));
scopes.push(scope(
format!("invite:source_prefix_domain:{prefix}:{domain}"),
86_400,
if high_risk { 15 } else { 50 },
target.count,
));
}
}
if input.source.as_ref().is_some_and(|source| source.trusted)
&& let Some(asn) = input.source.as_ref().and_then(|source| source.asn)
{
for target in &input.target_domains {
let domain = normalize_domain(&target.domain);
let high_risk = high_risk_domain(&domain);
scopes.push(scope(
format!("invite:source_asn_domain:{asn}:{domain}"),
3600,
if high_risk { 50 } else { 150 },
target.count,
));
scopes.push(scope(
format!("invite:source_asn_domain:{asn}:{domain}"),
86_400,
if high_risk { 150 } else { 500 },
target.count,
));
}
}
Ok(scopes)
}
pub(super) fn evaluate_invite_abuse(
input: &InviteInput,
actor: &ActorFacts,
config: &InvitePolicyConfig<'_>,
) -> Result<Option<InviteAbuseDecision>, RateLimitPolicyError> {
validate_counts(input)?;
let high_risk_domain_counts: Vec<(String, i32)> = input
.target_domains
.iter()
.filter(|target| high_risk_domain(&target.domain))
.map(|target| (normalize_domain(&target.domain), target.count))
.collect();
let high_risk_targets = high_risk_domain_counts
.iter()
.try_fold(0_i32, |total, (_, count)| total.checked_add(*count))
.ok_or(RateLimitPolicyError::ArithmeticOverflow)?;
if !actor.email_verified && high_risk_targets >= 3 {
return Ok(Some(InviteAbuseDecision {
reason: InviteAbuseReason::UnverifiedHighRiskDomainBurst,
action: InviteAbuseAction::QuarantineActor,
subject_kind: InviteAbuseSubjectKind::ActorEmail,
subject_key: subject_hash(&actor.email, config),
}));
}
if input.target_count >= 30 && checked_mul(high_risk_targets, 2)? >= input.target_count {
return Ok(Some(InviteAbuseDecision {
reason: InviteAbuseReason::WorkspaceHighRiskDomainBurst,
action: InviteAbuseAction::QuarantineWorkspace,
subject_kind: InviteAbuseSubjectKind::Workspace,
subject_key: workspace_subject_key(&input.workspace_id),
}));
}
if let Some(prefix) = source_prefix(input.source.as_ref())
&& high_risk_targets >= 10
&& checked_mul(high_risk_targets, 2)? >= input.target_count
&& let Some((domain, _)) = high_risk_domain_counts.iter().max_by_key(|(_, count)| *count)
{
return Ok(Some(InviteAbuseDecision {
reason: InviteAbuseReason::SourceHighRiskDomainBurst,
action: InviteAbuseAction::QuarantineSourceCohort,
subject_kind: InviteAbuseSubjectKind::SourcePrefixDomain,
subject_key: source_cohort_subject_key(&prefix, domain),
}));
}
if input.target_count >= 10 && checked_mul(high_risk_targets, 2)? >= input.target_count {
return Ok(Some(InviteAbuseDecision {
reason: InviteAbuseReason::HighRiskDomainBurst,
action: if input.target_count >= 20 {
InviteAbuseAction::BanActor
} else {
InviteAbuseAction::QuarantineActor
},
subject_kind: InviteAbuseSubjectKind::ActorEmail,
subject_key: subject_hash(&actor.email, config),
}));
}
Ok(None)
}
fn sum_domain_usage(target_domains: &[TargetDomain]) -> Result<BTreeMap<String, i32>, RateLimitPolicyError> {
let mut domains = BTreeMap::new();
for domain in target_domains {
if domain.count < 0 {
return Err(RateLimitPolicyError::NegativeCount);
}
let count = domains.entry(normalize_domain(&domain.domain)).or_insert(0_i32);
*count = count
.checked_add(domain.count)
.ok_or(RateLimitPolicyError::ArithmeticOverflow)?;
}
Ok(domains)
}
pub fn plan_invite_commit(
scope_keys: &[String],
usage: InviteCommitUsage,
) -> Result<InviteCommitPlan, RateLimitPolicyError> {
if usage.target_count < 0 {
return Err(RateLimitPolicyError::NegativeCount);
}
let domain_usage = sum_domain_usage(&usage.target_domains)?;
let domain_total = domain_usage.values().try_fold(0_i32, |total, count| {
total
.checked_add(*count)
.ok_or(RateLimitPolicyError::ArithmeticOverflow)
})?;
if domain_total != usage.target_count {
return Err(RateLimitPolicyError::InconsistentTargetCount);
}
let scope_usage = scope_keys
.iter()
.map(|scope_key| {
let plain_prefixes = [
"invite:single_request:",
"invite:user:",
"invite:workspace:",
"invite:quota_subject:owner:",
"invite:quota_subject:workspace:",
"invite:source_prefix:",
];
let domain_prefixes = [
"invite:user_domain:",
"invite:workspace_domain:",
"invite:quota_subject_domain:owner:",
"invite:quota_subject_domain:workspace:",
"invite:source_prefix_domain:",
"invite:source_asn_domain:",
];
let domain = if scope_key
.strip_prefix("invite:actor_subject:actor_email_sha256:v1:")
.is_some_and(|hash| hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()))
|| plain_prefixes.iter().any(|prefix| {
scope_key
.strip_prefix(prefix)
.is_some_and(|identity| !identity.is_empty())
}) {
None
} else if let Some(domain) = scope_key.strip_prefix("invite:target_domain_global:") {
(!domain.is_empty()).then_some(domain)
} else if domain_prefixes.iter().any(|prefix| {
scope_key
.strip_prefix(prefix)
.is_some_and(|identity| identity.contains(':'))
}) {
scope_key.rsplit_once(':').map(|(_, domain)| domain)
} else {
return Err(RateLimitPolicyError::InvalidScopeIdentity);
};
match domain {
Some(domain) => domain_usage
.get(domain)
.copied()
.ok_or(RateLimitPolicyError::InvalidScopeIdentity),
None => Ok(usage.target_count),
}
})
.collect::<Result<Vec<_>, _>>()?;
Ok(InviteCommitPlan { scope_usage })
}
#[cfg(test)]
#[path = "../tests/rate_limit/invite/tests.rs"]
mod tests;