alumni_lookup

Authentication & Authorization

Updated: December 1, 2025
Purpose: Document the current authentication system, authorization patterns, and future role-based access plans.


Table of Contents

  1. Current Authentication Setup
  2. Google SSO (Optional)
  3. User Model
  4. Authorization Patterns
  5. Controller Authentication Requirements
  6. Public Endpoints
  7. Future Role System (Conceptual)

Current Authentication Setup

Devise Configuration

The application uses Devise for authentication with the following modules enabled:

# app/models/user.rb
devise :database_authenticatable, :registerable,
       :recoverable, :rememberable, :validatable
Module Purpose
database_authenticatable Email/password authentication
registerable User registration (admin-controlled)
recoverable Password reset via email
rememberable “Remember me” session persistence
validatable Email/password validation

Note: User registration is disabled in routes (skip: [:registrations]). New users are created by admins only.


Google SSO (Optional)

Staff can optionally link their Google account for convenient sign-in. This is an optional convenience feature — email/password authentication always works.

How It Works

  1. Admin creates user account with email and temporary password
  2. User logs in first time with email/password, sets permanent password
  3. User can optionally link Google from profile settings
  4. Future logins: “Sign in with Google” button OR email/password

Security Model

Scenario Result
New Google user, no account Rejected — “No account found” message
Existing user signs in with Google Success, auto-links Google UID
Signed-in user links Google Success, Google UID saved
User unlinks Google Success, must use email/password

User Model Fields

# SSO-related columns
t.string :google_uid        # Google unique identifier
t.datetime :google_linked_at # When Google was linked

Helper Methods

# app/models/user.rb

# Check if Google is linked
def google_connected?
  google_uid.present?
end

# Unlink Google account
def unlink_google!
  update!(google_uid: nil, google_linked_at: nil)
end

# Find user by Google OAuth and auto-link
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

Production Setup

  1. Create OAuth credentials in Google Cloud Console
  2. Add authorized redirect URI: https://lookup.bualum.co/users/auth/google_oauth2/callback
  3. Set environment variables:
    heroku config:set GOOGLE_CLIENT_ID=your-client-id
    heroku config:set GOOGLE_CLIENT_SECRET=your-client-secret
    

Note: If environment variables are not set, the app works normally — it just won’t show the Google sign-in option.

OmniAuth Configuration

The app uses custom origin-based validation for OmniAuth requests (config/initializers/omniauth.rb). This replaces the default CSRF token validation to handle edge cases with fresh sessions on the login page.

# Origin checking provides CSRF protection without session dependency
OmniAuth.config.request_validation_phase do |env|
  request = ActionDispatch::Request.new(env)
  origin = request.headers['Origin'] || request.headers['Referer']
  # Validates that origin matches the app's host
end

This is secure because browsers prevent spoofing the Origin header.

Session Management

# config/routes.rb
devise_for :users, skip: [:registrations]

# Authenticated root
authenticated :user do
  root to: "alumni#search", as: :authenticated_root
end

# Unauthenticated root (login page)
devise_scope :user do
  unauthenticated do
    root to: "devise/sessions#new", as: :unauthenticated_root
  end
end

Custom Authentication Helper

# app/controllers/application_controller.rb
def authenticate_user!
  redirect_to unauthenticated_root_path, alert: "You must be logged in to access this page." unless current_user
end

User Model

Database Schema

# db/schema.rb
create_table "users" do |t|
  t.string "name"
  t.string "email"
  t.datetime "last_login"
  t.datetime "datetime_added"
  t.boolean "admin"                 # Legacy admin flag (still checked for compatibility)
  t.string "role"                   # Role enum: 'staff' (default) or 'admin'
  t.string "encrypted_password"
  t.string "reset_password_token"
  t.datetime "reset_password_sent_at"
  t.datetime "remember_created_at"
  t.string "otp"                    # One-time password (if used)
  t.datetime "otp_sent_at"
  t.timestamps
end

Role Enum

Users have a role field with two possible values:

Model Definition

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable, omniauth_providers: [:google_oauth2]
  
  enum :role, { staff: 'staff', admin: 'admin' }, default: :staff

  before_validation :set_temporary_password, on: :create

  # Default admin to false if not set
  after_initialize do
    self.admin ||= false
  end

  has_many :assigned_alumni, class_name: "Alumni", foreign_key: "primary_contact_id"

  private

  def set_temporary_password
    self.password = SecureRandom.alphanumeric(10) if password.blank?
  end
end

Authorization Helpers

# app/models/user.rb

# Admin check - returns true if user has admin privileges.
# Checks role enum first, falls back to legacy admin boolean for compatibility.
def admin?
  role == 'admin' || read_attribute(:admin) == true
end

# Staff check - returns true for any authenticated internal user.
# Admins are also considered staff (can do everything staff can do).
def staff?
  role.in?(%w[staff admin])
end

Authorization Patterns

Pattern 1: Standardized Admin Check (All Admin-Only Controllers)

All admin-only controllers use the centralized ensure_admin! method from ApplicationController:

# 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

Usage in controllers:

class Settings::SettingsController < ApplicationController
  before_action :authenticate_user!
  before_action :ensure_admin!
  # ...
end

Pattern 2: UsersController Admin Check

UsersController uses a slightly different pattern for redirecting non-admin users to their profile:

class UsersController < ApplicationController
  before_action :authenticate_user!
  before_action :authorize_admin!, except: [:edit, :update]

  private

  def authorize_admin!
    redirect_to edit_profile_path, alert: "Access denied." unless current_user.admin?
  end
end

Pattern 3: Profile Access (Self or Admin)

# app/controllers/users_controller.rb
def authorize_profile_access!
  @user = params[:id] ? User.find(params[:id]) : current_user
  redirect_to root_path, alert: "Access denied." unless current_user == @user || current_user.admin?
end

Controller Authentication Requirements

Controllers Requiring Authentication

Controller Auth Method Admin Required
AlumniController authenticate_user! No
AlumniAffinitiesController authenticate_user! No
BatchSearchController (none explicit)* No
ChampionSignupsController authenticate_user! No
EngagementActivitiesController authenticate_user! No
EngagementStatsController (none explicit)* No
PeopleController ensure_admin! Yes
StatisticsController authenticate_user! No
UsersController authenticate_user! Most actions

*Controllers without explicit before_action may still require auth via routes or Devise helpers.

Settings Namespace (All Admin-Only)

All Settings controllers use ensure_admin! from ApplicationController:

Controller Auth Method
Settings::SettingsController ensure_admin!
Settings::AffinitiesController ensure_admin!
Settings::AlumniController ensure_admin!
Settings::CollegesController ensure_admin!
Settings::EngagementActivitiesController ensure_admin!
Settings::MajorsController ensure_admin!

API Namespace

Controller Auth Required
Api::AffinitiesController No (internal use)
Api::AlumniController No (internal use)
Api::MajorsController No (internal use)
Api::ActivityDescriptionsController No (internal use)

Note: API endpoints are intended for internal JavaScript consumption and are not publicly documented. They should have authentication added if exposed externally.


Public Endpoints

Champions Subdomain (External)

The champions subdomain is intentionally public to allow alumni to sign up without authentication:

# app/controllers/champions/champion_signups_controller.rb
class Champions::ChampionSignupsController < ApplicationController
  layout "public"
  # No authenticate_user! callback - intentionally public
end
Endpoint Method Purpose
/ GET Signup wizard start
/signups POST Process signup steps
/signups/:id GET Confirmation page

Health Check

get "up" => "rails/health#show"  # Standard Rails health check

Future Role System (Conceptual)

Note: This section outlines planned changes. No implementation exists yet.

Two-Portal Role Architecture

The application uses separate role hierarchies for each portal:

┌─────────────────────────────────────────────────────────────────────────┐
│                    Two-Portal Role Architecture                          │
├─────────────────────────────────────────────────────────────────────────┤
│  LOOKUP PORTAL (lookup.bualum.co) - Internal Staff                      │
│  ├─ Admin       │ Full system access, settings, user management         │
│  └─ Staff       │ Internal lookup tools, no settings                    │
├─────────────────────────────────────────────────────────────────────────┤
│  CHAMPION PORTAL (champions.bualum.co) - External Alumni                │
│  ├─ CLC         │ Regional champion coordination (admin for champions)  │
│  └─ Champion    │ External portal access only                           │
└─────────────────────────────────────────────────────────────────────────┘

Key Design Principle: CLC is to Champion as Admin is to Staff. Each portal has its own role hierarchy.

Proposed Permission Matrix

Lookup Portal (Internal):

Feature Admin Staff
Alumni Search
Engagement Stats
Champion Signup Management
Settings
User Management
Data Imports

Champion Portal (External):

Feature CLC Champion
Champion Dashboard
Own Profile Management
Event RSVP
Regional Champion List
Regional Stats
Champion Management

Implementation Options

# Mixing internal and external users in one model
class User < ApplicationRecord
  enum role: {
    champion: 0,
    city_leader: 1,
    staff: 2,
    admin: 3
  }
end

Pros: Simple, single user table Cons: Mixes internal and external users, complex permission logic

# Internal staff users (Lookup Portal)
class User < ApplicationRecord
  devise :database_authenticatable, :recoverable, :rememberable, :validatable
  
  enum role: { staff: 0, admin: 1 }
end

# External champion users (Champion Portal)
class Champion < ApplicationRecord
  devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
  
  belongs_to :alumni, primary_key: :buid, foreign_key: :buid, optional: true
  
  enum role: { champion: 0, city_leader: 1 }
end

Pros: Clean separation, different auth flows, clear boundary between portals Cons: More complex, two authentication systems

Option C: Role-Based with Pundit

# Use Pundit for policy-based authorization
class AlumniPolicy < ApplicationPolicy
  def search?
    user.staff? || user.super_admin?
  end
  
  def show?
    user.staff? || user.super_admin?
  end
end

Pros: Flexible, testable policies Cons: Adds gem dependency, learning curve

For the alumni_lookup application, Option B (Separate Champion Model) is recommended because:

  1. Clear separation between internal staff and external champions
  2. Different authentication flows - staff via SSO (future), champions via email
  3. Simpler security model - external users can’t accidentally access internal tools
  4. Alumni linking - Champions can be linked to their Alumni record

Migration Path

  1. Phase 1: Add Champion model with basic authentication
  2. Phase 2: Implement champion portal with profile management
  3. Phase 3: Add CityLeader role as Champion subtype or separate model
  4. Phase 4: Consider Pundit for fine-grained internal permissions


Last updated: December 1, 2025