use chrono::{DateTime, Utc};
use super::{
ActorFacts, InviteAbuseFacts, InviteAbusePersistencePlan, InviteAbuseReason, InviteAbuseStatus, InviteAbuseSubject,
InviteAbuseSubjectKind, InviteActivityFacts, InviteEvidencePlan, InviteInput, InviteOperation, InvitePolicyConfig,
InvitePolicyDecision, InvitePolicyPlan, QuotaFacts, RateLimitDenialReason, RateLimitPolicyError, TargetDomain,
WORKSPACE_INVITE_PURPOSE, WorkspaceFacts, build_invite_scopes, email_domain, evaluate_invite_abuse,
evaluate_quota_projection, invite_abuse_status, invite_action_retry_after, normalize_domain, short_hash,
source_cohort_subject_key, source_prefix, subject_hash, validate_counts, workspace_subject_key,
};
use crate::access_control::Deployment;
fn deny_plan(
reason: RateLimitDenialReason,
scope_key: Option<String>,
retry_after_seconds: Option<i32>,
) -> InvitePolicyPlan {
InvitePolicyPlan {
decision: InvitePolicyDecision::Deny {
reason,
scope_key,
retry_after_seconds,
},
purpose: WORKSPACE_INVITE_PURPOSE,
scopes: Vec::new(),
persistence: None,
}
}
fn active_subject<'a>(
abuse: &'a InviteAbuseFacts,
kind: InviteAbuseSubjectKind,
key: &str,
) -> Option<&'a InviteAbuseSubject> {
abuse
.active_subjects
.iter()
.find(|subject| subject.kind == kind && subject.key == key)
}
pub fn plan_invite(
deployment: Deployment,
input: &InviteInput,
actor: &ActorFacts,
workspace: &WorkspaceFacts,
quota: Option<&QuotaFacts>,
activity: &InviteActivityFacts,
abuse: &InviteAbuseFacts,
config: &InvitePolicyConfig<'_>,
now: DateTime<Utc>,
) -> Result<InvitePolicyPlan, RateLimitPolicyError> {
if config.subject_hash_salt.trim().is_empty() {
return Err(RateLimitPolicyError::MissingSubjectHashSalt);
}
validate_counts(input)?;
let actor_domain = email_domain(&actor.email)?;
if !abuse.valid {
return Ok(deny_plan(RateLimitDenialReason::PolicyStateInvalid, None, None));
}
let actor_subject = subject_hash(&actor.email, config);
if active_subject(abuse, InviteAbuseSubjectKind::ActorEmail, &actor_subject).is_some() {
return Ok(deny_plan(
RateLimitDenialReason::AbuseSubject,
Some(format!("invite:actor_subject:{actor_subject}")),
None,
));
}
let workspace_subject = workspace_subject_key(&input.workspace_id);
if active_subject(abuse, InviteAbuseSubjectKind::Workspace, &workspace_subject)
.is_some_and(|subject| subject.status == InviteAbuseStatus::Quarantined)
{
return Ok(deny_plan(
RateLimitDenialReason::AbuseWorkspace,
Some(format!("invite:workspace_subject:{workspace_subject}")),
None,
));
}
if let Some(prefix) = source_prefix(input.source.as_ref()) {
for target in &input.target_domains {
let subject_key = source_cohort_subject_key(&prefix, &target.domain);
if active_subject(abuse, InviteAbuseSubjectKind::SourcePrefixDomain, &subject_key)
.is_some_and(|subject| subject.status == InviteAbuseStatus::Quarantined)
{
return Ok(deny_plan(
RateLimitDenialReason::AbuseSourceCohort,
Some(format!("invite:source_cohort_subject:{subject_key}")),
None,
));
}
}
}
let Some(quota) = quota else {
return Ok(deny_plan(RateLimitDenialReason::QuotaStateUnavailable, None, None));
};
if let Some(reason) = evaluate_quota_projection(quota, now) {
return Ok(deny_plan(reason, None, None));
}
if let Some(retry_after_seconds) = invite_action_retry_after(deployment, config, actor, Some(quota), now) {
return Ok(deny_plan(
RateLimitDenialReason::NewAccountActionDelay,
None,
Some(retry_after_seconds),
));
}
if input.operation == InviteOperation::CreateInviteLink {
return Ok(InvitePolicyPlan {
decision: InvitePolicyDecision::Allow,
purpose: WORKSPACE_INVITE_PURPOSE,
scopes: Vec::new(),
persistence: None,
});
}
if let Some(decision) = evaluate_invite_abuse(input, actor, config)? {
let status = invite_abuse_status(decision.subject_kind, decision.action, decision.reason)
.expect("invite abuse evaluator emits a canonical protocol combination");
let subject = InviteAbuseSubject {
kind: decision.subject_kind,
key: decision.subject_key,
status,
};
let scope_key = match subject.kind {
InviteAbuseSubjectKind::ActorEmail => format!("invite:actor_subject:{}", subject.key),
InviteAbuseSubjectKind::Workspace => format!("invite:workspace_subject:{}", subject.key),
InviteAbuseSubjectKind::SourcePrefixDomain => {
format!("invite:source_cohort_subject:{}", subject.key)
}
};
let target_domains = input
.target_domains
.iter()
.map(|target| TargetDomain {
domain: normalize_domain(&target.domain),
count: target.count,
})
.collect();
let persistence = InviteAbusePersistencePlan {
reason: decision.reason,
action: decision.action,
subject_user_id: (subject.kind == InviteAbuseSubjectKind::ActorEmail).then(|| input.actor_user_id.clone()),
evidence: InviteEvidencePlan {
actor_email_hash: actor_subject,
actor_domain,
source_prefix_hash: source_prefix(input.source.as_ref()).map(|prefix| short_hash(&prefix)),
source_asn: input.source.as_ref().and_then(|source| source.asn).map(i64::from),
target_domains,
requested: input.target_count,
},
subject,
};
return Ok(InvitePolicyPlan {
decision: InvitePolicyDecision::Deny {
reason: match persistence.reason {
InviteAbuseReason::UnverifiedHighRiskDomainBurst => RateLimitDenialReason::UnverifiedHighRiskDomainBurst,
InviteAbuseReason::WorkspaceHighRiskDomainBurst => RateLimitDenialReason::WorkspaceHighRiskDomainBurst,
InviteAbuseReason::SourceHighRiskDomainBurst => RateLimitDenialReason::SourceHighRiskDomainBurst,
InviteAbuseReason::HighRiskDomainBurst => RateLimitDenialReason::HighRiskDomainBurst,
},
scope_key: Some(scope_key),
retry_after_seconds: None,
},
purpose: WORKSPACE_INVITE_PURPOSE,
scopes: Vec::new(),
persistence: Some(persistence),
});
}
Ok(InvitePolicyPlan {
decision: InvitePolicyDecision::Reserve,
purpose: WORKSPACE_INVITE_PURPOSE,
scopes: build_invite_scopes(input, actor, workspace, quota, activity, config, now)?,
persistence: None,
})
}