panda_pal 5.17.0 → 5.17.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa9bee5cce120a4bf340d69ebd142e6f11f8d65bb0b4d850bb781662469ba84e
4
- data.tar.gz: b1040ec6ecbd33c7ff5fe0eef396a2e5d1764ec893a1c439b5cd8de0ec7d726c
3
+ metadata.gz: 2f32b5b78d11428c9f4be64fc963ae2fac80cae44abfd66dcda0f39be8b17006
4
+ data.tar.gz: cb494809a3aa72956b16bad0b7b17ba095156a1c4a7fa7261a51738d92442630
5
5
  SHA512:
6
- metadata.gz: 03ffb7a4bf0fe654ad310e8a8c34e69b1297ed02ab3a38a989d0712a630e2c41ca52cb76333ef0d400acabd3688a5c2d33621ab24d59027698e444581260403d
7
- data.tar.gz: 522d2c9847b0225228d808a053a0eb278d872750883758bc60ccb87128162645fe989d7fa1e14f7fb9384fea69e7fae540dcaf02d4927f1691dc21c1f53223fe
6
+ metadata.gz: 7819962aa331b91ae3b6ad0d95fdb8e5bf314d691cdaa4f120a1f12b199d26c916a811b9f071b9fd73802c8893ae70b6a6158a7ea9457b602f1ea2aa496d4092
7
+ data.tar.gz: 5cfd2d2f902e658968b3ce2ce2495c5a58e605071db2c97436186b2893f178d2c198856ac42acd9371f6462d108a2a959834958b84ad1fa9d4f9c0740686cc93
@@ -9,6 +9,16 @@ module PandaPal
9
9
  self.data ||= {}.with_indifferent_access
10
10
  end
11
11
 
12
+ # ActiveModel 8.0 skips its own "mark every duped attribute as changed" step
13
+ # when the source record isn't persisted, and validate_v1p3_launch deletes the
14
+ # login session before duping it. Under partial_inserts the resulting INSERT
15
+ # then omits session_secret, which lands NULL and breaks every later
16
+ # panda_token signature check. Force a full row on every dup.
17
+ def initialize_dup(other)
18
+ super
19
+ attribute_names.each { |name| attribute_will_change!(name) }
20
+ end
21
+
12
22
  delegate :dig, to: :data
13
23
 
14
24
  def self.for_request(request, params = request.params, enforce_tenant: true)
@@ -383,7 +393,7 @@ module PandaPal
383
393
  ROLE_TYPES.each do |rt|
384
394
  # LIS V1
385
395
  if role.downcase.start_with?(rt[:urn])
386
- return clean_role(ContextTypeURN, role), rt
396
+ return clean_role(rt[:urn], role), rt
387
397
  end
388
398
 
389
399
  # LIS V2
@@ -50,6 +50,34 @@ module Apartment
50
50
  bits[0] = "default" if bits[0].nil? || bits[0].empty?
51
51
  bits
52
52
  end
53
+
54
+ # Lazily switch a long-lived worker thread to `tenant_name` for the duration of the block,
55
+ # without touching the DB up front. Actively switching checks out a connection and runs
56
+ # SET search_path even for work that never queries anything, so this only *stages* the
57
+ # tenant; the ActiveRecord `checkout` hook registered below applies it the moment a
58
+ # connection is actually acquired, and only then.
59
+ #
60
+ # For anything that runs app code on a worker thread outside a normal request/job cycle --
61
+ # ActionCable's `:work` callback below, CableRoom's `Room#around_work` below, or a similar
62
+ # hook of your own -- this is the one place that logic lives; call it instead of
63
+ # reimplementing the stage/release/restore dance yourself.
64
+ def self.with_lazy_tenant(tenant_name)
65
+ Thread.current[:cable_tenant] = {
66
+ adapter: Apartment::Tenant.adapter,
67
+ tenant: tenant_name,
68
+ }
69
+
70
+ # If this thread already holds a connection from earlier work, release it back to the pool
71
+ # so the checkout hook below gets a fresh checkout to apply the schema to.
72
+ pool = Apartment.connection_class.connection_pool
73
+ pool.release_connection if pool.active_connection?
74
+
75
+ Apartment::Tenant.adapter.instance_variable_set(:@current, tenant_name)
76
+
77
+ yield
78
+ ensure
79
+ Thread.current[:cable_tenant] = nil
80
+ end
53
81
  end
54
82
 
55
83
  module Adapters
@@ -319,30 +347,10 @@ ActiveSupport.on_load(:action_cable) do
319
347
  end
320
348
  end
321
349
 
322
- # Lazily switch any worker threads to the correct tenant when they are working
323
- # Actively calling `switch_tenant` checks out a DB connection and calls `SET search_path`.
324
- # The message processing may not interface with the DB, so this would be a huge waste.
325
- # Instead, we ensure that the thread will trigger a :checkout if it needs a connection,
326
- # at which time we hack-in the correct tenant/schema.
327
-
350
+ # Lazily switch any worker threads to the correct tenant when they are working (see
351
+ # Apartment::Tenant.with_lazy_tenant above for why this is lazy, not an active switch).
328
352
  ActionCable::Server::Worker.set_callback :work, :around do |_, blk|
329
- # Bit of a hack, but placing this here ensures the adapter is initialized (since such may incur DB calls (requiring a connection)
330
- # which could then cause `checkout` to recurse)
331
- Thread.current[:cable_tenant] = {
332
- adapter: Apartment::Tenant.adapter,
333
- tenant: connection.tenant,
334
- }
335
-
336
- # If the current thread already has a connection checked out, release it back to the pool so that the later after_checkout
337
- # callback can set the schema properly.
338
- pool = Apartment.connection_class.connection_pool
339
- pool.release_connection if pool.active_connection?
340
-
341
- Apartment::Tenant.adapter.instance_variable_set(:@current, connection.tenant)
342
-
343
- blk.call
344
- ensure
345
- Thread.current[:cable_tenant] = nil
353
+ Apartment::Tenant.with_lazy_tenant(connection.tenant, &blk)
346
354
  end
347
355
 
348
356
  ActiveSupport.on_load(:active_record) do
@@ -358,6 +366,17 @@ ActiveSupport.on_load(:action_cable) do
358
366
  end
359
367
  end
360
368
 
369
+ # cable_room runs a Room's own code -- message handlers, startup, shutdown, snapshotting -- on a
370
+ # Host worker thread, never inside ActionCable's `:work` callback (its worker pool deliberately
371
+ # isn't an ActionCable::Server::Worker subclass, so it doesn't share that callback chain).
372
+ # `Room#around_work` is its equivalent seam; hook it the same lazy way, on every Room, via
373
+ # CableRoom::Room::Base so every app Room picks it up.
374
+ if defined?(CableRoom)
375
+ CableRoom::Room::Base.around_work do |room, blk|
376
+ Apartment::Tenant.with_lazy_tenant(room.tenant, &blk)
377
+ end
378
+ end
379
+
361
380
  if defined?(Delayed)
362
381
  module PandaPal::Plugins
363
382
  class ApartmentDelayedJobsPlugin < ::Delayed::Plugin
@@ -27,6 +27,10 @@ module PandaPal
27
27
  csp_entry(:connect_src, "http://localhost:3035")
28
28
  csp_entry(:connect_src, "ws://localhost:3035")
29
29
 
30
+ # Vite dev server
31
+ csp_entry(:connect_src, "http://localhost:3036")
32
+ csp_entry(:connect_src, "ws://localhost:3036")
33
+
30
34
  # Allow stuff like rack-mini-profiler to work in development:
31
35
  # https://github.com/MiniProfiler/rack-mini-profiler/issues/327
32
36
  csp_entry(:script_src, "'unsafe-eval'")
@@ -129,21 +129,23 @@ module PandaPal::Helpers
129
129
  type = type.to_s.downcase
130
130
  type = type[2..] if type.start_with?('t-')
131
131
 
132
+ base_payload = {
133
+ v: 1,
134
+ o: current_organization.id,
135
+ s: current_session.id,
136
+ }
137
+
132
138
  if type == 'url'
133
139
  raise StandardError, "URL is required" unless url.present?
134
140
  to_sign = PandaPal::Session.format_url_for_signing(url)
135
- payload.merge!(
141
+ payload = base_payload.merge(
136
142
  typ: 'T-URI',
137
143
  usig: current_session.sign_value(to_sign),
138
144
  )
139
145
  else
140
146
  @cached_link_nonces ||= {}
141
- @cached_link_nonces[type] ||= begin
142
- payload = {
143
- v: 1,
144
- o: current_organization.id,
145
- s: current_session.id,
146
- }
147
+ payload = @cached_link_nonces[type] ||= begin
148
+ payload = base_payload.dup
147
149
 
148
150
  if type == 'nonce'
149
151
  current_session[:link_nonce] = SecureRandom.hex
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = '5.17.0'
2
+ VERSION = '5.17.1'
3
3
  end
@@ -0,0 +1,72 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe PandaPal::Helpers::SessionReplacement do
4
+ let(:organization) { create(:panda_pal_organization) }
5
+ let(:session) { PandaPal::Session.create!(panda_pal_organization: organization, data: {}) }
6
+
7
+ let(:host_class) do
8
+ Class.new do
9
+ # Stub the concern's controller-only class macros.
10
+ def self.helper_method(*args); end
11
+ def self.prepend_around_action(*args); end
12
+
13
+ include PandaPal::Helpers::SessionReplacement
14
+
15
+ attr_reader :current_organization, :current_session
16
+
17
+ def initialize(organization, session)
18
+ @current_organization = organization
19
+ @current_session = session
20
+ end
21
+
22
+ def request
23
+ @request ||= Struct.new(:remote_ip).new('127.0.0.1')
24
+ end
25
+ end
26
+ end
27
+
28
+ subject(:controller) { host_class.new(organization, session) }
29
+
30
+ def decoded_payload(token)
31
+ JSON.parse(Base64.urlsafe_decode64(token.split('.').first))
32
+ end
33
+
34
+ describe '#link_nonce' do
35
+ it 'returns the same real payload on repeated calls within one request' do
36
+ first = controller.link_nonce(type: :nonce)
37
+ second = controller.link_nonce(type: :nonce)
38
+ third = controller.link_nonce(type: :nonce)
39
+
40
+ [first, second, third].each do |token|
41
+ expect(token.split('.').first).not_to eq(Base64.urlsafe_encode64('null'))
42
+ payload = decoded_payload(token)
43
+ expect(payload['typ']).to eq('T-NONCE')
44
+ expect(payload['o']).to eq(organization.id)
45
+ expect(payload['s']).to eq(session.id)
46
+ expect(payload['nnc']).to be_present
47
+ end
48
+
49
+ expect(second).to eq(first)
50
+ expect(third).to eq(first)
51
+ end
52
+
53
+ it 'builds a T-URI payload with the org and session the verifier requires' do
54
+ token = controller.link_nonce('https://example.test/a/b', type: :url)
55
+
56
+ payload = decoded_payload(token)
57
+ expect(payload['typ']).to eq('T-URI')
58
+ expect(payload['o']).to eq(organization.id)
59
+ expect(payload['s']).to eq(session.id)
60
+ expect(payload['usig']).to be_present
61
+ end
62
+
63
+ it 'still caches one nonce per type per request rather than rotating it' do
64
+ controller.link_nonce(type: :nonce)
65
+ nonce_after_first = session[:link_nonce]
66
+
67
+ controller.link_nonce(type: :nonce)
68
+
69
+ expect(session[:link_nonce]).to eq(nonce_after_first)
70
+ end
71
+ end
72
+ end
@@ -6,6 +6,53 @@ module PandaPal
6
6
  expect(PandaPal::Session.new.session_secret).to_not be_nil
7
7
  end
8
8
 
9
+ describe '#dup' do
10
+ # Mirrors validate_v1p3_launch: load the login session, delete it, dup it into
11
+ # the org's tenant. Only fails on Rails 8.0 with partial_inserts left on, so it
12
+ # is the appraisal run that gates this, not the default (Rails 8.1) lockfile.
13
+ it 'persists every attribute, including session_secret, on the duped record' do
14
+ original = PandaPal::Session.find(create(:panda_pal_session).id)
15
+ original_secret = original.session_secret
16
+ original.delete
17
+
18
+ with_partial_inserts(true) do
19
+ duped = original.dup
20
+ duped.save!
21
+
22
+ reloaded = PandaPal::Session.find(duped.id)
23
+ expect(reloaded.session_secret).to eq(original_secret)
24
+ end
25
+ end
26
+
27
+ # Rails 7.0 split partial_writes into partial_inserts/partial_updates; on 6.1
28
+ # the single partial_writes flag is what controls the INSERT.
29
+ def with_partial_inserts(value)
30
+ setting = PandaPal::Session.respond_to?(:partial_inserts) ? 'partial_inserts' : 'partial_writes'
31
+ previous = PandaPal::Session.public_send(setting)
32
+ PandaPal::Session.public_send("#{setting}=", value)
33
+ yield
34
+ ensure
35
+ PandaPal::Session.public_send("#{setting}=", previous) unless previous.nil?
36
+ end
37
+ end
38
+
39
+ describe Session::RoleStore do
40
+ it 'parses an LTI 1.1 (URN-style) institution role' do
41
+ role_store = Session::RoleStore.new('urn:lti:instrole:ims/lis/Administrator')
42
+ expect(role_store.institution_roles).to eq(['administrator'])
43
+ end
44
+
45
+ it 'parses an LTI 1.1 (URN-style) system role' do
46
+ role_store = Session::RoleStore.new('urn:lti:sysrole:ims/lis/SysAdmin')
47
+ expect(role_store.system_roles).to eq(['sys_admin'])
48
+ end
49
+
50
+ it 'parses an LTI 1.3 (URL-style) institution role' do
51
+ role_store = Session::RoleStore.new(['http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator'])
52
+ expect(role_store.institution_roles).to eq(['administrator'])
53
+ end
54
+ end
55
+
9
56
  describe '.for_panda_token' do
10
57
  it 'returns nil for a nil token' do
11
58
  expect(Session.for_panda_token(nil)).to be_nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: panda_pal
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.17.0
4
+ version: 5.17.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
@@ -210,6 +210,7 @@ files:
210
210
  - spec/internal/db/schema.rb
211
211
  - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
212
212
  - spec/lib/panda_pal/helpers/secure_headers_spec.rb
213
+ - spec/lib/panda_pal/helpers/session_replacement_spec.rb
213
214
  - spec/models/panda_pal/api_call_spec.rb
214
215
  - spec/models/panda_pal/organization/settings_validation_spec.rb
215
216
  - spec/models/panda_pal/organization/task_scheduling_spec.rb
@@ -254,6 +255,7 @@ test_files:
254
255
  - spec/internal/db/schema.rb
255
256
  - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
256
257
  - spec/lib/panda_pal/helpers/secure_headers_spec.rb
258
+ - spec/lib/panda_pal/helpers/session_replacement_spec.rb
257
259
  - spec/models/panda_pal/api_call_spec.rb
258
260
  - spec/models/panda_pal/organization/settings_validation_spec.rb
259
261
  - spec/models/panda_pal/organization/task_scheduling_spec.rb