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 +4 -4
- data/app/models/panda_pal/session.rb +62 -8
- data/lib/panda_pal/helpers/controller_helper.rb +1 -1
- 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/models/panda_pal/session_account_roles_spec.rb +182 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa9bee5cce120a4bf340d69ebd142e6f11f8d65bb0b4d850bb781662469ba84e
|
|
4
|
+
data.tar.gz: b1040ec6ecbd33c7ff5fe0eef396a2e5d1764ec893a1c439b5cd8de0ec7d726c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
243
|
-
|
|
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 =
|
|
249
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
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,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.
|
|
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
|