# 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**: ```ruby # 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**: ```javascript // 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**: ```ruby # 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**: ```erb

You've been invited to enroll in <%= @cohort.name %>

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

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

``` --- ### 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby 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**: ```ruby 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**: ```ruby # ✅ Safe - Uses ActiveRecord Cohort.where("name ILIKE ?", "%#{sanitize_sql_like(search)}%") # ❌ Dangerous - Raw SQL with interpolation Cohort.where("name ILIKE '%#{search}%'") ``` **XSS Prevention**: ```ruby # 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**: ```ruby # config/initializers/csrf_protection.rb Rails.application.config.action_controller.csrf_protection_enabled = true ``` **API Controllers**: ```ruby class Api::V1::BaseController < ActionController::API # APIs typically skip CSRF (use token auth instead) skip_before_action :verify_authenticity_token end ``` **Web Controllers**: ```ruby class ApplicationController < ActionController::Base protect_from_forgery with: :exception end ``` --- ### 2. CORS Configuration ```ruby # 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 ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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 ```ruby # 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**: ```ruby 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # 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**: ```ruby # Clear purpose statements in UI # app/views/student/enrollment/show.html.erb

Document Upload

Your documents will be used for:

``` ### 3. Data Retention **Principle**: Don't keep data longer than necessary **Implementation**: ```ruby # 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**: ```ruby # 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 ```bash # 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 ```ruby # 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**: ```ruby # 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 ```bash # .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 ```bash # 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 # .gitignore .env .env.production config/secrets.yml config/master.key ``` --- ## 📚 Security Resources - **OWASP Top 10**: https://owasp.org/Top10/ - **POPIA Guide**: https://www.justice.gov.za/legislation/acts/2013-013.pdf - **Rails Security**: https://guides.rubyonrails.org/security.html - **Vue.js Security**: https://vuejs.org/guide/best-practices/security.html --- **Document Status**: ✅ Complete **Last Security Review**: 2026-01-14 **Next Review**: After Phase 1 Implementation