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.
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.
admin? and access_level == 1 patternsadmin / staff rolesevent_checkin| 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 |
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
| 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) |
CpChampion model with separate Devise authenticationcan_event_checkin, can_event_manage flags (patterns ready, flags added when feature is built)EventPolicy, RegistrantPolicy Pundit policiesGoal: Consolidate admin? and access_level == 1 into a single, consistent pattern.
Duration: 2-4 hours
Completed: November 30, 2025
admin? method to User model - Uses admin boolean as single source of truthensure_admin! to ApplicationController - Standardized authorization callbackensure_admin!:
PeopleControllerSettings::SettingsControllerSettings::MajorsControllerSettings::CollegesControllerSettings::AffinitiesControllerSettings::AlumniControllerSettings::EngagementActivitiesControlleraccess_level column - Migration 20251130233249_remove_access_level_from_users.rbadmin? method (3 tests)# Some controllers use this:
current_user.admin?
# Others use this:
current_user&.access_level == 1
Both mean “admin” but the inconsistency creates maintenance burden.
# 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
admin: true is adminadmin: false is not adminadmin: nil is not adminGoal: Add role enum to User model without changing behavior yet.
Duration: 4-6 hours
Completed: November 30, 2025
20251130234911_add_role_to_users.rb
role column (string, default: ‘staff’, not null)roleenum :role, { staff: 'staff', admin: 'admin' }admin? to check role OR legacy admin booleanstaff? method (returns true for both staff and admin)sync_role_with_admin_flag callback for consistencyrole parameter# 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
admin? returns true for role: ‘admin’admin? returns true for legacy admin: true (backwards compat)staff? returns true for both staff and admin rolesGoal: Differentiate what Staff can do vs what only Admins can do.
Duration: 6-10 hours
Completed: November 30, 2025
ensure_staff! method to ApplicationController for authenticated user checksensure_staff! for staff-accessible features:
AlumniController - staff can search, view, update alumniAlumniAffinitiesController - staff can manage affinitiesBatchSearchController - staff can use batch searchStatisticsController - staff can view statisticsEngagementStatsController - staff can view; admin-only clear_cacheEngagementActivitiesController - staff can view activitiesChampionSignupsController - staff can manage; admin-only destroy, merge_duplicatesensure_admin!:
Settings::SettingsControllerSettings::MajorsControllerSettings::CollegesControllerSettings::AffinitiesControllerSettings::AlumniControllerSettings::EngagementActivitiesControllerauthorize_profile_access!)test/controllers/staff_access_test.rb with 18 comprehensive tests| 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 | ❌ | ✅ |
# 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
Goal: Enable users to reset their own passwords via email.
Duration: 4-6 hours
Completed: November 30, 2025
lookup.bualum.co (internal portal)champions.bualum.co via instance method (not class-level)Rails.env.production? checkdefault_url_optionsplease-change-me@example.com to Alumni Lookup <noreply@email.bualum.co>MailgunDeliveryMethod class in config/initializers/mailgun_delivery.rb:mailgun_api delivery methodapp/views/devise/passwords/new.html.erb - Matches login page stylingapp/views/devise/passwords/edit.html.erb - Clean, modern designapp/views/devise/mailer/reset_password_instructions.html.erbtest/integration/password_reset_test.rb - 11 tests covering:
config/environments/production.rb - Updated default_url_options, set delivery_method to :mailgun_apiconfig/environments/test.rb - Added default_url_optionsconfig/initializers/devise.rb - Fixed mailer_sender to use Mailgun domainconfig/initializers/mailgun_delivery.rb - Custom Mailgun API delivery method (NEW)app/mailers/champion_signup_mailer.rb - Production host via instance method overrideapp/views/devise/passwords/new.html.erb - Tailwind stylingapp/views/devise/passwords/edit.html.erb - Tailwind stylingapp/views/devise/mailer/reset_password_instructions.html.erb - Branded emailapp/views/devise/sessions/new.html.erb - Added forgot password linkGoal: Allow staff to link their Google account for convenient sign-in (after admin creates their account).
Duration: 8-12 hours
Completed: December 1, 2025
Why not self-registration with Google?
Flow:
Why Google?
@belmont.edu emails# Gemfile
gem 'omniauth-google-oauth2', '~> 1.2'
gem 'omniauth-rails_csrf_protection', '~> 2.0'
# 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
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
app/controllers/users/omniauth_callbacks_controller.rb:
# 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
app/views/devise/sessions/new.html.erb:
app/views/users/_form.html.erb:
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]
UsersController:
except: list for authorize_admin!https://lookup.bualum.co/users/auth/google_oauth2/callback
heroku config:set GOOGLE_CLIENT_ID=your-client-id
heroku config:set GOOGLE_CLIENT_SECRET=your-client-secret
| 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) |
Created test/integration/google_sso_test.rb with 13 tests:
google_connected? returns true when google_uid presentgoogle_connected? returns false when no google_uidunlink_google! clears google_uid and google_linked_atfrom_google_oauth finds existing user and links Googlefrom_google_oauth returns nil for non-existent emailfrom_google_oauth does not overwrite existing google_uidgoogle_connected? methodAll 298 tests pass (13 new Google SSO tests).
Gemfile — Added omniauth-google-oauth2, omniauth-rails_csrf_protectiondb/migrate/20251201225113_add_google_uid_to_users.rb — New migrationconfig/initializers/devise.rb — Added Google OAuth2 configurationapp/models/user.rb — Added :omniauthable, SSO methodsapp/controllers/users/omniauth_callbacks_controller.rb — NEWapp/controllers/users_controller.rb — Added unlink_google actionconfig/routes.rb — OmniAuth callbacks, unlink route (before resources :users)app/views/devise/sessions/new.html.erb — Google sign-in buttonapp/views/users/_form.html.erb — Google link/unlink sectiontest/integration/google_sso_test.rb — NEW (13 tests)During production testing, several issues were discovered and fixed:
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.
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.
Session Store Configuration — Updated config/initializers/session_store.rb with same_site: :lax and secure: true (production) for proper OAuth cookie handling.
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
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) |
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.
This section documents the decision-making process around Role-Based Access Control (RBAC) architecture for future reference.
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.
For the current scale of the Lookup Portal, full RBAC adds complexity without sufficient benefit:
staff and admin are the only roles needed for the Lookup Portalcan_event_checkin) are few and well-definedchampion, city_leader) live on a separate CpChampion model, not UserThe overhead of maintaining Role, Permission, RolePermission, and UserRole tables (plus the admin UI to manage them) is not justified for this use case.
The chosen approach combines:
role column with staff and admin valuescan_event_checkin, can_event_manage added as neededensure_staff!, ensure_admin!) and Pundit policiesThis provides clear role separation while keeping the implementation simple and maintainable.
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:
Consider migrating to full database RBAC when any of these triggers occur:
| 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.
These features are documented here for context but will be implemented as part of Champion Portal:
# 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.
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
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.
| 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/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"
| Document | Status |
|---|---|
AUTHENTICATION.md |
✅ Updated with SSO, password reset, role helpers |
PERMISSIONS_MATRIX.md |
✅ Roles marked as enforced |
CHANGELOG.md |
✅ All phases documented |
| 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 |
Champion Portal can now:
champion / city_leader rolesEvent Check-in can now:
can_event_checkin / can_event_manage permission flagsProject completed: December 1, 2025