panda_pal 5.19.0 → 5.19.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 +4 -4
- data/README.md +152 -703
- data/app/models/panda_pal/organization.rb +1 -1
- data/app/models/panda_pal/platform/canvas.rb +1 -1
- data/app/models/panda_pal/platform.rb +1 -1
- data/config/initializers/apartment.rb +18 -12
- data/db/migrate/20171205194657_remove_old_organization_settings.rb +1 -0
- data/docs/action-cable.md +60 -0
- data/docs/api-calls.md +39 -0
- data/docs/architecture.md +210 -0
- data/docs/before-you-build.md +83 -0
- data/docs/configuration.md +120 -0
- data/docs/deep-linking.md +355 -0
- data/docs/development.md +31 -0
- data/docs/frontend.md +129 -0
- data/docs/grade-passback.md +27 -0
- data/docs/installation.md +129 -0
- data/docs/multi-tenancy.md +117 -0
- data/docs/multiple-tools.md +73 -0
- data/docs/organizations.md +159 -0
- data/docs/roles-and-abilities.md +82 -0
- data/docs/routing-and-launches.md +108 -0
- data/docs/security.md +60 -0
- data/docs/sessions.md +150 -0
- data/docs/task-scheduling.md +49 -0
- data/docs/upgrading.md +69 -0
- data/lib/panda_pal/version.rb +1 -1
- data/panda_pal.gemspec +1 -1
- metadata +21 -2
- /data/{AGS.md → docs/ags.md} +0 -0
|
@@ -160,7 +160,7 @@ module PandaPal
|
|
|
160
160
|
|
|
161
161
|
# Trust issuers added to the Org settings
|
|
162
162
|
if (issuer = platform.platform_uri rescue nil).present?
|
|
163
|
-
trusted_issuers = settings
|
|
163
|
+
trusted_issuers = settings&.dig(:lti, :trusted_platforms) || []
|
|
164
164
|
return true if trusted_issuers.include?(issuer)
|
|
165
165
|
end
|
|
166
166
|
|
|
@@ -427,7 +427,7 @@ module PandaPal
|
|
|
427
427
|
end
|
|
428
428
|
|
|
429
429
|
canvas_uri = URI.parse(self.canvas_url)
|
|
430
|
-
lti_id ||= self.settings
|
|
430
|
+
lti_id ||= self.settings&.dig(:canvas, :external_tool_id)
|
|
431
431
|
canvas_uri.path = "/#{context_path}/external_tools/#{lti_id}"
|
|
432
432
|
|
|
433
433
|
canvas_params = {}
|
|
@@ -58,7 +58,7 @@ module Apartment
|
|
|
58
58
|
# connection is actually acquired, and only then.
|
|
59
59
|
#
|
|
60
60
|
# For anything that runs app code on a worker thread outside a normal request/job cycle --
|
|
61
|
-
# ActionCable's `:work` callback below,
|
|
61
|
+
# ActionCable's `:work` callback below, or a similar
|
|
62
62
|
# hook of your own -- this is the one place that logic lives; call it instead of
|
|
63
63
|
# reimplementing the stage/release/restore dance yourself.
|
|
64
64
|
def self.with_lazy_tenant(tenant_name)
|
|
@@ -382,17 +382,6 @@ ActiveSupport.on_load(:action_cable) do
|
|
|
382
382
|
end
|
|
383
383
|
end
|
|
384
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
|
-
|
|
396
385
|
if defined?(Delayed)
|
|
397
386
|
module PandaPal::Plugins
|
|
398
387
|
class ApartmentDelayedJobsPlugin < ::Delayed::Plugin
|
|
@@ -437,3 +426,20 @@ if defined?(Delayed)
|
|
|
437
426
|
|
|
438
427
|
Delayed::Worker.plugins << PandaPal::Plugins::ApartmentDelayedJobsPlugin
|
|
439
428
|
end
|
|
429
|
+
|
|
430
|
+
if defined?(Apartment::Sidekiq::Middleware::Client)
|
|
431
|
+
module PandaPal::Plugins
|
|
432
|
+
module ApartmentSidekiqClientConnectionRelease
|
|
433
|
+
def call(*args, &block)
|
|
434
|
+
pool = ActiveRecord::Base.connection_pool
|
|
435
|
+
if pool.method(:with_connection).parameters.any? { |_, name| name == :prevent_permanent_checkout }
|
|
436
|
+
pool.with_connection(prevent_permanent_checkout: true) { super }
|
|
437
|
+
else
|
|
438
|
+
pool.with_connection { super }
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
Apartment::Sidekiq::Middleware::Client.prepend(PandaPal::Plugins::ApartmentSidekiqClientConnectionRelease)
|
|
445
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# ActionCable.
|
|
2
|
+
|
|
3
|
+
PandaPal patches ActionCable so channels run in the right tenant and can read the PandaPal session.
|
|
4
|
+
|
|
5
|
+
## Authenticating a connection.
|
|
6
|
+
|
|
7
|
+
`connection.panda_session` reads the panda token from the cable request (header or `panda_token`
|
|
8
|
+
param), validates it, and switches the connection to the session's tenant. Channels delegate
|
|
9
|
+
`panda_session` to the connection.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# app/channels/application_cable/connection.rb
|
|
13
|
+
module ApplicationCable
|
|
14
|
+
class Connection < ActionCable::Connection::Base
|
|
15
|
+
identified_by :current_session
|
|
16
|
+
|
|
17
|
+
def connect
|
|
18
|
+
self.current_session = panda_session || reject_unauthorized_connection
|
|
19
|
+
rescue PandaPal::Session::SessionNonceMismatch # Invalid or expired token
|
|
20
|
+
reject_unauthorized_connection
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Needed for authorize!/authorized? in channels (CanCan only)
|
|
24
|
+
def current_ability
|
|
25
|
+
@current_ability ||= Ability.new(panda_session)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
On the frontend, use `@inst_proserv/toolkit/cable`. It connects to `/cable` with a short-lived,
|
|
32
|
+
signed `T-URL` panda token, and refreshes the token before every reconnect. See
|
|
33
|
+
[frontend.md](frontend.md#actioncable). A full-key token in a `panda_token` param also works, but
|
|
34
|
+
it puts the session secret in server logs.
|
|
35
|
+
|
|
36
|
+
To set the tenant yourself, assign `self.tenant = org.tenant_name` on the connection.
|
|
37
|
+
|
|
38
|
+
## Tenant handling.
|
|
39
|
+
|
|
40
|
+
- **Lazy switching.** Channel work runs in the connection's tenant. PandaPal only stages the tenant
|
|
41
|
+
and applies it when the work actually checks out a database connection, so messages that never
|
|
42
|
+
touch the database don't pay for a `SET search_path`.
|
|
43
|
+
- **Scoped broadcasts.** `broadcasting_for` (and so `broadcast_to` and `stream_for`) includes the
|
|
44
|
+
tenant, so a broadcast in one Organization never reaches another.
|
|
45
|
+
|
|
46
|
+
## CanCan.
|
|
47
|
+
|
|
48
|
+
When CanCan is loaded, channels get `authorize!` and `authorized?`, which call
|
|
49
|
+
`connection.current_ability`. A `CanCan::AccessDenied` raised during `subscribed` rejects the
|
|
50
|
+
subscription instead of raising an error.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
class ReportChannel < ApplicationCable::Channel
|
|
54
|
+
def subscribed
|
|
55
|
+
report = Report.find(params[:id])
|
|
56
|
+
authorize! :read, report
|
|
57
|
+
stream_for report
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
data/docs/api-calls.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Generated API calls.
|
|
2
|
+
|
|
3
|
+
During UAT, clients often need to trigger logic by hand. A deployer can mint a URL in the console
|
|
4
|
+
that runs a piece of code when someone visits it. No console access is needed after that.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
org.create_api(<<~RUBY, expiration: 30.days.from_now, uses: 10)
|
|
8
|
+
arg1 = p[:some_param] # `p` is a Hash of the params sent to the request
|
|
9
|
+
PandaPal::Organization.current.trigger_canvas_sync()
|
|
10
|
+
{ foo: "bar" } # Returned to the client. Anything JSON serializable works.
|
|
11
|
+
RUBY
|
|
12
|
+
|
|
13
|
+
# Or call a method on the Organization
|
|
14
|
+
org.create_api(:symbol_of_method_on_organization, expiration: 30.days.from_now, uses: 10)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`create_api` returns a URL like `/panda_pal/call_logic?some_param=TODO&token=JWT.TOKEN.IS.HERE`. You
|
|
18
|
+
can `GET` or `POST` it. PandaPal tries to work out which params the logic reads and adds them as
|
|
19
|
+
`param=TODO`. Pass `host:` to get an absolute URL.
|
|
20
|
+
|
|
21
|
+
The code runs in the Organization's tenant. The response wraps its return value:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"status": "ok",
|
|
26
|
+
"uses_remaining": 9,
|
|
27
|
+
"result": {
|
|
28
|
+
"foo": "bar"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Errors come back as `{ "status": "error", "message": "..." }` with a 500 status, or a 403 when the
|
|
34
|
+
token is invalid.
|
|
35
|
+
|
|
36
|
+
## Revoking.
|
|
37
|
+
|
|
38
|
+
Delete the `PandaPal::ApiCall` record to revoke the URL. The logic and `uses:` live in the database,
|
|
39
|
+
so you can change them. `expiration:` is part of the token, so it can't change.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Architecture.
|
|
2
|
+
|
|
3
|
+
PandaPal is a mountable Rails engine. Your app mounts it, declares placements and launch routes, and
|
|
4
|
+
gets back validated launches, a session that works inside an `<iframe>`, and a database schema per
|
|
5
|
+
customer. This page explains how those pieces fit together. For how to use each one, follow the
|
|
6
|
+
links.
|
|
7
|
+
|
|
8
|
+
## The big pieces.
|
|
9
|
+
|
|
10
|
+
```mermaid
|
|
11
|
+
flowchart TB
|
|
12
|
+
subgraph Canvas
|
|
13
|
+
LMS[Canvas LMS]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
subgraph App["Your Rails app"]
|
|
17
|
+
direction TB
|
|
18
|
+
Elevator["Apartment elevator<br/>(picks the tenant)"]
|
|
19
|
+
subgraph Engine["PandaPal engine (mounted at /lti)"]
|
|
20
|
+
V13[LtiV1P3Controller<br/>OIDC login, launch, JWKS]
|
|
21
|
+
V10[LtiV1P0Controller<br/>LTI 1.0 launch, XML config]
|
|
22
|
+
ApiCall[ApiCallController]
|
|
23
|
+
end
|
|
24
|
+
Helpers["ControllerHelper and SessionReplacement<br/>(mixed into every controller)"]
|
|
25
|
+
AppCtl[Your controllers]
|
|
26
|
+
Cable[ActionCable patches]
|
|
27
|
+
Install["Organization#install_lti<br/>(console or rake)"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
subgraph DB[Postgres]
|
|
31
|
+
Public[("public schema<br/>Organization, LtiRegistration,<br/>login sessions")]
|
|
32
|
+
Tenants[("one schema per Organization<br/>Session, your models")]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
LMS -- launches --> Elevator --> Engine
|
|
36
|
+
Elevator --> AppCtl
|
|
37
|
+
Engine -- redirect with panda token --> AppCtl
|
|
38
|
+
AppCtl --- Helpers
|
|
39
|
+
Engine --> Public
|
|
40
|
+
AppCtl --> Tenants
|
|
41
|
+
Cable --> Tenants
|
|
42
|
+
Install -- "Canvas API (Bearcat)" --> LMS
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Piece | Lives in | Job |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| Engine controllers | `app/controllers/panda_pal/` | Tool config, OIDC login, launch validation, Deep Linking, JWKS, and API calls. |
|
|
48
|
+
| Controller helpers | `lib/panda_pal/helpers/controller_helper.rb`, `session_replacement.rb` | `validate_launch!`, `current_session`, `switch_tenant`, `redirect_with_session_to`, and friends. Included in every controller at boot. |
|
|
49
|
+
| Route helper | `lib/panda_pal/helpers/route_helper.rb` | `lti_nav`: declares a placement and its launch route together. |
|
|
50
|
+
| Models | `app/models/panda_pal/` | `Organization`, `Session`, `LtiRegistration`, `ApiCall`, and `Platform`. |
|
|
51
|
+
| Platform | `app/models/panda_pal/platform.rb`, `platform/canvas.rb` | Issuer URLs, JWKS fetching, service tokens, and the Canvas install API. |
|
|
52
|
+
| Tenancy | `config/initializers/apartment.rb` | Apartment config, the elevator, sharding, tenant-scoped cache, ActionCable, and Delayed Job hooks. |
|
|
53
|
+
| Global config | `lib/panda_pal.rb` | `PandaPal.lti_options`, placements, tools, scopes, and the signing key. |
|
|
54
|
+
|
|
55
|
+
## Two kinds of data.
|
|
56
|
+
|
|
57
|
+
PandaPal splits data across Postgres schemas with ros-apartment:
|
|
58
|
+
|
|
59
|
+
- **`public`** holds what PandaPal needs before it knows the customer: `PandaPal::Organization`,
|
|
60
|
+
`PandaPal::LtiRegistration`, and short-lived login sessions created during the LTI 1.3 OIDC step.
|
|
61
|
+
- **Each Organization's schema** (named after `Organization#name`) holds its `PandaPal::Session`
|
|
62
|
+
rows and all of your app's tenant models.
|
|
63
|
+
|
|
64
|
+
With [sharding](multi-tenancy.md#sharding), an Organization's schema can live on another database.
|
|
65
|
+
Its tenant name becomes `shard:name`, and the `Organization` row stays in `public` on the default
|
|
66
|
+
database.
|
|
67
|
+
|
|
68
|
+
## LTI 1.3 launch flow.
|
|
69
|
+
|
|
70
|
+
```mermaid
|
|
71
|
+
sequenceDiagram
|
|
72
|
+
autonumber
|
|
73
|
+
participant B as Browser (Canvas iframe)
|
|
74
|
+
participant C as Canvas
|
|
75
|
+
participant P as PandaPal engine
|
|
76
|
+
participant DB as Postgres
|
|
77
|
+
participant A as Your controller
|
|
78
|
+
|
|
79
|
+
B->>P: POST /lti/v1p3/oidc_login (iss, login_hint, client_id)
|
|
80
|
+
Note over P: Forward to beta/test domain if needed
|
|
81
|
+
P->>DB: Create login Session in public (org 0, nonce)
|
|
82
|
+
P-->>B: Auto-submit form to Canvas auth URL (state = login session's panda token)
|
|
83
|
+
B->>C: OIDC authorization request
|
|
84
|
+
C-->>B: Auto-submit form with id_token and state
|
|
85
|
+
B->>P: POST /lti/v1p3/resource_link_request
|
|
86
|
+
P->>DB: Look up LtiRegistration, then Organization by client and deployment ID
|
|
87
|
+
Note over P: Check trusted issuer, JWT signature (JWKS), state, nonce, aud, exp, and replay
|
|
88
|
+
P->>DB: Move the session into the Organization's schema and set its tool
|
|
89
|
+
Note over P: Store launch params, placement, and LTI version on the session
|
|
90
|
+
P-->>B: Redirect to the lti_nav route with a single-use panda token
|
|
91
|
+
B->>A: GET /organizations/:id/... ?panda_token=...
|
|
92
|
+
A-->>B: Render the app with the full session key
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
What happens at each stage:
|
|
96
|
+
|
|
97
|
+
1. **Login (steps 1 to 3).** PandaPal resolves the platform from the issuer and starts a session in
|
|
98
|
+
`public`, because it doesn't know the Organization yet. The session's panda token becomes the
|
|
99
|
+
OIDC `state`, and a random nonce goes into the session.
|
|
100
|
+
2. **Launch (steps 7 to 9).** `validate_launch!` decodes the `id_token`. It finds the Organization
|
|
101
|
+
through `PandaPal::LtiRegistration` (client ID plus optional deployment ID), then falls back to
|
|
102
|
+
`Organization#key`. It verifies the token against the platform's cached JWKS, refetching once if
|
|
103
|
+
the key ID is new, and checks `state`, `nonce`, audience, expiration, and nonce replay. Then it
|
|
104
|
+
copies the login session into the Organization's schema and deletes the original.
|
|
105
|
+
3. **Hand-off (steps 10 to 12).** `resource_link_request` stores the launch on the session and
|
|
106
|
+
redirects to the route `lti_nav` declared for that placement and tool. The redirect carries a
|
|
107
|
+
single-use `T-NONCE` panda token instead of the session secret. (In development, it carries the
|
|
108
|
+
full session key, so page reloads keep working.)
|
|
109
|
+
|
|
110
|
+
Deep Linking launches follow the same path through `deep_link_request`, and redirect to the
|
|
111
|
+
placement's `deep_link_redirect_url` instead.
|
|
112
|
+
|
|
113
|
+
## LTI 1.0/1.1 launch flow.
|
|
114
|
+
|
|
115
|
+
LTI 1.0 has no login step. Canvas posts the launch to `/lti/v1p0/launch` (auto launch) or straight
|
|
116
|
+
to your route (`auto_launch: false`). `validate_launch!` checks the OAuth 1.0a signature and
|
|
117
|
+
timestamp against the Organization found by `oauth_consumer_key`. It reads the tool from the signed
|
|
118
|
+
`custom_panda_pal_tool` field, switches to the Organization's tenant, and starts the session there.
|
|
119
|
+
From then on, it's the same as LTI 1.3.
|
|
120
|
+
|
|
121
|
+
## Every request after the launch.
|
|
122
|
+
|
|
123
|
+
```mermaid
|
|
124
|
+
flowchart TD
|
|
125
|
+
R["Request from the iframe<br/>(X-Panda-Token header, or panda_token param)"] --> E{Apartment elevator}
|
|
126
|
+
E -- "/organizations/:id or /o/:name in path" --> T[Switch to that tenant]
|
|
127
|
+
E -- "panda token present" --> TT["Switch to the tenant in the token header (o)"]
|
|
128
|
+
E -- neither --> PUB[Stay in public]
|
|
129
|
+
T --> F
|
|
130
|
+
TT --> F
|
|
131
|
+
PUB --> F
|
|
132
|
+
F["forbid_access_if_lacking_session"] --> S["current_session<br/>Session.for_panda_token"]
|
|
133
|
+
S --> V{"Token valid?<br/>signature, type, expiry, tenant"}
|
|
134
|
+
V -- no --> X[401]
|
|
135
|
+
V -- yes --> O{"Session belongs to<br/>current Organization and tenant?"}
|
|
136
|
+
O -- no --> X
|
|
137
|
+
O -- yes --> AC[Your action runs]
|
|
138
|
+
AC --> AS[after_action saves the session if it changed]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The elevator only picks a schema. It decodes the token's header without checking the signature.
|
|
142
|
+
Authentication happens later, when `current_session` verifies the token and `valid_session?`
|
|
143
|
+
checks that the session, Organization, and tenant all agree. That's why every non-launch action
|
|
144
|
+
needs `forbid_access_if_lacking_session`.
|
|
145
|
+
|
|
146
|
+
## Sessions and panda tokens.
|
|
147
|
+
|
|
148
|
+
Browsers block third-party cookies in `<iframe>`s, so PandaPal doesn't depend on the Rails session
|
|
149
|
+
cookie. Each `PandaPal::Session` row has a random `session_secret`, and the client proves it holds a
|
|
150
|
+
session with a panda token:
|
|
151
|
+
|
|
152
|
+
- A **full-key token** (`typ: KEY`) contains the secret. The page gets it once, usually in the
|
|
153
|
+
`js-env` meta tag, and sends it in a header.
|
|
154
|
+
- A **limited-use token** (`T-NONCE`, `T-IP`, `T-EXP`, or `T-URI`) contains only an HMAC of its
|
|
155
|
+
header, signed with the secret. These go in URLs, where they might be logged.
|
|
156
|
+
|
|
157
|
+
Every token's header carries the Organization ID (`o`) and session ID (`s`). That's how the
|
|
158
|
+
elevator can pick a tenant before any database lookup, and how `T-URI` tokens minted in the browser
|
|
159
|
+
by `@inst_proserv/toolkit` can be verified on the server. See [sessions.md](sessions.md) for the
|
|
160
|
+
formats.
|
|
161
|
+
|
|
162
|
+
## Finding the tool.
|
|
163
|
+
|
|
164
|
+
One app can serve several tools (see [multiple-tools.md](multiple-tools.md)). PandaPal works out
|
|
165
|
+
which tool was launched once, at launch time, and stores it in `panda_pal_sessions.tool`:
|
|
166
|
+
|
|
167
|
+
- **LTI 1.3:** from the `PandaPal::LtiRegistration` that matches the launch's client ID and
|
|
168
|
+
deployment ID. If there's no registration, it's the default tool.
|
|
169
|
+
- **LTI 1.0:** from the signed `custom_panda_pal_tool` launch param.
|
|
170
|
+
|
|
171
|
+
After that, placements, custom params, launch routes, and Deep Linking redirects are all looked up
|
|
172
|
+
through the session's tool.
|
|
173
|
+
|
|
174
|
+
## Outside the request cycle.
|
|
175
|
+
|
|
176
|
+
| Context | How it gets a tenant |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| Sidekiq jobs | `ros-apartment-sidekiq` records the tenant on enqueue and switches on perform. |
|
|
179
|
+
| Delayed Job | PandaPal's plugin does the same, using a `tenant` column. |
|
|
180
|
+
| Scheduled tasks | `ScheduledTaskExecutor` switches to each Organization before running its task. |
|
|
181
|
+
| ActionCable | `connection.panda_session` switches the connection to the token's tenant. Worker threads apply it lazily, on first database checkout. |
|
|
182
|
+
| Rake tasks and the console | Start in `public`. Use `org.switch_tenant`. The console auto-switches in development. |
|
|
183
|
+
|
|
184
|
+
`Rails.cache` keys are prefixed with the current tenant everywhere, so a cached value never crosses
|
|
185
|
+
Organizations.
|
|
186
|
+
|
|
187
|
+
## Shared state that needs a shared cache.
|
|
188
|
+
|
|
189
|
+
Several things live in `Rails.cache`:
|
|
190
|
+
|
|
191
|
+
- Platform JWKS (24 hours)
|
|
192
|
+
- LTI 1.3 launch nonces, for replay protection
|
|
193
|
+
- AGS service access tokens
|
|
194
|
+
- Canvas account admin lookups (one hour)
|
|
195
|
+
|
|
196
|
+
In production, use a cache store that every app process shares, such as Redis or Memcached. With a
|
|
197
|
+
per-process store, a replayed `id_token` could slip past the nonce check on another process.
|
|
198
|
+
|
|
199
|
+
## Extension points.
|
|
200
|
+
|
|
201
|
+
| Hook | Use it to | Docs |
|
|
202
|
+
| --- | --- | --- |
|
|
203
|
+
| `OrganizationExtension` | Add methods, callbacks, and scheduled tasks to `PandaPal::Organization`. | [organizations.md](organizations.md#extending-the-model) |
|
|
204
|
+
| `PandaPal.register_extension` | Do the same from a gem. | [organizations.md](organizations.md#settings-validation) |
|
|
205
|
+
| `define_setting`, `settings_structure` | Declare and validate Organization settings. | [organizations.md](organizations.md#settings-validation) |
|
|
206
|
+
| `before_lti_install`, `after_lti_install`, `around_lti_install` | Run code around Canvas installs. | [installation.md](installation.md#install-callbacks) |
|
|
207
|
+
| `panda_pal_interactive_install` callbacks | Run code around the interactive install. | [installation.md](installation.md#install-callbacks) |
|
|
208
|
+
| `PandaPal::Organization.extend_platform_api` | Add methods to the platform API. | [organizations.md](organizations.md#canvas-helpers) |
|
|
209
|
+
| `PandaPal::SecureHeaders.apply_defaults` | Start from PandaPal's headers and customize. | [security.md](security.md#secure-headers) |
|
|
210
|
+
| `PandaPal.jwk=` | Sign with a different key. | [configuration.md](configuration.md#lti-13-private-key) |
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Before you build it.
|
|
2
|
+
|
|
3
|
+
PandaPal apps often reimplement things PandaPal already does. Check this list before you write a
|
|
4
|
+
helper of your own.
|
|
5
|
+
|
|
6
|
+
## Launches and links.
|
|
7
|
+
|
|
8
|
+
| If you need to... | Use | Docs |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| Link from an email or notification into the tool in Canvas | `org.canvas_lti_launch_url(placement_or_context, params:)` | [organizations.md](organizations.md#canvas-helpers) |
|
|
11
|
+
| Redirect without losing the session | `redirect_with_session_to :some_url` | [sessions.md](sessions.md#links-and-redirects) |
|
|
12
|
+
| Build a link that keeps the session | `url_with_session(:some_path)` or `link_nonce` | [sessions.md](sessions.md#links-and-redirects) |
|
|
13
|
+
| Make a signed `GET` link (download, image, new tab) on the server | `link_nonce(url, type: :url)` | [sessions.md](sessions.md#links-and-redirects) |
|
|
14
|
+
| Make a signed `GET` link in the browser | `signURL(url)` or `api.download_file(url)` | [frontend.md](frontend.md#minting-panda-tokens-in-the-browser) |
|
|
15
|
+
| Route the user to Canvas beta or test domains | `PandaPal.lti_environments` (forwarding is automatic) | [routing-and-launches.md](routing-and-launches.md#canvas-beta-and-test) |
|
|
16
|
+
| Send launches from one placement to a different screen | `lti_nav placement: 'controller#action'` | [routing-and-launches.md](routing-and-launches.md#launch-routes-with-lti_nav) |
|
|
17
|
+
| Ship a second tool (another label, placement, or audience) | `PandaPal.define_tool` | [multiple-tools.md](multiple-tools.md) |
|
|
18
|
+
|
|
19
|
+
## Users and roles.
|
|
20
|
+
|
|
21
|
+
| If you need to... | Use | Docs |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| Find the launching user's record | `current_session.user` | [sessions.md](sessions.md#reading-launch-data) |
|
|
24
|
+
| Read a custom param, ignoring unsubstituted `$Canvas...` values | `current_session.get_lti_cust_param(:name)` | [configuration.md](configuration.md#custom-params) |
|
|
25
|
+
| Check whether the user is a Canvas Site Admin | `current_session.canvas_site_admin?` | [roles-and-abilities.md](roles-and-abilities.md#roles-from-the-launch) |
|
|
26
|
+
| Parse LTI role URNs (`Instructor`, `Learner`, and so on) | `current_session.lti_roles` | [roles-and-abilities.md](roles-and-abilities.md#roles-from-the-launch) |
|
|
27
|
+
| Check account admin roles, including on parent accounts | `canvas_account_role_labels(account, inherited: true)` | [roles-and-abilities.md](roles-and-abilities.md#canvas-role-labels) |
|
|
28
|
+
| Memoize a permission check for the session | `cache_on_session :method_name` | [roles-and-abilities.md](roles-and-abilities.md#ability-helpers) |
|
|
29
|
+
|
|
30
|
+
## Data and tenants.
|
|
31
|
+
|
|
32
|
+
| If you need to... | Use | Docs |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| Store per-user state across requests | `current_session[:key]` | [sessions.md](sessions.md#using-the-session) |
|
|
35
|
+
| Cache a value for the rest of the session | `current_session.cache(:key) { ... }` | [sessions.md](sessions.md#reading-launch-data) |
|
|
36
|
+
| Keep `Rails.cache` from leaking across customers | Nothing. Keys are tenant-scoped automatically. | [multi-tenancy.md](multi-tenancy.md#rails-cache) |
|
|
37
|
+
| Run a background job in the right tenant | Nothing, with `ros-apartment-sidekiq`. Delayed Job needs a `tenant` column. | [multi-tenancy.md](multi-tenancy.md#background-jobs) |
|
|
38
|
+
| Run code for every Organization in a rake task | `org.switch_tenant { ... }` | [multi-tenancy.md](multi-tenancy.md#rake-tasks) |
|
|
39
|
+
| Run code in a tenant on your own worker thread | `Apartment::Tenant.with_lazy_tenant(org.tenant_name) { ... }` | [multi-tenancy.md](multi-tenancy.md) |
|
|
40
|
+
| Clean up old sessions | Nothing, with `sidekiq-scheduler`. Otherwise `rake panda_pal:clean_sessions`. | [sessions.md](sessions.md#session-cleanup) |
|
|
41
|
+
| Test across database shards | `with_multiple_shards(...)` | [multi-tenancy.md](multi-tenancy.md#testing-with-shards) |
|
|
42
|
+
|
|
43
|
+
## Organizations and Canvas.
|
|
44
|
+
|
|
45
|
+
| If you need to... | Use | Docs |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| Call the Canvas API for an Organization | `org.bearcat_client` | [organizations.md](organizations.md#canvas-helpers) |
|
|
48
|
+
| Read the Organization's Canvas URL or token | `org.canvas_url`, `org.canvas_api_token` | [organizations.md](organizations.md#canvas-helpers) |
|
|
49
|
+
| Validate Organization settings | `settings_structure` or `define_setting` | [organizations.md](organizations.md#settings-validation) |
|
|
50
|
+
| Add methods or callbacks to Organizations | `OrganizationExtension` | [organizations.md](organizations.md#extending-the-model) |
|
|
51
|
+
| Create an Organization with a guided editor | `rake panda_pal:org:new` | [installation.md](installation.md#rake-tasks) |
|
|
52
|
+
| Install or update the tool in Canvas | `org.install_lti` or `rake panda_pal:org:install` | [installation.md](installation.md) |
|
|
53
|
+
| Run setup after an install | `after_lti_install` | [installation.md](installation.md#install-callbacks) |
|
|
54
|
+
| Run a cron job per Organization | `scheduled_task` | [task-scheduling.md](task-scheduling.md) |
|
|
55
|
+
| Let a client trigger logic during UAT | `org.create_api(...)` | [api-calls.md](api-calls.md) |
|
|
56
|
+
|
|
57
|
+
## Grades and content.
|
|
58
|
+
|
|
59
|
+
| If you need to... | Use | Docs |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| Post a grade from an LTI 1.3 launch | `ags_client.post_score(...)` | [ags.md](ags.md) |
|
|
62
|
+
| Post a grade from an LTI 1.1 launch | `PandaPal::Jobs::GradePassbackJob` | [grade-passback.md](grade-passback.md) |
|
|
63
|
+
| Let teachers pick content to embed in Canvas | `PandaPal::DeepLinkingHelpers` | [deep-linking.md](deep-linking.md) |
|
|
64
|
+
| Sign a JWT with the tool's key | `PandaPal.jwk` | [configuration.md](configuration.md#lti-13-private-key) |
|
|
65
|
+
|
|
66
|
+
## Realtime and frontend.
|
|
67
|
+
|
|
68
|
+
| If you need to... | Use | Docs |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| Authenticate an ActionCable connection | `connection.panda_session` | [action-cable.md](action-cable.md#authenticating-a-connection) |
|
|
71
|
+
| Check permissions in a channel | `authorize!` in `subscribed` | [action-cable.md](action-cable.md#cancan) |
|
|
72
|
+
| Keep cable broadcasts inside one Organization | Nothing. Broadcasts are tenant-scoped automatically. | [action-cable.md](action-cable.md#tenant-handling) |
|
|
73
|
+
| Open a cable connection from the browser | `subscribe_channel` or `useModelSubscription` | [frontend.md](frontend.md#actioncable) |
|
|
74
|
+
| Call your API with the session and CSRF token | `APIBase` | [frontend.md](frontend.md#api-calls) |
|
|
75
|
+
| Use client-side routing without changing the iframe URL | `createLtiHistory` or `<LtiRouter>` | [frontend.md](frontend.md#routing-inside-the-iframe) |
|
|
76
|
+
|
|
77
|
+
## Security.
|
|
78
|
+
|
|
79
|
+
| If you need to... | Use | Docs |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| Block requests that don't come from a launch | `prepend_before_action :forbid_access_if_lacking_session` | [security.md](security.md#require-a-session) |
|
|
82
|
+
| Allow Canvas to frame the tool | Nothing. The default secure headers allow it. | [security.md](security.md#secure-headers) |
|
|
83
|
+
| Accept launches only from trusted platforms | Nothing. It's checked on every LTI 1.3 launch. | [configuration.md](configuration.md#trusted-platforms) |
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Configuration.
|
|
2
|
+
|
|
3
|
+
All tool-level configuration lives in an initializer, usually `config/initializers/lti.rb` or `config/initializers/panda_pal.rb`.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# config/initializers/lti.rb
|
|
7
|
+
PandaPal.lti_options = {
|
|
8
|
+
title: 'Teacher Reports',
|
|
9
|
+
description: 'Reports for teachers',
|
|
10
|
+
}
|
|
11
|
+
PandaPal.lti_properties = { oauth_compliant: 'true' }
|
|
12
|
+
|
|
13
|
+
# Environments reflect those used by Canvas
|
|
14
|
+
PandaPal.lti_environments = {
|
|
15
|
+
domain: ENV['PROD_DOMAIN'],
|
|
16
|
+
beta_domain: ENV['BETA_DOMAIN'],
|
|
17
|
+
test_domain: ENV['TEST_DOMAIN'],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
PandaPal.lti_custom_params = { custom_canvas_role: '$Canvas.membership.roles' }
|
|
21
|
+
|
|
22
|
+
# LTI 1.3 scopes to request on the Developer Key (see ags.md)
|
|
23
|
+
PandaPal.lti_scopes = [PandaPal::LtiConstants::AgsScopes::SCORE]
|
|
24
|
+
|
|
25
|
+
PandaPal.stage_placement(:account_navigation, {
|
|
26
|
+
enabled: true,
|
|
27
|
+
text: 'Teacher Reports',
|
|
28
|
+
visibility: 'admins',
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## `lti_options`.
|
|
33
|
+
|
|
34
|
+
| Key | Purpose |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `title`, `description` | Tool name and description in the tool config and the Canvas install. |
|
|
37
|
+
| `lti_spec_version` | Default LTI version for `install_lti` (`'v1p3'` if unset). |
|
|
38
|
+
| `platform` | `'canvas.instructure.com'` (default) or `'bridgeapp.com'`. See [Bridge vs. Canvas](#bridge-vs-canvas). |
|
|
39
|
+
| `settings_structure` | Validation schema for `Organization#settings`. See [organizations.md](organizations.md#settings-validation). |
|
|
40
|
+
| `launch_url`, `secure_launch_url`, `launch_path`, `secure_launch_path` | LTI 1.0 launch URL. See [Launch URL property](#launch-url-property). |
|
|
41
|
+
|
|
42
|
+
## Placements.
|
|
43
|
+
|
|
44
|
+
`PandaPal.stage_placement(placement, options)` registers a Canvas placement (`:account_navigation`,
|
|
45
|
+
`:course_navigation`, `:user_navigation`, `:editor_button`, and so on). The options become that
|
|
46
|
+
placement's settings in the tool config. Pass `tool:` to attach the placement to a
|
|
47
|
+
[non-default tool](multiple-tools.md), and `deep_linking: true` to make it a
|
|
48
|
+
[Deep Linking](deep-linking.md) placement.
|
|
49
|
+
|
|
50
|
+
`PandaPal.stage_navigation` is a deprecated alias of `stage_placement`.
|
|
51
|
+
|
|
52
|
+
Most apps don't call `stage_placement` for launch routes directly. `lti_nav` in `routes.rb` does it
|
|
53
|
+
for you. See [routing-and-launches.md](routing-and-launches.md).
|
|
54
|
+
|
|
55
|
+
## Environments.
|
|
56
|
+
|
|
57
|
+
`lti_environments` maps Canvas environments to your app's domains. PandaPal uses it to:
|
|
58
|
+
|
|
59
|
+
- Forward an LTI 1.3 OIDC login that carries `canvas_environment=beta` (or `test`) to the matching
|
|
60
|
+
domain.
|
|
61
|
+
- Add every placement's `target_link_uri`, rewritten for each environment domain, to the Developer
|
|
62
|
+
Key's redirect URIs on install.
|
|
63
|
+
|
|
64
|
+
## Custom params.
|
|
65
|
+
|
|
66
|
+
`lti_custom_params` is the set of custom fields sent in every launch. Read them back with
|
|
67
|
+
`current_session.get_lti_cust_param(:canvas_role)`. By default, that returns `nil` when Canvas didn't
|
|
68
|
+
substitute the variable (the raw `$Canvas...` string came back). Pass `default: true` to fall back to
|
|
69
|
+
the configured value, or `default: false` to return `nil` whenever the value equals the configured
|
|
70
|
+
one.
|
|
71
|
+
|
|
72
|
+
## Launch URL property.
|
|
73
|
+
|
|
74
|
+
From the LTI spec: "The launch_url contains the URL to which the LTI Launch is to be sent. The
|
|
75
|
+
secure_launch_url is the URL to use if secure http is required. One of either the launch_url or the
|
|
76
|
+
secure_launch_url must be specified."
|
|
77
|
+
|
|
78
|
+
Bridge validates that this URL exists. Set one of these in `PandaPal.lti_options`:
|
|
79
|
+
|
|
80
|
+
1. `secure_launch_url: 'https://domain.com/path'` for a full, static, secure URL.
|
|
81
|
+
2. `secure_launch_path: '/path'` for a secure URL on the current host.
|
|
82
|
+
3. `launch_url: 'http://domain.com/path'` for a full, static URL.
|
|
83
|
+
4. `launch_path: '/path'` for a URL on the current host.
|
|
84
|
+
5. Nothing, to get the current host with the root path (`http://appdomain.com/`).
|
|
85
|
+
6. `launch_url: false` to leave the property out.
|
|
86
|
+
|
|
87
|
+
## Bridge vs. Canvas.
|
|
88
|
+
|
|
89
|
+
The LTI 1.0 XML config differs slightly by platform. `PandaPal.lti_options[:platform]` controls it:
|
|
90
|
+
`'canvas.instructure.com'` (default) or `'bridgeapp.com'`.
|
|
91
|
+
|
|
92
|
+
## LTI 1.3 private key.
|
|
93
|
+
|
|
94
|
+
LTI 1.3 signs tokens (the JWKS endpoint, Deep Linking responses, and AGS service tokens) with an RSA
|
|
95
|
+
private key. PandaPal looks for it in this order:
|
|
96
|
+
|
|
97
|
+
1. `PandaPal.lti_private_key = OpenSSL::PKey::RSA.new(key)`
|
|
98
|
+
2. The `LTI_PRIVATE_KEY` environment variable (PEM content)
|
|
99
|
+
3. The `LTI_PRIVATE_KEY_PATH` environment variable (absolute, or relative to `Rails.root`)
|
|
100
|
+
4. In development only, a bundled dev key
|
|
101
|
+
|
|
102
|
+
Set `LTI_PRIVATE_KEY_PASSWORD` if the key is encrypted. To generate one:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Generate a new encrypted private key
|
|
106
|
+
openssl genrsa -aes256 -out encrypted_private_key.pem 2048
|
|
107
|
+
|
|
108
|
+
# Or encrypt an existing private key
|
|
109
|
+
openssl rsa -aes256 -in existing_private_key.pem -out encrypted_private_key.pem
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The public half is served at `<mount>/v1p3/public_jwks`. To sign with a different JWK, set
|
|
113
|
+
`PandaPal.jwk = JWT::JWK.new(...)`.
|
|
114
|
+
|
|
115
|
+
## Trusted platforms.
|
|
116
|
+
|
|
117
|
+
LTI 1.3 launches are only accepted from trusted issuers. Instructure-hosted Canvas (production, beta,
|
|
118
|
+
test, and loadtest) is trusted by default, and any Canvas issuer is trusted when Rails runs in
|
|
119
|
+
development or test. To
|
|
120
|
+
trust another issuer for one Organization, add it to `settings[:lti][:trusted_platforms]`.
|