mirror of https://github.com/docusealco/docuseal
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
170 lines
5.3 KiB
170 lines
5.3 KiB
# frozen_string_literal: true
|
|
|
|
# InvitationService
|
|
# Handles business logic for cohort admin invitations
|
|
# Implements Winston's security requirements: rate limiting, token validation, atomic operations
|
|
class InvitationService
|
|
# Rate limit configuration
|
|
MAX_INVITATIONS_PER_EMAIL = 5
|
|
INVITATION_EXPIRY_HOURS = 24
|
|
|
|
# Create invitation with rate limiting
|
|
def self.create_invitation(institution, email, role, created_by)
|
|
# Layer 1: Rate limiting check
|
|
if CohortAdminInvitation.rate_limit_check(email, institution)
|
|
SecurityEvent.log(:rate_limit_exceeded, created_by, {
|
|
email: email,
|
|
institution_id: institution.id,
|
|
reason: "Max #{MAX_INVITATIONS_PER_EMAIL} pending invitations per email"
|
|
})
|
|
raise RateLimit::LimitApproached, "Too many pending invitations for #{email}"
|
|
end
|
|
|
|
# Layer 2: Validate role
|
|
unless %w[cohort_admin cohort_super_admin].include?(role)
|
|
raise ArgumentError, "Invalid role: #{role}"
|
|
end
|
|
|
|
# Layer 3: Check authorization
|
|
unless created_by.cohort_super_admin? && created_by.managed_institutions.exists?(institution.id)
|
|
SecurityEvent.log(:insufficient_privileges, created_by, {
|
|
action: 'create_invitation',
|
|
institution_id: institution.id,
|
|
required_role: 'cohort_super_admin'
|
|
})
|
|
raise CanCan::AccessDenied, 'Only super admins can create invitations'
|
|
end
|
|
|
|
# Layer 4: Create invitation with atomic token generation
|
|
invitation = CohortAdminInvitation.new(
|
|
institution: institution,
|
|
email: email,
|
|
role: role,
|
|
created_by: created_by,
|
|
expires_at: INVITATION_EXPIRY_HOURS.hours.from_now
|
|
)
|
|
|
|
# Generate secure token
|
|
invitation.generate_token
|
|
|
|
if invitation.save
|
|
# Log successful creation
|
|
SecurityEvent.log(:invitation_created, created_by, {
|
|
institution_id: institution.id,
|
|
email: email,
|
|
role: role,
|
|
token_preview: invitation.token_preview
|
|
})
|
|
|
|
# Queue email delivery
|
|
CohortAdminInvitationJob.perform_async(invitation.id)
|
|
|
|
invitation
|
|
else
|
|
raise ActiveRecord::RecordInvalid, invitation
|
|
end
|
|
end
|
|
|
|
# Accept invitation with Redis single-use enforcement
|
|
def self.accept_invitation(raw_token, accepting_user)
|
|
# Layer 1: Find invitation by token preview
|
|
preview = raw_token[0..7] + '...'
|
|
invitation = CohortAdminInvitation.active.find_by(token_preview: preview)
|
|
|
|
unless invitation
|
|
SecurityEvent.log(:token_validation_failure, accepting_user, {
|
|
reason: 'Invitation not found or expired',
|
|
token_preview: preview
|
|
})
|
|
return nil
|
|
end
|
|
|
|
# Layer 2: Email verification
|
|
unless invitation.email == accepting_user.email
|
|
SecurityEvent.log(:token_validation_failure, accepting_user, {
|
|
reason: 'Email mismatch',
|
|
expected_email: invitation.email,
|
|
actual_email: accepting_user.email,
|
|
token_preview: preview
|
|
})
|
|
return nil
|
|
end
|
|
|
|
# Layer 3: Token validation with Redis single-use enforcement
|
|
unless invitation.valid_token?(raw_token)
|
|
SecurityEvent.log(:token_validation_failure, accepting_user, {
|
|
reason: 'Invalid or used token',
|
|
token_preview: preview,
|
|
institution_id: invitation.institution_id
|
|
})
|
|
return nil
|
|
end
|
|
|
|
# Layer 4: Create account access record (atomic transaction)
|
|
begin
|
|
AccountAccess.create!(
|
|
account: invitation.institution.account,
|
|
user: accepting_user,
|
|
institution: invitation.institution,
|
|
role: invitation.role
|
|
)
|
|
|
|
# Log successful acceptance
|
|
SecurityEvent.log(:invitation_accepted, accepting_user, {
|
|
institution_id: invitation.institution_id,
|
|
role: invitation.role,
|
|
invitation_id: invitation.id
|
|
})
|
|
|
|
invitation
|
|
rescue ActiveRecord::RecordNotUnique
|
|
# User already has access to this institution
|
|
SecurityEvent.log(:invitation_accepted, accepting_user, {
|
|
institution_id: invitation.institution_id,
|
|
note: 'User already has access',
|
|
invitation_id: invitation.id
|
|
})
|
|
invitation
|
|
end
|
|
end
|
|
|
|
# Revoke invitation
|
|
def self.revoke_invitation(invitation, revoked_by)
|
|
# Check authorization
|
|
unless revoked_by.cohort_super_admin? && revoked_by.managed_institutions.exists?(invitation.institution_id)
|
|
SecurityEvent.log(:insufficient_privileges, revoked_by, {
|
|
action: 'revoke_invitation',
|
|
invitation_id: invitation.id
|
|
})
|
|
raise CanCan::AccessDenied, 'Only super admins can revoke invitations'
|
|
end
|
|
|
|
# Mark as used (revoked)
|
|
invitation.update!(used_at: Time.current)
|
|
|
|
SecurityEvent.log(:invitation_revoked, revoked_by, {
|
|
institution_id: invitation.institution_id,
|
|
email: invitation.email,
|
|
invitation_id: invitation.id
|
|
})
|
|
|
|
true
|
|
end
|
|
|
|
# Cleanup expired invitations (daily job)
|
|
def self.cleanup_expired
|
|
expired = CohortAdminInvitation.expired.where.not(used_at: nil)
|
|
count = expired.count
|
|
expired.destroy_all
|
|
count
|
|
end
|
|
|
|
# Get active invitations for institution
|
|
def self.active_invitations(institution, user)
|
|
unless user.cohort_super_admin? && user.managed_institutions.exists?(institution.id)
|
|
raise CanCan::AccessDenied, 'Access denied'
|
|
end
|
|
|
|
CohortAdminInvitation.active.where(institution: institution)
|
|
end
|
|
end |