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.
@@ -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.dig(:lti, :trusted_platforms) || []
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.dig(:canvas, :external_tool_id)
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 = {}
@@ -192,7 +192,7 @@ module PandaPal
192
192
  def self.find_org_setting(paths, org = current_organization)
193
193
  paths.each do |p|
194
194
  p = p.split('.').map(&:to_sym)
195
- val = org.settings.dig(*p)
195
+ val = org.settings&.dig(*p)
196
196
  return val if val.present?
197
197
  end
198
198
  nil
@@ -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, CableRoom's `Room#around_work` below, or a similar
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
@@ -35,6 +35,7 @@ class RemoveOldOrganizationSettings < PandaPal::MiscHelper::MigrationClass
35
35
  end
36
36
 
37
37
  remove_column :panda_pal_organizations, :old_settings
38
+ PandaPal::Organization.reset_column_information
38
39
  end
39
40
 
40
41
  def down
@@ -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]`.