fe 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +147 -3
  3. data/app/controllers/concerns/fe/access_control_concern.rb +39 -0
  4. data/app/controllers/concerns/fe/admin/question_sheets_controller_concern.rb +2 -1
  5. data/app/controllers/concerns/fe/answer_pages_controller_concern.rb +3 -1
  6. data/app/controllers/concerns/fe/answer_sheets_controller_concern.rb +4 -1
  7. data/app/controllers/fe/admin/elements_controller.rb +4 -3
  8. data/app/controllers/fe/admin/email_templates_controller.rb +2 -1
  9. data/app/controllers/fe/admin/question_pages_controller.rb +2 -1
  10. data/app/controllers/fe/reference_pages_controller.rb +4 -0
  11. data/app/controllers/fe/reference_sheets_controller.rb +8 -2
  12. data/app/controllers/fe/references_controller.rb +4 -0
  13. data/app/controllers/fe/submit_pages_controller.rb +2 -0
  14. data/app/views/fe/answer_pages/update.js.erb +2 -1
  15. data/app/views/fe/questions/fe/_attachment_field.html.erb +8 -8
  16. data/lib/fe/access_control.rb +72 -0
  17. data/lib/fe/engine.rb +4 -0
  18. data/lib/fe/version.rb +1 -1
  19. data/lib/fe.rb +1 -0
  20. data/spec/controllers/fe/admin/elements_controller_spec.rb +29 -0
  21. data/spec/controllers/fe/admin/email_templates_controller_spec.rb +8 -0
  22. data/spec/controllers/fe/admin/question_pages_controller_spec.rb +9 -0
  23. data/spec/controllers/fe/admin/question_sheets_controller_spec.rb +30 -0
  24. data/spec/controllers/fe/answer_pages_controller_spec.rb +142 -0
  25. data/spec/controllers/fe/answer_sheets_controller_spec.rb +114 -0
  26. data/spec/controllers/fe/reference_pages_controller_spec.rb +32 -0
  27. data/spec/controllers/fe/reference_sheets_controller_spec.rb +88 -1
  28. data/spec/controllers/fe/references_controller_spec.rb +29 -1
  29. data/spec/controllers/fe/submit_pages_controller_spec.rb +44 -1
  30. data/spec/dummy/app/controllers/application_controller.rb +8 -0
  31. data/spec/dummy/config/initializers/fe.rb +2 -0
  32. data/spec/lib/fe/access_control_spec.rb +173 -0
  33. metadata +10 -8
  34. data/spec/controllers/fe/reference_pages_controller.rb +0 -4
  35. data/spec/dummy/log/test.log +0 -9115
  36. data/spec/dummy/tmp/local_secret.txt +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63c54a49ce10c97d77e21066af68ae2e4d138f57e1d78789a0b304f2ea26d771
4
- data.tar.gz: 860211697773fc5643d50e85617065cd49697725ac2479fb644d321eac620c91
3
+ metadata.gz: e5901527c0d559c346f9b5260706bae3c5dca50aa53acad6e3389a9953ad3258
4
+ data.tar.gz: 6ce8e73924606203040a84222cf1ff2127855dff223331b9f83524a91ee8effb
5
5
  SHA512:
6
- metadata.gz: 20f8c1697604eb9bdf58be607a9c81e86ee4dd0ca9e12e740113c173cbe1402a6371dfa9861ef6861c46b1d9fc6e9ae9f48c069dea829a130ac92d4e32f0e685
7
- data.tar.gz: 498cec05620ede4dbee334badd0c2e3810c7d3719816a8a3b7c986238badc9deba878ae0a75ef0a84385330bd17a0a40a2c5cae404e5b32e6a0d688ccaf30591
6
+ metadata.gz: 2d944254d553ae54b9ac39016114c797a2176fcdedaee5543e50da0fa9dc6162a81087df058f893900194c963c321141c63de5c21d3e25c3c74a103a263a7bc5
7
+ data.tar.gz: 120a3a78d3a8b443ca1c2cf9f43c36166e0883dfd8d075cc61655637053d02b4600100c9bcdce96a6102fe5fa40ca0e4c7ca150c13ba342234027aa10fe77c70
data/README.md CHANGED
@@ -58,8 +58,152 @@ Run a specific spec:
58
58
 
59
59
  bundle exec rails server
60
60
 
61
- ### Changelog
62
-
63
- #### 2.2
61
+ ## Access control
62
+
63
+ fe denies access by default. It never decides who a person is or what they may
64
+ see. The host app answers two questions, and until it does, fe refuses every
65
+ request. fe also checks at boot: the app raises `Fe::MissingAccessHook` and
66
+ won't start until both hooks exist.
67
+
68
+ ### Required hooks
69
+
70
+ Define both on `ApplicationController`, or on the fe controllers themselves.
71
+ Private, protected and public methods all count, and so do methods added by a
72
+ module you include or prepend in `config.to_prepare`.
73
+
74
+ class ApplicationController < ActionController::Base
75
+ private
76
+
77
+ # May the current person use this answer sheet?
78
+ # access is :look (viewing it) or :change (anything else).
79
+ def fe_can_use_answer_sheet?(answer_sheet, access)
80
+ if answer_sheet.is_a?(Fe::ReferenceSheet)
81
+ # Reference writers open their sheet from a link that carries its key.
82
+ Fe.access_key_matches?(answer_sheet, params[:a])
83
+ else
84
+ current_user.present? && answer_sheet.owner == current_user
85
+ end
86
+ end
87
+
88
+ # May the current person use fe's admin screens (the form builder)?
89
+ def fe_admin?
90
+ current_user&.admin?
91
+ end
92
+ end
93
+
94
+ `fe_can_use_answer_sheet?(answer_sheet, access)`
95
+
96
+ - `:look` is used by the `show` actions. `:change` covers every other action
97
+ that works on an answer sheet (saving answers, submitting, and so on).
98
+ - It judges whatever is in `@answer_sheet` when the check runs. If nothing has
99
+ loaded the sheet yet, fe calls `get_answer_sheet` first (yours, if you
100
+ override it), so moving, re-declaring or skipping that callback doesn't skip
101
+ the check. To change how the sheet loads, override `get_answer_sheet`. If the
102
+ loader renders or redirects, that is the response. If there is still no
103
+ sheet (for example a reference link with a wrong key), fe leaves the
104
+ response to the action.
105
+ - Reference sheets reach the same hook as `Fe::ReferenceSheet` records. A hook
106
+ that only checks the signed-in user locks reference writers out, so give
107
+ them their own branch, as in the example. The reference's access key is in
108
+ `params[:a]`, and `Fe.access_key_matches?(answer_sheet, params[:a])` is the
109
+ safe way to check it. Whether a key alone is enough is up to your app.
110
+
111
+ `fe_admin?` takes no arguments. It guards the admin controllers
112
+ (`Fe::Admin::QuestionSheetsController`, `QuestionPagesController`,
113
+ `ElementsController`, `EmailTemplatesController`) and the `index` and `create`
114
+ actions of `Fe::AnswerSheetsController`. The `index` and `create` rule follows
115
+ fe's own code for those actions, so it also covers any controller of yours that
116
+ inherits them without defining its own. If you define `index` or `create`
117
+ yourself (in a subclass, a reopened class or a prepended module), even one that
118
+ only calls `super`, fe's admin check skips it and your action owns its check.
119
+
120
+ The boot check needs `fe_can_use_answer_sheet?` on every class in
121
+ `Fe::ANSWER_SHEET_CONTROLLERS` and `fe_admin?` on every class in
122
+ `Fe::ADMIN_CONTROLLERS` plus `Fe::AnswerSheetsController`. Defining both on
123
+ `ApplicationController` covers all of them.
124
+
125
+ ### Changing fe's controllers
126
+
127
+ Reopening an fe controller (with `class_eval`, or a module you include or
128
+ prepend) keeps fe's checks. Replacing one with your own class of the same name
129
+ doesn't: your class gets only the checks it includes or inherits. The checks
130
+ for answer sheets, answer pages and question sheets live in fe concerns
131
+ (`Fe::AnswerSheetsControllerConcern`, `Fe::AnswerPagesControllerConcern`,
132
+ `Fe::Admin::QuestionSheetsControllerConcern`), so a replacement that includes
133
+ the concern keeps them. fe's other controllers declare their checks in the
134
+ class itself, so a replacement for one of them loses those checks unless you
135
+ add them. The boot check only looks for the hook methods, so it can't tell.
136
+
137
+ ### Optional: `fe_access_denied(kind)`
138
+
139
+ When a check fails, fe sends `403 Forbidden`. To do something else, define
140
+ `fe_access_denied(kind)`. `kind` is `:answer_sheet` or `:admin`.
141
+
142
+ def fe_access_denied(kind)
143
+ redirect_to root_path, alert: 'You do not have access to that.'
144
+ end
145
+
146
+ If it renders or redirects, that replaces the 403. If it does neither, fe still
147
+ sends 403. Exceptions raised in your hooks are not rescued.
148
+
149
+ ### Other settings
150
+
151
+ - `Fe.answer_sheet_types`: the answer sheet classes fe accepts in the
152
+ `answer_sheet_type` param. Any other class is refused as not found. It
153
+ defaults to `nil`, which means `Fe.answer_sheet_class` plus
154
+ `Fe::ReferenceSheet`. Setting it replaces that default, so list every class
155
+ your app uses, including your `Fe.answer_sheet_class` and
156
+ `Fe::ReferenceSheet`. Because the default is `nil`, `+=` raises on it. Set it
157
+ where you set `Fe.answer_sheet_class` (for example in a `config.to_prepare`
158
+ block, if that's where your app sets the class):
159
+
160
+ Fe.answer_sheet_class = 'Fe::Application'
161
+ Fe.answer_sheet_types = %w[Fe::Application Fe::ReferenceSheet MyOtherSheet]
162
+
163
+ - `Fe.element_types`: the element classes the form builder accepts in the
164
+ `element_type` param. It defaults to fe's own element classes, so add yours
165
+ to it:
166
+
167
+ Fe.element_types += %w[MyRatingField]
168
+
169
+ - `Fe.access_key_matches?(answer_sheet, key)`: constant-time comparison of a
170
+ key against the sheet's `access_key`. False for a blank or non-string key,
171
+ and false when the sheet's own `access_key` is blank.
172
+ - `Fe.verify_access_hooks_on_boot` (default `true`): set to `false` to turn off
173
+ the boot check. It is meant only for fe's own test app. Leave it on in a real
174
+ app.
175
+
176
+ ### Upgrading to 2.4
177
+
178
+ - fe no longer calls `check_valid_user`. Move that logic into
179
+ `fe_can_use_answer_sheet?` and `fe_admin?`.
180
+ - fe accepts only the classes in `Fe.answer_sheet_types` in the
181
+ `answer_sheet_type` param, and by default that is just
182
+ `Fe.answer_sheet_class` and `Fe::ReferenceSheet`. Every other sheet class is
183
+ refused as not found. fe's pages send the sheet's class in that param on
184
+ every page load and save, so if your app has any other answer sheet classes,
185
+ set `Fe.answer_sheet_types` to list all of them (see above).
186
+ - The form builder accepts only the element classes in `Fe.element_types`. Add
187
+ any element classes of your own.
188
+ - `index` and `create` on `Fe::AnswerSheetsController` are admin-only
189
+ (`fe_admin?`), and so are those actions in any controller of yours that
190
+ inherits them without defining its own.
191
+ - `Fe::ReferenceSheetsController` allows only `edit`, `show` and `done`. Other
192
+ actions return 404. `show` is checked with `:look`, and a `show` link with a
193
+ wrong or missing key now gets the not-found page with a 404 instead of an
194
+ error.
195
+ - If your app overrides `fe/questions/fe/_attachment_field.html.erb`, add
196
+ `a: params[:a]` to its upload, delete and page reload URLs, as fe's own
197
+ partial now does. Without the key, reference writers get 403 when they
198
+ attach a file.
199
+ - Define both hooks before deploying, or the app won't boot.
200
+
201
+ ## Changelog
202
+
203
+ ### 2.4
204
+
205
+ - **Deny by default**: fe refuses answer sheets and its admin screens unless the host app allows them, and the app won't boot until it defines fe's access hooks. See [Upgrading to 2.4](#upgrading-to-24).
206
+
207
+ ### 2.2
64
208
 
65
209
  - **Accidental overwrite protection**: When multiple tabs/windows have the same form open, autosave could silently overwrite another tab's answers. Now an MD5 digest of the current answers is tracked, and saves are rejected if the answers have changed since the page was loaded.
@@ -0,0 +1,39 @@
1
+ module Fe::AccessControlConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ private
5
+
6
+ # Must not rely on get_answer_sheet having run first: re-declaring a callback
7
+ # moves it to the end of the chain. A request that still has no sheet after
8
+ # loading is left to the action.
9
+ def fe_authorize_answer_sheet!
10
+ return if performed?
11
+ get_answer_sheet if @answer_sheet.nil? && respond_to?(:get_answer_sheet, true)
12
+ return if performed? || @answer_sheet.nil?
13
+
14
+ access = action_name == 'show' ? :look : :change
15
+ allowed = respond_to?(:fe_can_use_answer_sheet?, true) && fe_can_use_answer_sheet?(@answer_sheet, access)
16
+ fe_deny!(:answer_sheet) unless allowed
17
+ end
18
+
19
+ def fe_require_admin!
20
+ return if performed?
21
+
22
+ allowed = respond_to?(:fe_admin?, true) && fe_admin?
23
+ fe_deny!(:admin) unless allowed
24
+ end
25
+
26
+ # A subclass, reopened class or prepended module that defines the action
27
+ # replaces fe's and owns its access control, so it must not be gated here.
28
+ def fe_require_admin_for_own_action!
29
+ return unless self.class.instance_method(action_name).owner == Fe::AnswerSheetsControllerConcern
30
+
31
+ fe_require_admin!
32
+ end
33
+
34
+ # Whatever the host's fe_access_denied does, the request must not continue.
35
+ def fe_deny!(kind)
36
+ fe_access_denied(kind) if respond_to?(:fe_access_denied, true)
37
+ head :forbidden unless performed?
38
+ end
39
+ end
@@ -3,7 +3,8 @@ module Fe::Admin::QuestionSheetsControllerConcern
3
3
 
4
4
  begin
5
5
  included do
6
- before_action :check_valid_user
6
+ include Fe::AccessControlConcern
7
+ before_action :fe_require_admin!
7
8
  before_action :get_question_sheet, only: [:show, :archive, :unarchive, :destroy, :edit, :update, :duplicate]
8
9
  layout 'fe/fe_admin'
9
10
  end
@@ -1,9 +1,11 @@
1
1
  module Fe::AnswerPagesControllerConcern
2
2
  extend ActiveSupport::Concern
3
+ include Fe::AccessControlConcern
3
4
 
4
5
  begin
5
6
  included do
6
7
  before_action :get_answer_sheet, only: [:show, :edit, :update, :save_file, :delete_file]
8
+ before_action :fe_authorize_answer_sheet!, only: [:show, :edit, :update, :save_file, :delete_file]
7
9
  before_action :set_quiet_reference_email_change, only: :update
8
10
  skip_before_action :verify_authenticity_token, only: :save_file
9
11
  end
@@ -165,7 +167,7 @@ module Fe::AnswerPagesControllerConcern
165
167
  end
166
168
 
167
169
  def answer_sheet_type
168
- (params[:answer_sheet_type] || Fe.answer_sheet_class || 'AnswerSheet').constantize
170
+ Fe.answer_sheet_class_for(params[:answer_sheet_type])
169
171
  end
170
172
 
171
173
  def answer_params
@@ -1,10 +1,13 @@
1
1
  module Fe::AnswerSheetsControllerConcern
2
2
  extend ActiveSupport::Concern
3
+ include Fe::AccessControlConcern
3
4
 
4
5
  begin
5
6
  included do
6
7
  layout 'fe/application'
8
+ before_action :fe_require_admin_for_own_action!, only: [:index, :create]
7
9
  before_action :get_answer_sheet, only: [:edit, :show, :send_reference_invite, :submit]
10
+ before_action :fe_authorize_answer_sheet!, only: [:edit, :show, :send_reference_invite, :submit]
8
11
  after_action :inject_answers_digest_script, only: :edit
9
12
  end
10
13
  rescue ActiveSupport::Concern::MultipleIncludedBlocks
@@ -94,7 +97,7 @@ module Fe::AnswerSheetsControllerConcern
94
97
 
95
98
  def answer_sheet_type
96
99
  return Fe::ReferenceSheet if params[:controller] == "fe/reference_sheets"
97
- (params[:answer_sheet_type] || Fe.answer_sheet_class || 'AnswerSheet').constantize
100
+ Fe.answer_sheet_class_for(params[:answer_sheet_type])
98
101
  end
99
102
 
100
103
  def get_answer_sheet
@@ -1,5 +1,6 @@
1
1
  class Fe::Admin::ElementsController < ApplicationController
2
- before_action :check_valid_user
2
+ include Fe::AccessControlConcern
3
+ before_action :fe_require_admin!
3
4
 
4
5
  before_action :get_page
5
6
 
@@ -19,7 +20,7 @@ class Fe::Admin::ElementsController < ApplicationController
19
20
  end
20
21
 
21
22
  def new
22
- @questions = params[:element_type].constantize.active.shared.order('label')
23
+ @questions = Fe.element_class_for(params[:element_type]).active.shared.order('label')
23
24
 
24
25
  @style = element_params[:style]
25
26
  if @style
@@ -48,7 +49,7 @@ class Fe::Admin::ElementsController < ApplicationController
48
49
 
49
50
  # POST /elements
50
51
  def create
51
- @element = params[:element_type].constantize.new(element_params)
52
+ @element = Fe.element_class_for(params[:element_type]).new(element_params)
52
53
  @element.required = true if @element.question?
53
54
  @question_sheet = @page.question_sheet
54
55
 
@@ -1,5 +1,6 @@
1
1
  class Fe::Admin::EmailTemplatesController < ApplicationController
2
- before_action :check_valid_user
2
+ include Fe::AccessControlConcern
3
+ before_action :fe_require_admin!
3
4
  layout 'fe/fe_admin'
4
5
 
5
6
  def index
@@ -1,5 +1,6 @@
1
1
  class Fe::Admin::QuestionPagesController < ApplicationController
2
- before_action :check_valid_user
2
+ include Fe::AccessControlConcern
3
+ before_action :fe_require_admin!
3
4
 
4
5
  before_action :get_sheet
5
6
 
@@ -1,12 +1,15 @@
1
1
  # gather reference information from Applicant
2
2
  module Fe
3
3
  class ReferencePagesController < ApplicationController
4
+ include Fe::AccessControlConcern
5
+
4
6
  skip_before_action :cas_filter, raise: false
5
7
  skip_before_action :authentication_filter, raise: false
6
8
 
7
9
  layout nil
8
10
 
9
11
  before_action :setup
12
+ before_action :fe_authorize_answer_sheet!
10
13
 
11
14
  MONTHS_KNOWN_OPTIONS = [
12
15
  ["3 months", 3],
@@ -35,6 +38,7 @@ module Fe
35
38
  private
36
39
  def setup
37
40
  @application = Application.find(params[:application_id])
41
+ @answer_sheet ||= @application
38
42
  end
39
43
 
40
44
  end
@@ -1,7 +1,8 @@
1
1
  # TODO determine how this relates to Fe::ReferencesController and if we can delete one of the two
2
2
  class Fe::ReferenceSheetsController < Fe::AnswerSheetsController
3
3
  skip_before_action :ssm_login_required, :login, raise: false
4
- before_action :edit_only, except: [:edit]
4
+ # Prepended so other actions stop before a sheet loads or the host's hook runs.
5
+ prepend_before_action :edit_only, except: [:edit, :show, :done]
5
6
 
6
7
  def edit
7
8
  @reference_sheet = @answer_sheet
@@ -19,6 +20,11 @@ class Fe::ReferenceSheetsController < Fe::AnswerSheetsController
19
20
  render 'fe/answer_sheets/edit', layout: 'fe/application'
20
21
  end
21
22
 
23
+ def show
24
+ return render(:not_found, status: :not_found) unless @answer_sheet
25
+ super
26
+ end
27
+
22
28
  protected
23
29
  def get_answer_sheet
24
30
  @answer_sheet ||= Fe::ReferenceSheet.find_by_id_and_access_key(params[:id], params[:a])
@@ -26,6 +32,6 @@ class Fe::ReferenceSheetsController < Fe::AnswerSheetsController
26
32
  end
27
33
 
28
34
  def edit_only
29
- return false
35
+ head :not_found
30
36
  end
31
37
  end
@@ -2,10 +2,13 @@
2
2
  # TODO determine how this relates to Fe::ReferenceSheetsController and if we can delete one of the two
3
3
  module Fe
4
4
  class ReferencesController < ApplicationController
5
+ include Fe::AccessControlConcern
6
+
5
7
  skip_before_action :cas_filter, raise: false
6
8
  skip_before_action :authentication_filter, raise: false
7
9
 
8
10
  before_action :setup
11
+ before_action :fe_authorize_answer_sheet!
9
12
 
10
13
  layout 'public'
11
14
 
@@ -70,6 +73,7 @@ module Fe
70
73
  private
71
74
  def setup
72
75
  @application = Application.find(params[:application_id])
76
+ @answer_sheet ||= @application
73
77
  end
74
78
  end
75
79
  end
@@ -1,6 +1,8 @@
1
1
  class Fe::SubmitPagesController < ApplicationController
2
+ include Fe::AccessControlConcern
2
3
 
3
4
  before_action :setup
5
+ before_action :fe_authorize_answer_sheet!
4
6
  skip_before_action :get_answer_sheet, raise: false
5
7
 
6
8
  layout nil
@@ -8,7 +8,8 @@
8
8
  $('#<%= dom_id(@answer.question) %>_filename').html('Current File: ' +
9
9
  '<%= link_to(@answer.attachment_file_name, @answer.attachment.expiring_url) %>' +
10
10
  '<%= link_to(image_tag("fe/icons/minus-button.png"),
11
- delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: @answer.question.id),
11
+ delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: @answer.question.id,
12
+ answer_sheet_type: @answer_sheet.class.name, a: params[:a]),
12
13
  method: :delete, remote: true) %>')
13
14
  $('#<%= dom_id(@answer.question) %>_filename').effect('highlight')
14
15
  <% elsif @answer %>
@@ -6,7 +6,7 @@
6
6
  <% if answer %>
7
7
  Current File: <%= link_to(answer.attachment.filename, url_for(answer.attachment)) %>
8
8
  <%= link_to(image_tag("fe/icons/minus-button.png"),
9
- delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name),
9
+ delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name, a: params[:a]),
10
10
  method: :delete, remote: true) %>
11
11
  <% end %>
12
12
  <% else %>
@@ -19,7 +19,7 @@
19
19
  if answer&.attachment&.attached? %>
20
20
  Current File: <%= link_to(answer.attachment.filename, url_for(answer.attachment)) %>
21
21
  <%= link_to(image_tag('fe/icons/minus-button.png'),
22
- delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name),
22
+ delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name, a: params[:a]),
23
23
  method: :delete, remote: true) %>
24
24
  <% end %>
25
25
  </span>
@@ -29,7 +29,7 @@
29
29
  <% session_key_name = Rails.application.config.session_options[:key] %>
30
30
  $('#<%= dom_id(attachment_field) %>_file_uploader').uploadify({
31
31
  uploader : '<%= asset_path "fe/uploadify/uploadify.swf" %>',
32
- script : '<%= save_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name).html_safe %>',
32
+ script : '<%= save_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name, a: params[:a]).html_safe %>',
33
33
  fileDesc : 'Pick a file',
34
34
  fileExt : '<%= attachment_field.content || "*.*" %>',
35
35
  auto : true,
@@ -58,15 +58,15 @@
58
58
  <% if answer&.attachment&.attached? %>
59
59
  Current File: <%= link_to(answer.attachment.filename, url_for(answer.attachment)) %>
60
60
  <%= link_to(image_tag("fe/icons/minus-button.png"),
61
- delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name),
61
+ delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name, a: params[:a]),
62
62
  method: :delete, remote: true, class: 'remove_current') %>
63
63
  <script>
64
64
  $('<%= base_element %> .remove_current').click(function() {
65
65
  $.ajax({
66
66
  type: 'DELETE',
67
- url: '<%= delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name).html_safe %>',
67
+ url: '<%= delete_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name, a: params[:a]).html_safe %>',
68
68
  success: function(result) {
69
- $.get("/fe/answer_sheets/<%= @answer_sheet.id %>/page/<%= @page.id %>/edit?answer_sheet_type=<%= @answer_sheet.class.name %>", function(data) {
69
+ $.get("<%= edit_fe_answer_sheet_page_path(@answer_sheet, @page, answer_sheet_type: @answer_sheet.class.name, a: params[:a]).html_safe %>", function(data) {
70
70
  $('<%= base_element %>').replaceWith($(data).find('<%= base_element %>'))
71
71
  });
72
72
  }
@@ -98,7 +98,7 @@
98
98
 
99
99
  let $input = $("<%= base_element %> .upload_field").html5_upload({
100
100
  url: function(number) {
101
- return "<%= save_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name).html_safe %>";
101
+ return "<%= save_file_fe_answer_sheet_page_path(@presenter.active_answer_sheet, @page, question_id: element_id, answer_sheet_type: @answer_sheet.class.name, a: params[:a]).html_safe %>";
102
102
  },
103
103
  sendBoundary: window.FormData || $.browser.mozilla,
104
104
  onStart: function(event, total) {
@@ -123,7 +123,7 @@
123
123
  // Re-render the attachment file to show the current file; use the page view, which is not the
124
124
  // most efficient way, but lets us avoid making an action just for this. And the amount of data
125
125
  // on a page is not that much
126
- $.get("/fe/answer_sheets/<%= @answer_sheet.id %>/page/<%= @page.id %>/edit?answer_sheet_type=<%= @answer_sheet.class.name %>", function(data) {
126
+ $.get("<%= edit_fe_answer_sheet_page_path(@answer_sheet, @page, answer_sheet_type: @answer_sheet.class.name, a: params[:a]).html_safe %>", function(data) {
127
127
  $('<%= base_element %>').replaceWith($(data).find('<%= base_element %>'))
128
128
  });
129
129
  },
@@ -0,0 +1,72 @@
1
+ module Fe
2
+ # Class names accepted from request params for answer sheets. Defaults to
3
+ # the answer sheet class plus Fe::ReferenceSheet.
4
+ mattr_accessor :answer_sheet_types
5
+ self.answer_sheet_types = nil
6
+
7
+ mattr_accessor :element_types
8
+ self.element_types = %w[
9
+ Fe::Section Fe::Paragraph Fe::TextField Fe::ChoiceField Fe::DateField
10
+ Fe::QuestionGrid Fe::QuestionGridWithTotal Fe::AttachmentField
11
+ Fe::ReferenceQuestion Fe::StateChooser
12
+ ]
13
+
14
+ def self.allowed_answer_sheet_types
15
+ answer_sheet_types || [answer_sheet_class, 'Fe::ReferenceSheet']
16
+ end
17
+
18
+ def self.answer_sheet_class_for(name)
19
+ return answer_sheet_class.constantize if name.blank?
20
+ raise ActiveRecord::RecordNotFound unless allowed_answer_sheet_types.include?(name)
21
+ name.constantize
22
+ end
23
+
24
+ def self.element_class_for(name)
25
+ raise ActiveRecord::RecordNotFound unless element_types.include?(name)
26
+ name.constantize
27
+ end
28
+
29
+ def self.access_key_matches?(answer_sheet, key)
30
+ return false unless key.is_a?(String) && !key.empty?
31
+ expected = answer_sheet.access_key
32
+ return false if expected.blank?
33
+ ActiveSupport::SecurityUtils.secure_compare(expected, key)
34
+ end
35
+
36
+ class MissingAccessHook < StandardError; end
37
+
38
+ ANSWER_SHEET_CONTROLLERS = %w[
39
+ Fe::AnswerSheetsController Fe::AnswerPagesController Fe::ReferenceSheetsController
40
+ Fe::SubmitPagesController Fe::ReferencePagesController Fe::ReferencesController
41
+ ].freeze
42
+
43
+ ADMIN_CONTROLLERS = %w[
44
+ Fe::Admin::QuestionSheetsController Fe::Admin::QuestionPagesController
45
+ Fe::Admin::ElementsController Fe::Admin::EmailTemplatesController
46
+ ].freeze
47
+
48
+ mattr_accessor :verify_access_hooks_on_boot
49
+ self.verify_access_hooks_on_boot = true
50
+
51
+ # Raises Fe::MissingAccessHook unless the host app defines the access hooks
52
+ # on every fe controller that calls them. Fe::AnswerSheetsController also
53
+ # calls fe_admin? (index and create), and Fe::ReferenceSheetsController
54
+ # inherits it. fe must never define either hook itself, or this check could
55
+ # not detect a missing host implementation.
56
+ def self.verify_access_hooks!
57
+ missing = {
58
+ fe_can_use_answer_sheet?: ANSWER_SHEET_CONTROLLERS,
59
+ fe_admin?: ADMIN_CONTROLLERS + %w[Fe::AnswerSheetsController]
60
+ }.flat_map do |hook, names|
61
+ names.map(&:constantize).reject { |klass|
62
+ klass.method_defined?(hook) || klass.private_method_defined?(hook)
63
+ }.map { |klass| [hook, klass.name] }
64
+ end
65
+ return if missing.empty?
66
+
67
+ lines = missing.map { |hook, name| " #{name} needs ##{hook}" }
68
+ raise MissingAccessHook,
69
+ "The host app must define fe's access hooks, and these are missing:\n#{lines.join("\n")}\n" \
70
+ 'See the "Access control" section of the fe README.'
71
+ end
72
+ end
data/lib/fe/engine.rb CHANGED
@@ -58,6 +58,10 @@ module Fe
58
58
  end
59
59
  end
60
60
 
61
+ config.after_initialize do
62
+ Fe.verify_access_hooks! if Fe.verify_access_hooks_on_boot
63
+ end
64
+
61
65
  config.generators do |g|
62
66
  g.test_framework :rspec, fixture: false
63
67
  g.fixture_replacement :factory_bot, dir: 'spec/factories'
data/lib/fe/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Fe
2
- VERSION = "2.3.2"
2
+ VERSION = "2.4.0"
3
3
  end
data/lib/fe.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "fe/engine"
2
+ require "fe/access_control"
2
3
 
3
4
  module Fe
4
5
  LANGUAGES = { 'es' => 'Español', 'pt' => 'Português' }
@@ -1,6 +1,24 @@
1
1
  require 'rails_helper'
2
2
 
3
3
  describe Fe::Admin::ElementsController, type: :controller do
4
+ context 'element_type param' do
5
+ let(:page) { create(:page) }
6
+ let(:question_sheet) { page.question_sheet }
7
+
8
+ it 'rejects a disallowed type on #new' do
9
+ expect {
10
+ get :new, params: {element_type: 'Fe::Application', question_sheet_id: question_sheet.id, page_id: page.id}, xhr: true
11
+ }.to raise_error(ActiveRecord::RecordNotFound)
12
+ end
13
+
14
+ it 'rejects a disallowed type on #create without creating anything' do
15
+ expect {
16
+ expect {
17
+ post :create, params: {element_type: 'Fe::Application', element: { style: 'style' }, question_sheet_id: question_sheet.id, page_id: page.id}, xhr: true
18
+ }.to raise_error(ActiveRecord::RecordNotFound)
19
+ }.not_to change { Fe::Element.count }
20
+ end
21
+ end
4
22
  context '#show' do
5
23
  it 'should work' do
6
24
  answer_sheet = create(:answer_sheet)
@@ -212,3 +230,14 @@ describe Fe::Admin::ElementsController, type: :controller do
212
230
  end
213
231
  end
214
232
  end
233
+
234
+ describe Fe::Admin::ElementsController, type: :controller do
235
+ it 'returns 403 on #create and creates nothing when the host says the person is not an admin' do
236
+ allow(controller).to receive(:fe_admin?).and_return(false)
237
+ page = create(:page)
238
+ expect {
239
+ post :create, params: {element_type: 'Fe::TextField', element: { style: 'style' }, question_sheet_id: page.question_sheet.id, page_id: page.id}, xhr: true
240
+ }.not_to change { Fe::Element.count }
241
+ expect(response).to have_http_status(:forbidden)
242
+ end
243
+ end
@@ -27,3 +27,11 @@ describe Fe::Admin::EmailTemplatesController, type: :controller do
27
27
  end
28
28
  end
29
29
  end
30
+
31
+ describe Fe::Admin::EmailTemplatesController, type: :controller do
32
+ it 'returns 403 on #index when the host says the person is not an admin' do
33
+ allow(controller).to receive(:fe_admin?).and_return(false)
34
+ get :index
35
+ expect(response).to have_http_status(:forbidden)
36
+ end
37
+ end
@@ -9,3 +9,12 @@ describe Fe::Admin::QuestionPagesController, type: :controller do
9
9
  end
10
10
  end
11
11
  end
12
+
13
+ describe Fe::Admin::QuestionPagesController, type: :controller do
14
+ it 'returns 403 on #show when the host says the person is not an admin' do
15
+ allow(controller).to receive(:fe_admin?).and_return(false)
16
+ page = create(:page)
17
+ get :show, params: {question_sheet_id: page.question_sheet.id, id: page.id}, xhr: true
18
+ expect(response).to have_http_status(:forbidden)
19
+ end
20
+ end