affine_core 0.0.5

AFFiNE primitive core.
Documentation
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,
  })
}