alumni_lookup

Phase 16 — Legal & IT Compliance

Canonical sources: Portal philosophy, posture, and language live in /docs/planning/champion-portal/source/README.md.

Compliance documents: /docs/compliance/LEGAL_REVIEW.md, /docs/compliance/IT_SECURITY_REVIEW.md

Legal drafts: /docs/compliance/drafts/ (ToS, Privacy Policy, Community Guidelines, Cookie Policy)

Status: ✅ Complete (16.1 ✅, 16.2 ✅, 16.3 ✅, 16.4 ✅, 16.5 ✅, 16.6 ✅) Prerequisites: Phase 15 Complete ✅ Type: Compliance & privacy implementation — models, controllers, views, mailers, security configuration


Overview

A comprehensive Legal Review and IT Security Review have been completed in preparation for the Alumni Network’s public beta launch. Those reviews identified implementation gaps that, if addressed proactively, will present the legal and IT security review teams with a locked-up case requiring only language approval — not engineering recommendations.

This phase implements every actionable recommendation from:

Draft legal documents (Terms of Service, Privacy Policy, Community Guidelines, Cookie Policy) have been prepared for legal counsel at /docs/compliance/drafts/. This phase builds the in-app infrastructure to display, version, and enforce those documents.

What This Phase Covers

  1. Legal policy pages served in-app with versioning and consent tracking
  2. Registration consent flow (ToS + PP acceptance with timestamp)
  3. Education privacy controls across directory, search, and services
  4. True account deletion replacing the current privacy-only stub
  5. CAN-SPAM compliance in all outbound emails
  6. Security headers (CSP, Permissions-Policy, Referrer-Policy)
  7. Age attestation at registration
  8. Data export (“Download My Data”) capability

What This Is Not


Traceability Matrix

Every sub-phase maps back to specific findings in the compliance reviews.

Sub-Phase Legal Review Gap # IT Security Review § Priority
16.1 #1 (ToS), #2 (PP), #3 (Consent), #4 (CG) 🔴 Critical
16.2 #6 (FERPA considerations) 🟡 High
16.3 #5 (Account Deletion), #14 (Data Export) 🟡 High
16.4 #7 (CAN-SPAM) 🟡 High
16.5 #16 (CSP) §17.1 (CSP), §17.4 (Headers) 🟡 High
16.6 #11 (Age Attestation) 🟢 Medium

Sub-Phase Summary

Sub-Phase Name Scope Status
16.1 Legal Policy Pages & Consent Flow Static policy pages + registration consent checkbox + re-consent ✅ Complete
16.2 Education Privacy Controls New privacy enum, 6 views, 3 search paths, 4 services ✅ Complete
16.3 Account Deletion & Data Export True deletion workflow + “Download My Data” ✅ Complete
16.4 CAN-SPAM Email Compliance Unsubscribe links + physical address in all emails ✅ Complete
16.5 Security Headers CSP, Permissions-Policy, Referrer-Policy ✅ Complete
16.6 Age Attestation Age confirmation checkbox at registration ✅ Complete

Goal

Serve the four legal documents as in-app pages, track policy versions, and require ToS + Privacy Policy acceptance at registration. Provide re-consent mechanism when policies are updated.

Scope

Database Changes

New table: cp_policy_acceptances

Column Type Notes
id bigint PK
champion_id bigint FK → cp_champions
policy_type string terms_of_service, privacy_policy
policy_version string e.g., "2025-06-01"
accepted_at datetime Timestamp of acceptance
ip_address inet IP at time of acceptance
created_at datetime  

New columns on cp_champions

Column Type Notes
terms_accepted_at datetime When ToS was last accepted
terms_version string Version string of accepted ToS
privacy_accepted_at datetime When PP was last accepted
privacy_version string Version string of accepted PP

Routes

# Policy pages (public, no auth required)
namespace :cp do
  get "terms",     to: "policies#terms",     as: :terms
  get "privacy",   to: "policies#privacy",   as: :privacy
  get "community-guidelines", to: "policies#community_guidelines", as: :community_guidelines
  get "cookies",   to: "policies#cookies",    as: :cookies
end

Policy Page Content (Initial Draft)

The four policy pages will be populated with the reviewed draft documents located at /docs/compliance/drafts/. These drafts are the starting content for the in-app views and may be revised after legal approval without a code change (see versioning approach below).

Route Source Draft
/terms docs/compliance/drafts/TERMS_OF_SERVICE.md
/privacy docs/compliance/drafts/PRIVACY_POLICY.md
/community-guidelines docs/compliance/drafts/COMMUNITY_GUIDELINES.md
/cookies docs/compliance/drafts/COOKIE_POLICY.md

Cross-document links to implement:

The draft documents reference each other by URL. These links must resolve to in-app routes:

From Link text Target route
Privacy Policy “Cookie Policy” /cookies
Terms of Service “Privacy Policy” /privacy
Terms of Service “Community Guidelines” /community-guidelines
Registration form “Terms of Service” /terms
Registration form “Privacy Policy” /privacy
Email footers “unsubscribe” /settings?section=notifications
Portal footer (all pages) All four policies respective routes

Versioning approach: Policy version strings (e.g., "2025-06-01") are defined in a config constant (Cp::PolicyVersion). Updating the constant triggers the re-consent flow for existing users without requiring a code change to the document content.

Controller: Cp::PoliciesController

Registration Changes (cp/registrations/new.html.erb)

Add before the submit button:

<div class="mt-4">
  <label class="flex items-start gap-2">
    <%= f.check_box :terms_accepted, required: true, class: "mt-1" %>
    <span class="text-sm text-gray-600">
      I agree to the
      <%= link_to "Terms of Service", cp_terms_path, target: "_blank", class: "text-belmontblue underline" %>
      and
      <%= link_to "Privacy Policy", cp_privacy_path, target: "_blank", class: "text-belmontblue underline" %>.
    </span>
  </label>
</div>

Controller Changes (Cp::RegistrationsController)

Add links to Terms, Privacy, Community Guidelines, and Cookie Policy in the champion portal footer (app/views/layouts/cp/_footer.html.erb or equivalent).

Tests

✅ Completion Summary — Implemented 2026-03-05

Status: Complete

What Was Implemented:

New Files:

Modified Files:

Tests: 37 new tests + 16 consent assertions in champion model test. Full suite: 4081 runs, 0 failures.


Sub-Phase 16.2: Education Privacy Controls

Goal

Allow alumni to control visibility of their education records. Options: show all (default), hide graduation year only, or hide all education details (display only “Belmont University graduate”). Education privacy must ripple into directory search/filter results so that hidden data cannot be inferred from search results.

Scope

Database Changes

New column on cp_champions

Column Type Default Notes
education_privacy integer (enum) 0 (show_all) Privacy level for education data

Enum values:

Value Name Behavior
0 education_show_all Full education display (degree, major, college, year)
1 education_hide_year Show degree/major/college, hide graduation year
2 education_hidden Show only “Belmont University graduate” — no degree, major, college, or year

Model Changes (Cp::Champion)

enum :education_privacy, {
  education_show_all: 0,
  education_hide_year: 1,
  education_hidden: 2
}, prefix: true, default: :education_show_all

Add helper methods:

def show_education_year?
  education_privacy_education_show_all?
end

def show_education_details?
  !education_privacy_education_hidden?
end

View Changes (6 files)

All education display locations must respect the privacy setting:

View Current Behavior Change
cp/directory/_champion_card.html.erb Shows degree + year Conditionally hide based on champion.education_privacy
cp/directory/show.html.erb Shows full education section Conditionally hide year and/or all details
cp/profile/show.html.erb Shows own education Always show full education to the champion themselves
cp/career_connect/_champion_card.html.erb Shows degree info Conditionally hide based on privacy
cp/almost_alumni/_mentor_card.html.erb Shows degree info Conditionally hide based on privacy
cp/almost_alumni/show.html.erb Shows mentor education Conditionally hide based on privacy

Display logic (for other-viewer):

<% if champion.education_privacy_education_show_all? %>
  <!-- Full education: degree, major, college, year -->
<% elsif champion.education_privacy_education_hide_year? %>
  <!-- Show degree, major, college — no year -->
<% else %>
  <!-- "Belmont University graduate" only -->
<% end %>

Self-view exception: On cp/profile/show.html.erb, always show full education with a privacy indicator badge (e.g., “🔒 Hidden from others”).

Search & Filter Changes (3 paths)

Critical user requirement: “This would need to ripple down into any searches/filters that might reference those areas so that the alum does not show up in a search result that would imply their year or education information.”

Path File Change
Graduation year filter Cp::DirectoryController#apply_degree_filters Exclude education_hidden and education_hide_year champions from grad year filter results
College filter Cp::DirectoryController#apply_degree_filters Exclude education_hidden champions from college filter results
Degree/major filter Cp::DirectoryController#apply_degree_filters Exclude education_hidden champions from degree/major filter results

Implementation approach:

# In apply_degree_filters, when grad_year filter is active:
scope = scope.where.not(education_privacy: :education_hide_year)
             .where.not(education_privacy: :education_hidden)

# When college/degree/major filter is active:
scope = scope.where.not(education_privacy: :education_hidden)

This ensures a champion with hidden education cannot be discovered through education-based filters.

Service Changes (4 services)

Service Change
AlumniLikeMeService Exclude education factors from scoring when champion has education_hidden
CareerConnectService Respect privacy when matching by career cluster / college
CommunityMatchingService Exclude college-based suggestions when education_hidden
CommunityDetectionService Skip college/major community detection when education_hidden

Settings Integration

Add education privacy control to cp/settings page (privacy section):

<label>Education Visibility</label>
<select name="champion[education_privacy]">
  <option value="education_show_all">Show all education details</option>
  <option value="education_hide_year">Hide graduation year</option>
  <option value="education_hidden">Show only "Belmont University graduate"</option>
</select>

Update Cp::SettingsController to permit education_privacy parameter.

Activity Tracking

Record ActivityRecorder.record(champion, :privacy_updated) when education privacy changes.

Tests

✅ Completion Summary — Implemented 2026-03-06

Status: Complete

What Was Implemented:

Spec Deviations:

Modified Files:

Tests: 8 new champion model tests for education privacy. Full suite: 4115 runs, 0 failures.


Sub-Phase 16.3: Account Deletion & Data Export

Goal

Replace the current stub request_account_deletion (which only sets privacy fields to hidden) with a true account deletion workflow. Additionally, provide a “Download My Data” export for champions to retrieve their personal data.

Current State

The existing request_account_deletion in Cp::SettingsController (lines 55-66) does NOT delete anything — it only sets email_privacy, phone_privacy, and address_privacy to hidden. The flash message misleadingly says “Account deletion request received.”

Scope

Account Deletion Workflow

Step 1: Confirmation page

Step 2: Deletion execution (Cp::AccountDeletionService)

New service that handles the full deletion:

class Cp::AccountDeletionService
  def initialize(champion)
    @champion = champion
  end

  def execute!
    ActiveRecord::Base.transaction do
      anonymize_discussions
      delete_messages
      remove_community_memberships
      remove_connections
      purge_uploaded_files
      remove_activity_events
      remove_policy_acceptances
      remove_notification_preferences
      remove_support_threads
      clear_pii_fields
      deactivate_account
    end
  end

  private

  def anonymize_discussions
    # Set author to nil or a sentinel, update display to "Former Member"
    @champion.discussion_posts.update_all(
      author_name_cache: "Former Member",
      champion_id: nil  # or keep FK but mark deleted
    )
  end

  def clear_pii_fields
    # Overwrite all personally identifiable fields
    @champion.update!(
      first_name: "Deleted",
      last_name: "User",
      email: "deleted+#{@champion.id}@deleted.belmontalum.com",
      phone: nil,
      address_line_1: nil, address_line_2: nil,
      city: nil, state: nil, zip_code: nil,
      bio: nil, headline: nil,
      linkedin_url: nil,
      employer: nil, job_title: nil,
      deleted_at: Time.current,
      # ... all other PII fields
    )
  end

  def deactivate_account
    # Prevent login
    @champion.update!(
      account_status: :deleted,
      deleted_at: Time.current
    )
  end
end

Step 3: Post-deletion

Database Changes

New columns on cp_champions

Column Type Notes
deleted_at datetime When account was deleted (null = active)
deletion_reason string Optional reason provided by champion
deletion_confirmed_at datetime When deletion was confirmed

Scope filter: Add scope :active, -> { where(deleted_at: nil) } to Cp::Champion and apply to directory/search queries.

Data Export (“Download My Data”)

Add a “Download My Data” button in Settings → Account section.

Controller: Cp::SettingsController#export_data

Service: Cp::DataExportService

Generates a JSON file containing all of the champion’s personal data:

Implementation:

class Cp::DataExportService
  def initialize(champion)
    @champion = champion
  end

  def generate
    {
      exported_at: Time.current.iso8601,
      profile: profile_data,
      communities: community_data,
      discussions: discussion_data,
      messages: sent_message_data,
      connections: connection_data,
      activity_events: activity_data,
      privacy_settings: privacy_data,
      policy_acceptances: acceptance_data
    }.to_json
  end
end

Tests

✅ Completion Summary — Implemented 2026-03-06

Status: Complete

What Was Implemented:

New Database Columns:

Spec Deviations:

New Files:

Tests: 10 new service tests + 7 new settings controller tests. Full suite: 4115 runs, 0 failures.


Sub-Phase 16.4: CAN-SPAM Email Compliance

Goal

Ensure all non-transactional emails comply with CAN-SPAM Act requirements: clear sender identification, physical postal address, and a functioning unsubscribe mechanism.

Current State

Scope

Add explicit unsubscribe language:

<p style="...">
  <a href="<%= cp_settings_url(section: 'notifications') %>">
    Manage preferences or unsubscribe
  </a>
</p>

Update the footer block in every text email template to include the full physical address:

---
Belmont Alumni Engagement
1900 Belmont Boulevard, Nashville, TN 37212

Manage preferences or unsubscribe: <%= cp_settings_url(section: 'notifications') %>

Files to update:

List-Unsubscribe Header

Add List-Unsubscribe and List-Unsubscribe-Post headers to all non-transactional mailers:

# In Cp::NotificationMailer (or a shared concern)
def add_unsubscribe_headers(champion)
  headers["List-Unsubscribe"] = "<#{cp_settings_url(section: 'notifications')}>"
  headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
end

This enables one-click unsubscribe in email clients (Gmail, Apple Mail).

Transactional Email Exemption

The following emails are transactional and do NOT require unsubscribe links (but should still include the physical address):

Tests

✅ Completion Summary — Implemented 2026-03-06

Status: Complete

What Was Implemented:

New Files:

Modified Files:

Tests: 2 new CAN-SPAM header tests + 3 new ChampionMailer tests. Full suite: 4115 runs, 0 failures.


Sub-Phase 16.5: Security Headers

Goal

Enable Content Security Policy, Permissions-Policy, and Referrer-Policy headers to harden the application against XSS, clickjacking, and information leakage.

Current State

Scope

Content Security Policy (config/initializers/content_security_policy.rb)

Uncomment and configure:

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.font_src    :self, "https://fonts.gstatic.com"
    policy.img_src     :self, "https:", :data  # Allow HTTPS images + data URIs for inline images
    policy.object_src  :none
    policy.script_src  :self
    policy.style_src   :self, :unsafe_inline  # Tailwind uses inline styles
    policy.connect_src :self
    policy.frame_src   :none
    policy.base_uri    :self
    policy.form_action :self, "https://accounts.google.com"  # Google OAuth
  end

  # Generate nonce for inline scripts
  config.content_security_policy_nonce_generator = ->(request) {
    request.session.id.to_s
  }
  config.content_security_policy_nonce_directives = %w[script-src]

  # Report-Only mode first (non-breaking)
  config.content_security_policy_report_only = true
end

Deployment strategy:

  1. Deploy in report_only mode first
  2. Monitor for violations in logs
  3. Adjust policy as needed
  4. Switch to enforcement mode after validation

Permissions-Policy (config/initializers/permissions_policy.rb)

Uncomment and configure:

Rails.application.configure do
  config.permissions_policy do |policy|
    policy.camera      :none
    policy.microphone  :none
    policy.geolocation :none
    policy.payment     :none
    policy.usb         :none
    policy.fullscreen  :self
    policy.gyroscope   :none
    policy.magnetometer :none
    policy.accelerometer :none
  end
end

Referrer-Policy

Add to config/application.rb or via middleware:

config.action_dispatch.default_headers.merge!(
  "Referrer-Policy" => "strict-origin-when-cross-origin"
)

HSTS (Explicit)

config.action_dispatch.default_headers.merge!(
  "Strict-Transport-Security" => "max-age=31536000; includeSubDomains"
)

(Only in production environment config to avoid HTTPS issues in development.)

Tests

✅ Completion Summary — Implemented 2025-01-14

Status: Complete

What Was Implemented:

Modified Files:

New Files:

Tests: 11 new integration tests. Full suite: 4126 runs, 0 failures.

Deployment Note: CSP is in report-only mode. Monitor logs for violations, then switch to enforcement by setting config.content_security_policy_report_only = false.


Sub-Phase 16.6: Age Attestation

Goal

Add an age confirmation checkbox at registration establishing a minimum age of 18. This is consistent with the intended audience (university alumni and current college students, virtually all of whom are adults) and eliminates COPPA/child privacy compliance concerns entirely.

Scope

Registration Form Change

Add after the consent checkbox (from 16.1):

<div class="mt-3">
  <label class="flex items-start gap-2">
    <%= f.check_box :age_confirmed, required: true, class: "mt-1" %>
    <span class="text-sm text-gray-600">
      I confirm that I am at least 18 years of age.
    </span>
  </label>
</div>

Model Change

Add virtual attribute to Cp::Champion:

attr_accessor :age_confirmed
validates :age_confirmed, acceptance: true, on: :create

Database Change

New column on cp_champions

Column Type Notes
age_confirmed_at datetime When age was attested

Set age_confirmed_at during registration (same as terms_accepted_at).

Tests

✅ Completion Summary — Implemented 2026-03-06

Status: Complete

What Was Implemented:

Database Column:

Modified Files:

Tests: Champion model tests for age_confirmed validation (reject nil/”0”, accept “1”/true). Full suite: 4115 runs, 0 failures.


Implementation Notes

Ordering & Dependencies

Sub-phases should be implemented in order because:

Migration Safety

All migrations are additive (new columns, new tables). No destructive changes. Safe for zero-downtime deployment.

Rollback Strategy


Estimated Effort

Sub-Phase Effort Files Changed New Files
16.1 Medium ~8 ~6 (migration, model, controller, 4 views)
16.2 Medium-Large ~12 ~2 (migration, helpers)
16.3 Large ~6 ~4 (migration, 2 services, confirmation view)
16.4 Small ~6 ~1 (mailer concern)
16.5 Small ~3 0 (config changes only)
16.6 Small ~3 ~1 (migration)
Total ~2-3 weeks ~38 ~14