Skip to content

Commit 4397650

Browse files
committed
Merge branch 'main' into cycomachead/141-implement-mass-assignment-idor-fixes/1
* main: (25 commits) chore: autocorrect RSpec/MatchWithSimpleRegex offenses chore: use Rails.env.local? for developer provider gate refactor: simplify developer login gate and trim comments fix: prevent account takeover by splitting user lookup paths refactor: remove redundant TokenRefreshable from SessionController feat: update assignments table styling and sync functionality feat: extend user sessions via token refresh and persistent cookies tweak postion of the page size dropdown style(test): fix rubocop layout offenses in staging interceptor spec feat: implement email interceptor for staging environment Bump json from 2.19.5 to 2.20.0 Bump rubocop-rspec from 3.9.0 to 3.10.2 Bump sentry-rails from 6.5.0 to 6.6.2 Bump axe-core-rspec from 4.11.3 to 4.12.0 Bump brakeman from 8.0.4 to 8.0.5 feat: update pagination and page size options for pending requests Use 3.3.10 locally and add safety assured to migration refactor: trust top-level base dates, drop all_dates parsing refactor: simplify Canvas base-date handling and document the API fixup spec and includes call ...
2 parents 935799f + e253007 commit 4397650

39 files changed

Lines changed: 1575 additions & 311 deletions

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
ruby 3.3
1+
ruby 3.3.10

Gemfile.lock

Lines changed: 18 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -80,24 +80,12 @@ GEM
8080
activerecord (>= 6.0.0)
8181
activesupport (>= 6.0.0)
8282
ast (2.4.3)
83-
axe-core-api (4.11.3)
83+
axe-core-api (4.12.0)
8484
dumb_delegator
85-
ostruct
86-
virtus
87-
axe-core-cucumber (4.11.3)
88-
axe-core-api (= 4.11.3)
89-
dumb_delegator
90-
ostruct
91-
virtus
92-
axe-core-rspec (4.11.3)
93-
axe-core-api (= 4.11.3)
94-
dumb_delegator
95-
ostruct
96-
virtus
97-
axiom-types (0.1.1)
98-
descendants_tracker (~> 0.0.4)
99-
ice_nine (~> 0.11.0)
100-
thread_safe (~> 0.3, >= 0.3.1)
85+
axe-core-cucumber (4.12.0)
86+
axe-core-api (= 4.12.0)
87+
axe-core-rspec (4.12.0)
88+
axe-core-api (= 4.12.0)
10189
base64 (0.3.0)
10290
benchmark (0.5.0)
10391
bigdecimal (4.1.2)
@@ -112,7 +100,7 @@ GEM
112100
msgpack (~> 1.2)
113101
bootstrap (5.3.8)
114102
popper_js (>= 2.11.8, < 3)
115-
brakeman (8.0.4)
103+
brakeman (8.0.5)
116104
racc
117105
builder (3.3.0)
118106
capybara (3.40.0)
@@ -127,15 +115,13 @@ GEM
127115
capybara-screenshot (1.0.27)
128116
capybara (>= 1.0, < 4)
129117
launchy
130-
cgi (0.5.1)
118+
cgi (0.5.2)
131119
chartkick (5.2.1)
132120
childprocess (5.1.0)
133121
logger (~> 1.5)
134122
codeclimate-test-reporter (1.0.7)
135123
simplecov
136124
coderay (1.1.3)
137-
coercible (1.0.0)
138-
descendants_tracker (~> 0.0.1)
139125
concurrent-ruby (1.3.7)
140126
connection_pool (3.0.2)
141127
crack (1.0.1)
@@ -180,8 +166,6 @@ GEM
180166
debug (1.11.1)
181167
irb (~> 1.10)
182168
reline (>= 0.3.8)
183-
descendants_tracker (0.0.4)
184-
thread_safe (~> 0.3, >= 0.3.1)
185169
diff-lcs (1.6.2)
186170
docile (1.4.1)
187171
domain_name (0.6.20240107)
@@ -247,9 +231,8 @@ GEM
247231
multi_xml (>= 0.5.2)
248232
hypershield (0.6.1)
249233
activerecord (>= 7.2)
250-
i18n (1.14.8)
234+
i18n (1.15.2)
251235
concurrent-ruby (~> 1.0)
252-
ice_nine (0.11.2)
253236
importmap-rails (2.2.3)
254237
actionpack (>= 6.0.0)
255238
activesupport (>= 6.0.0)
@@ -263,7 +246,7 @@ GEM
263246
jbuilder (2.15.1)
264247
actionview (>= 7.0.0)
265248
activesupport (>= 7.0.0)
266-
json (2.19.5)
249+
json (2.20.0)
267250
jwt (3.2.0)
268251
base64
269252
language_server-protocol (3.17.0.5)
@@ -372,15 +355,15 @@ GEM
372355
pg (1.6.3-x86_64-darwin)
373356
pg (1.6.3-x86_64-linux)
374357
popper_js (2.11.8)
375-
pp (0.6.3)
358+
pp (0.6.4)
376359
prettyprint
377360
prettyprint (0.2.0)
378361
prism (1.9.0)
379362
pry (0.16.0)
380363
coderay (~> 1.1)
381364
method_source (~> 1.0)
382365
reline (>= 0.6.0)
383-
psych (5.3.1)
366+
psych (5.4.0)
384367
date
385368
stringio
386369
public_suffix (7.0.5)
@@ -503,9 +486,10 @@ GEM
503486
rubocop (>= 1.72)
504487
rubocop-performance (>= 1.24)
505488
rubocop-rails (>= 2.30)
506-
rubocop-rspec (3.9.0)
489+
rubocop-rspec (3.10.2)
507490
lint_roller (~> 1.1)
508-
rubocop (~> 1.81)
491+
regexp_parser (>= 2.0)
492+
rubocop (~> 1.86, >= 1.86.2)
509493
ruby-progressbar (1.13.0)
510494
rubyzip (3.3.0)
511495
safely_block (1.0.0)
@@ -524,10 +508,10 @@ GEM
524508
rexml (~> 3.2, >= 3.2.5)
525509
rubyzip (>= 1.2.2, < 4.0)
526510
websocket (~> 1.0)
527-
sentry-rails (6.5.0)
511+
sentry-rails (6.6.2)
528512
railties (>= 5.2.0)
529-
sentry-ruby (~> 6.5.0)
530-
sentry-ruby (6.5.0)
513+
sentry-ruby (~> 6.6.2)
514+
sentry-ruby (6.6.2)
531515
bigdecimal
532516
concurrent-ruby (~> 1.0, >= 1.0.2)
533517
logger
@@ -564,7 +548,6 @@ GEM
564548
memoist3 (~> 1.0.0)
565549
win32ole
566550
thor (1.5.0)
567-
thread_safe (0.3.6)
568551
tilt (2.7.0)
569552
timecop (0.9.11)
570553
timeout (0.6.1)
@@ -582,10 +565,6 @@ GEM
582565
uri (1.1.1)
583566
useragent (0.16.11)
584567
version_gem (1.1.9)
585-
virtus (2.0.0)
586-
axiom-types (~> 0.1)
587-
coercible (~> 1.0)
588-
descendants_tracker (~> 0.0, >= 0.0.3)
589568
web-console (4.2.1)
590569
actionview (>= 6.0.0)
591570
activemodel (>= 6.0.0)
@@ -603,7 +582,7 @@ GEM
603582
win32ole (1.9.3)
604583
xpath (3.2.0)
605584
nokogiri (~> 1.8)
606-
zeitwerk (2.7.5)
585+
zeitwerk (2.8.2)
607586

608587
PLATFORMS
609588
aarch64-linux

app/controllers/api/v1/requests_controller.rb

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,38 @@ def index
99

1010
def create
1111
find_extension_params
12-
# Get External Assignment object to find initial due date
13-
assignment_response = @canvas_facade.get_assignment(
14-
@course_to_lms.external_course_id.to_i,
15-
@assignment.external_assignment_id.to_i
16-
)
17-
if assignment_response.status != 200
18-
render json: assignment_response.to_json, status: :internal_server_error
12+
course_id = @course_to_lms.external_course_id.to_i
13+
assignment_id = @assignment.external_assignment_id.to_i
14+
15+
# Query the base ("Everyone") dates. get_base_dates reads the assignment
16+
# with override_assignment_dates=false, whose top-level dates are the
17+
# base dates for any number of overrides. See docs/Canvas_Dates_API.md.
18+
base_dates = @canvas_facade.get_base_dates(course_id, assignment_id)
19+
if base_dates.nil?
20+
render json: { error: 'Could not fetch assignment dates from Canvas' }.to_json,
21+
status: :internal_server_error
1922
return
2023
end
21-
assignment_json = JSON.parse(assignment_response.body)
2224

2325
# Provision Extension
24-
response = @canvas_facade.provision_extension(
25-
@course_to_lms.external_course_id.to_i,
26-
params[:student_uid].to_i,
27-
@assignment.external_assignment_id.to_i,
28-
params[:new_due_date]
29-
)
30-
unless response.success?
31-
render json: response.body, status: response.status
26+
begin
27+
override = @canvas_facade.provision_extension(
28+
course_id,
29+
params[:student_uid].to_i,
30+
assignment_id,
31+
params[:new_due_date]
32+
)
33+
rescue CanvasFacade::CanvasAPIError, FailedPipelineError, NotFoundError => e
34+
render json: { error: e.message }.to_json, status: :internal_server_error
3235
return
3336
end
34-
assignment_override = JSON.parse(response.body)
3537

3638
@extension = Extension.new(
3739
assignment_id: @assignment.id,
3840
student_email: nil,
39-
initial_due_date: assignment_json['due_at'],
40-
new_due_date: assignment_override['due_at'],
41-
external_extension_id: assignment_override['id'],
41+
initial_due_date: base_dates['due_at'],
42+
new_due_date: override.override_due_date || params[:new_due_date],
43+
external_extension_id: override.id,
4244
last_processed_by_id: nil
4345
)
4446
unless @extension.save

app/controllers/application_controller.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
class ApplicationController < ActionController::Base
2+
include TokenRefreshable
3+
24
before_action :authenticated!, unless: -> { excluded_controller_action? }
35

46
rescue_from LmsFacade::LmsAPIError, with: :handle_lms_api_error
@@ -54,7 +56,11 @@ def authenticated!
5456
elsif current_user.lms_credentials.empty?
5557
return handle_authentication_failure('User has no credentials.')
5658
elsif current_user.lms_credentials.first.expire_time < Time.zone.now
57-
return handle_authentication_failure('You have been logged out.')
59+
# The Canvas access token has expired. Rather than logging the user out
60+
# immediately, try to use the stored (long-lived) refresh token to obtain
61+
# a fresh access token so the session can continue. We only log the user
62+
# out when there is no refresh token or the refresh actually fails.
63+
return handle_authentication_failure('You have been logged out.') unless refresh_user_token(current_user)
5864
end
5965
end
6066
true

app/controllers/course_settings_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def course_settings_params
6464
:auto_approve_days,
6565
:auto_approve_extended_request_days,
6666
:max_auto_approve,
67+
:enable_min_hours_before_deadline,
68+
:min_hours_before_deadline,
6769
:enable_gradescope,
6870
:gradescope_course_url,
6971
:extend_late_due_date,

app/controllers/courses_controller.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def show
3535
@assignments = @course.enabled_assignments
3636
else
3737
@assignments = @course.assignments
38+
@assignments_last_synced_at = assignments_last_synced_at
3839
end
3940
render_role_based_view
4041
end
@@ -51,6 +52,8 @@ def new
5152
# TODO: Why do some courses have empty enrollments?
5253
existing_canvas_ids = @user.courses.pluck(:canvas_id)
5354
@courses_teacher = filter_courses(@courses, UserToCourse.staff_roles, existing_canvas_ids)
55+
# Track if any teacher courses, so we still show the semester filter even if the selected semester filters out all courses.
56+
@has_any_teacher_courses = @courses_teacher.any?
5457
@courses_student = filter_courses(@courses, [ UserToCourse::STUDENT_ROLE ], existing_canvas_ids)
5558

5659
if @selected_semester.present?
@@ -125,6 +128,16 @@ def enrollments_last_synced_at
125128
nil
126129
end
127130

131+
# Returns the time assignments were last synced from the LMS, or nil if never synced.
132+
def assignments_last_synced_at
133+
synced_at = @course.course_to_lms&.recent_assignment_sync&.dig('synced_at')
134+
return nil if synced_at.blank?
135+
136+
Time.zone.parse(synced_at.to_s)
137+
rescue ArgumentError, TypeError
138+
nil
139+
end
140+
128141
def set_course
129142
@course = Course.find_by(id: params[:id])
130143
redirect_to courses_path, alert: 'Course not found.' unless @course
@@ -149,6 +162,8 @@ def filter_by_semester(courses, semester)
149162
end
150163

151164
# TODO: This should be moved to the Canvas Facade
165+
# TODO: Canvas enrollments can have multiple roles,
166+
# we SHOULD only look at the first one that matches our known roles.
152167
def filter_courses(courses, roles, exclude_ids = [])
153168
missing_enrollments = courses.select { |course| course['enrollments'].blank? }
154169
Rails.logger.warn("Canvas API by #{current_user.id}: Courses with missing enrollments: #{missing_enrollments.pluck('id').join(', ')}") unless missing_enrollments.empty?

app/controllers/session_controller.rb

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
class SessionController < ApplicationController
2-
include TokenRefreshable
3-
42
## Login work flow explained here
53
# Currently the login only supports third-party authentication with Canvas.
64
# But the structure to support multiple login methods is largely in place.
@@ -65,14 +63,25 @@ def omniauth_callback
6563
expires_at: expires_at
6664
)
6765

68-
# Persist / update the user just like `create`
69-
user = find_or_create_user(user_data, access_token)
66+
developer = auth.provider == 'developer'
67+
user =
68+
if developer
69+
developer_lookup_or_create(user_data, access_token)
70+
else
71+
canvas_lookup_or_create(user_data, access_token)
72+
end
7073

71-
# Auto-enroll developer login users in test courses
72-
if auth.provider == 'developer'
73-
ensure_developer_test_enrollments(user)
74+
# Either path returns nil when it refuses the login (e.g. canvas_lookup
75+
# declining to re-key an existing account to a new canvas_uid).
76+
if user.nil?
77+
redirect_to root_path,
78+
alert: 'We could not link your account. Please contact an administrator.'
79+
return
7480
end
7581

82+
# Auto-enroll developer login users in test courses
83+
ensure_developer_test_enrollments(user) if developer
84+
7685
redirect_to courses_path, notice: "Logged in! Welcome, #{user_data['name']}!"
7786
rescue StandardError => e
7887
Rails.logger.error("OmniAuth callback error: #{e.message}")
@@ -101,10 +110,43 @@ def ensure_developer_test_enrollments(user)
101110
end
102111
end
103112

104-
# TODO: Refactor.
105-
def find_or_create_user(user_data, auth_token)
106-
auth_token.token
107-
user = nil
113+
def developer_login_allowed?
114+
Rails.env.local?
115+
end
116+
117+
# Canonical production login path. Canvas owns identity, so users are keyed by
118+
# canvas_uid: we refresh the email on a canvas_uid match and create a new
119+
# account when the canvas_uid is unknown. We return nil (rather than re-keying)
120+
# when a different canvas_uid presents an email that already belongs to an
121+
# account, leaving the existing account untouched.
122+
def canvas_lookup_or_create(user_data, auth_token)
123+
user = User.find_by(canvas_uid: user_data['id'])
124+
125+
if user
126+
user.email = user_data['email']
127+
elsif User.exists?(email: user_data['primary_email'])
128+
Rails.logger.warn(
129+
"Refusing to link canvas_uid=#{user_data['id']} to existing account " \
130+
"with email=#{user_data['primary_email']}"
131+
)
132+
return nil
133+
else
134+
user = User.new(canvas_uid: user_data['id'])
135+
user.assign_attributes(
136+
email: user_data['email'],
137+
name: user_data['name']
138+
)
139+
end
140+
141+
persist_login!(user, auth_token)
142+
end
143+
144+
# Developer / test masquerade path. INTENTIONALLY matches an existing user by
145+
# email so a developer can log in *as* them without Canvas. Gated to dev/test
146+
# only; returns nil otherwise. Do not use this matching for the Canvas path.
147+
def developer_lookup_or_create(user_data, auth_token)
148+
return nil unless developer_login_allowed?
149+
108150
if User.exists?(email: user_data['primary_email'])
109151
user = User.find_by(email: user_data['primary_email'])
110152
user.canvas_uid = user_data['id']
@@ -118,6 +160,13 @@ def find_or_create_user(user_data, auth_token)
118160
name: user_data['name']
119161
)
120162
end
163+
164+
persist_login!(user, auth_token)
165+
end
166+
167+
# Shared finalization for both login paths: persist the user, refresh their
168+
# LMS credentials, and establish the session.
169+
def persist_login!(user, auth_token)
121170
user.save!
122171
update_user_credential(user, auth_token)
123172

0 commit comments

Comments
 (0)