panda_pal 5.0.0 → 5.2.3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +208 -90
  3. data/app/controllers/panda_pal/lti_controller.rb +0 -18
  4. data/app/controllers/panda_pal/lti_v1_p0_controller.rb +34 -0
  5. data/app/controllers/panda_pal/lti_v1_p3_controller.rb +98 -0
  6. data/app/lib/lti_xml/base_platform.rb +4 -4
  7. data/app/lib/panda_pal/launch_url_helpers.rb +69 -0
  8. data/app/lib/panda_pal/lti_jwt_validator.rb +88 -0
  9. data/app/lib/panda_pal/misc_helper.rb +13 -0
  10. data/app/models/panda_pal/organization.rb +21 -47
  11. data/app/models/panda_pal/organization_concerns/settings_validation.rb +127 -0
  12. data/app/models/panda_pal/organization_concerns/task_scheduling.rb +204 -0
  13. data/app/models/panda_pal/platform.rb +40 -0
  14. data/app/views/panda_pal/lti_v1_p3/login.html.erb +1 -0
  15. data/app/views/panda_pal/partials/_auto_submit_form.html.erb +9 -0
  16. data/config/dev_lti_key.key +27 -0
  17. data/config/routes.rb +12 -2
  18. data/db/migrate/20160412205931_create_panda_pal_organizations.rb +1 -1
  19. data/db/migrate/20160413135653_create_panda_pal_sessions.rb +1 -1
  20. data/db/migrate/20160425130344_add_panda_pal_organization_to_session.rb +1 -1
  21. data/db/migrate/20170106165533_add_salesforce_id_to_organizations.rb +1 -1
  22. data/db/migrate/20171205183457_encrypt_organization_settings.rb +1 -1
  23. data/db/migrate/20171205194657_remove_old_organization_settings.rb +8 -3
  24. data/lib/panda_pal.rb +28 -15
  25. data/lib/panda_pal/engine.rb +8 -39
  26. data/lib/panda_pal/helpers.rb +1 -0
  27. data/lib/panda_pal/helpers/controller_helper.rb +139 -44
  28. data/lib/panda_pal/helpers/route_helper.rb +8 -8
  29. data/lib/panda_pal/helpers/secure_headers.rb +79 -0
  30. data/lib/panda_pal/version.rb +1 -1
  31. data/panda_pal.gemspec +6 -2
  32. data/spec/dummy/config/application.rb +7 -1
  33. data/spec/dummy/config/environments/development.rb +0 -14
  34. data/spec/dummy/config/environments/production.rb +0 -11
  35. data/spec/models/panda_pal/organization/settings_validation_spec.rb +175 -0
  36. data/spec/models/panda_pal/organization/task_scheduling_spec.rb +144 -0
  37. data/spec/models/panda_pal/organization_spec.rb +0 -89
  38. data/spec/spec_helper.rb +4 -0
  39. metadata +64 -8
  40. data/spec/dummy/config/initializers/assets.rb +0 -11
@@ -0,0 +1,204 @@
1
+ return unless defined?(Sidekiq.schedule)
2
+
3
+ require_relative 'settings_validation'
4
+
5
+ module PandaPal
6
+ module OrganizationConcerns
7
+ module TaskScheduling
8
+ extend ActiveSupport::Concern
9
+ include OrganizationConcerns::SettingsValidation
10
+
11
+ included do
12
+ after_commit :sync_schedule, on: [:create, :update]
13
+ after_commit :unschedule_tasks, on: :destroy
14
+ end
15
+
16
+ class_methods do
17
+ def _schedule_descriptors
18
+ @_schedule_descriptors ||= {}
19
+ end
20
+
21
+ def settings_structure
22
+ return super unless _schedule_descriptors.present?
23
+
24
+ super.tap do |struc|
25
+ struc[:properties] ||= {}
26
+
27
+ struc[:properties][:timezone] ||= {
28
+ type: 'String',
29
+ required: false,
30
+ validate: ->(timezone, *args) {
31
+ ActiveSupport::TimeZone[timezone].present? ? nil : "<path> Invalid Timezone '#{timezone}'"
32
+ },
33
+ }
34
+
35
+ struc[:properties][:task_schedules] = {
36
+ type: 'Hash',
37
+ required: false,
38
+ properties: _schedule_descriptors.keys.reduce({}) do |hash, k|
39
+ desc = _schedule_descriptors[k]
40
+
41
+ hash.tap do |hash|
42
+ kl = ' ' * (k.to_s.length - 4)
43
+ hash[k.to_sym] = hash[k.to_s] = {
44
+ required: false,
45
+ description: <<~MARKDOWN,
46
+ Override schedule for '#{k.to_s}' task.
47
+
48
+ **Default**: #{desc[:schedule].is_a?(String) ? desc[:schedule] : '<Computed>'}
49
+
50
+ Set to `false` to disable or supply a Cron string:
51
+ ```yaml
52
+ #{k.to_s}: 0 0 0 * * * America/Denver
53
+ ##{kl} │ │ │ │ │ │ └── Timezone (Optional)
54
+ ##{kl} │ │ │ │ │ └── Day of Week
55
+ ##{kl} │ │ │ │ └── Month
56
+ ##{kl} │ │ │ └── Day of Month
57
+ ##{kl} │ │ └── Hour
58
+ ##{kl} │ └── Minute
59
+ ##{kl} └── Second (Optional)
60
+ ````
61
+ MARKDOWN
62
+ json_schema: {
63
+ oneOf: [
64
+ { type: 'string', pattern: '^((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,6})(\w+\/\w+)?$' },
65
+ { enum: [false] },
66
+ ],
67
+ default: desc[:schedule].is_a?(String) ? desc[:schedule] : '0 0 3 * * * America/Denver',
68
+ },
69
+ validate: ->(value, *args, errors:, **kwargs) {
70
+ begin
71
+ Rufus::Scheduler.parse(value) if value
72
+ nil
73
+ rescue ArgumentError
74
+ errors << "<path> must be false or a Crontab string"
75
+ end
76
+ }
77
+ }
78
+ end
79
+ end,
80
+ }
81
+ end
82
+ end
83
+
84
+ def scheduled_task(cron_time, name_or_method = nil, worker: nil, queue: nil, &block)
85
+ task_key = (name_or_method.presence || "scheduled_task_#{caller_locations[0].lineno}").to_s
86
+ raise "Task key '#{task_key}' already taken!" if _schedule_descriptors.key?(task_key)
87
+
88
+ _schedule_descriptors[task_key] = {
89
+ key: task_key,
90
+ schedule: cron_time,
91
+ worker: worker || block || name_or_method.to_sym,
92
+ queue: queue || 'default',
93
+ }
94
+ end
95
+
96
+ def remove_scheduled_task(name_or_method)
97
+ dval = _schedule_descriptors.delete(name_or_method.to_s)
98
+ Rails.logger.warn("No task with key '#{name_or_method}' to delete!") unless dval.present?
99
+ end
100
+
101
+ def sync_schedules
102
+ # Ensure deleted Orgs are removed
103
+ existing_orgs = pluck(:name)
104
+ old_schedules = Sidekiq.get_schedule.select do |k, v|
105
+ m = k.match(/^org:([a-z0-9_]+)\-/i)
106
+ m.present? && !existing_orgs.include?(m[1])
107
+ end
108
+ old_schedules.keys.each do |k|
109
+ Sidekiq.remove_schedule(k)
110
+ end
111
+
112
+ find_each(&:sync_schedule)
113
+ end
114
+ end
115
+
116
+ def generate_schedule
117
+ schedule = {}
118
+ self.class._schedule_descriptors.values.each do |desc|
119
+ cron_time = schedule_task_cron_time(desc)
120
+ next unless cron_time.present?
121
+
122
+ schedule["org:#{name}-#{desc[:key]}"] = {
123
+ 'cron' => cron_time,
124
+ 'queue' => desc[:queue],
125
+ 'class' => ScheduledTaskExecutor.to_s,
126
+ 'args' => [name, desc[:key]],
127
+ }
128
+ end
129
+ schedule
130
+ end
131
+
132
+ def sync_schedule
133
+ new_schedules = generate_schedule
134
+ unschedule_tasks(new_schedules.keys)
135
+ new_schedules.each do |k, v|
136
+ Sidekiq.set_schedule(k, v)
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def unschedule_tasks(new_task_keys = nil)
143
+ current_schedules = Sidekiq.get_schedule.select { |k,v| k.starts_with?("org:#{name}-") }
144
+ del_tasks = current_schedules.keys
145
+ del_tasks -= new_task_keys if new_task_keys
146
+ del_tasks.each do |k|
147
+ Sidekiq.remove_schedule(k)
148
+ end
149
+ end
150
+
151
+ def schedule_task_cron_time(desc)
152
+ cron_time = nil
153
+ cron_time = settings&.dig(:task_schedules, desc[:key].to_s) if cron_time.nil?
154
+ cron_time = settings&.dig(:task_schedules, desc[:key].to_sym) if cron_time.nil?
155
+ cron_time = desc[:schedule] if cron_time.nil?
156
+
157
+ return nil unless cron_time.present?
158
+
159
+ cron_time = instance_exec(&cron_time) if cron_time.is_a?(Proc)
160
+ if !Rufus::Scheduler.parse(cron_time).zone.present? && settings && settings[:timezone]
161
+ cron_time += " #{settings[:timezone]}"
162
+ end
163
+
164
+ cron_time
165
+ end
166
+
167
+ class ScheduledTaskExecutor
168
+ include Sidekiq::Worker
169
+
170
+ def perform(org_name, task_key)
171
+ org = Organization.find_by!(name: org_name)
172
+ task = Organization._schedule_descriptors[task_key]
173
+ worker = task[:worker]
174
+
175
+ Apartment::Tenant.switch(org.name) do
176
+ if worker.is_a?(Proc)
177
+ org.instance_exec(&worker)
178
+ elsif worker.is_a?(Symbol)
179
+ org.send(worker)
180
+ elsif worker.is_a?(String)
181
+ worker.constantize.perform_async
182
+ elsif worker.is_a?(Class)
183
+ worker.perform_async
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ SidekiqScheduler::Scheduler.instance.dynamic = true
193
+
194
+ module SidekiqScheduler
195
+ module Schedule
196
+ original_schedule_setter = instance_method(:schedule=)
197
+
198
+ define_method :schedule= do |sched|
199
+ original_schedule_setter.bind(self).(sched).tap do
200
+ PandaPal::Organization.sync_schedules
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,40 @@
1
+ module PandaPal
2
+ class Platform
3
+ def public_jwks
4
+ response = HTTParty.get(jwks_url)
5
+ return nil unless response.success?
6
+
7
+ JSON::JWK::Set.new(JSON.parse(response.body))
8
+ rescue
9
+ nil
10
+ end
11
+ end
12
+
13
+ class Platform::Canvas < Platform
14
+ attr_accessor :base_url
15
+
16
+ def initialize(base_url)
17
+ @base_url = base_url
18
+ end
19
+
20
+ def host
21
+ base_url
22
+ end
23
+
24
+ def jwks_url
25
+ "#{base_url}/api/lti/security/jwks"
26
+ end
27
+
28
+ def authentication_redirect_url
29
+ "#{base_url}/api/lti/authorize_redirect"
30
+ end
31
+
32
+ def grant_url
33
+ "#{base_url}/login/oauth2/token"
34
+ end
35
+ end
36
+
37
+ class Platform
38
+ CANVAS = Platform::Canvas.new('https://canvas.instructure.com')
39
+ end
40
+ end
@@ -0,0 +1 @@
1
+ <%= render "panda_pal/partials/auto_submit_form" %>
@@ -0,0 +1,9 @@
1
+ <%= form_tag(@form_action, method: @method, id: 'redirect-form') do %>
2
+ <% @form_data.each do |k, v| %>
3
+ <%= hidden_field_tag(k, v) %>
4
+ <% end %>
5
+ <% end %>
6
+
7
+ <script type="text/javascript" nonce="<%= content_security_policy_script_nonce %>">
8
+ document.getElementById('redirect-form').submit();
9
+ </script>
@@ -0,0 +1,27 @@
1
+ -----BEGIN RSA PRIVATE KEY-----
2
+ MIIEowIBAAKCAQEAt+Za2scYD223YjVvAeuxpYvBjTf4yp2Y3udPFllG85zIVH/K
3
+ XKsP4QVP9de/fh4FzouZPOfXMDY1KmWSlMK9jNYUm2rh0zp7+X/F6cnc5QMCzbqV
4
+ MNnfALI4kvGM8yNPLbMJx4nA47T7T9TmlMJUZQxtyvC8zxBMv8Mv4Ae0arXevpI1
5
+ Vcm/3Huz6dAJPRjTnL2CBsXrmNzf51OJU5r27HYFE4367g9njix1lGOcQqVDVjnT
6
+ uXFTlidfUa2Bp2v5owzZU7Fe9jSQmeAQpqQ/XdYzWQNEJPrz8ESz5jP3brJ4q5Y5
7
+ FaK9l8DHILqomGWhttYQ6IMCfdpLb4D4B2gcLQIDAQABAoIBAAuaXislmrAGhSaO
8
+ JoXhgCDo03p8iJcIIIgX4haP5XkjcERcl8EHDgZtlmD1juB/NnCUwENmgV5KXUpi
9
+ hEAclWcYbs5rjPoN25qfZDZfBS/x47BlUFp3tKlPlWA4G2OP28QPYtOTLndviNe9
10
+ oBrMtBR4F0lRrSgHaEBFKXUiJ1EAMycKcXab63HA7Fp9HArLAu0NM0VWCsJBmxB/
11
+ BprW4LtYRG7iJl9kfsHyAzOaAV5H1zLqhfA2YvEI2XADrILQtpPwZpmNKq/5L5On
12
+ RlGy1WcBgxj8cqWdqir1PxVN/xDKVnUT9Mrf+SHn7xzgDL2uXvhD1Qt/5iLeVlvN
13
+ nQTNJyECgYEA681T7A0oj9/FuqLwE1gxm+RUczv+EyXcNvHCUkpFSaiyEyqdQB9f
14
+ EpHgeg7pXmlRmUwNkT3ZAH7O0uv3CVR6b8j9N7XmYbmcuRU2A6loeiBB/HkOOt21
15
+ hxiC+tXD/K7c5BfRkThL6ca213xqztbqxxj/C8qGnWVaLc7SLPlJZfUCgYEAx6bp
16
+ 7tRGq8Q5xkyKvSDUSln9MI/iZjSysd44rTtj8Vo9LfQsBi5JbWm0+UEtaZoabeC1
17
+ xaAp/ZETLe4oGu7CJxBreSE+UE/a6zc5MIcJblP+8RIiAYkf67iHNwSbiqSBvPqj
18
+ S0HFxUZqw1NigmqsptHrPtvuTUZI8Hx3hKMgwlkCgYAa8RrlnZtE1QyChptnmmwQ
19
+ o8YCZJhjF7BRls3dGR9RizTNe9D7wpnaRVCgoZOIdgAcw9PJBIgGxnZbIxrWthBH
20
+ NW+5Lc9k2xBNFV9Wi8SkL4tajXpSv4I+LU7J2iLKfDBA33fSX9xMmafKdyy89VFd
21
+ 7j01264Fzc6/7SGWgeUhAQKBgDKGSgMXkz7arKhDLIUKLs8WEN3eO7QTt/kNPJiS
22
+ RAuLA5qChTWXNxvKOXMujFiCGBggWr/FdXrm4MypzVpre5S5Mgl4YTWfz83grsda
23
+ FQfnl8fYB+UNl5dmnklNEDO4x+BUKUjdPzhaRqBhlLdeWYzp6LeCnr7Nf53kUbau
24
+ NZcZAoGBAJcYyWegsQCEVMY/ck42uNmZ00DmK1XSAU7hYulBo8iD3OqvXZ7Etppw
25
+ SaRhSTCoTsLBWlqGccNDGoXH/r2/jJAFb8hUjGa2Z5dKP9byWMONEQEh6g8wzL/w
26
+ XRthIsgdIE58a+cJGwHh/raUQwvD72S1C/epQSYcLe3TdUumtZm/
27
+ -----END RSA PRIVATE KEY-----
@@ -1,4 +1,14 @@
1
1
  PandaPal::Engine.routes.draw do
2
- get '/config' => 'lti#tool_config'
3
- get '/launch' => 'lti#launch'
2
+ get '/config' => 'lti_v1_p0#tool_config' # Legacy Support
3
+
4
+ scope '/v1p0', as: 'v1p0' do
5
+ get '/config' => 'lti_v1_p0#tool_config'
6
+ end
7
+
8
+ scope '/v1p3', as: 'v1p3' do
9
+ get '/config' => 'lti_v1_p3#tool_config'
10
+ post '/oidc_login' => 'lti_v1_p3#login'
11
+ post '/resource_link_request' => 'lti_v1_p3#resource_link_request'
12
+ get '/public_jwks' => 'lti_v1_p3#public_jwks'
13
+ end
4
14
  end
@@ -1,4 +1,4 @@
1
- class CreatePandaPalOrganizations < ActiveRecord::Migration[5.1]
1
+ class CreatePandaPalOrganizations < PandaPal::MiscHelper::MigrationClass
2
2
  def change
3
3
  create_table :panda_pal_organizations do |t|
4
4
  t.string :name
@@ -1,4 +1,4 @@
1
- class CreatePandaPalSessions < ActiveRecord::Migration[5.1]
1
+ class CreatePandaPalSessions < PandaPal::MiscHelper::MigrationClass
2
2
  def change
3
3
  create_table :panda_pal_sessions do |t|
4
4
  t.string :session_key
@@ -1,4 +1,4 @@
1
- class AddPandaPalOrganizationToSession < ActiveRecord::Migration[5.1]
1
+ class AddPandaPalOrganizationToSession < PandaPal::MiscHelper::MigrationClass
2
2
  def change
3
3
  add_column :panda_pal_sessions, :panda_pal_organization_id, :integer
4
4
  add_index :panda_pal_sessions, :panda_pal_organization_id
@@ -1,4 +1,4 @@
1
- class AddSalesforceIdToOrganizations < ActiveRecord::Migration[5.1]
1
+ class AddSalesforceIdToOrganizations < PandaPal::MiscHelper::MigrationClass
2
2
  def change
3
3
  add_column :panda_pal_organizations, :salesforce_id, :string, unique: true
4
4
  end
@@ -1,4 +1,4 @@
1
- class EncryptOrganizationSettings < ActiveRecord::Migration[5.1]
1
+ class EncryptOrganizationSettings < PandaPal::MiscHelper::MigrationClass
2
2
  def up
3
3
  # don't rerun this if it was already run before we renamed the migration.
4
4
  existing_versions = execute ("SELECT * from schema_migrations where version = '30171205183457'")
@@ -1,4 +1,4 @@
1
- class RemoveOldOrganizationSettings < ActiveRecord::Migration[5.1]
1
+ class RemoveOldOrganizationSettings < PandaPal::MiscHelper::MigrationClass
2
2
  def current_tenant
3
3
  @current_tenant ||= PandaPal::Organization.find_by_name(Apartment::Tenant.current)
4
4
  end
@@ -10,14 +10,16 @@ class RemoveOldOrganizationSettings < ActiveRecord::Migration[5.1]
10
10
  execute "DELETE from schema_migrations where version = '30171205194657'"
11
11
  return
12
12
  end
13
+
13
14
  # migrations run for public and local tenants. However, PandaPal::Organization
14
15
  # is going to always go to public tenant. So don't do this active record
15
16
  # stuff unless we are on the public tenant.
16
- if current_tenant == 'public'
17
+ if Apartment::Tenant.current == 'public'
17
18
  #PandaPal::Organization.connection.schema_cache.clear!
18
19
  #PandaPal::Organization.reset_column_information
19
20
  PandaPal::Organization.find_each do |o|
20
21
  # Would like to just be able to do this:
22
+ # PandaPal::Organization.reset_column_information
21
23
  # o.settings = YAML.load(o.old_settings)
22
24
  # o.save!
23
25
  # but for some reason that is always making the settings null. Instead we will encrypt the settings manually.
@@ -26,14 +28,17 @@ class RemoveOldOrganizationSettings < ActiveRecord::Migration[5.1]
26
28
  key = o.encryption_key
27
29
  encrypted_settings = PandaPal::Organization.encrypt_settings(YAML.load(o.old_settings), iv: iv, key: key)
28
30
  o.update_columns(encrypted_settings_iv: [iv].pack("m"), encrypted_settings: encrypted_settings)
31
+ o = PandaPal::Organization.find_by!(name: o.name)
32
+ raise "Failed to migrate PandaPal Settings" if o.settings != YAML.load(o.old_settings)
29
33
  end
30
34
  end
35
+
31
36
  remove_column :panda_pal_organizations, :old_settings
32
37
  end
33
38
 
34
39
  def down
35
40
  add_column :panda_pal_organizations, :old_settings, :text
36
- if current_tenant == 'public'
41
+ if Apartment::Tenant.current == 'public'
37
42
  PandaPal::Organization.find_each do |o|
38
43
  o.old_settings = o.settings.to_yaml
39
44
  o.save
@@ -7,11 +7,11 @@ module PandaPal
7
7
  class NotMounted < StandardError;end
8
8
 
9
9
  @@lti_navigation = {}
10
- @@staged_navigation = {}
11
10
  @@lti_options = {}
12
11
  @@lti_properties = {}
13
12
  @@lti_environments = {}
14
13
  @@lti_custom_params = {}
14
+ @@lti_private_key = nil
15
15
 
16
16
  def self.lti_options= lti_options
17
17
  @@lti_options = lti_options
@@ -45,31 +45,44 @@ module PandaPal
45
45
  @@lti_custom_params.deep_dup
46
46
  end
47
47
 
48
- def self.register_navigation(navigation)
49
- @@lti_navigation[navigation] ||= {}
50
- end
51
-
52
48
  def self.stage_navigation(navigation, options)
53
- @@staged_navigation[navigation] = {} unless @@staged_navigation.has_key?(navigation)
54
- @@staged_navigation[navigation].merge!(options)
49
+ @@lti_navigation[navigation] ||= {}
50
+ @@lti_navigation[navigation].merge!(options)
55
51
  end
56
52
 
57
53
  def self.lti_paths
58
54
  @@lti_navigation.deep_dup
59
55
  end
60
56
 
61
- def self.propagate_lti_navigation
62
- @@staged_navigation.each do |k,v|
63
- lti_navigation(k,v)
64
- @@staged_navigation.delete(k)
65
- end
57
+ def self.lti_private_key
58
+ key = @@lti_private_key.presence
59
+ key ||= ENV['LTI_PRIVATE_KEY'].presence
60
+ key ||= File.read(File.join( File.dirname(__FILE__), "../config/dev_lti_key.key")) if Rails.env.development?
61
+ return nil unless key.present?
62
+
63
+ key = OpenSSL::PKey::RSA.new(key) if key.is_a?(String)
64
+ key
65
+ end
66
+
67
+ def self.lti_private_key=(v)
68
+ @@lti_private_key = k
66
69
  end
67
70
 
68
71
  private
69
72
 
70
- def self.lti_navigation(navigation, options)
71
- raise "lti navigation '#{navigation}' has not been registered!" unless @@lti_navigation.has_key?(navigation)
72
- @@lti_navigation[navigation].merge!(options)
73
+ def self.validate_pandapal_config!
74
+ errors = []
75
+ validate_lti_navigation(errors)
76
+ if errors.present?
77
+ lines = errors.map { |e| " - #{e}" }
78
+ raise "PandaPal was not configured correctly:\n#{lines.join("\n")}"
79
+ end
73
80
  end
74
81
 
82
+ def self.validate_lti_navigation(errors = [])
83
+ @@lti_navigation.each do |k, v|
84
+ errors << "lti navigation '#{k}' does not have a Route!" unless (LaunchUrlHelpers.launch_url(k) rescue nil)
85
+ end
86
+ errors
87
+ end
75
88
  end