Updated: December 1, 2025
Purpose: Document the current authentication system, authorization patterns, and future role-based access plans.
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.
Staff can optionally link their Google account for convenient sign-in. This is an optional convenience feature — email/password authentication always works.
| 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 |
# SSO-related columns
t.string :google_uid # Google unique identifier
t.datetime :google_linked_at # When Google was linked
# 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
https://lookup.bualum.co/users/auth/google_oauth2/callbackheroku 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.
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.
# 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
# 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
# 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
Users have a role field with two possible values:
# 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
# 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
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
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
# 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 | 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.
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! |
| 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.
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 |
get "up" => "rails/health#show" # Standard Rails health check
Note: This section outlines planned changes. No implementation exists yet.
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.
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 | ✅ | ❌ |
# 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
# 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:
Last updated: December 1, 2025