Skip to content

Commit 29dca00

Browse files
committed
Pundit Policies
Closes #70
1 parent 633fdf5 commit 29dca00

51 files changed

Lines changed: 1810 additions & 11 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ A Rails 8 API backend that syncs college course schedules to Google Calendar wit
6767
- Audit logging with Audits1984 and Console1984
6868
- Admin access via Google OAuth + access_level enum (user/admin/super_admin/owner)
6969

70+
**Authorization System (Pundit)**
71+
- Role-based access control via User `access_level`: user/admin/super_admin/owner
72+
- **user** (0): Can manage their own resources only
73+
- **admin** (1): VIEW all resources for support, manage public data (courses/faculty), NO destructive actions
74+
- **super_admin** (2): View AND modify all resources, perform destructive actions, access feature flags. **Cannot delete owners**
75+
- **owner** (3): Full access including managing other admins
76+
- Three policy categories: User-Owned (users manage their own), Public-Read (everyone reads, admins manage), Admin-Only (admins view only)
77+
- All controllers use `authorize @resource` to check permissions via policies in `app/policies/`
78+
- See `docs/authorization.md` for complete documentation
79+
7080
### Domain Models
7181

7282
**User & Authentication**
@@ -119,10 +129,11 @@ A Rails 8 API backend that syncs college course schedules to Google Calendar wit
119129
2. **Always annotate after route changes**: Run `bundle exec annotaterb routes` after modifying routes
120130
3. **Prefer background jobs**: Use ActiveJob for blocking operations (Google Calendar API calls, web scraping, etc.)
121131
4. **Full test coverage required**: Write RSpec tests for all new code
122-
5. **Respect user edits**: The calendar sync intelligently detects and preserves user modifications in Google Calendar
123-
6. **Template validation**: Calendar preference templates are validated; invalid syntax is rejected
124-
7. **OAuth token encryption**: All sensitive tokens are encrypted with Lockbox
125-
8. **Multi-email support**: Design features to support users with multiple connected Google accounts
132+
5. **Use Pundit authorization**: All controller actions must use `authorize @resource` to check permissions
133+
6. **Respect user edits**: The calendar sync intelligently detects and preserves user modifications in Google Calendar
134+
7. **Template validation**: Calendar preference templates are validated; invalid syntax is rejected
135+
8. **OAuth token encryption**: All sensitive tokens are encrypted with Lockbox
136+
9. **Multi-email support**: Design features to support users with multiple connected Google accounts
126137

127138
## Common Development Workflows
128139

@@ -145,8 +156,17 @@ A Rails 8 API backend that syncs college course schedules to Google Calendar wit
145156
3. Add validation tests
146157
4. Update preview endpoint tests
147158

159+
### Adding Authorization to Controllers
160+
1. Add `authorize @resource` after finding/creating records
161+
2. Use `policy_scope(Model)` to filter collections by user permissions
162+
3. Create corresponding policy in `app/policies/` if it doesn't exist
163+
4. Write RSpec tests in `spec/policies/` to verify permissions
164+
5. See `docs/authorization.md` for policy patterns
165+
148166
### Running Tests for Specific Features
167+
- Authorization policies: `bundle exec rspec spec/policies/`
149168
- Calendar preferences: `bundle exec rspec spec/models/calendar_preference_spec.rb`
150169
- Template rendering: `bundle exec rspec spec/services/calendar_template_renderer_spec.rb`
151170
- Preference resolution: `bundle exec rspec spec/services/preference_resolver_spec.rb`
152171
- Google Calendar sync: `bundle exec rspec spec/services/google_calendar_service_spec.rb`
172+
- always make and follow pundit policies

app/controllers/admin/calendars_controller.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module Admin
44
class CalendarsController < Admin::BaseController
55
def index
66
# Get calendars from the database with associations
7-
@calendars = GoogleCalendar
7+
@calendars = policy_scope(GoogleCalendar)
88
.includes(:oauth_credential, :user)
99
.left_joins(:google_calendar_events)
1010
.select("google_calendars.*, MAX(google_calendar_events.updated_at) as max_event_updated_at")
@@ -24,6 +24,8 @@ def index
2424

2525
def destroy
2626
calendar = GoogleCalendar.find(params[:id])
27+
authorize calendar
28+
2729
# Enqueue job to delete calendar from Google
2830
GoogleCalendarDeleteJob.perform_later(calendar.google_calendar_id)
2931
# Delete the database record

app/controllers/admin/courses_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module Admin
44
class CoursesController < Admin::ApplicationController
55
def index
6-
@courses = Course.includes(:term, :faculty).order(created_at: :desc).page(params[:page])
6+
@courses = policy_scope(Course).includes(:term, :faculty).order(created_at: :desc).page(params[:page])
77
end
88
end
99
end

app/controllers/admin/faculties_controller.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
module Admin
44
class FacultiesController < Admin::ApplicationController
55
def index
6-
@faculties = Faculty.order(:last_name, :first_name).page(params[:page])
6+
@faculties = policy_scope(Faculty).order(:last_name, :first_name).page(params[:page])
77
end
88

99
def missing_rmp_ids
10-
@faculties = Faculty.where(rmp_id: nil).order(:last_name, :first_name).page(params[:page])
10+
@faculties = policy_scope(Faculty).where(rmp_id: nil).order(:last_name, :first_name).page(params[:page])
1111
end
1212

1313
def search_rmp
1414
@faculty = Faculty.find(params[:id])
15+
authorize @faculty
1516
service = RateMyProfessorService.new
1617

1718
search_result = service.search_professors(@faculty.full_name, count: 10)
@@ -30,6 +31,8 @@ def search_rmp
3031

3132
def assign_rmp_id
3233
@faculty = Faculty.find(params[:id])
34+
authorize @faculty
35+
3336
rmp_id = params[:rmp_id]
3437

3538
if rmp_id.blank?

app/controllers/admin/users_controller.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class UsersController < Admin::ApplicationController
77
BETA_FEATURE_FLAG = FlipperFlags::V1
88

99
def index
10-
@users = User.order(created_at: :desc)
10+
@users = policy_scope(User).order(created_at: :desc)
1111

1212
if params[:search].present?
1313
@users = @users.where("email ILIKE ?", "%#{params[:search]}%")
@@ -21,6 +21,8 @@ def index
2121
end
2222

2323
def show
24+
authorize @user
25+
2426
# Eager load enrollments with their associations for the view
2527
@enrollments_by_term = @user.enrollments
2628
.includes(:course, :term)
@@ -38,9 +40,12 @@ def show
3840
end
3941

4042
def edit
43+
authorize @user
4144
end
4245

4346
def update
47+
authorize @user
48+
4449
if @user.update(user_params)
4550
redirect_to admin_user_path(@user), notice: "User was successfully updated."
4651
else
@@ -49,12 +54,15 @@ def update
4954
end
5055

5156
def destroy
57+
authorize @user
58+
5259
@user.destroy
5360
redirect_to admin_users_path, notice: "User was successfully deleted."
5461
end
5562

5663
def revoke_oauth_credential
5764
credential = @user.oauth_credentials.find(params[:credential_id])
65+
authorize credential
5866
credential_email = credential.email
5967

6068
# Queue the revocation job
@@ -66,6 +74,8 @@ def revoke_oauth_credential
6674
end
6775

6876
def enable_beta
77+
authorize @user, :update? # Beta management requires update permission
78+
6979
Flipper.enable_actor(BETA_FEATURE_FLAG, @user)
7080
redirect_to admin_user_path(@user), notice: "#{@user.email} has been added to the beta test group."
7181
rescue => e
@@ -74,6 +84,8 @@ def enable_beta
7484
end
7585

7686
def disable_beta
87+
authorize @user, :update? # Beta management requires update permission
88+
7789
Flipper.disable_actor(BETA_FEATURE_FLAG, @user)
7890
redirect_to admin_user_path(@user), notice: "Beta tester access removed for #{@user.email}."
7991
rescue => e

app/controllers/api/api_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,13 @@ class ApiController < ApplicationController
99

1010
skip_before_action :verify_authenticity_token
1111

12+
# Handle Pundit authorization errors with JSON response
13+
rescue_from Pundit::NotAuthorizedError, with: :pundit_not_authorized
14+
15+
private
16+
17+
def pundit_not_authorized
18+
render json: { error: "You are not authorized to perform this action." }, status: :forbidden
19+
end
1220
end
1321
end

app/controllers/api/calendar_preferences_controller.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ class CalendarPreferencesController < ApiController
66

77
# GET /api/calendar_preferences
88
def index
9-
global_pref = current_user.calendar_preferences.find_by(scope: :global)
10-
event_type_prefs = current_user.calendar_preferences.where(scope: :event_type)
9+
preferences = policy_scope(current_user.calendar_preferences)
10+
global_pref = preferences.find_by(scope: :global)
11+
event_type_prefs = preferences.where(scope: :event_type)
1112

1213
render json: {
1314
global: global_pref ? preference_json(global_pref) : nil,
@@ -18,12 +19,14 @@ def index
1819
# GET /api/calendar_preferences/global
1920
# GET /api/calendar_preferences/:event_type
2021
def show
22+
authorize @calendar_preference
2123
render json: preference_json(@calendar_preference)
2224
end
2325

2426
# PUT /api/calendar_preferences/global
2527
# PUT /api/calendar_preferences/:event_type
2628
def update
29+
authorize @calendar_preference
2730
if @calendar_preference.update(calendar_preference_params)
2831
render json: preference_json(@calendar_preference)
2932
else
@@ -33,6 +36,7 @@ def update
3336

3437
# DELETE /api/calendar_preferences/:event_type
3538
def destroy
39+
authorize @calendar_preference
3640
@calendar_preference.destroy
3741
head :no_content
3842
end

app/controllers/api/event_preferences_controller.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ def show
1212
preferenceable: @preferenceable
1313
)
1414

15+
# Authorize either the existing preference or create a new one to check authorization
16+
authorize preference || EventPreference.new(user: current_user, preferenceable: @preferenceable)
17+
1518
# Get resolved preferences with sources
1619
resolver = PreferenceResolver.new(current_user)
1720
resolved_data = resolver.resolve_with_sources(@preferenceable)
@@ -40,6 +43,8 @@ def update
4043
preferenceable: @preferenceable
4144
)
4245

46+
authorize preference
47+
4348
if preference.update(event_preference_params)
4449
render json: event_preference_json(preference)
4550
else
@@ -56,6 +61,7 @@ def destroy
5661
)
5762

5863
if preference
64+
authorize preference
5965
preference.destroy
6066
head :no_content
6167
else

app/controllers/api/user_extension_config_controller.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module Api
44
class UserExtensionConfigController < ApiController
55
def set
66
config = UserExtensionConfig.find_or_initialize_by(user_id: current_user.id)
7+
authorize config
78

89
config.military_time = params[:military_time] unless params[:military_time].nil?
910
config.default_color_lecture = params[:default_color_lecture] unless params[:default_color_lecture].nil?
@@ -29,6 +30,8 @@ def get
2930
return
3031
end
3132

33+
authorize config
34+
3235
render json: {
3336
military_time: config.military_time,
3437
default_color_lecture: config.default_color_lecture,

app/controllers/api/users_controller.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def onboard
3232
end
3333

3434
def is_processed
35+
authorize current_user
36+
3537
term_uid = params[:term_uid]
3638

3739
if term_uid.blank?
@@ -51,6 +53,7 @@ def is_processed
5153
end
5254

5355
def get_email
56+
authorize current_user
5457

5558
email = Email.where(user_id: current_user.id, primary: true).pick(:email)
5659

@@ -68,6 +71,7 @@ def add_email_to_g_cal
6871
email = email.to_s.strip
6972
# Create/update Email record with g_cal = true
7073
email_record = current_user.emails.find_or_initialize_by(email: email)
74+
authorize email_record
7175
email_record.g_cal = true
7276
email_record.save!
7377

@@ -111,6 +115,8 @@ def remove_email_from_g_cal
111115
return
112116
end
113117

118+
authorize email_record
119+
114120
# Check if user has a calendar to remove access from
115121
calendar_id = current_user.google_course_calendar_id
116122
if calendar_id.blank?
@@ -144,6 +150,7 @@ def request_g_cal
144150

145151
# Create/update Email record with g_cal = true
146152
email_record = current_user.emails.find_or_initialize_by(email: email)
153+
authorize email_record
147154
email_record.g_cal = true
148155
email_record.save!
149156

@@ -267,12 +274,16 @@ def group_meeting_times(meeting_times)
267274
end
268275

269276
def get_ics_url
277+
authorize current_user
278+
270279
render json: {
271280
ics_url: current_user.cal_url_with_extension
272281
}
273282
end
274283

275284
def get_processed_events_by_term
285+
authorize current_user
286+
276287
term_uid = params[:term_uid]
277288

278289
if term_uid.blank?

0 commit comments

Comments
 (0)