Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/controllers/announcements_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ def publicly_accessible?
requesting_unread? || action_name.to_sym == :mark_as_read
end

# The announcement bell polls this on every page. There are no global announcements on the preview
# instance, but a 403 on every poll would surface to the previewer as a stream of errors.
def preview_sandbox_accessible?
true
end

private

def requesting_unread?
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class ApplicationController < ActionController::Base
include ApplicationAbilityConcern
include ApplicationAnnouncementsConcern
include ApplicationPaginationConcern
# Last, so its `before_action` runs after the tenant is deduced and the user authenticated — both of
# which it reads.
include ApplicationPreviewSandboxConcern

rescue_from AuthenticationError, with: :handle_authentication_error
rescue_from IllegalStateError, with: :handle_illegal_state_error
Expand Down
8 changes: 8 additions & 0 deletions app/controllers/attachment_references_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ def show
end
end

protected

# A previewed assessment's questions carry attachments (description images, template files) and its
# answers accept uploads, so both reading and writing an attachment are part of attempting one.
def preview_sandbox_accessible?
true
end

private

def file_params
Expand Down
68 changes: 68 additions & 0 deletions app/controllers/concerns/application_preview_sandbox_concern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true
# Confines a marketplace previewer to the preview flow, at the request layer.
#
# On the preview instance a non-administrator may reach only what a controller
# explicitly claims. `preview_sandbox_accessible?` defaults to false and is overridden by the handful
# of actions the preview flow actually makes, mirroring how `publicly_accessible?` marks out the
# unauthenticated surface.
#
module ApplicationPreviewSandboxConcern
extend ActiveSupport::Concern

included do
before_action :enforce_preview_sandbox_lock!

# The root payload reports the lock to the courseless navigation shell, which has no course to read
# a flag off — the 404 page drops its "go back home" link on it. One fact, one source: the same
# predicate this concern enforces, rather than a second guess at who is confined.
helper_method :preview_sandbox_locked?
end

protected

# Whether this action belongs to the marketplace preview flow, and may therefore run for a
# non-administrator on the preview instance. Deny by default; override in the controllers that serve
# the flow.
#
# Devise is exempt wholesale rather than by action: sign-in, sign-up, password reset and
# confirmation all happen on the preview host, so a previewer who arrives without a session must be
# able to complete them or the sandbox is unreachable. Exempting the base class cannot miss one of
# the four subclasses.
#
# The root payload (locale, time zone, and the courses the user is in — here, only the container) is
# fetched on every page, the previewer's included. Singled out the same way `publicly_accessible?`
# singles out that one action.
#
# @return [Boolean]
def preview_sandbox_accessible?
devise_controller? || (controller_name == 'application' && action_name.to_sym == :index)
end

# Whether `assessment_id` names an assessment a previewer was actually handed: the snapshot a listed
# listing currently serves.
#
# Deliberately no `can?` call. This runs before `load_and_authorize_resource :course`, and
# `Course::Controller#current_ability` memoizes on `current_course`; building the ability here would
# freeze a nil-course one for the rest of the request and deny the previewer everything downstream.
#
# @param [Integer, String, nil] assessment_id
# @return [Boolean]
def previewable_assessment?(assessment_id)
Course::Assessment::Marketplace::Listing.serving_assessment?(assessment_id)
end

private

def enforce_preview_sandbox_lock!
return unless preview_sandbox_locked?
return if preview_sandbox_accessible?

raise CanCan::AccessDenied
end

def preview_sandbox_locked?
return false if current_user&.administrator?

Course::Assessment::Marketplace::PreviewContainerService.preview_instance?(current_tenant)
end
end
25 changes: 23 additions & 2 deletions app/controllers/course/assessment/assessments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ class Course::Assessment::AssessmentsController < Course::Assessment::Controller
include Course::Assessment::Question::KoditsuQuestionConcern
include Course::Assessment::KoditsuAssessmentInvitationConcern

before_action :load_submissions, only: [:show]
before_action :load_submissions, only: [:show], unless: :crumb_request?
after_action :create_koditsu_invitation_job, only: [:update]
after_action :create_fetch_koditsu_submissions_job, only: [:update]

include Course::Assessment::MonitoringConcern
include Course::Statistics::CountsConcern

before_action :load_question_duplication_data, only: [:show, :reorder]
before_action :load_question_duplication_data, only: [:show, :reorder], unless: :crumb_request?

def index
@assessments = @assessments.ordered_by_date_and_title.with_submissions_by(current_user)
Expand All @@ -37,6 +37,7 @@ def index
def show
@assessment_time = @assessment.time_for(current_course_user)
return render 'authenticate' unless can_access_assessment?
return render 'crumb' if crumb_request?

@question_assessments = @assessment.question_assessments.with_question_actables
@assessment_conditions = @assessment.assessment_conditions.includes({ conditional: :actable })
Expand Down Expand Up @@ -255,6 +256,15 @@ def plagiarism

protected

# Both breadcrumb handles on a preview submission page fetch `show`, so the previewer needs it —
# but only for a title, and only for the snapshot they were handed. Not the page: a previewer is a
# `manager`, and `show` serves a manager the whole authoring surface. The index is not here either:
# it is the whole container, one row per published snapshot and per restored authoring copy, each
# with an Attempt button.
def preview_sandbox_accessible?
crumb_request? && previewable_assessment?(params[:id])
end

def load_assessment_options
return super if skip_tab_filter?

Expand All @@ -263,6 +273,17 @@ def load_assessment_options

private

# Whether this `show` is asking only for what a breadcrumb renders — the assessment's title and its
# tab's. Both crumb handles on any assessment page fetch `show`, and so does the assessment page
# itself; one endpoint serving both is what made the marketplace sandbox's crumb allowance a licence
# to read the authoring surface. Splitting them on the request rather than on the viewer keeps the
# payload the same for everyone and saves the page's ~50 queries on a fetch that renders two strings.
#
# @return [Boolean]
def crumb_request?
action_name.to_sym == :show && params[:crumb].present?
end

# Drives the view-only version badge on the container course's assessment index. Every published
# snapshot keeps its original title and shares one tab, so without it an admin sees an
# undifferentiated pile of identically-named assessments. Skipped everywhere else.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def show
includes(current_version: :assessment).find_by(id: params[:id])
raise CanCan::AccessDenied unless @listing

# The SNAPSHOT, never the authoring copy (design §4.2).
# The SNAPSHOT, never the authoring copy.
@assessment = @listing.current_version&.assessment
raise CanCan::AccessDenied unless @assessment

Expand All @@ -44,6 +44,23 @@ def show
end
end

def launch_preview
ActsAsTenant.without_tenant do
@listing = Course::Assessment::Marketplace::Listing.published.
includes(current_version: :assessment).find_by(id: params[:id])
raise CanCan::AccessDenied unless @listing

# A preview rehearses the SNAPSHOT, never the authoring copy — the same row a duplicate would
# copy. Guarded here rather than in the service so a published listing with no
# snapshot is denied instead of crashing on a nil deep inside provisioning.
raise CanCan::AccessDenied unless @listing.current_version&.assessment

authorize!(:preview_in_marketplace, @listing)
url = Course::Assessment::Marketplace::PreviewLaunchService.launch(@listing, current_user)
render json: { url: url }
end
end

private

def authorize_access!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true
# Lets a marketplace previewer self-service reset THEIR OWN submission for an assessment inside the
# "Marketplace Preview Sandbox" container course, so they can relaunch "Try it hands-on" for a
# genuinely fresh attempt (Course::Assessment::Marketplace::PreviewLaunchService otherwise resumes
# an existing submission rather than resetting it).
#
# `update`, not `destroy`: the submission row is kept (same id) so the previewer stays on
# the same submission edit page and sees it come back blank.
# See Course::Assessment::Submission#reset_preview! for the clear-and-reset logic.
class Course::Assessment::Marketplace::PreviewSubmissionsController < Course::Assessment::Marketplace::Controller
before_action :ensure_preview_course!
before_action :load_assessment

def update
submission = @assessment.submissions.find_by(creator: current_user)
return head :not_found unless submission

authorize!(:reset_own_preview_submission, submission)
submission.reset_preview!
head :no_content
end

protected

# The one action written for previewers, and the only one they can reach that is not also an
# ordinary course action. The submission id never comes from the client (see above), so nothing here
# needs vetting beyond `ensure_preview_course!`.
def preview_sandbox_accessible?
true
end

private

# This self-service shortcut only ever exists inside the preview sandbox. A real course's
# teaching staff already have a vetted removal flow
# (Course::Assessment::Submission::SubmissionsController#delete/#delete_all); this action
# deliberately skips that flow's randomization/monitoring bookkeeping, since preview assessments
# are plain content-frozen copies with neither feature configured.
def ensure_preview_course!
raise CanCan::AccessDenied unless current_course.preview?
end

def load_assessment
@assessment = current_course.assessments.find(params[:assessment_id])
end
end
10 changes: 10 additions & 0 deletions app/controllers/course/assessment/submission/answer/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,14 @@ class Course::Assessment::Submission::Answer::Controller < \
singleton: true, through: :answer

helper Course::Assessment::Submission::SubmissionsHelper.name.sub(/Helper$/, '')

protected

# Every action in this subtree (saving an answer, uploading a text-response file, adding a scribble, annotating code)
# is reached through `load_and_authorize_resource :submission`, which the preview ability confines to
# `creator_id: user.id`. Claimed once on the base rather than per subclass, so a new answer type
# does not silently break previews.
def preview_sandbox_accessible?
true
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ class Course::Assessment::Submission::SubmissionsController < # rubocop:disable
include Course::Assessment::LiveFeedback::ThreadConcern
include Course::Assessment::LiveFeedback::MessageConcern

# What a marketplace previewer may do here (see ApplicationPreviewSandboxConcern). Every one of
# these rides on `load_and_authorize_resource :submission` or an explicit `authorize!` against
# `@submission`, which the preview ability confines to `creator_id: user.id` — so allowing the action
# does not widen which submission it can reach. `create` is the exception and vets the assessment
# itself.
#
# Deliberately absent: every collection action. `publish_all`, `force_submit_all`, `unsubmit_all`,
# `download_all` and friends authorize against `@assessment` on verbs a manager's blanket
# `can :manage, Course::Assessment` already satisfies, so in a course every previewer shares they
# would reach every other previewer's submission. The live-feedback actions are absent too:
# `fetch_live_feedback_chat` reads a thread from a bare `answer_id` with no authorization at all.
PREVIEWER_ACTIONS = [
:create, :edit, :update, :auto_grade, :reevaluate_answer, :generate_feedback, :reload_answer
].to_set.freeze

before_action :authorize_assessment!, only: :create
skip_authorize_resource :submission, only: [:edit, :update, :auto_grade]
before_action :authorize_submission!, only: [:edit, :update]
Expand Down Expand Up @@ -325,6 +340,18 @@ def delete_all
render partial: 'jobs/submitted', locals: { job: job }
end

protected

def preview_sandbox_accessible?
return false unless PREVIEWER_ACTIONS.include?(action_name.to_sym)
# `create` backs the `attempt` route and mints the submission every other action here is scoped
# to, so it is the one that has to vet the assessment. Without this, guessing a container
# assessment id would hand the guesser a submission on it and legitimise everything downstream.
return true unless action_name.to_sym == :create

previewable_assessment?(params[:assessment_id])
end

private

# When a grader opens a (submitted) submission, make sure every rubric-based answer has a v2 grading
Expand Down
8 changes: 8 additions & 0 deletions app/controllers/course/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ def sidebar
#
# To re-enable, restore the original condition.
@home_redirects_to_learn = false

@preview_sandbox_admin = current_course.preview? && current_user&.administrator?
@preview_restricted = current_course.preview? && !@preview_sandbox_admin
end

protected
Expand All @@ -53,6 +56,11 @@ def publicly_accessible?
Set[:index, :show, :sidebar].include?(action_name.to_sym)
end

# The layout payload is fetched on every course page, so the previewer's submission page needs it.
def preview_sandbox_accessible?
action_name.to_sym == :sidebar
end

private

def course_params
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/course/user_notifications_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ def publicly_accessible?
Set[:fetch].include?(action_name.to_sym)
end

# `PopupNotifier` polls `fetch` on every course page and dismisses through `mark_as_read`; both are
# scoped to the previewer's own notifications, `mark_as_read` by `load_and_authorize_resource`.
def preview_sandbox_accessible?
Set[:fetch, :mark_as_read].include?(action_name.to_sym)
end

private

# Fetches the first unread popup `UserNotification` for the current course and returns JSON data
Expand Down
5 changes: 5 additions & 0 deletions app/controllers/csrf_token_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ def csrf_token
def publicly_accessible?
true
end

# Every mutating request the previewer makes needs a token first.
def preview_sandbox_accessible?
true
end
end
5 changes: 5 additions & 0 deletions app/controllers/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def publicly_accessible?
true
end

# Autograding a preview submission is a job, and the submission page polls it here.
def preview_sandbox_accessible?
true
end

private

def load_job
Expand Down
Loading