use super::audit::{OrgAuditEntry, OrgAuditEvent};
use super::config::InvitationConfig;
use super::error::{OrganizationError, Result};
use super::manager::MembershipCreateParams;
use super::rate_limit::{
InvitationRateLimiter, OptionalInvitationRateLimiter, WithInvitationRateLimiter,
};
use super::seats::{SeatChecker, UnlimitedSeats};
use super::storage::{
InvitationStore, MembershipStore, OptionalAuditStore, OrgAuditStore, OrganizationStore,
WithAuditStore,
};
use super::utils::{current_timestamp, is_valid_email};
use tracing::{debug, info, instrument};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct InvitationCreateParams {
pub id: String,
pub org_id: String,
pub email: String,
pub invited_by: String,
pub token: String,
pub expires_at: u64,
pub created_at: u64,
}
pub struct InvitationManager<I, M, O, S = UnlimitedSeats, A = (), L = ()>
where
I: InvitationStore,
M: MembershipStore,
O: OrganizationStore,
S: SeatChecker,
A: OptionalAuditStore,
L: OptionalInvitationRateLimiter,
{
invitation_store: I,
membership_store: M,
org_store: O,
seat_checker: S,
audit_store: A,
rate_limiter: L,
config: InvitationConfig,
}
impl<I, M, O> InvitationManager<I, M, O, UnlimitedSeats, (), ()>
where
I: InvitationStore,
M: MembershipStore,
O: OrganizationStore,
{
#[must_use]
pub fn new_without_seats(
invitation_store: I,
membership_store: M,
org_store: O,
config: InvitationConfig,
) -> Self {
Self {
invitation_store,
membership_store,
org_store,
seat_checker: UnlimitedSeats,
audit_store: (),
rate_limiter: (),
config,
}
}
}
impl<I, M, O, S> InvitationManager<I, M, O, S, (), ()>
where
I: InvitationStore,
M: MembershipStore,
O: OrganizationStore,
S: SeatChecker,
{
#[must_use]
pub fn new(
invitation_store: I,
membership_store: M,
org_store: O,
seat_checker: S,
config: InvitationConfig,
) -> Self {
Self {
invitation_store,
membership_store,
org_store,
seat_checker,
audit_store: (),
rate_limiter: (),
config,
}
}
}
impl<I, M, O, S, L> InvitationManager<I, M, O, S, (), L>
where
I: InvitationStore,
M: MembershipStore,
O: OrganizationStore,
S: SeatChecker,
L: OptionalInvitationRateLimiter,
{
pub fn with_audit_store<AuditStore: OrgAuditStore + Clone + 'static>(
self,
audit_store: AuditStore,
) -> InvitationManager<I, M, O, S, WithAuditStore<AuditStore>, L> {
InvitationManager {
invitation_store: self.invitation_store,
membership_store: self.membership_store,
org_store: self.org_store,
seat_checker: self.seat_checker,
audit_store: WithAuditStore(audit_store),
rate_limiter: self.rate_limiter,
config: self.config,
}
}
}
impl<I, M, O, S, A> InvitationManager<I, M, O, S, A, ()>
where
I: InvitationStore,
M: MembershipStore,
O: OrganizationStore,
S: SeatChecker,
A: OptionalAuditStore,
{
pub fn with_rate_limiter(
self,
rate_limiter: InvitationRateLimiter,
) -> InvitationManager<I, M, O, S, A, WithInvitationRateLimiter> {
InvitationManager {
invitation_store: self.invitation_store,
membership_store: self.membership_store,
org_store: self.org_store,
seat_checker: self.seat_checker,
audit_store: self.audit_store,
rate_limiter: WithInvitationRateLimiter(rate_limiter),
config: self.config,
}
}
}
impl<I, M, O, S, A, L> InvitationManager<I, M, O, S, A, L>
where
I: InvitationStore,
M: MembershipStore,
O: OrganizationStore,
S: SeatChecker,
A: OptionalAuditStore,
L: OptionalInvitationRateLimiter,
{
pub fn invitation_store(&self) -> &I {
&self.invitation_store
}
pub fn config(&self) -> &InvitationConfig {
&self.config
}
#[instrument(skip(self, invitation_factory))]
pub async fn invite<F>(
&self,
org_id: &str,
email: &str,
actor_id: &str,
invitation_factory: F,
) -> Result<I::Invitation>
where
F: FnOnce(InvitationCreateParams) -> I::Invitation,
{
self.rate_limiter.check_invitation_rate(org_id, actor_id)?;
if !is_valid_email(email) {
return Err(OrganizationError::invalid_email(email));
}
self.org_store
.find_by_id(org_id)
.await?
.ok_or_else(|| OrganizationError::not_found(org_id))?;
let actor_membership = self
.membership_store
.get_membership(org_id, actor_id)
.await?
.ok_or(OrganizationError::NotMember)?;
let actor_role = self.membership_store.membership_role(&actor_membership);
if !self.membership_store.can_manage_members(&actor_role) {
return Err(OrganizationError::insufficient_permission(
"can_manage_members",
));
}
if self
.invitation_store
.find_pending_by_email(org_id, email)
.await?
.is_some()
{
return Err(OrganizationError::InvitationAlreadyExists);
}
let pending_count = self.invitation_store.count_pending(org_id).await?;
if pending_count >= self.config.max_pending_per_org {
return Err(OrganizationError::max_pending_invitations(
self.config.max_pending_per_org,
));
}
let member_count = self.membership_store.count_members(org_id).await?;
let total_count = member_count + pending_count;
if !self
.seat_checker
.has_seat_available(org_id, total_count)
.await?
{
let limit = self
.seat_checker
.get_seat_limit(org_id)
.await?
.unwrap_or(total_count);
return Err(OrganizationError::seat_limit_reached(total_count, limit));
}
let now = current_timestamp();
let expires_at = now + self.config.expiry_seconds();
let token = generate_secure_token();
let invitation = invitation_factory(InvitationCreateParams {
id: Uuid::new_v4().to_string(),
org_id: org_id.to_string(),
email: email.to_string(),
invited_by: actor_id.to_string(),
token,
expires_at,
created_at: now,
});
self.invitation_store.create(&invitation).await?;
info!(
org_id,
email,
actor_id,
invitation_id = %self.invitation_store.invitation_id(&invitation),
"Invitation created"
);
self.audit_store
.record(
OrgAuditEntry::new(OrgAuditEvent::InvitationSent, org_id, actor_id)
.with_details(format!("email={email}")),
)
.await;
Ok(invitation)
}
#[instrument(skip(self, membership_factory))]
pub async fn accept<F>(
&self,
token: &str,
user_id: &str,
membership_factory: F,
) -> Result<M::Membership>
where
F: FnOnce(&I::Invitation, MembershipCreateParams) -> M::Membership,
{
let invitation = self
.invitation_store
.find_by_token(token)
.await?
.ok_or(OrganizationError::InvalidToken)?;
if self.invitation_store.is_expired(&invitation) {
return Err(OrganizationError::InvitationExpired);
}
if self.invitation_store.is_revoked(&invitation) {
return Err(OrganizationError::InvalidToken);
}
let org_id = self.invitation_store.invitation_org_id(&invitation);
let invitation_id = self.invitation_store.invitation_id(&invitation);
if self.membership_store.is_member(&org_id, user_id).await? {
return Err(OrganizationError::AlreadyMember);
}
let now = current_timestamp();
let membership = membership_factory(
&invitation,
MembershipCreateParams {
org_id: org_id.clone(),
user_id: user_id.to_string(),
is_owner: false,
joined_at: now,
},
);
self.membership_store.add_member(&membership).await?;
self.invitation_store.mark_accepted(&invitation_id).await?;
info!(org_id, user_id, invitation_id, "Invitation accepted");
self.audit_store
.record(
OrgAuditEntry::new(OrgAuditEvent::InvitationAccepted, &org_id, user_id)
.with_target(user_id),
)
.await;
Ok(membership)
}
#[instrument(skip(self))]
pub async fn revoke(&self, invitation_id: &str, actor_id: &str) -> Result<()> {
let invitation = self
.invitation_store
.find_by_id(invitation_id)
.await?
.ok_or_else(|| OrganizationError::invitation_not_found(invitation_id))?;
let org_id = self.invitation_store.invitation_org_id(&invitation);
let actor_membership = self
.membership_store
.get_membership(&org_id, actor_id)
.await?
.ok_or(OrganizationError::NotMember)?;
let actor_role = self.membership_store.membership_role(&actor_membership);
if !self.membership_store.can_manage_members(&actor_role) {
return Err(OrganizationError::insufficient_permission(
"can_manage_members",
));
}
self.invitation_store.mark_revoked(invitation_id).await?;
info!(org_id, invitation_id, actor_id, "Invitation revoked");
self.audit_store
.record(
OrgAuditEntry::new(OrgAuditEvent::InvitationRevoked, &org_id, actor_id)
.with_details(format!("invitation_id={invitation_id}")),
)
.await;
Ok(())
}
pub async fn list_pending(&self, org_id: &str) -> Result<Vec<I::Invitation>> {
self.invitation_store
.list_pending(org_id)
.await
.map_err(Into::into)
}
pub async fn get(&self, invitation_id: &str) -> Result<Option<I::Invitation>> {
self.invitation_store
.find_by_id(invitation_id)
.await
.map_err(Into::into)
}
pub async fn get_by_token(&self, token: &str) -> Result<Option<I::Invitation>> {
self.invitation_store
.find_by_token(token)
.await
.map_err(Into::into)
}
pub async fn cleanup_expired(&self) -> Result<usize> {
let count = self.invitation_store.delete_expired().await?;
if count > 0 {
debug!(count, "Expired invitations cleaned up");
}
Ok(count)
}
}
fn generate_secure_token() -> String {
use base64::Engine;
use rand::Rng;
let mut bytes = [0u8; 32];
rand::thread_rng().fill(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}