panda_pal 5.17.0 → 5.17.2

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: 1d2cfdbfc9d2c60cc080b545d2ee582ce212623a45c286d8610b8ac5f6c1af37
4
+ data.tar.gz: 4918d3bee6808bfbf22e98e23f3cbedcd493d4ec7c946ac67e0c8ca5485d9eca
5
5
  SHA512:
6
- metadata.gz: 03ffb7a4bf0fe654ad310e8a8c34e69b1297ed02ab3a38a989d0712a630e2c41ca52cb76333ef0d400acabd3688a5c2d33621ab24d59027698e444581260403d
7
- data.tar.gz: 522d2c9847b0225228d808a053a0eb278d872750883758bc60ccb87128162645fe989d7fa1e14f7fb9384fea69e7fae540dcaf02d4927f1691dc21c1f53223fe
6
+ metadata.gz: 0e06e419047c6a29e1ed8f03966bae4c31ed9e5b57ad6953133b890f6dab407d384ea1f071e0c88e01f7a69ef790f9b132f98a973aa3602b6b902d4871722799
7
+ data.tar.gz: 6d19e0a9ea13914f8045d62525dd73dcb97aadfc780dc1c4907fe3b80df9483144d1cc3cc087d509aa7cfba344aa64aaa4b7bd36b05fea354201d7e387094db4
@@ -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
@@ -120,10 +148,7 @@ module Apartment
120
148
 
121
149
  raise ActiveRecord::StatementInvalid, "PandaPal/Apartment Mutli-DB support does not currently support DB roles" if ActiveRecord::Base.current_role != ActiveRecord::Base.default_role
122
150
 
123
- unless ActiveRecord::Base.connected?
124
- Apartment.establish_connection multi_tenantify(tenant, false)
125
- Apartment.connection.verify!
126
- end
151
+ establish_connection_pool(tenant) unless connection_pool_registered?
127
152
 
128
153
  Apartment.connection.enable_query_cache! if query_cache_enabled
129
154
 
@@ -143,6 +168,25 @@ module Apartment
143
168
 
144
169
  protected
145
170
 
171
+ ESTABLISH_CONNECTION_LOCK = Mutex.new
172
+
173
+ def connection_pool_registered?
174
+ !ActiveRecord::Base.connection_handler.retrieve_connection_pool(
175
+ ActiveRecord::Base.connection_specification_name,
176
+ role: ActiveRecord::Base.current_role,
177
+ shard: ActiveRecord::Base.current_shard,
178
+ ).nil?
179
+ end
180
+
181
+ def establish_connection_pool(tenant)
182
+ ESTABLISH_CONNECTION_LOCK.synchronize do
183
+ next if connection_pool_registered?
184
+
185
+ Apartment.establish_connection multi_tenantify(tenant, false)
186
+ Apartment.connection.verify!
187
+ end
188
+ end
189
+
146
190
  def persistent_schemas
147
191
  map_to_schemas(super)
148
192
  end
@@ -319,30 +363,10 @@ ActiveSupport.on_load(:action_cable) do
319
363
  end
320
364
  end
321
365
 
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
-
366
+ # Lazily switch any worker threads to the correct tenant when they are working (see
367
+ # Apartment::Tenant.with_lazy_tenant above for why this is lazy, not an active switch).
328
368
  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
369
+ Apartment::Tenant.with_lazy_tenant(connection.tenant, &blk)
346
370
  end
347
371
 
348
372
  ActiveSupport.on_load(:active_record) do
@@ -358,6 +382,17 @@ ActiveSupport.on_load(:action_cable) do
358
382
  end
359
383
  end
360
384
 
385
+ # cable_room runs a Room's own code -- message handlers, startup, shutdown, snapshotting -- on a
386
+ # Host worker thread, never inside ActionCable's `:work` callback (its worker pool deliberately
387
+ # isn't an ActionCable::Server::Worker subclass, so it doesn't share that callback chain).
388
+ # `Room#around_work` is its equivalent seam; hook it the same lazy way, on every Room, via
389
+ # CableRoom::Room::Base so every app Room picks it up.
390
+ if defined?(CableRoom)
391
+ CableRoom::Room::Base.around_work do |room, blk|
392
+ Apartment::Tenant.with_lazy_tenant(room.tenant, &blk)
393
+ end
394
+ end
395
+
361
396
  if defined?(Delayed)
362
397
  module PandaPal::Plugins
363
398
  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.2'
3
3
  end
@@ -53,6 +53,72 @@ RSpec.describe PandaPal::Organization, type: :model do
53
53
  expect(PandaPal::ApiCall.connection.exec_query("SELECT current_database()").pluck("current_database")[0]).to eq("panda_pal_test2")
54
54
  end
55
55
 
56
+ context "connection pool lifecycle" do
57
+ def alt_pool
58
+ ActiveRecord::Base.connection_handler.retrieve_connection_pool(
59
+ ActiveRecord::Base.connection_specification_name,
60
+ role: ActiveRecord::Base.current_role,
61
+ shard: "apt:alt",
62
+ )
63
+ end
64
+
65
+ def count_establish_connection_calls
66
+ calls = Queue.new
67
+ allow(Apartment).to receive(:establish_connection).and_wrap_original do |original, *args, **kwargs|
68
+ calls << true
69
+ original.call(*args, **kwargs)
70
+ end
71
+ yield
72
+ calls.size
73
+ end
74
+
75
+ def switch_in_threads(tenant, count)
76
+ start = Queue.new
77
+ threads = count.times.map do
78
+ Thread.new do
79
+ start.pop
80
+ Apartment::Tenant.switch(tenant) { PandaPal::ApiCall.count }
81
+ ensure
82
+ ActiveRecord::Base.connection_handler.clear_active_connections!
83
+ end
84
+ end
85
+ count.times { start << true }
86
+ threads.map(&:value)
87
+ end
88
+
89
+ it "reuses an existing shard pool whose connections have all been closed" do
90
+ org2.switch_tenant { PandaPal::ApiCall.count }
91
+ pool = alt_pool
92
+ ActiveRecord::Base.connection_handler.clear_active_connections!
93
+ pool.disconnect!
94
+
95
+ calls = count_establish_connection_calls do
96
+ org2.switch_tenant { expect(PandaPal::ApiCall.count).to eq(0) }
97
+ end
98
+
99
+ expect(calls).to eq(0)
100
+ expect(alt_pool).to equal(pool)
101
+ end
102
+
103
+ it "establishes a missing shard pool once when many threads need it at the same time" do
104
+ org2.switch_tenant { PandaPal::ApiCall.count }
105
+ ActiveRecord::Base.connection_handler.clear_active_connections!
106
+ ActiveRecord::Base.connection_handler.remove_connection_pool(
107
+ ActiveRecord::Base.connection_specification_name,
108
+ role: ActiveRecord::Base.current_role,
109
+ shard: "apt:alt",
110
+ )
111
+
112
+ results = nil
113
+ calls = count_establish_connection_calls do
114
+ results = switch_in_threads("alt:on_alt", 8)
115
+ end
116
+
117
+ expect(results).to eq([0] * 8)
118
+ expect(calls).to eq(1)
119
+ end
120
+ end
121
+
56
122
  # load_async is a Rails 7.0+ feature.
57
123
  context "load_async", skip: (ActiveRecord::Relation.method_defined?(:load_async) ? false : "requires Rails 7.0+ load_async") do
58
124
  it "works across shards" do
@@ -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.2
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