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;