alumni_lookup

Authentication & Roles Implementation Guide

Updated: December 1, 2025
Status:PROJECT COMPLETE
Purpose: Foundational auth/authorization improvements for the Lookup Portal that lay groundwork for Champion Portal and Event Check-in Integration.


Table of Contents

  1. Overview
  2. Why This Comes First
  3. Scope Summary
  4. Phase 1: Unify Admin Checks
  5. Phase 2: Add Role Infrastructure
  6. Phase 3: Admin vs Staff Separation
  7. Phase 4: Forgot Password Flow
  8. Phase 5: Google SSO for Internal Users
  9. Phase 6: Permission Flags Infrastructure
  10. Deferred to Champion Portal
  11. Testing Requirements
  12. Documentation Updates

Overview

This project consolidates and strengthens the authentication and authorization foundation of the Lookup Portal. It prepares shared infrastructure that both Champion Portal and Event Check-in Integration will depend on.

Goals

  1. Unify inconsistent admin checks — Consolidate admin? and access_level == 1 patterns
  2. Add role infrastructure — Replace binary admin/non-admin with explicit admin / staff roles
  3. Implement Admin vs Staff separation — Staff can do daily work; Admins can access settings/imports
  4. Add Forgot Password flow — Enable users to reset passwords without admin intervention
  5. Add Google SSO — Optional federated login for staff (admin creates account first, user can link Google)
  6. Prepare permission flags — Infrastructure for cross-cutting permissions like event_checkin

Current State

Component Status Notes
Devise authentication ✅ Working Email/password only
Admin checks Unified (Phase 1) All controllers use ensure_admin! from ApplicationController
Admin/non-admin separation ✅ Working admin boolean is single source of truth
Role enum Implemented (Phase 2) staff and admin roles with staff? and admin? helpers
Staff vs Admin access Enforced (Phase 3) ensure_staff! for operational features, ensure_admin! for settings
Password reset Working (Phase 4) Styled views, branded email, Mailgun configured
SSO Working (Phase 5) Google SSO for internal users, optional account linking
Permission flags Infrastructure Ready (Phase 6) Patterns established; flags added by consuming features

Why This Comes First

Both Champion Portal and Event Check-in Integration assume this work is complete:

Dependency Champion Portal Needs Event Check-in Needs
Role infrastructure CLC vs Champion roles build on Staff vs Admin pattern Staff/Admin determines who manages events
Permission flags Portal-specific permissions event_checkin, event_manage flags
SSO foundation OmniAuth patterns for Google/Apple/Facebook Same patterns if SSO needed for event volunteers
Password reset Champions need self-service reset N/A (but staff benefit too)
Pundit policies Authorization patterns EventPolicy, RegistrantPolicy

Estimated total effort: 3-4 weeks


Scope Summary

In Scope (This Project) — ALL COMPLETE

Feature Phase Status
Unify admin checks 1 ✅ Complete
Add role enum to User model 2 ✅ Complete
Admin vs Staff separation 3 ✅ Complete
Forgot Password flow (test + UI) 4 ✅ Complete
Google SSO for internal users 5 ✅ Complete
Permission flags infrastructure 6 ✅ Complete (patterns ready)

Deferred (Champion Portal)

Deferred (Event Check-in)


Phase 1: Unify Admin Checks ✅ COMPLETE

Goal: Consolidate admin? and access_level == 1 into a single, consistent pattern.

Duration: 2-4 hours

Completed: November 30, 2025

What Was Done

  1. Added admin? method to User model - Uses admin boolean as single source of truth
  2. Added ensure_admin! to ApplicationController - Standardized authorization callback
  3. Updated all controllers to use ensure_admin!:
    • PeopleController
    • Settings::SettingsController
    • Settings::MajorsController
    • Settings::CollegesController
    • Settings::AffinitiesController
    • Settings::AlumniController
    • Settings::EngagementActivitiesController
  4. Removed access_level column - Migration 20251130233249_remove_access_level_from_users.rb
  5. Added tests for admin? method (3 tests)
  6. Updated documentation - AUTHENTICATION.md, PERMISSIONS_MATRIX.md, CHANGELOG.md

Original Problem (Now Resolved)

# Some controllers use this:
current_user.admin?

# Others use this:
current_user&.access_level == 1

Both mean “admin” but the inconsistency creates maintenance burden.

Final Implementation

# app/models/user.rb
def admin?
  read_attribute(:admin) == true
end
# app/controllers/application_controller.rb
def ensure_admin!
  unless current_user&.admin?
    redirect_to root_path, alert: 'You are not authorized to access this page.'
  end
end

Testing ✅

Documentation ✅


Phase 2: Add Role Infrastructure ✅ COMPLETE

Goal: Add role enum to User model without changing behavior yet.

Duration: 4-6 hours

Completed: November 30, 2025

What Was Done

  1. Created migration 20251130234911_add_role_to_users.rb
    • Added role column (string, default: ‘staff’, not null)
    • Added index on role
    • Backfilled existing admins with ‘admin’ role
  2. Updated User model with role enum and helpers:
    • enum :role, { staff: 'staff', admin: 'admin' }
    • Updated admin? to check role OR legacy admin boolean
    • Added staff? method (returns true for both staff and admin)
    • Added sync_role_with_admin_flag callback for consistency
  3. Updated UsersController to permit role parameter
  4. Updated user form with role dropdown (replaces admin checkbox)
  5. Updated users index to show role badges instead of checkmark
  6. Updated test fixtures with role values
  7. Added 7 new tests for role functionality

Final Implementation

# app/models/user.rb
enum :role, { staff: 'staff', admin: 'admin' }, default: :staff

def admin?
  role == 'admin' || read_attribute(:admin) == true
end

def staff?
  role.in?(%w[staff admin])
end

Testing ✅

Documentation ✅


Phase 3: Admin vs Staff Separation ✅ COMPLETE

Goal: Differentiate what Staff can do vs what only Admins can do.

Duration: 6-10 hours

Completed: November 30, 2025

What Was Done

  1. Added ensure_staff! method to ApplicationController for authenticated user checks
  2. Updated controllers to use ensure_staff! for staff-accessible features:
    • AlumniController - staff can search, view, update alumni
    • AlumniAffinitiesController - staff can manage affinities
    • BatchSearchController - staff can use batch search
    • StatisticsController - staff can view statistics
    • EngagementStatsController - staff can view; admin-only clear_cache
    • EngagementActivitiesController - staff can view activities
    • ChampionSignupsController - staff can manage; admin-only destroy, merge_duplicates
  3. Settings controllers remain admin-only via ensure_admin!:
    • Settings::SettingsController
    • Settings::MajorsController
    • Settings::CollegesController
    • Settings::AffinitiesController
    • Settings::AlumniController
    • Settings::EngagementActivitiesController
  4. UsersController remains admin-only (except self-edit via authorize_profile_access!)
  5. Added “Role Access” comments to all controllers
  6. Created test/controllers/staff_access_test.rb with 18 comprehensive tests

Feature Access Matrix

Feature Staff Admin
Alumni search, view, update
Engagement stats (view)
Champion signups list/edit
Batch search
Statistics
CSV exports
Edit own profile
Settings namespace
User management
Cache clearing
Champion signup delete/merge

Final Implementation

# app/controllers/application_controller.rb
def ensure_admin!
  unless current_user&.admin?
    redirect_to root_path, alert: 'You are not authorized to access this page.'
  end
end

def ensure_staff!
  unless current_user&.staff?
    redirect_to root_path, alert: 'You are not authorized to access this page.'
  end
end

Testing ✅

Documentation ✅


Phase 4: Forgot Password Flow ✅ COMPLETE

Goal: Enable users to reset their own passwords via email.

Duration: 4-6 hours

Completed: November 30, 2025

What Was Done

  1. Fixed mailer URL configuration:
    • Production mailer default URL set to lookup.bualum.co (internal portal)
    • ChampionSignupMailer overrides to champions.bualum.co via instance method (not class-level)
    • Mailgun initializer wrapped in Rails.env.production? check
    • Test environment configured with default_url_options
  2. Fixed Devise mailer_sender:
    • Changed from placeholder please-change-me@example.com to Alumni Lookup <noreply@email.bualum.co>
    • Emails now use Mailgun verified domain for proper deliverability
  3. Configured custom Mailgun delivery method:
    • Created MailgunDeliveryMethod class in config/initializers/mailgun_delivery.rb
    • Uses Mailgun API directly (not SMTP) for reliable delivery
    • Registered as :mailgun_api delivery method
  4. Styled password reset views with Tailwind:
    • app/views/devise/passwords/new.html.erb - Matches login page styling
    • app/views/devise/passwords/edit.html.erb - Clean, modern design
  5. Branded password reset email:
    • app/views/devise/mailer/reset_password_instructions.html.erb
    • Belmont navy header with white text
    • Clear CTA button with fallback text link
    • Footer with Belmont address
  6. Added “Forgot password?” link to login page:
    • Link appears next to “Remember me” checkbox
    • Styled consistently with login form
  7. Created integration tests:
    • test/integration/password_reset_test.rb - 11 tests covering:
      • Password reset request flow
      • Reset with valid/invalid/expired tokens
      • Password requirements
      • Navigation links

Testing ✅

Files Changed


Phase 5: Google SSO for Internal Users ✅ COMPLETE

Goal: Allow staff to link their Google account for convenient sign-in (after admin creates their account).

Duration: 8-12 hours

Completed: December 1, 2025

Design Decision: Admin-Created Accounts + Optional Google Linking

Why not self-registration with Google?

Flow:

  1. Admin creates user account (email + auto-generated temp password)
  2. User receives welcome email with login link
  3. User logs in first time, sets permanent password
  4. User can optionally link Google account in profile settings
  5. Future logins: “Sign in with Google” button OR email/password

Why Google?

What Was Done

  1. Added OmniAuth gems:
    # Gemfile
    gem 'omniauth-google-oauth2', '~> 1.2'
    gem 'omniauth-rails_csrf_protection', '~> 2.0'
    
  2. Configured OmniAuth in Devise:
    # config/initializers/devise.rb
    if ENV['GOOGLE_CLIENT_ID'].present? && ENV['GOOGLE_CLIENT_SECRET'].present?
      config.omniauth :google_oauth2,
        ENV['GOOGLE_CLIENT_ID'],
        ENV['GOOGLE_CLIENT_SECRET'],
        {
          scope: 'email,profile',
          prompt: 'select_account'
        }
    end
    
  3. Created migration 20251201225113_add_google_uid_to_users.rb:
    class AddGoogleUidToUsers < ActiveRecord::Migration[7.1]
      def change
        add_column :users, :google_uid, :string
        add_column :users, :google_linked_at, :datetime
        add_index :users, :google_uid, unique: true
      end
    end
    
  4. Created OmniAuth callbacks controller app/controllers/users/omniauth_callbacks_controller.rb:
    • Handles Google sign-in callback
    • Two flows: linking (if signed in) or sign-in (if not)
    • Auto-links Google UID on first SSO sign-in
    • Rejects users without existing accounts
    • Prevents linking same Google account to multiple users
  5. Updated User model with SSO methods:
    # app/models/user.rb
    devise :database_authenticatable, :recoverable, :rememberable, :validatable,
           :omniauthable, omniauth_providers: [:google_oauth2]
       
    def google_connected?
      google_uid.present?
    end
       
    def unlink_google!
      update!(google_uid: nil, google_linked_at: nil)
    end
       
    def self.from_google_oauth(auth)
      user = find_by(email: auth.info.email)
      return nil unless user
      user.update!(google_uid: auth.uid, google_linked_at: Time.current) unless user.google_uid
      user
    end
    
  6. Updated login page app/views/devise/sessions/new.html.erb:
    • Added “Sign in with Google” button with Google logo SVG
    • Shows only when OmniAuth is configured (env vars present)
    • “Or continue with email” divider
    • Existing email/password form unchanged
  7. Updated profile page app/views/users/_form.html.erb:
    • Added “Google Sign-In” section
    • Shows “Link Google Account” button when not connected
    • Shows “Unlink Google” button with linked date when connected
    • Only appears when OmniAuth is configured
  8. Updated routes config/routes.rb:
    devise_for :users, skip: [:registrations], controllers: {
      omniauth_callbacks: 'users/omniauth_callbacks'
    }
       
    # Note: unlink_google must come BEFORE resources :users to avoid route conflict
    delete 'users/unlink_google', to: 'users#unlink_google', as: :unlink_google
    resources :users, only: [:index, :new, :create, :edit, :update, :destroy]
    
  9. Added unlink_google action to UsersController:
    • Added to except: list for authorize_admin!
    • Allows current user to unlink their own Google account

Google Cloud Console Setup (Production)

  1. Go to Google Cloud Console
  2. Create or select a project
  3. Enable the Google+ API or Google Identity API
  4. Go to “APIs & Services” → “Credentials” → “Create Credentials” → “OAuth client ID”
  5. Select “Web application”
  6. Add authorized redirect URI:
    https://lookup.bualum.co/users/auth/google_oauth2/callback
    
  7. Copy Client ID and Client Secret
  8. Set Heroku environment variables:
    heroku config:set GOOGLE_CLIENT_ID=your-client-id
    heroku config:set GOOGLE_CLIENT_SECRET=your-client-secret
    

Security Model

Scenario Result
New Google user, no account Rejected with “contact administrator” message
Existing user signs in with Google Success, auto-links Google UID
Signed-in user links Google Success, Google UID saved to profile
User with linked Google signs in Success via Google SSO
User wants to unlink Google Can unlink from profile (must have password set)

Testing ✅

Created test/integration/google_sso_test.rb with 13 tests:

All 298 tests pass (13 new Google SSO tests).

Files Changed

Documentation ✅

Production Deployment Fixes

During production testing, several issues were discovered and fixed:

  1. Nested Form Issue — The Google link/unlink buttons were inside the profile form, causing form submission instead of OAuth flow. Fixed by moving the Google Sign-In section outside the main form_with block in app/views/users/_form.html.erb.

  2. CSRF Token Issue on Login Page — Fresh sessions on the login page caused CSRF token mismatches with omniauth-rails_csrf_protection. Fixed by adding custom origin-based validation in config/initializers/omniauth.rb that checks the Origin/Referer header instead of relying on session-based CSRF tokens.

  3. Session Store Configuration — Updated config/initializers/session_store.rb with same_site: :lax and secure: true (production) for proper OAuth cookie handling.


Phase 6: Permission Flags Infrastructure ✅ COMPLETE

Goal: Establish the pattern for permission flags that Event Check-in will use.

Status: Infrastructure ready. The patterns from Phases 1-5 provide everything needed.

Completed: December 1, 2025

What Was Established

The work in Phases 1-5 has already created all the infrastructure needed for permission flags:

Requirement How It’s Met
Role enum on User model role column with staff/admin values (Phase 2)
Role helper methods admin?, staff? on User model (Phase 2)
Authorization callbacks ensure_admin!, ensure_staff! in ApplicationController (Phases 1 & 3)
Admin bypass pattern All helpers check admin? first (implicit in design)
Controller patterns Consistent before_action usage across all controllers (Phase 3)

Adding Permission Flags (Future)

When Event Check-in or other features need permission flags, the pattern is:

# 1. Migration
add_column :users, :can_event_checkin, :boolean, default: false, null: false

# 2. User model helper (with admin bypass)
def can_event_checkin?
  admin? || read_attribute(:can_event_checkin)
end

# 3. Controller usage
before_action :authorize_event_checkin!

def authorize_event_checkin!
  unless current_user&.can_event_checkin?
    redirect_to root_path, alert: 'You are not authorized for event check-in.'
  end
end

This pattern follows the established conventions and requires no additional infrastructure.

RBAC Consideration

This section documents the decision-making process around Role-Based Access Control (RBAC) architecture for future reference.

Full RBAC Model

The traditional RBAC approach uses separate database tables:

This architecture allows administrators to create custom roles and modify role-permission mappings through a UI without developer involvement.

Why Full RBAC Was Deferred

For the current scale of the Lookup Portal, full RBAC adds complexity without sufficient benefit:

The overhead of maintaining Role, Permission, RolePermission, and UserRole tables (plus the admin UI to manage them) is not justified for this use case.

Current Hybrid Approach

The chosen approach combines:

  1. Role enum on User model: role column with staff and admin values
  2. Boolean permission flags: Individual columns like can_event_checkin, can_event_manage added as needed
  3. Code-defined access rules: Controller callbacks (ensure_staff!, ensure_admin!) and Pundit policies

This provides clear role separation while keeping the implementation simple and maintainable.

Code-Defined Role-Permission Mapping Alternative

If more granular permission control is needed without full database RBAC, a code-defined mapping provides a middle ground:

# Example pattern for code-defined permissions
# Could be added to app/models/concerns/role_permissions.rb

ROLE_PERMISSIONS = {
  staff: %w[
    alumni.search alumni.view alumni.update
    engagement_stats.view
    champion_signups.view champion_signups.edit
    batch_search.use statistics.view
  ],
  admin: %w[
    alumni.search alumni.view alumni.update alumni.delete
    engagement_stats.view engagement_stats.clear_cache
    champion_signups.view champion_signups.edit champion_signups.delete champion_signups.merge
    batch_search.use statistics.view
    settings.access users.manage imports.manage
  ]
}.freeze

def has_permission?(permission)
  return true if admin?  # Admins bypass all checks
  ROLE_PERMISSIONS.fetch(role.to_sym, []).include?(permission.to_s)
end

When to use this pattern:

When to Graduate to Full RBAC

Consider migrating to full database RBAC when any of these triggers occur:

  1. Admin-managed roles: Administrators need to create custom roles without developer involvement
  2. Role proliferation: More than ~5 roles with complex, overlapping permission combinations
  3. Multi-tenancy requirements: Per-organization or per-region role customization
  4. Audit requirements: Need database-level audit trail of permission changes (git history is insufficient)
  5. Dynamic permissions: Permissions need to be added/removed at runtime without deployment

RBAC Decision Matrix

Consideration Full RBAC (database tables) Hybrid (code-defined)
Roles change frequently ✅ Better ❌ Overkill
Roles are stable ❌ Overkill ✅ Better
Need UI to manage roles ✅ Required ❌ Not needed
2-4 roles total ❌ Overkill ✅ Perfect fit
Audit trail for permission changes ✅ Built-in ❌ Need git history
Development velocity ❌ Slower (more tables/UI) ✅ Faster
Runtime flexibility ✅ High ❌ Requires deploy

Current recommendation: The hybrid approach (role enum + permission flags) is the right fit for the Lookup Portal’s current and anticipated near-term needs.

Testing

Documentation


Deferred to Champion Portal

These features are documented here for context but will be implemented as part of Champion Portal:

Apple and Facebook SSO

# Future Champion Portal additions
gem 'omniauth-apple'
gem 'omniauth-facebook'

Champions (external alumni) will use these providers. The OmniAuth infrastructure from Phase 5 makes this straightforward.

CpChampion Model

Separate Devise model for Champion Portal authentication:

# app/models/cp_champion.rb
class CpChampion < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :omniauthable,
         omniauth_providers: [:google_oauth2, :apple, :facebook]
  
  enum :verification_status, { unverified: 0, email_verified: 1, champion_verified: 2 }
  enum :role, { champion: 0, city_leader: 1 }
end

CLC Role and Regional Scoping

City Leadership Council members have elevated permissions within the Champion Portal:

# Future fields on cp_champions
t.string :region
t.string :city

See docs/planning/champion-portal/features/01-AUTHENTICATION.md for full specification.


Testing Requirements

Minimum Test Coverage

Phase Required Tests
Phase 1 Admin check unification (3 tests)
Phase 2 Role enum and helpers (5 tests)
Phase 3 Staff vs Admin access (10+ tests)
Phase 4 Password reset flow (4 tests)
Phase 5 Google SSO (5 tests)
Phase 6 Permission flag helpers (3 tests)

Test Fixtures

# test/fixtures/users.yml
admin_user:
  name: "Admin User"
  email: "admin@belmont.edu"
  role: admin
  admin: true

staff_user:
  name: "Staff User"  
  email: "staff@belmont.edu"
  role: staff
  admin: false

staff_with_google:
  name: "Google User"
  email: "google@belmont.edu"
  role: staff
  google_uid: "123456789"

Documentation Updates ✅ COMPLETE

Document Status
AUTHENTICATION.md ✅ Updated with SSO, password reset, role helpers
PERMISSIONS_MATRIX.md ✅ Roles marked as enforced
CHANGELOG.md ✅ All phases documented


Project Summary

Timeline (Actual)

Phase Completed Notes
Phase 1: Unify Admin Checks Nov 30, 2025 Consolidated all admin checks
Phase 2: Role Infrastructure Nov 30, 2025 Added role enum, helpers
Phase 3: Admin vs Staff Nov 30, 2025 Enforced access separation
Phase 4: Forgot Password Nov 30, 2025 Mailgun, styled views
Phase 5: Google SSO Dec 1, 2025 OAuth2 with origin-based CSRF
Phase 6: Permission Flags Dec 1, 2025 Patterns ready for future use

Test Coverage

What’s Ready for Next Projects

Champion Portal can now:

Event Check-in can now:


Project completed: December 1, 2025