panda_pal 5.16.21 → 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 +4 -4
- data/app/models/panda_pal/session.rb +73 -9
- data/config/initializers/apartment.rb +42 -23
- data/lib/panda_pal/helpers/controller_helper.rb +1 -1
- data/lib/panda_pal/helpers/secure_headers.rb +4 -0
- data/lib/panda_pal/helpers/session_replacement.rb +9 -7
- data/lib/panda_pal/version.rb +1 -1
- data/spec/internal/app/models/account.rb +7 -0
- data/spec/internal/app/models/admin.rb +6 -0
- data/spec/internal/db/schema.rb +24 -0
- data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +72 -0
- data/spec/models/panda_pal/session_account_roles_spec.rb +182 -0
- data/spec/models/panda_pal/session_spec.rb +47 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f32b5b78d11428c9f4be64fc963ae2fac80cae44abfd66dcda0f39be8b17006
|
|
4
|
+
data.tar.gz: cb494809a3aa72956b16bad0b7b17ba095156a1c4a7fa7261a51738d92442630
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
@@ -239,22 +249,76 @@ module PandaPal
|
|
|
239
249
|
labels.is_a?(String) ? labels.split(',') : []
|
|
240
250
|
end
|
|
241
251
|
|
|
242
|
-
# Retrieve the User's Role Labels in the specified Account
|
|
243
|
-
|
|
252
|
+
# Retrieve the User's Role Labels in the specified Account.
|
|
253
|
+
#
|
|
254
|
+
# +account+ accepts an Account-like object (anything responding to
|
|
255
|
+
# +canvas_id+), a Canvas Account id, or one of:
|
|
256
|
+
#
|
|
257
|
+
# [<tt>'self'</tt>] the Account this Tool is installed on (the default).
|
|
258
|
+
# [<tt>:root</tt>] a synonym for <tt>'self'</tt>. In an LTI context the
|
|
259
|
+
# installed Account IS the root of everything the Tool can
|
|
260
|
+
# see, and that — deliberately — need not be Canvas's own
|
|
261
|
+
# root Account: a Tool installed on a sub-account has no
|
|
262
|
+
# business reasoning about Accounts above its install.
|
|
263
|
+
#
|
|
264
|
+
# With <tt>inherited: true</tt>, roles held on any ANCESTOR of +account+ are
|
|
265
|
+
# included too, because a Canvas Account Admin administers their Account and
|
|
266
|
+
# everything beneath it — so an Admin above +account+ genuinely holds that
|
|
267
|
+
# role over +account+. Off by default: a caller asking "which roles are
|
|
268
|
+
# granted AT this Account" would not expect inherited ones, so opting in is
|
|
269
|
+
# explicit, as CanvasSync's +canvas_account_admin?+ does.
|
|
270
|
+
def canvas_account_role_labels(account = 'self', inherited: false)
|
|
244
271
|
account = 'self' if account.to_s == "root"
|
|
245
272
|
account = account.canvas_id if account.respond_to?(:canvas_id)
|
|
246
273
|
|
|
247
274
|
if defined?(::Admin) && ::Admin < ::ActiveRecord::Base
|
|
248
|
-
account =
|
|
249
|
-
|
|
275
|
+
account = panda_pal_organization.canvas_account_id if account == 'self'
|
|
276
|
+
accounts = inherited ? canvas_account_ancestry(account) : [account]
|
|
277
|
+
adm_query = ::Admin.where(canvas_account_id: accounts, workflow_state: "active", canvas_user_id: canvas_user_id)
|
|
250
278
|
adm_query.pluck(:role_name)
|
|
251
279
|
else
|
|
252
|
-
Rails.cache.fetch([self.class.name, "AccountAdminLinks", account, canvas_user_id], expires_in: 1.hour) do
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
280
|
+
Rails.cache.fetch([self.class.name, "AccountAdminLinks", account, inherited, canvas_user_id], expires_in: 1.hour) do
|
|
281
|
+
accounts = inherited ? canvas_account_ancestry(account) : [account]
|
|
282
|
+
accounts.flat_map { |acct|
|
|
283
|
+
admin_entries = canvas_sync_client.account_admins(acct, user_id: [canvas_user_id])
|
|
284
|
+
admin_entries = admin_entries.select{|ent| ent[:workflow_state] == 'active' }
|
|
285
|
+
admin_entries.map{|ent| ent[:role] }
|
|
286
|
+
}.uniq
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# +account+ plus every Account above it, nearest first.
|
|
292
|
+
#
|
|
293
|
+
# Reads the local Account mirror when the host app has one — no API traffic,
|
|
294
|
+
# and it works whether or not that model uses the +ancestry+ gem, since only
|
|
295
|
+
# +canvas_parent_account_id+ is needed. Falls back to walking Canvas.
|
|
296
|
+
#
|
|
297
|
+
# Traversal is topological and deliberately does NOT skip deleted Accounts:
|
|
298
|
+
# dropping one would silently truncate the chain and deny an Admin above it.
|
|
299
|
+
# Whether a role still counts is decided by the Admin record's own
|
|
300
|
+
# +workflow_state+, not by the shape of the tree.
|
|
301
|
+
def canvas_account_ancestry(account)
|
|
302
|
+
ids = [account]
|
|
303
|
+
seen = Set.new([account.to_s])
|
|
304
|
+
|
|
305
|
+
parent_of =
|
|
306
|
+
if defined?(::Account) && ::Account < ::ActiveRecord::Base
|
|
307
|
+
->(id) { ::Account.find_by(canvas_id: id)&.canvas_parent_account_id }
|
|
308
|
+
else
|
|
309
|
+
->(id) { canvas_sync_client.account(id)[:parent_account_id] }
|
|
256
310
|
end
|
|
311
|
+
|
|
312
|
+
current = account
|
|
313
|
+
while (parent = parent_of.call(current)).present?
|
|
314
|
+
break if seen.include?(parent.to_s) # cycle guard — never trust remote topology
|
|
315
|
+
|
|
316
|
+
ids << parent
|
|
317
|
+
seen << parent.to_s
|
|
318
|
+
current = parent
|
|
257
319
|
end
|
|
320
|
+
|
|
321
|
+
ids
|
|
258
322
|
end
|
|
259
323
|
|
|
260
324
|
def lti_roles
|
|
@@ -329,7 +393,7 @@ module PandaPal
|
|
|
329
393
|
ROLE_TYPES.each do |rt|
|
|
330
394
|
# LIS V1
|
|
331
395
|
if role.downcase.start_with?(rt[:urn])
|
|
332
|
-
return clean_role(
|
|
396
|
+
return clean_role(rt[:urn], role), rt
|
|
333
397
|
end
|
|
334
398
|
|
|
335
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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/panda_pal/version.rb
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minimal stand-in for the CanvasSync-generated Account. Present so that
|
|
4
|
+
# Session#canvas_account_ancestry exercises its local-mirror branch (the one
|
|
5
|
+
# host apps actually hit) rather than the Canvas API fallback.
|
|
6
|
+
class Account < ActiveRecord::Base
|
|
7
|
+
end
|
data/spec/internal/db/schema.rb
CHANGED
|
@@ -3,4 +3,28 @@
|
|
|
3
3
|
ActiveRecord::Schema.define do
|
|
4
4
|
# Set up any tables you need to exist for your test suite that don't belong
|
|
5
5
|
# in migrations.
|
|
6
|
+
|
|
7
|
+
# Stand-ins for the CanvasSync-generated, tenant-scoped models that
|
|
8
|
+
# Session#canvas_account_role_labels duck-types on. Only the columns it
|
|
9
|
+
# actually reads are declared. `accounts` deliberately has NO `ancestry`
|
|
10
|
+
# column: that gem is optional in host apps, so the traversal has to work
|
|
11
|
+
# from `canvas_parent_account_id` alone.
|
|
12
|
+
create_table :accounts, force: true do |t|
|
|
13
|
+
t.bigint :canvas_id, null: false
|
|
14
|
+
t.bigint :canvas_parent_account_id
|
|
15
|
+
t.string :name
|
|
16
|
+
t.string :workflow_state, default: "active"
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
add_index :accounts, :canvas_id, unique: true
|
|
20
|
+
|
|
21
|
+
create_table :admins, force: true do |t|
|
|
22
|
+
t.bigint :canvas_id
|
|
23
|
+
t.bigint :canvas_user_id, null: false
|
|
24
|
+
t.bigint :canvas_account_id, null: false
|
|
25
|
+
t.string :role_name
|
|
26
|
+
t.string :workflow_state, default: "active"
|
|
27
|
+
t.timestamps
|
|
28
|
+
end
|
|
29
|
+
add_index :admins, [ :canvas_user_id, :canvas_account_id ]
|
|
6
30
|
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
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
module PandaPal
|
|
6
|
+
RSpec.describe Session, type: :model do
|
|
7
|
+
describe '#canvas_account_role_labels' do
|
|
8
|
+
# The Tool is installed on a SUB-ACCOUNT here, which is the only topology
|
|
9
|
+
# that distinguishes the install Account from Canvas's own root. A Tool
|
|
10
|
+
# installed at the top is the degenerate case where every reading of
|
|
11
|
+
# "root" agrees, so it can't tell a correct implementation from a broken
|
|
12
|
+
# one.
|
|
13
|
+
#
|
|
14
|
+
# 1 root
|
|
15
|
+
# └── 20 parent
|
|
16
|
+
# └── 300 install <- current_organization.canvas_account_id
|
|
17
|
+
# └── 4000 child
|
|
18
|
+
# └── 21 sibling
|
|
19
|
+
let(:canvas_user_id) { 9001 }
|
|
20
|
+
let!(:organization) { create(:panda_pal_organization, canvas_account_id: 300) }
|
|
21
|
+
# Created BEFORE the tenant switch below: panda_pal_sessions lives outside
|
|
22
|
+
# the tenant schema, while accounts/admins live inside it.
|
|
23
|
+
let!(:session) do
|
|
24
|
+
create(:panda_pal_session, panda_pal_organization: organization, data: {
|
|
25
|
+
launch_params: {
|
|
26
|
+
"https://purl.imsglobal.org/spec/lti/claim/custom" => { "canvas_user_id" => canvas_user_id },
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def admin!(account_id, role_name: "AccountAdmin", workflow_state: "active")
|
|
32
|
+
Admin.create!(canvas_user_id: canvas_user_id, canvas_account_id: account_id,
|
|
33
|
+
role_name: role_name, workflow_state: workflow_state)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
before do
|
|
37
|
+
organization.switch_tenant
|
|
38
|
+
Account.create!(canvas_id: 1, canvas_parent_account_id: nil, name: "root")
|
|
39
|
+
Account.create!(canvas_id: 20, canvas_parent_account_id: 1, name: "parent")
|
|
40
|
+
Account.create!(canvas_id: 21, canvas_parent_account_id: 1, name: "sibling")
|
|
41
|
+
Account.create!(canvas_id: 300, canvas_parent_account_id: 20, name: "install")
|
|
42
|
+
Account.create!(canvas_id: 4000, canvas_parent_account_id: 300, name: "child")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
after { Apartment::Tenant.reset }
|
|
46
|
+
|
|
47
|
+
context 'by default (inherited: false)' do
|
|
48
|
+
it 'returns roles held at the install Account' do
|
|
49
|
+
admin!(300, role_name: "AccountAdmin")
|
|
50
|
+
|
|
51
|
+
expect(session.canvas_account_role_labels).to eq(["AccountAdmin"])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'ignores roles held above the install Account' do
|
|
55
|
+
admin!(1, role_name: "AccountAdmin")
|
|
56
|
+
|
|
57
|
+
expect(session.canvas_account_role_labels).to eq([])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'treats :root as the install Account, not as Canvas account 1' do
|
|
61
|
+
admin!(300, role_name: "Ops")
|
|
62
|
+
admin!(1, role_name: "AccountAdmin")
|
|
63
|
+
|
|
64
|
+
expect(session.canvas_account_role_labels(:root)).to eq(["Ops"])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'reads an explicitly requested Account' do
|
|
68
|
+
admin!(4000, role_name: "Sub-Account Admin")
|
|
69
|
+
|
|
70
|
+
expect(session.canvas_account_role_labels(4000)).to eq(["Sub-Account Admin"])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'with inherited: true' do
|
|
75
|
+
it 'includes a role held on the immediate parent' do
|
|
76
|
+
admin!(20, role_name: "AccountAdmin")
|
|
77
|
+
|
|
78
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq(["AccountAdmin"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'includes a role held on Canvas\'s root Account, several levels up' do
|
|
82
|
+
admin!(1, role_name: "AccountAdmin")
|
|
83
|
+
|
|
84
|
+
expect(session.canvas_account_role_labels(:root, inherited: true)).to eq(["AccountAdmin"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'still includes a role held at the Account itself' do
|
|
88
|
+
admin!(300, role_name: "AccountAdmin")
|
|
89
|
+
|
|
90
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq(["AccountAdmin"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'does NOT include a role held BELOW the Account' do
|
|
94
|
+
admin!(4000, role_name: "AccountAdmin")
|
|
95
|
+
|
|
96
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq([])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'does NOT include a role held on a sibling branch' do
|
|
100
|
+
admin!(21, role_name: "AccountAdmin")
|
|
101
|
+
|
|
102
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq([])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'excludes inactive rows anywhere in the chain' do
|
|
106
|
+
admin!(1, role_name: "AccountAdmin", workflow_state: "inactive")
|
|
107
|
+
|
|
108
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq([])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'ignores another user\'s roles in the chain' do
|
|
112
|
+
Admin.create!(canvas_user_id: canvas_user_id + 1, canvas_account_id: 1, role_name: "AccountAdmin")
|
|
113
|
+
|
|
114
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq([])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'gathers roles from several levels at once' do
|
|
118
|
+
admin!(300, role_name: "Ops")
|
|
119
|
+
admin!(20, role_name: "Department Admin")
|
|
120
|
+
admin!(1, role_name: "AccountAdmin")
|
|
121
|
+
|
|
122
|
+
expect(session.canvas_account_role_labels(inherited: true))
|
|
123
|
+
.to contain_exactly("Ops", "Department Admin", "AccountAdmin")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'walks up from an explicitly requested Account, not from the install' do
|
|
127
|
+
admin!(300, role_name: "AccountAdmin")
|
|
128
|
+
|
|
129
|
+
expect(session.canvas_account_role_labels(4000, inherited: true)).to eq(["AccountAdmin"])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'accepts an Account-like object' do
|
|
133
|
+
admin!(1, role_name: "AccountAdmin")
|
|
134
|
+
|
|
135
|
+
expect(session.canvas_account_role_labels(Account.find_by(canvas_id: 300), inherited: true))
|
|
136
|
+
.to eq(["AccountAdmin"])
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe '#canvas_account_ancestry' do
|
|
142
|
+
let!(:organization) { create(:panda_pal_organization, canvas_account_id: 300) }
|
|
143
|
+
let!(:session) { create(:panda_pal_session, panda_pal_organization: organization, data: {}) }
|
|
144
|
+
|
|
145
|
+
before { organization.switch_tenant }
|
|
146
|
+
after { Apartment::Tenant.reset }
|
|
147
|
+
|
|
148
|
+
it 'returns the Account plus its ancestors, nearest first' do
|
|
149
|
+
Account.create!(canvas_id: 1, canvas_parent_account_id: nil)
|
|
150
|
+
Account.create!(canvas_id: 20, canvas_parent_account_id: 1)
|
|
151
|
+
Account.create!(canvas_id: 300, canvas_parent_account_id: 20)
|
|
152
|
+
|
|
153
|
+
expect(session.canvas_account_ancestry(300)).to eq([ 300, 20, 1 ])
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'returns just the Account when it is already the top' do
|
|
157
|
+
Account.create!(canvas_id: 1, canvas_parent_account_id: nil)
|
|
158
|
+
|
|
159
|
+
expect(session.canvas_account_ancestry(1)).to eq([ 1 ])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'returns the id unchanged when the Account is not in the local mirror' do
|
|
163
|
+
expect(session.canvas_account_ancestry(300)).to eq([ 300 ])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'stops rather than looping forever on a cyclic hierarchy' do
|
|
167
|
+
Account.create!(canvas_id: 300, canvas_parent_account_id: 20)
|
|
168
|
+
Account.create!(canvas_id: 20, canvas_parent_account_id: 300)
|
|
169
|
+
|
|
170
|
+
expect(session.canvas_account_ancestry(300)).to eq([ 300, 20 ])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'keeps walking through a deleted intermediate Account' do
|
|
174
|
+
Account.create!(canvas_id: 1, canvas_parent_account_id: nil)
|
|
175
|
+
Account.create!(canvas_id: 20, canvas_parent_account_id: 1, workflow_state: "deleted")
|
|
176
|
+
Account.create!(canvas_id: 300, canvas_parent_account_id: 20)
|
|
177
|
+
|
|
178
|
+
expect(session.canvas_account_ancestry(300)).to eq([ 300, 20, 1 ])
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
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.
|
|
4
|
+
version: 5.17.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Instructure CustomDev
|
|
@@ -202,17 +202,21 @@ files:
|
|
|
202
202
|
- spec/core/apartment_multidb_spec.rb
|
|
203
203
|
- spec/factories/panda_pal_organizations.rb
|
|
204
204
|
- spec/factories/panda_pal_sessions.rb
|
|
205
|
+
- spec/internal/app/models/account.rb
|
|
206
|
+
- spec/internal/app/models/admin.rb
|
|
205
207
|
- spec/internal/config/database.yml
|
|
206
208
|
- spec/internal/config/routes.rb
|
|
207
209
|
- spec/internal/config/storage.yml
|
|
208
210
|
- spec/internal/db/schema.rb
|
|
209
211
|
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
210
212
|
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
213
|
+
- spec/lib/panda_pal/helpers/session_replacement_spec.rb
|
|
211
214
|
- spec/models/panda_pal/api_call_spec.rb
|
|
212
215
|
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
213
216
|
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
214
217
|
- spec/models/panda_pal/organization_spec.rb
|
|
215
218
|
- spec/models/panda_pal/platform_spec.rb
|
|
219
|
+
- spec/models/panda_pal/session_account_roles_spec.rb
|
|
216
220
|
- spec/models/panda_pal/session_spec.rb
|
|
217
221
|
- spec/spec_helper.rb
|
|
218
222
|
homepage: http://instructure.com
|
|
@@ -243,16 +247,20 @@ test_files:
|
|
|
243
247
|
- spec/core/apartment_multidb_spec.rb
|
|
244
248
|
- spec/factories/panda_pal_organizations.rb
|
|
245
249
|
- spec/factories/panda_pal_sessions.rb
|
|
250
|
+
- spec/internal/app/models/account.rb
|
|
251
|
+
- spec/internal/app/models/admin.rb
|
|
246
252
|
- spec/internal/config/database.yml
|
|
247
253
|
- spec/internal/config/routes.rb
|
|
248
254
|
- spec/internal/config/storage.yml
|
|
249
255
|
- spec/internal/db/schema.rb
|
|
250
256
|
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
251
257
|
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
258
|
+
- spec/lib/panda_pal/helpers/session_replacement_spec.rb
|
|
252
259
|
- spec/models/panda_pal/api_call_spec.rb
|
|
253
260
|
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
254
261
|
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
255
262
|
- spec/models/panda_pal/organization_spec.rb
|
|
256
263
|
- spec/models/panda_pal/platform_spec.rb
|
|
264
|
+
- spec/models/panda_pal/session_account_roles_spec.rb
|
|
257
265
|
- spec/models/panda_pal/session_spec.rb
|
|
258
266
|
- spec/spec_helper.rb
|