affine_core 0.0.5

AFFiNE primitive core.
Documentation
use super::{AccessGrant, QuotaUsage};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InvitationStatus {
  Pending,
  WaitingReview,
  WaitingSeat,
  Accepted,
  Declined,
  Revoked,
  Expired,
  Unknown,
}

impl InvitationStatus {
  pub const CHARGEABLE: [Self; 3] = [Self::Pending, Self::WaitingReview, Self::WaitingSeat];

  pub fn parse(value: &str) -> Self {
    match value {
      "pending" => Self::Pending,
      "waiting_review" => Self::WaitingReview,
      "waiting_seat" => Self::WaitingSeat,
      "accepted" => Self::Accepted,
      "declined" => Self::Declined,
      "revoked" => Self::Revoked,
      "expired" => Self::Expired,
      _ => Self::Unknown,
    }
  }

  pub const fn as_str(self) -> &'static str {
    match self {
      Self::Pending => "pending",
      Self::WaitingReview => "waiting_review",
      Self::WaitingSeat => "waiting_seat",
      Self::Accepted => "accepted",
      Self::Declined => "declined",
      Self::Revoked => "revoked",
      Self::Expired => "expired",
      Self::Unknown => "unknown",
    }
  }

  fn is_chargeable(self) -> bool {
    Self::CHARGEABLE.contains(&self)
  }

  fn is_activatable(self) -> bool {
    self.is_chargeable()
  }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InvitationKind {
  Email,
  Link,
}

impl InvitationKind {
  pub fn parse(value: &str) -> Option<Self> {
    match value {
      "email" => Some(Self::Email),
      "link" => Some(Self::Link),
      _ => None,
    }
  }

  pub const fn as_str(self) -> &'static str {
    match self {
      Self::Email => "email",
      Self::Link => "link",
    }
  }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InvitationRole {
  Admin,
  Member,
}

impl InvitationRole {
  pub fn parse(value: &str) -> Option<Self> {
    match value {
      "admin" => Some(Self::Admin),
      "member" => Some(Self::Member),
      _ => None,
    }
  }

  pub const fn as_str(self) -> &'static str {
    match self {
      Self::Admin => "admin",
      Self::Member => "member",
    }
  }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SeatReservationKind {
  ReviewLink,
  EmailInvite,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SeatAuthority {
  SelfActivation,
  ManageMembers { allowed: bool },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SeatReviewRequest {
  pub reservation: SeatReservationKind,
  pub requested_seats: i64,
  pub collision: bool,
  pub authority_allowed: bool,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SeatReviewFacts {
  pub grant: AccessGrant,
  pub usage: QuotaUsage,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SeatActivationRequest {
  pub authority: SeatAuthority,
  pub actor_is_target: bool,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SeatActivationFacts {
  pub grant: AccessGrant,
  pub usage: QuotaUsage,
  pub status: Option<InvitationStatus>,
  pub kind: Option<InvitationKind>,
  pub requested_role: Option<InvitationRole>,
  pub collision: bool,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SeatMutationIntent {
  Reserve {
    seat_delta: i64,
    status: InvitationStatus,
    kind: InvitationKind,
    role: InvitationRole,
  },
  Activate {
    seat_delta: i64,
    kind: InvitationKind,
    role: InvitationRole,
  },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SeatDenialReason {
  Forbidden,
  AccountMismatch,
  Collision,
  InvitationNotFound,
  InvitationNotActivatable,
  InvalidInvitation,
  SeatLimitExceeded { limit: i32 },
  ArithmeticOverflow,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SeatOperationPlan {
  Mutate(SeatMutationIntent),
  Deny(SeatDenialReason),
}

pub fn plan_seat_review_reservation(request: SeatReviewRequest, facts: SeatReviewFacts) -> SeatOperationPlan {
  if !request.authority_allowed {
    return SeatOperationPlan::Deny(SeatDenialReason::Forbidden);
  }
  if request.collision {
    return SeatOperationPlan::Deny(SeatDenialReason::Collision);
  }
  match check_seat_limit(&facts.grant, facts.usage, request.requested_seats) {
    Ok(()) => {
      let (status, kind) = match request.reservation {
        SeatReservationKind::ReviewLink => (InvitationStatus::WaitingReview, InvitationKind::Link),
        SeatReservationKind::EmailInvite => (InvitationStatus::Pending, InvitationKind::Email),
      };
      SeatOperationPlan::Mutate(SeatMutationIntent::Reserve {
        seat_delta: request.requested_seats,
        status,
        kind,
        role: InvitationRole::Member,
      })
    }
    Err(reason) => SeatOperationPlan::Deny(reason),
  }
}

pub fn plan_seat_activation(request: SeatActivationRequest, facts: SeatActivationFacts) -> SeatOperationPlan {
  match request.authority {
    SeatAuthority::SelfActivation if !request.actor_is_target => {
      return SeatOperationPlan::Deny(SeatDenialReason::AccountMismatch);
    }
    SeatAuthority::ManageMembers { allowed: false } => {
      return SeatOperationPlan::Deny(SeatDenialReason::Forbidden);
    }
    _ => {}
  }
  if facts.collision {
    return SeatOperationPlan::Deny(SeatDenialReason::Collision);
  }
  let Some(status) = facts.status else {
    return SeatOperationPlan::Deny(SeatDenialReason::InvitationNotFound);
  };
  if !status.is_activatable() {
    return SeatOperationPlan::Deny(SeatDenialReason::InvitationNotActivatable);
  }
  let (Some(kind), Some(role)) = (facts.kind, facts.requested_role) else {
    return SeatOperationPlan::Deny(SeatDenialReason::InvalidInvitation);
  };
  let valid_shape = match request.authority {
    SeatAuthority::SelfActivation => {
      kind == InvitationKind::Email
        && matches!(status, InvitationStatus::Pending | InvitationStatus::WaitingSeat)
        && matches!(role, InvitationRole::Admin | InvitationRole::Member)
    }
    SeatAuthority::ManageMembers { allowed: true } => {
      kind == InvitationKind::Link
        && matches!(status, InvitationStatus::WaitingReview | InvitationStatus::WaitingSeat)
        && role == InvitationRole::Member
    }
    SeatAuthority::ManageMembers { allowed: false } => false,
  };
  if !valid_shape {
    return SeatOperationPlan::Deny(SeatDenialReason::InvalidInvitation);
  }
  let seat_delta = if status.is_chargeable() { 0 } else { 1 };
  match check_seat_limit(&facts.grant, facts.usage, seat_delta) {
    Ok(()) => SeatOperationPlan::Mutate(SeatMutationIntent::Activate { seat_delta, kind, role }),
    Err(reason) => SeatOperationPlan::Deny(reason),
  }
}

fn check_seat_limit(grant: &AccessGrant, usage: QuotaUsage, requested_seats: i64) -> Result<(), SeatDenialReason> {
  if requested_seats < 0 {
    return Err(SeatDenialReason::ArithmeticOverflow);
  }
  match usage.charged_seats.checked_add(requested_seats) {
    Some(next) if next <= i64::from(grant.limits.seat_limit) => Ok(()),
    Some(_) => Err(SeatDenialReason::SeatLimitExceeded {
      limit: grant.limits.seat_limit,
    }),
    None => Err(SeatDenialReason::ArithmeticOverflow),
  }
}

#[cfg(test)]
#[path = "../tests/access_control/seat/tests.rs"]
mod tests;