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.
docuseal/docs/architecture/security.md

25 KiB

Security Architecture - FloDoc

Document: Security Design & Implementation Version: 1.0 Last Updated: 2026-01-14 Compliance: POPIA, OWASP Top 10


🛡️ Security Overview

FloDoc implements defense-in-depth security across all layers. The system handles sensitive student PII and must comply with South African POPIA (Protection of Personal Information Act).

Security Principles:

  1. Least Privilege: Users only access what they need
  2. Defense in Depth: Multiple security layers
  3. Zero Trust: Verify everything, trust nothing
  4. Audit Everything: Complete audit trail
  5. Secure by Default: Safe defaults, opt-in for risky features

🔐 Authentication

1. TP Portal (Devise + JWT)

Authentication Flow:

User Login (Email/Password)
    ↓
Devise Validates
    ↓
2FA Check (if enabled)
    ↓
JWT Token Generated
    ↓
Token Stored (HTTP-only cookie or localStorage)
    ↓
Subsequent Requests Include Token

Implementation:

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :two_factor_authenticatable,
         :otp_secret_encryption_key => ENV['OTP_SECRET_KEY']

  # JWT token generation
  def generate_jwt
    payload = {
      user_id: id,
      exp: 7.days.from_now.to_i,
      role: role
    }
    JWT.encode(payload, ENV['JWT_SECRET_KEY'], 'HS256')
  end

  def valid_otp?(code)
    return true unless otp_required_for_login?
    super
  end
end

Token Storage:

// Frontend - Secure storage
// Option 1: HTTP-only cookie (more secure)
// Option 2: Memory (less persistent but secure)

// Recommended: Memory storage with refresh
const authStore = defineStore('auth', {
  state: () => ({
    token: null,
    user: null
  }),

  actions: {
    async login(email, password) {
      const response = await axios.post('/api/v1/auth/token', { email, password })
      this.token = response.data.token
      // Store in memory only
      axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`
    },

    async refresh() {
      if (this.isTokenExpiringSoon) {
        const response = await axios.post('/api/v1/auth/refresh')
        this.token = response.data.token
      }
    }
  },

  getters: {
    isTokenExpiringSoon: (state) => {
      if (!state.token) return false
      const decoded = jwtDecode(state.token)
      const expiry = decoded.exp * 1000
      return expiry - Date.now() < 5 * 60 * 1000 // 5 minutes
    }
  }
})

Session Management:

  • Token expiry: 7 days
  • Auto-refresh: 5 minutes before expiry
  • Logout: Token invalidation on server
  • Concurrent session limit: 3 sessions per user

2. Student Portal (Ad-hoc Token Authentication)

Authentication Flow:

TP Creates Enrollment
    ↓
Generate Unique Token (32 bytes)
    ↓
Send Email with Link: /enroll/:token
    ↓
Student Clicks Link
    ↓
Token Validated (1-time use, expires in 7 days)
    ↓
Session Established (no account needed)

Token Generation:

# app/models/cohort_enrollment.rb
class CohortEnrollment < ApplicationRecord
  before_create :generate_secure_token

  def generate_secure_token
    self.token = SecureRandom.urlsafe_base64(32)
    self.token_expires_at = 7.days.from_now
  end

  def valid_token?(provided_token)
    return false if token_used?
    return false if token_expires_at < Time.current
    provided_token == token
  end

  def mark_token_used!
    update!(token_used: true, token_used_at: Time.current)
  end
end

Token Security:

  • Length: 256-bit (32 bytes URL-safe base64)
  • Entropy: High (SecureRandom)
  • Expiry: 7 days from creation
  • One-time use: Marked as used after first access
  • Revocation: Can be regenerated by TP admin

Email Template:

<!-- app/views/cohort_mailer/enrollment_invitation.html.erb -->
<p>You've been invited to enroll in <%= @cohort.name %></p>

<p>
  <%= link_to 'Click here to enroll',
              student_enroll_url(token: @enrollment.token) %>
</p>

<p>
  This link expires in 7 days and can only be used once.<br>
  If you didn't request this, please ignore this email.
</p>

3. Sponsor Portal (Single Email Token)

Authentication Flow:

Cohort Reaches Sponsor Phase
    ↓
Generate Single Token for Bulk Signing
    ↓
Send Email to Sponsor
    ↓
Sponsor Clicks Link: /sponsor/:token
    ↓
Token Validated (expires in 3 days)
    ↓
Bulk Signing Interface

Special Characteristics:

  • Single Email Rule: One email per cohort, not per student
  • Bulk Operations: Sign once for all students
  • Short Expiry: 3 days (more sensitive)
  • Read-Only Preview: Can view before signing

Implementation:

# app/models/cohort.rb
class Cohort < ApplicationRecord
  def generate_sponsor_token
    self.sponsor_token = SecureRandom.urlsafe_base64(32)
    self.sponsor_token_expires_at = 3.days.from_now
    save!
  end

  def sponsor_token_valid?
    return false if sponsor_token.blank?
    sponsor_token_expires_at > Time.current
  end

  def send_sponsor_invitation
    generate_sponsor_token
    CohortMailer.sponsor_invitation(self).deliver_later
  end
end

🔑 Authorization

1. Role-Based Access Control (RBAC)

Roles:

  • tp_admin: Full access to TP portal
  • tp_user: Limited access (view only)
  • student: Ad-hoc access to own enrollment
  • sponsor: Ad-hoc access to cohort signing

Ability Class:

# app/models/ability.rb
class Ability
  include CanCanCan

  def initialize(user)
    return unless user.present?

    if user.tp_admin?
      can :manage, :all
      cannot :manage, User unless user.super_admin?
    elsif user.tp_user?
      can :read, Cohort, institution_id: user.institution_id
      can :manage, CohortEnrollment, cohort: { institution_id: user.institution_id }
    end
  end
end

Controller Authorization:

# app/controllers/tp/cohorts_controller.rb
class tp::CohortsController < ApplicationController
  before_action :authenticate_user!
  load_and_authorize_resource

  def index
    @cohorts = current_institution.cohorts
  end

  def create
    @cohort = current_institution.cohorts.new(cohort_params)
    if @cohort.save
      redirect_to tp_cohort_path(@cohort)
    else
      render :new
    end
  end
end

2. Ad-hoc Token Authorization

Token-Based CanCanCan:

# app/controllers/student/enrollment_controller.rb
class Student::EnrollmentController < ApplicationController
  skip_before_action :authenticate_user!
  before_action :validate_token

  def show
    # @enrollment set by before_action
  end

  private

  def validate_token
    @enrollment = CohortEnrollment.find_by(token: params[:token])

    unless @enrollment&.valid_token?(params[:token])
      redirect_to root_path, alert: 'Invalid or expired link'
      return
    end

    # Auto-mark as used after first access
    @enrollment.mark_token_used! unless @enrollment.token_used?
  end
end

Authorization Matrix:

Action TP Admin TP User Student Sponsor
Create Cohort
View Cohorts
Manage Enrollments
Enroll Self (token)
Upload Documents (token)
View Cohort (Sponsor) (token)
Bulk Sign (token)
Finalize Cohort

🔒 Data Security

1. Encryption at Rest

Database Encryption:

# app/models/cohort_enrollment.rb
class CohortEnrollment < ApplicationRecord
  # Encrypt sensitive fields
  encrypts :student_email
  encrypts :student_name
  encrypts :student_surname
  encrypts :student_id

  # Rails 7+ built-in encryption
  # Requires: ENCRYPTION_KEY in environment
end

Configuration:

# config/initializers/active_record_encryption.rb
Rails.application.config.active_record.encryption.primary_key = ENV['ENCRYPTION_PRIMARY_KEY']
Rails.application.config.active_record.encryption.deterministic_key = ENV['ENCRYPTION_DETERMINISTIC_KEY']
Rails.application.config.active_record.encryption.key_derivation_salt = ENV['ENCRYPTION_SALT']

Key Management:

  • Use environment variables (never commit keys)
  • Rotate keys periodically
  • Backup keys securely
  • Use AWS KMS or similar for production

2. File Upload Security

Active Storage Configuration:

# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

minio:
  service: S3
  access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
  secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
  region: us-east-1
  endpoint: <%= ENV['AWS_ENDPOINT_URL'] %>
  bucket: <%= ENV['AWS_BUCKET_NAME'] %>

Upload Validation:

# app/models/cohort_enrollment.rb
class CohortEnrollment < ApplicationRecord
  has_many_attached :documents

  validate :documents_are_valid

  private

  def documents_are_valid
    documents.each do |doc|
      # Size limit (5MB)
      if doc.byte_size > 5.megabytes
        errors.add(:documents, 'must be less than 5MB')
      end

      # Type validation
      allowed_types = ['application/pdf', 'image/jpeg', 'image/png']
      unless allowed_types.include?(doc.content_type)
        errors.add(:documents, 'must be PDF, JPG, or PNG')
      end

      # Virus scan (if ClamAV available)
      if defined?(ClamAV)
        clam = ClamAV.instance
        scan = clam.scanfile(doc.path)
        if scan.is_a?(ClamAV::VirusResponse)
          errors.add(:documents, 'contains virus')
        end
      end
    end
  end
end

Content Security Policy:

# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
  policy.default_src :self
  policy.font_src    :self, :https, :data
  policy.img_src     :self, :https, :data
  policy.object_src  :none
  policy.script_src  :self, :https
  policy.style_src   :self, :https, :unsafe_inline
  policy.connect_src :self, :https, 'http://localhost:3000' if Rails.env.development?
end

3. Input Validation & Sanitization

Model Validations:

class Cohort < ApplicationRecord
  validates :name, presence: true, length: { maximum: 100 }
  validates :sponsor_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :program_type, inclusion: { in: %w[learnership internship candidacy] }

  # Sanitize inputs
  before_validation :strip_whitespace

  private

  def strip_whitespace
    self.name = name&.strip
    self.sponsor_email = sponsor_email&.strip&.downcase
  end
end

Controller Parameter Sanitization:

class Api::V1::CohortsController < Api::V1::BaseController
  def create
    # Strong parameters prevent mass assignment
    @cohort = current_institution.cohorts.new(cohort_params)
    # ...
  end

  private

  def cohort_params
    params.require(:cohort).permit(
      :name,
      :program_type,
      :sponsor_email,
      :template_id,
      required_student_uploads: [],
      cohort_metadata: [:start_date, :duration_months, :stipend_amount]
    )
  end
end

SQL Injection Prevention:

# ✅ Safe - Uses ActiveRecord
Cohort.where("name ILIKE ?", "%#{sanitize_sql_like(search)}%")

# ❌ Dangerous - Raw SQL with interpolation
Cohort.where("name ILIKE '%#{search}%'")

XSS Prevention:

# Rails automatically escapes in views
<%= @cohort.name %>  # Safe

# Mark as safe only if trusted
<%= raw @trusted_html %>  # Only if you control the content

🛡️ Web Security

1. CSRF Protection

Rails Default:

# config/initializers/csrf_protection.rb
Rails.application.config.action_controller.csrf_protection_enabled = true

API Controllers:

class Api::V1::BaseController < ActionController::API
  # APIs typically skip CSRF (use token auth instead)
  skip_before_action :verify_authenticity_token
end

Web Controllers:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end

2. CORS Configuration

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:3000' if Rails.env.development?
    origins 'https://flodoc.com' if Rails.env.production?

    resource '/api/v1/*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: false,
      expose: ['Authorization']
  end
end

3. Security Headers

# config/initializers/secure_headers.rb
Rails.application.config.middleware.use SecureHeaders::Middleware

SecureHeaders::Configuration.configure do |config|
  config.csp = {
    default_src: %w['self'],
    script_src: %w['self' 'unsafe-inline'],
    style_src: %w['self' 'unsafe-inline'],
    img_src: %w['self' data:],
    font_src: %w['self' https: data:],
    connect_src: %w['self'],
    object_src: %w['none'],
    base_uri: %w['self'],
    form_action: %w['self']
  }

  config.hsts = "max-age=#{1.year.to_i}; includeSubDomains; preload"
  config.x_frame_options = "DENY"
  config.x_content_type_options = "nosniff"
  config.x_xss_protection = "1; mode=block"
  config.referrer_policy = "strict-origin-when-cross-origin"
end

📝 Audit & Logging

1. Audit Trail

Audit Model:

# app/models/audit_log.rb
class AuditLog < ApplicationRecord
  belongs_to :user, optional: true
  belongs_to :auditable, polymorphic: true

  enum action: {
    cohort_created: 0,
    cohort_activated: 1,
    enrollment_created: 2,
    enrollment_completed: 3,
    sponsor_signed: 4,
    cohort_finalized: 5,
    document_uploaded: 6,
    token_generated: 7,
    login: 8,
    logout: 9
  }

  validates :action, :auditable, presence: true
end

Audit Logger:

# app/controllers/concerns/auditable.rb
module Auditable
  extend ActiveSupport::Concern

  included do
    after_action :log_action, if: :auditable_action?
  end

  private

  def log_action
    AuditLog.create!(
      user: current_user,
      auditable: @auditable,
      action: action_name,
      ip_address: request.remote_ip,
      user_agent: request.user_agent,
      metadata: { params: params.except(:password, :token) }
    )
  end

  def auditable_action?
    %w[create update destroy].include?(action_name)
  end
end

# Usage in controllers
class tp::CohortsController < ApplicationController
  include Auditable

  def create
    @cohort = current_institution.cohorts.new(cohort_params)
    @auditable = @cohort
    # ...
  end
end

Audit Queries:

# Find all actions by a user
AuditLog.where(user: user).order(created_at: :desc)

# Find all actions on a cohort
AuditLog.where(auditable: cohort)

# Find suspicious activity
AuditLog.where('created_at > ?', 1.hour.ago)
        .where(action: :login)
        .group(:ip_address)
        .count
        .select { |_, count| count > 10 }

2. Application Logging

# config/environments/production.rb
config.log_level = :info
config.logger = ActiveSupport::Logger.new(STDOUT)

# Log format
config.log_tags = [:request_id]

# Filter sensitive parameters
config.filter_parameters += [:password, :token, :secret, :key]

Custom Logging:

class CohortWorkflowService
  def initialize(cohort, user)
    @cohort = cohort
    @user = user
    @logger = Rails.logger
  end

  def advance_to_active
    @logger.info(
      "[CohortWorkflow] User #{@user.id} advancing cohort #{@cohort.id} to active"
    )

    if @cohort.update(status: 'active')
      @logger.info("[CohortWorkflow] Success: cohort #{@cohort.id}")
      true
    else
      @logger.error(
        "[CohortWorkflow] Failed: cohort #{@cohort.id}, errors: #{@cohort.errors.full_messages}"
      )
      false
    end
  end
end

🚨 Incident Response

1. Security Incident Detection

Suspicious Activity Alerts:

# app/services/security_monitor.rb
class SecurityMonitor
  def self.check_suspicious_activity
    # Multiple failed logins
    failed_logins = AuditLog.where(
      action: :login_failed,
      created_at: 1.hour.ago..Time.current
    ).group(:ip_address).count

    failed_logins.each do |ip, count|
      if count > 5
        SecurityMailer.suspicious_activity(ip, count).deliver_later
      end
    end

    # Multiple token access from different IPs
    token_usage = AuditLog.where(
      action: :token_access,
      created_at: 24.hours.ago..Time.current
    ).group(:token_id, :ip_address).count

    # Alert if same token used from > 2 IPs
  end
end

Scheduled Monitoring:

# config/schedule.rb (using whenever gem)
every 30.minutes do
  runner "SecurityMonitor.check_suspicious_activity"
end

2. Incident Response Plan

Severity Levels:

Critical (Immediate action):

  • Unauthorized database access
  • Token leak
  • PII exposure
  • Ransomware detected

High (2 hours):

  • Multiple failed login attempts
  • Suspicious API usage
  • File upload anomalies

Medium (24 hours):

  • Unusual traffic patterns
  • Failed webhook deliveries
  • Performance degradation

Response Steps:

  1. Contain: Disable affected accounts/tokens
  2. Investigate: Review audit logs
  3. Remediate: Patch vulnerability
  4. Notify: Inform affected parties (POPIA requirement)
  5. Document: Create incident report
  6. Prevent: Implement additional controls

🔒 POPIA Compliance

1. Data Minimization

Principle: Only collect what's necessary

Implementation:

# app/models/cohort_enrollment.rb
class CohortEnrollment < ApplicationRecord
  # Only collect required fields
  validates :student_email, presence: true
  validates :student_name, presence: true, if: :required_for_program?

  def required_for_program?
    cohort.program_type == 'learnership'
  end
end

2. Purpose Limitation

Principle: Use data only for specified purposes

Implementation:

# Clear purpose statements in UI
# app/views/student/enrollment/show.html.erb
<h2>Document Upload</h2>
<p>
  Your documents will be used for:
  <ul>
    <li>Verification by training provider</li>
    <li>Submission to sponsor for signing</li>
    <li>Legal compliance with learnership agreement</li>
  </ul>
</p>

3. Data Retention

Principle: Don't keep data longer than necessary

Implementation:

# app/models/cohort.rb
class Cohort < ApplicationRecord
  # Auto-archive after 2 years
  scope :for_archival, -> { where('created_at < ?', 2.years.ago) }

  def archive!
    # Move to cold storage
    # Anonymize PII
    update!(status: 'archived')
  end
end

# Scheduled job
class ArchiveOldDataJob < ApplicationJob
  def perform
    Cohort.for_archival.find_each(&:archive!)
  end
end

4. Right to Access & Deletion

Implementation:

# app/controllers/api/v1/user/data_controller.rb
class Api::V1::User::DataController < Api::V1::BaseController
  # GET /api/v1/user/data/export
  def export
    data = {
      profile: current_user,
      enrollments: current_user.cohort_enrollments,
      audit_logs: AuditLog.where(user: current_user)
    }
    send_data data.to_json, filename: "my-data-#{Date.current}.json"
  end

  # DELETE /api/v1/user/data
  def delete
    # Anonymize instead of hard delete for audit
    current_user.anonymize!
    sign_out
    head :no_content
  end
end

🛡️ OWASP Top 10 Mitigation

A01:2021 - Broken Access Control

  • Role-based authorization (CanCanCan)
  • Institution isolation
  • Token-based ad-hoc access
  • Audit all access attempts

A02:2021 - Cryptographic Failures

  • HTTPS enforced
  • Password hashing (Devise bcrypt)
  • JWT signing with secrets
  • Database encryption

A03:2021 - Injection

  • ActiveRecord parameterized queries
  • Strong parameters
  • Input validation
  • SQL LIKE sanitization

A04:2021 - Insecure Design

  • Threat modeling completed
  • Security by default
  • Defense in depth
  • Least privilege principle

A05:2021 - Security Misconfiguration

  • Secure headers
  • CORS configuration
  • No default credentials
  • Environment-based configs

A06:2021 - Vulnerable and Outdated Components

  • Regular dependency updates
  • Security scanning (bundler-audit)
  • Ruby/Rails version maintained
  • Vue.js security updates

A07:2021 - Identification and Authentication Failures

  • 2FA for TP users
  • Token expiration
  • Rate limiting
  • Secure token generation

A08:2021 - Software and Data Integrity Failures

  • Signed JWT tokens
  • Webhook signature verification
  • Code signing in CI/CD
  • Dependency lock files

A09:2021 - Security Logging and Monitoring Failures

  • Comprehensive audit logs
  • Failed login tracking
  • Suspicious activity alerts
  • Log retention policy

A10:2021 - Server-Side Request Forgery (SSRF)

  • Webhook URL validation
  • No arbitrary URL fetching
  • Internal network restrictions
  • URL whitelist for webhooks

🔍 Security Testing

1. Static Analysis

# Ruby security scanning
bundle exec brakeman
bundle exec bundler-audit

# JavaScript security scanning
npm audit
yarn audit

# Code quality
bundle exec rubocop --only security

2. Dynamic Testing

# spec/security/authentication_spec.rb
RSpec.describe 'Authentication Security', type: :request do
  it 'rejects invalid JWT tokens' do
    get '/api/v1/cohorts', headers: { 'Authorization' => 'Bearer invalid' }
    expect(response).to have_http_status(:unauthorized)
  end

  it 'prevents token reuse after logout' do
    token = user.generate_jwt
    user.invalidate_all_tokens!
    get '/api/v1/cohorts', headers: { 'Authorization' => "Bearer #{token}" }
    expect(response).to have_http_status(:unauthorized)
  end
end

3. Penetration Testing Checklist

Authentication:

  • Brute force protection
  • Session fixation prevention
  • Token leakage protection
  • 2FA bypass attempts

Authorization:

  • Horizontal privilege escalation
  • Vertical privilege escalation
  • IDOR (Insecure Direct Object Reference)
  • Mass assignment

Input Validation:

  • SQL injection
  • XSS (reflected, stored, DOM)
  • Command injection
  • File upload bypass

API Security:

  • Rate limiting bypass
  • CORS misconfiguration
  • Authentication bypass
  • Information disclosure

📊 Security Metrics

Track These Metrics:

  • Failed login attempts per day
  • Token reuse attempts
  • Unauthorized access attempts
  • Security incidents per month
  • Time to detect incidents
  • Time to remediate incidents
  • Dependency vulnerabilities
  • Code coverage for security tests

Dashboard:

# Admin dashboard security widget
class SecurityDashboard
  def self.metrics
    {
      failed_logins: AuditLog.where(action: :login_failed, created_at: 24.hours.ago..).count,
      suspicious_ips: AuditLog.where('created_at > ?', 1.hour.ago).group(:ip_address).count.select { |_, c| c > 5 },
      tokens_issued: CohortEnrollment.where('created_at > ?', 24.hours.ago).count,
      security_incidents: SecurityIncident.where('created_at > ?', 30.days.ago).count
    }
  end
end

🎯 Security Checklist

Application Security

  • All inputs validated
  • SQL injection prevented
  • XSS prevented
  • CSRF protected
  • Authentication required
  • Authorization enforced
  • Sensitive data encrypted
  • Secure headers configured
  • CORS properly configured
  • Rate limiting implemented

Data Security

  • Database encryption enabled
  • File uploads validated
  • PII minimized
  • Retention policy defined
  • Backup encryption
  • Access logging enabled

Authentication & Authorization

  • 2FA for TP users
  • Token expiration
  • Secure token generation
  • Session management
  • Role-based access control
  • Institution isolation

Monitoring & Logging

  • Audit logs implemented
  • Failed login tracking
  • Suspicious activity alerts
  • Incident response plan
  • Log retention policy
  • Security metrics tracked

Compliance

  • POPIA compliance
  • OWASP Top 10 addressed
  • Security testing performed
  • Documentation complete
  • Incident response ready

🔐 Environment Security

Development

# .env.example (never commit .env)
DATABASE_URL=postgresql://localhost/flo_doc
SECRET_KEY_BASE=dev_secret
JWT_SECRET_KEY=dev_jwt_secret
ENCRYPTION_PRIMARY_KEY=dev_encryption_key

Production

# Use environment variables or secrets manager
DATABASE_URL=postgresql://...
SECRET_KEY_BASE=$(rails secret)
JWT_SECRET_KEY=$(openssl rand -base64 32)
ENCRYPTION_PRIMARY_KEY=$(openssl rand -base64 32)

Never commit secrets:

# .gitignore
.env
.env.production
config/secrets.yml
config/master.key

📚 Security Resources


Document Status: Complete Last Security Review: 2026-01-14 Next Review: After Phase 1 Implementation