panda_pal 5.16.21 → 5.17.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a5f35f89d8e121395cef557e6305357a29eab6ea9a63434ec3a38548c92d566
4
- data.tar.gz: 6fccf06a199c9c59c01ebb0981cbfb4f86b374303cd7a30521fa0fa604705b9c
3
+ metadata.gz: aa9bee5cce120a4bf340d69ebd142e6f11f8d65bb0b4d850bb781662469ba84e
4
+ data.tar.gz: b1040ec6ecbd33c7ff5fe0eef396a2e5d1764ec893a1c439b5cd8de0ec7d726c
5
5
  SHA512:
6
- metadata.gz: 4b23c1838a9a7b19a8c82d0f57f24d5c2b97ed4d2265379c52832b00ce7fd742bad1388d0697391a594908e2a1dac0f3b65c28cbbf1d2dbe6d1ec3ae6ba2cd68
7
- data.tar.gz: 578b81b0921ae8fa4ed8b8bb99ed2c00326fa354c08dc8ff9aca4d354a752aded035aee21658654283369bf29e74eee17b7ba4f900e8ad8fe6594d55ead280fd
6
+ metadata.gz: 03ffb7a4bf0fe654ad310e8a8c34e69b1297ed02ab3a38a989d0712a630e2c41ca52cb76333ef0d400acabd3688a5c2d33621ab24d59027698e444581260403d
7
+ data.tar.gz: 522d2c9847b0225228d808a053a0eb278d872750883758bc60ccb87128162645fe989d7fa1e14f7fb9384fea69e7fae540dcaf02d4927f1691dc21c1f53223fe
@@ -239,24 +239,78 @@ module PandaPal
239
239
  labels.is_a?(String) ? labels.split(',') : []
240
240
  end
241
241
 
242
- # Retrieve the User's Role Labels in the specified Account, defaulting to the Root Account
243
- def canvas_account_role_labels(account = 'self')
242
+ # Retrieve the User's Role Labels in the specified Account.
243
+ #
244
+ # +account+ accepts an Account-like object (anything responding to
245
+ # +canvas_id+), a Canvas Account id, or one of:
246
+ #
247
+ # [<tt>'self'</tt>] the Account this Tool is installed on (the default).
248
+ # [<tt>:root</tt>] a synonym for <tt>'self'</tt>. In an LTI context the
249
+ # installed Account IS the root of everything the Tool can
250
+ # see, and that — deliberately — need not be Canvas's own
251
+ # root Account: a Tool installed on a sub-account has no
252
+ # business reasoning about Accounts above its install.
253
+ #
254
+ # With <tt>inherited: true</tt>, roles held on any ANCESTOR of +account+ are
255
+ # included too, because a Canvas Account Admin administers their Account and
256
+ # everything beneath it — so an Admin above +account+ genuinely holds that
257
+ # role over +account+. Off by default: a caller asking "which roles are
258
+ # granted AT this Account" would not expect inherited ones, so opting in is
259
+ # explicit, as CanvasSync's +canvas_account_admin?+ does.
260
+ def canvas_account_role_labels(account = 'self', inherited: false)
244
261
  account = 'self' if account.to_s == "root"
245
262
  account = account.canvas_id if account.respond_to?(:canvas_id)
246
263
 
247
264
  if defined?(::Admin) && ::Admin < ::ActiveRecord::Base
248
- account = current_organization.canvas_account_id if account == 'self'
249
- adm_query = ::Admin.where(canvas_account_id: account, workflow_state: "active", canvas_user_id: canvas_user_id)
265
+ account = panda_pal_organization.canvas_account_id if account == 'self'
266
+ accounts = inherited ? canvas_account_ancestry(account) : [account]
267
+ adm_query = ::Admin.where(canvas_account_id: accounts, workflow_state: "active", canvas_user_id: canvas_user_id)
250
268
  adm_query.pluck(:role_name)
251
269
  else
252
- Rails.cache.fetch([self.class.name, "AccountAdminLinks", account, canvas_user_id], expires_in: 1.hour) do
253
- admin_entries = canvas_sync_client.account_admins(account, user_id: [canvas_user_id])
254
- admin_entries = admin_entries.select{|ent| ent[:workflow_state] == 'active' }
255
- admin_entries.map{|ent| ent[:role] }
270
+ Rails.cache.fetch([self.class.name, "AccountAdminLinks", account, inherited, canvas_user_id], expires_in: 1.hour) do
271
+ accounts = inherited ? canvas_account_ancestry(account) : [account]
272
+ accounts.flat_map { |acct|
273
+ admin_entries = canvas_sync_client.account_admins(acct, user_id: [canvas_user_id])
274
+ admin_entries = admin_entries.select{|ent| ent[:workflow_state] == 'active' }
275
+ admin_entries.map{|ent| ent[:role] }
276
+ }.uniq
256
277
  end
257
278
  end
258
279
  end
259
280
 
281
+ # +account+ plus every Account above it, nearest first.
282
+ #
283
+ # Reads the local Account mirror when the host app has one — no API traffic,
284
+ # and it works whether or not that model uses the +ancestry+ gem, since only
285
+ # +canvas_parent_account_id+ is needed. Falls back to walking Canvas.
286
+ #
287
+ # Traversal is topological and deliberately does NOT skip deleted Accounts:
288
+ # dropping one would silently truncate the chain and deny an Admin above it.
289
+ # Whether a role still counts is decided by the Admin record's own
290
+ # +workflow_state+, not by the shape of the tree.
291
+ def canvas_account_ancestry(account)
292
+ ids = [account]
293
+ seen = Set.new([account.to_s])
294
+
295
+ parent_of =
296
+ if defined?(::Account) && ::Account < ::ActiveRecord::Base
297
+ ->(id) { ::Account.find_by(canvas_id: id)&.canvas_parent_account_id }
298
+ else
299
+ ->(id) { canvas_sync_client.account(id)[:parent_account_id] }
300
+ end
301
+
302
+ current = account
303
+ while (parent = parent_of.call(current)).present?
304
+ break if seen.include?(parent.to_s) # cycle guard — never trust remote topology
305
+
306
+ ids << parent
307
+ seen << parent.to_s
308
+ current = parent
309
+ end
310
+
311
+ ids
312
+ end
313
+
260
314
  def lti_roles
261
315
  @lti_roles ||= RoleStore.new(launch_params["https://purl.imsglobal.org/spec/lti/claim/roles"] || launch_params['ext_roles'] || '')
262
316
  end
@@ -114,7 +114,7 @@ module PandaPal::Helpers
114
114
  @current_session.save!
115
115
 
116
116
  @decoded_lti_jwt = decoded_jwt
117
- rescue JSON::JWT::VerificationFailed => e
117
+ rescue JSON::JWT::Exception => e
118
118
  payload = Array(e.message)
119
119
 
120
120
  render json: {
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = '5.16.21'
2
+ VERSION = '5.17.0'
3
3
  end
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal stand-in for the CanvasSync-generated Admin roster model that
4
+ # Session#canvas_account_role_labels reads role labels from.
5
+ class Admin < ActiveRecord::Base
6
+ end
@@ -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,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
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.16.21
4
+ version: 5.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
@@ -202,6 +202,8 @@ 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
@@ -213,6 +215,7 @@ files:
213
215
  - spec/models/panda_pal/organization/task_scheduling_spec.rb
214
216
  - spec/models/panda_pal/organization_spec.rb
215
217
  - spec/models/panda_pal/platform_spec.rb
218
+ - spec/models/panda_pal/session_account_roles_spec.rb
216
219
  - spec/models/panda_pal/session_spec.rb
217
220
  - spec/spec_helper.rb
218
221
  homepage: http://instructure.com
@@ -243,6 +246,8 @@ test_files:
243
246
  - spec/core/apartment_multidb_spec.rb
244
247
  - spec/factories/panda_pal_organizations.rb
245
248
  - spec/factories/panda_pal_sessions.rb
249
+ - spec/internal/app/models/account.rb
250
+ - spec/internal/app/models/admin.rb
246
251
  - spec/internal/config/database.yml
247
252
  - spec/internal/config/routes.rb
248
253
  - spec/internal/config/storage.yml
@@ -254,5 +259,6 @@ test_files:
254
259
  - spec/models/panda_pal/organization/task_scheduling_spec.rb
255
260
  - spec/models/panda_pal/organization_spec.rb
256
261
  - spec/models/panda_pal/platform_spec.rb
262
+ - spec/models/panda_pal/session_account_roles_spec.rb
257
263
  - spec/models/panda_pal/session_spec.rb
258
264
  - spec/spec_helper.rb