panda_pal 5.19.1 → 5.20.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/README.md +152 -703
- data/app/controllers/panda_pal/lti_v1_p3_controller.rb +3 -0
- 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 +1 -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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Multi-tenancy.
|
|
2
|
+
|
|
3
|
+
PandaPal uses [ros-apartment](https://github.com/rails-on-services/apartment) to give each
|
|
4
|
+
Organization its own Postgres schema. `PandaPal::Organization` and `PandaPal::LtiRegistration` live
|
|
5
|
+
in the shared `public` schema. Everything else, including `PandaPal::Session`, lives in the
|
|
6
|
+
Organization's schema.
|
|
7
|
+
|
|
8
|
+
## How a request picks its tenant.
|
|
9
|
+
|
|
10
|
+
PandaPal installs an Apartment elevator that checks, in order:
|
|
11
|
+
|
|
12
|
+
1. A path like `/orgs/123` or `/organizations/123` (Organization ID)
|
|
13
|
+
2. A path like `/o/acme`, `/orgs/acme`, or `/organizations/acme` (Organization name)
|
|
14
|
+
3. A panda token in the request (see [sessions.md](sessions.md#passing-the-token-to-the-frontend))
|
|
15
|
+
4. ActiveStorage blob URLs with an `organization_id` param
|
|
16
|
+
|
|
17
|
+
If none match, the request runs in `public`. The elevator only picks a schema. Authentication still
|
|
18
|
+
happens in `forbid_access_if_lacking_session` (see [security.md](security.md)).
|
|
19
|
+
|
|
20
|
+
To replace it, delete the middleware and add your own:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
Rails.application.config.middleware.delete Apartment::Elevators::Generic
|
|
24
|
+
Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request|
|
|
25
|
+
if match = request.path.match(/\/(?:orgs|organizations)\/(\d+)/)
|
|
26
|
+
PandaPal::Organization.find_by(id: match[1])&.tenant_name # tenant_name, not name, so shards work
|
|
27
|
+
end
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Switching tenants in code.
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
org.switch_tenant { ... } # Run a block in the Organization's tenant
|
|
35
|
+
org.switch_tenant # Switch for the rest of the thread
|
|
36
|
+
switch_tenant(org) { ... } # Global helper. Takes an Organization or a name.
|
|
37
|
+
switch_tenant("acme") { ... }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
In controllers, `around_action :switch_tenant` switches to `current_organization`. It must run as an
|
|
41
|
+
`around_action` (or with a block).
|
|
42
|
+
|
|
43
|
+
Use `org.tenant_name` rather than `org.name` whenever you hand a tenant to Apartment yourself. With
|
|
44
|
+
sharding, they differ.
|
|
45
|
+
|
|
46
|
+
### Rails cache.
|
|
47
|
+
|
|
48
|
+
`Rails.cache` keys are prefixed with the current tenant automatically, so cached values never leak
|
|
49
|
+
between Organizations.
|
|
50
|
+
|
|
51
|
+
### Console.
|
|
52
|
+
|
|
53
|
+
The Rails console prompt shows the app, environment, and current tenant. On startup, the console
|
|
54
|
+
switches to the `local` Organization (or the first one) in development, or to the only Organization
|
|
55
|
+
when there's just one.
|
|
56
|
+
|
|
57
|
+
## Background jobs.
|
|
58
|
+
|
|
59
|
+
Jobs need to run in the tenant that enqueued them.
|
|
60
|
+
|
|
61
|
+
- **Sidekiq**: add `ros-apartment-sidekiq` to your Gemfile. PandaPal raises an error at boot if
|
|
62
|
+
Sidekiq is present without it.
|
|
63
|
+
- **Delayed Job**: PandaPal registers a Delayed Job plugin that records the tenant on enqueue and
|
|
64
|
+
switches to it on perform. Your `delayed_jobs` table needs a `tenant` column.
|
|
65
|
+
|
|
66
|
+
## Rake tasks.
|
|
67
|
+
|
|
68
|
+
Rake tasks start in `public`. Loop over Organizations and switch into each one:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# lib/tasks/lti_tasks.rake
|
|
72
|
+
namespace :lti_tasks do
|
|
73
|
+
task some_rake_task: :environment do
|
|
74
|
+
PandaPal::Organization.find_each do |org|
|
|
75
|
+
org.switch_tenant do
|
|
76
|
+
SomeJob.perform_later
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Sharding.
|
|
84
|
+
|
|
85
|
+
PandaPal can put tenants on more than one database. Add a `shard` string column to
|
|
86
|
+
`panda_pal_organizations` to turn it on.
|
|
87
|
+
|
|
88
|
+
On boot, PandaPal finds shards from environment variables named `SHARD_DB_<ID>_URL` or
|
|
89
|
+
`HEROKU_POSTGRESQL_<ID>_URL`, where `<ID>` is the shard name. Each shard's config is merged over the
|
|
90
|
+
default database config.
|
|
91
|
+
|
|
92
|
+
- Pick a shard with `PandaPal::Organization.new(shard: "shard_a")`. A blank `shard` (or
|
|
93
|
+
`"default"`) means the default database. PandaPal doesn't pick a shard for you.
|
|
94
|
+
- `panda_pal:org:new` lists the configured shards in its editor template.
|
|
95
|
+
- Creating an Organization on a shard that isn't configured raises an error.
|
|
96
|
+
- `shard` can't change after creation.
|
|
97
|
+
- The Organization record itself always stays in the `public` schema of the default database.
|
|
98
|
+
- `org.tenant_name` is `shard:name` (for example, `shard_a:acme`) for sharded Organizations.
|
|
99
|
+
|
|
100
|
+
Sharding doesn't support Rails database roles. To use native Rails sharding for a model instead, add
|
|
101
|
+
it to Apartment's `excluded_models`.
|
|
102
|
+
|
|
103
|
+
### Testing with shards.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# spec/spec_helper.rb
|
|
107
|
+
require "panda_pal/spec_helper"
|
|
108
|
+
RSpec.configure { |config| config.include PandaPal::SpecHelper }
|
|
109
|
+
|
|
110
|
+
# spec/models/thing_spec.rb
|
|
111
|
+
with_multiple_shards(
|
|
112
|
+
shards: { alt: "myapp_test2" }, # shard name => database name
|
|
113
|
+
tenant_names: %w[on_primary alt:on_alt], # Schemas to drop before the run
|
|
114
|
+
transactional: false,
|
|
115
|
+
determine_shard: ->(org) { org.name.include?("alt") ? "alt" : nil },
|
|
116
|
+
)
|
|
117
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Multiple tools.
|
|
2
|
+
|
|
3
|
+
One app can publish more than one LTI tool. For example, a teacher-facing tool and a separate,
|
|
4
|
+
student-facing editor button with its own label and visibility. In Canvas, each tool is its own
|
|
5
|
+
Developer Key (LTI 1.3) or its own External Tool (LTI 1.0), with its own placements.
|
|
6
|
+
|
|
7
|
+
Everything configured without a `tool:` belongs to the default tool (`PandaPal::DEFAULT_TOOL`), so
|
|
8
|
+
single-tool apps don't change.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# config/initializers/lti.rb
|
|
12
|
+
PandaPal.define_tool(:translate,
|
|
13
|
+
title: 'Teacher Reports - Translate', # Merged over lti_options for this tool's config
|
|
14
|
+
custom_params: { mode: 'translate' }, # Merged over lti_custom_params for this tool's config
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PandaPal.stage_placement(:editor_button, tool: :translate, text: 'Translate', visibility: 'public')
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# config/routes.rb
|
|
22
|
+
lti_nav editor_button: 'translate#editor_button', tool: :translate # Served at <mount>/translate/editor_button
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Config.
|
|
26
|
+
|
|
27
|
+
Each tool has its own config URLs: `/v1p3/config?tool=translate` and `/v1p0/config?tool=translate`.
|
|
28
|
+
The default tool's URLs don't change.
|
|
29
|
+
|
|
30
|
+
## Install.
|
|
31
|
+
|
|
32
|
+
`org.install_lti(host: ..., tool: :translate)`. On LTI 1.3, this creates (or reuses) the tool's own
|
|
33
|
+
Developer Key and records it as a `PandaPal::LtiRegistration` on the Organization. The
|
|
34
|
+
Organization's `key` keeps pointing at the default tool. On LTI 1.0, both tools share the
|
|
35
|
+
Organization's consumer key and secret.
|
|
36
|
+
|
|
37
|
+
To install a second tool along with the default one, use an install callback (see
|
|
38
|
+
[installation.md](installation.md#install-callbacks)):
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# app/models/organization_extension.rb
|
|
42
|
+
included do
|
|
43
|
+
after_lti_install :install_translate_tool
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def install_translate_tool
|
|
47
|
+
install = current_lti_install
|
|
48
|
+
return unless install.tool == PandaPal::DEFAULT_TOOL # the nested :translate install runs these callbacks too
|
|
49
|
+
install_lti(**install.to_h.except(:installation), tool: :translate)
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Launches.
|
|
54
|
+
|
|
55
|
+
`current_session.lti_tool` says which tool was launched. It's stored in the `tool` column of
|
|
56
|
+
`panda_pal_sessions`.
|
|
57
|
+
|
|
58
|
+
- On LTI 1.3, the tool comes from the registration matching the launch's client ID (and deployment
|
|
59
|
+
ID).
|
|
60
|
+
- On LTI 1.0, it comes from the signed `custom_panda_pal_tool` field that every non-default tool
|
|
61
|
+
config includes.
|
|
62
|
+
|
|
63
|
+
PandaPal routes each launch, and each Deep Linking redirect, using that tool's placements.
|
|
64
|
+
|
|
65
|
+
## Registering by hand.
|
|
66
|
+
|
|
67
|
+
If a tool's Developer Key was set up outside `install_lti`, register it yourself:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
org.lti_registrations.create!(tool: 'translate', client_id: '10000000000123', deployment_id: nil)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A registration with a `deployment_id` takes priority over one without.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Organizations.
|
|
2
|
+
|
|
3
|
+
A `PandaPal::Organization` is one installation of your tool, usually one Canvas customer. Each
|
|
4
|
+
Organization gets its own Postgres schema (see [multi-tenancy.md](multi-tenancy.md)).
|
|
5
|
+
|
|
6
|
+
## Attributes.
|
|
7
|
+
|
|
8
|
+
| Attribute | Purpose |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `id` | Primary key. |
|
|
11
|
+
| `name` | Unique name, also the tenant's schema name. Letters, numbers, and `_` only. It can't change after creation (use `rename!` if you must). |
|
|
12
|
+
| `key` | LTI 1.0: the consumer key. LTI 1.3: `CLIENT_ID` or `CLIENT_ID/DEPLOYMENT_ID` of the default tool. |
|
|
13
|
+
| `secret` | LTI 1.0: the shared secret. LTI 1.3: the Developer Key `api_key`. |
|
|
14
|
+
| `canvas_account_id` | The Canvas account the tool belongs to. `install_lti` installs here by default. |
|
|
15
|
+
| `settings` | Encrypted hash of settings. See [Settings validation](#settings-validation). |
|
|
16
|
+
| `salesforce_id` | Unique ID of this Organization in Salesforce. |
|
|
17
|
+
| `shard` | Optional. The database shard, if you use [sharding](multi-tenancy.md#sharding). |
|
|
18
|
+
| `lti_registrations` | Extra LTI 1.3 Developer Keys or deployments (see [multiple-tools.md](multiple-tools.md)). |
|
|
19
|
+
|
|
20
|
+
Create Organizations with the `panda_pal:org:new` rake task (see
|
|
21
|
+
[installation.md](installation.md#rake-tasks)), or in the console.
|
|
22
|
+
|
|
23
|
+
## `current_organization`.
|
|
24
|
+
|
|
25
|
+
| Where | How it's found |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| Controllers | The session's Organization, then (deprecated) `oauth_consumer_key` or `organization_id` params, then the current tenant. |
|
|
28
|
+
| Anywhere else | `current_organization` (a global helper) or `PandaPal::Organization.current` reads the current tenant. |
|
|
29
|
+
|
|
30
|
+
`PandaPal::Organization.for_apt_tenant(tenant)` finds the Organization for a tenant name, including
|
|
31
|
+
shard-qualified names like `shard_a:acme`.
|
|
32
|
+
|
|
33
|
+
## Extending the model.
|
|
34
|
+
|
|
35
|
+
Put app-specific Organization code in an `OrganizationExtension` concern. PandaPal includes it in
|
|
36
|
+
`PandaPal::Organization` automatically.
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# app/models/organization_extension.rb
|
|
40
|
+
module OrganizationExtension
|
|
41
|
+
extend ActiveSupport::Concern
|
|
42
|
+
|
|
43
|
+
included do
|
|
44
|
+
after_lti_install :configure_canvas
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def sync_now
|
|
48
|
+
# ...
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Canvas helpers.
|
|
54
|
+
|
|
55
|
+
When the platform is Canvas, Organizations also get:
|
|
56
|
+
|
|
57
|
+
| Method | Purpose |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `install_lti`, `reinstall_lti!`, `lti_installations` | See [installation.md](installation.md). |
|
|
60
|
+
| `canvas_url` | `settings[:canvas][:base_url]` (or a few common variants). |
|
|
61
|
+
| `canvas_api_token` | `settings[:canvas][:api_token]` (or a few common variants). |
|
|
62
|
+
| `bearcat_client` | A Bearcat client for the Canvas API. Uses `canvas_sync_client` when CanvasSync defines it. |
|
|
63
|
+
| `root_account_info` | The Canvas root account, cached for 24 hours. |
|
|
64
|
+
| `canvas_lti_launch_url(placement_or_context, ...)` | A Canvas URL that launches this tool. See below. |
|
|
65
|
+
| `trusted_platform?(platform)` | Whether an LTI 1.3 issuer may launch into this Organization. |
|
|
66
|
+
|
|
67
|
+
`canvas_lti_launch_url` builds a link into Canvas that launches the tool:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
org.canvas_lti_launch_url(:account_navigation) # Into the Organization's account
|
|
71
|
+
org.canvas_lti_launch_url(course) # course_navigation in a course (anything with canvas_id)
|
|
72
|
+
org.canvas_lti_launch_url("courses/12") # A context path
|
|
73
|
+
org.canvas_lti_launch_url(:global, params: { report_id: 1 }, host: request) # Launch with extra params
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It needs `settings[:canvas][:external_tool_id]`, or pass `lti_id:`.
|
|
77
|
+
|
|
78
|
+
To add your own methods to the platform API, use
|
|
79
|
+
`PandaPal::Organization.extend_platform_api { def my_helper; end }`.
|
|
80
|
+
|
|
81
|
+
## Settings validation.
|
|
82
|
+
|
|
83
|
+
Describe the shape of `settings`, and PandaPal validates it on every save. The
|
|
84
|
+
`panda_pal:org:new` editor is prefilled from it too.
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
PandaPal.lti_options = {
|
|
88
|
+
title: 'LBS Gradebook',
|
|
89
|
+
settings_structure: {
|
|
90
|
+
allow_additional: true, # Allow keys that aren't in :properties
|
|
91
|
+
# Or validate each extra key against a spec:
|
|
92
|
+
# allow_additional: { type: 'String' },
|
|
93
|
+
validate: ->(value, spec, **kwargs) {
|
|
94
|
+
# kwargs[:errors] is an Array to push errors to; kwargs[:path] is the current path.
|
|
95
|
+
kwargs[:errors] << "Your error message at <path>" unless value.present?
|
|
96
|
+
# Or return a String (or Array of Strings). <path> is replaced with the real path.
|
|
97
|
+
nil
|
|
98
|
+
},
|
|
99
|
+
properties: {
|
|
100
|
+
canvas: {
|
|
101
|
+
type: 'Hash',
|
|
102
|
+
required: true,
|
|
103
|
+
properties: {
|
|
104
|
+
api_token: { type: 'String', required: true },
|
|
105
|
+
base_url: { type: 'String', required: true },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
catalog: { # :validate, :allow_additional, and :properties work at every level
|
|
109
|
+
type: 'Hash',
|
|
110
|
+
required: false,
|
|
111
|
+
allow_additional: false,
|
|
112
|
+
properties: {},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
You can also add settings from code, which is handy in gems and extensions:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
PandaPal::Organization.define_setting("report_timezone", { type: 'String', required: false })
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A dotted path (`"parent.child"`) adds a setting inside an existing `Hash` setting. PandaPal itself
|
|
126
|
+
defines `lti` and `lti.trusted_platforms` this way. `PandaPal.register_extension('Organization',
|
|
127
|
+
'::MyGem::OrgExtension')` includes more modules, the same way `OrganizationExtension` is included.
|
|
128
|
+
|
|
129
|
+
### Legacy settings structure.
|
|
130
|
+
|
|
131
|
+
The older YAML-style format still works. `is_required` maps to `required`, and `data_type` maps to
|
|
132
|
+
`type`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
PandaPal.lti_options = {
|
|
136
|
+
title: 'LBS Gradebook',
|
|
137
|
+
settings_structure: YAML.load("
|
|
138
|
+
canvas:
|
|
139
|
+
is_required: true
|
|
140
|
+
data_type: Hash
|
|
141
|
+
api_token:
|
|
142
|
+
is_required: true
|
|
143
|
+
data_type: String
|
|
144
|
+
reports:
|
|
145
|
+
submissions_report_time_length:
|
|
146
|
+
is_required: true
|
|
147
|
+
data_type: ActiveSupport::Duration
|
|
148
|
+
").deep_symbolize_keys
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`data_type` is compared with the value's class name. For example, `30.minutes.class.to_s` is
|
|
153
|
+
`"ActiveSupport::Duration"`.
|
|
154
|
+
|
|
155
|
+
## Encryption.
|
|
156
|
+
|
|
157
|
+
`settings` is encrypted with the first 32 characters of the app's `secret_key_base` (from
|
|
158
|
+
`Rails.application.secret_key_base`, credentials, `secrets.yml` before Rails 7.2, or the
|
|
159
|
+
`SECRET_KEY_BASE` environment variable). If that value changes, existing settings can't be read.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Roles and abilities.
|
|
2
|
+
|
|
3
|
+
## Roles from the launch.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
roles = current_session.lti_roles # PandaPal::Session::RoleStore
|
|
7
|
+
roles.system_roles # e.g. ["sys_admin"]
|
|
8
|
+
roles.institution_roles # e.g. ["administrator"]
|
|
9
|
+
roles.context_roles # e.g. ["instructor", "teaching_assistant"]
|
|
10
|
+
roles.context_types # e.g. ["course_offering"]
|
|
11
|
+
roles.other_roles # Anything that didn't match a known role URN
|
|
12
|
+
|
|
13
|
+
current_session.canvas_site_admin? # true if the user is a Canvas Site Admin
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`lti_roles` reads the LTI 1.3 roles claim, or `ext_roles` on LTI 1.0. It understands both LIS v1
|
|
17
|
+
URNs (`urn:lti:role:ims/lis/Instructor`) and LIS v2 URLs
|
|
18
|
+
(`http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor`).
|
|
19
|
+
|
|
20
|
+
## Canvas role labels.
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
current_session.canvas_role_labels # Roles in the launch context
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`canvas_role_labels` reads the `canvas_role` custom param. Add
|
|
27
|
+
`custom_canvas_role: '$Canvas.membership.roles'` to `PandaPal.lti_custom_params` to use it.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
current_session.canvas_account_role_labels # Roles on the Organization's account
|
|
31
|
+
current_session.canvas_account_role_labels(account) # Roles on another account
|
|
32
|
+
current_session.canvas_account_role_labels(account, inherited: true) # Include roles on parent accounts
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`canvas_account_role_labels` returns the user's active admin roles on an account. `account` can be:
|
|
36
|
+
|
|
37
|
+
- an object that responds to `canvas_id`
|
|
38
|
+
- a Canvas account ID
|
|
39
|
+
- `'self'` or `:root` (the default)
|
|
40
|
+
|
|
41
|
+
It reads the app's `Admin` model when there is one, and `'self'` means the Organization's
|
|
42
|
+
`canvas_account_id`. Otherwise, it calls the Canvas API through `canvas_sync_client`, passes
|
|
43
|
+
`'self'` through to the API as is, and caches the result for one hour.
|
|
44
|
+
|
|
45
|
+
With `inherited: true`, roles on any parent account count too, because an admin of a parent account
|
|
46
|
+
administers everything beneath it. `canvas_account_ancestry(account)` returns that chain of account
|
|
47
|
+
IDs, nearest first. It reads the app's `Account` model (`canvas_parent_account_id`) when there is one,
|
|
48
|
+
and walks the Canvas API otherwise.
|
|
49
|
+
|
|
50
|
+
## Ability helpers.
|
|
51
|
+
|
|
52
|
+
Every controller, and any class that includes `PandaPal::Concerns::AbilityHelper` (like a CanCan
|
|
53
|
+
`Ability`), gets:
|
|
54
|
+
|
|
55
|
+
| Method | Purpose |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `panda_pal_session` | The `PandaPal::Session`. Set `@panda_pal_session`, or `@controller` to read it from a controller. |
|
|
58
|
+
| `rails_session` | The Rails session. Set `@rails_session` or `@controller`. |
|
|
59
|
+
| `is_lti_launch?` | `true` if there's a PandaPal session. |
|
|
60
|
+
| `cache_on_session(key) { ... }` | Memoize a value on the session. |
|
|
61
|
+
| `launch_params`, `get_lti_cust_param`, `lti_launch_placement`, `canvas_site_admin?`, `lti_roles`, `canvas_role_labels`, `canvas_account_role_labels` | Delegated to `panda_pal_session`. |
|
|
62
|
+
|
|
63
|
+
`cache_on_session` also works as a class macro, to cache a method's result on the session:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class Ability
|
|
67
|
+
include CanCan::Ability
|
|
68
|
+
include PandaPal::Concerns::AbilityHelper
|
|
69
|
+
|
|
70
|
+
def initialize(controller)
|
|
71
|
+
@controller = controller
|
|
72
|
+
# ...
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def admin?
|
|
76
|
+
canvas_account_role_labels(inherited: true).include?("AccountAdmin")
|
|
77
|
+
end
|
|
78
|
+
cache_on_session :admin?
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For CanCan in ActionCable channels, see [action-cable.md](action-cable.md).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Routing and launches.
|
|
2
|
+
|
|
3
|
+
## Mounting the engine.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# config/routes.rb
|
|
7
|
+
mount PandaPal::Engine, at: '/lti'
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Mount the engine before any `lti_nav` routes. It serves:
|
|
11
|
+
|
|
12
|
+
| Path | Purpose |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `/v1p0/config`, `/v1p3/config` | Tool configuration (LTI 1.0 XML, LTI 1.3 JSON). Add `?tool=` for a [non-default tool](multiple-tools.md). |
|
|
15
|
+
| `/config` | Legacy alias for `/v1p0/config`. |
|
|
16
|
+
| `/v1p0/launch` | LTI 1.0/1.1 launch. |
|
|
17
|
+
| `/v1p3/oidc_login` | LTI 1.3 OIDC login. |
|
|
18
|
+
| `/v1p3/resource_link_request` | LTI 1.3 launch. |
|
|
19
|
+
| `/v1p3/deep_link_request` | LTI 1.3 Deep Linking launch. |
|
|
20
|
+
| `/v1p3/public_jwks` | The tool's public JWKS. |
|
|
21
|
+
| `/call_logic` | [Generated API calls](api-calls.md). |
|
|
22
|
+
|
|
23
|
+
## Launch routes with `lti_nav`.
|
|
24
|
+
|
|
25
|
+
`lti_nav` declares a placement and the app route a launch lands on, in one line. It also calls
|
|
26
|
+
`PandaPal.stage_placement` for you.
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# config/routes.rb
|
|
30
|
+
mount PandaPal::Engine, at: '/lti'
|
|
31
|
+
|
|
32
|
+
scope '/organizations/:organization_id' do
|
|
33
|
+
lti_nav account_navigation: 'accounts#launch_landing'
|
|
34
|
+
lti_nav course_navigation: 'courses#launch_landing'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# For a non-default tool, served at <mount>/translate/editor_button
|
|
38
|
+
lti_nav editor_button: 'translate#editor_button', tool: :translate
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### `auto_launch`.
|
|
42
|
+
|
|
43
|
+
- **`auto_launch: true`** (the default for LTI 1.3): PandaPal validates the launch, creates the
|
|
44
|
+
session, switches to the Organization's tenant, and redirects to your action with a
|
|
45
|
+
`panda_token`. Your action just renders.
|
|
46
|
+
- **`auto_launch: false`** (the default for LTI 1.0/1.1): Canvas posts straight to your action, and
|
|
47
|
+
your controller validates the launch itself (see below).
|
|
48
|
+
|
|
49
|
+
When you don't set it and `:organization_id` is in the route path, `auto_launch` is `true`.
|
|
50
|
+
|
|
51
|
+
## What an auto launch stores.
|
|
52
|
+
|
|
53
|
+
After an auto launch, `current_session` holds:
|
|
54
|
+
|
|
55
|
+
| Key | Value |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `current_session.launch_params` | The decoded LTI 1.3 `id_token`, or the LTI 1.0 POST params. |
|
|
58
|
+
| `current_session[:launch_url_params]` | Query params on the launch URL. |
|
|
59
|
+
| `current_session.lti_launch_placement` | The placement that was launched. |
|
|
60
|
+
| `current_session[:lti_version]` | `'v1p3'` or `'v1p0'`. |
|
|
61
|
+
| `current_session.lti_tool` | Which [tool](multiple-tools.md) was launched. |
|
|
62
|
+
| `current_session.lti_platform` | The LTI 1.3 platform (issuer, JWKS, and so on). |
|
|
63
|
+
|
|
64
|
+
See [sessions.md](sessions.md#reading-launch-data) for the helpers that read these.
|
|
65
|
+
|
|
66
|
+
## Validating launches yourself.
|
|
67
|
+
|
|
68
|
+
For `auto_launch: false` routes, validate the launch in your controller:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class LaunchController < ApplicationController
|
|
72
|
+
skip_before_action :verify_authenticity_token # Canvas posts the launch without a CSRF token
|
|
73
|
+
skip_before_action :forbid_access_if_lacking_session, raise: false # No session yet
|
|
74
|
+
before_action :validate_launch!
|
|
75
|
+
around_action :switch_tenant
|
|
76
|
+
|
|
77
|
+
def account
|
|
78
|
+
current_session[:canvas_user_id] = params[:custom_canvas_user_id]
|
|
79
|
+
redirect_with_session_to :accounts_url
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`validate_launch!` checks the OAuth 1.0a signature (LTI 1.0/1.1) or the `id_token` JWT (LTI 1.3),
|
|
85
|
+
and starts a session. Pass `version: :v1p0` or `version: :v1p3` to accept only one version. It
|
|
86
|
+
renders a 401 when validation fails.
|
|
87
|
+
|
|
88
|
+
`switch_tenant` must run as an `around_action` (or be given a block).
|
|
89
|
+
|
|
90
|
+
## Canvas beta and test.
|
|
91
|
+
|
|
92
|
+
When an LTI 1.3 login comes from Canvas beta or test (`canvas_environment` is set), PandaPal:
|
|
93
|
+
|
|
94
|
+
1. Forwards the login to the matching domain in `PandaPal.lti_environments`, if there is one.
|
|
95
|
+
2. Rejects the launch with a 400 error if the Organization's `canvas_url` doesn't point at that
|
|
96
|
+
environment (for example, a production Organization launched from beta).
|
|
97
|
+
|
|
98
|
+
## Data segregation in routes.
|
|
99
|
+
|
|
100
|
+
Put your app routes under an Organization scope, so every request switches to the right tenant:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
scope '/organizations/:organization_id' do
|
|
104
|
+
resources :accounts, only: :index
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
See [multi-tenancy.md](multi-tenancy.md) for the other ways PandaPal picks a tenant.
|
data/docs/security.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Security.
|
|
2
|
+
|
|
3
|
+
## Validate every launch.
|
|
4
|
+
|
|
5
|
+
Always check that a launch came from Canvas and wasn't spoofed. Auto-launch routes do this for you.
|
|
6
|
+
For any other launch point, call `validate_launch!` (see
|
|
7
|
+
[routing-and-launches.md](routing-and-launches.md#validating-launches-yourself)).
|
|
8
|
+
|
|
9
|
+
LTI 1.3 launches also check that:
|
|
10
|
+
|
|
11
|
+
- The issuer is trusted for the Organization (see
|
|
12
|
+
[configuration.md](configuration.md#trusted-platforms)).
|
|
13
|
+
- The `id_token` signature matches the platform's JWKS. PandaPal refetches the JWKS once if the key
|
|
14
|
+
ID is unknown.
|
|
15
|
+
- The `state` and `nonce` match the OIDC login.
|
|
16
|
+
- The launch isn't from Canvas beta or test for a production Organization (see
|
|
17
|
+
[routing-and-launches.md](routing-and-launches.md#canvas-beta-and-test)).
|
|
18
|
+
|
|
19
|
+
## Require a session.
|
|
20
|
+
|
|
21
|
+
Every action that isn't a launch point or a public API endpoint should require a session:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# app/controllers/application_controller.rb
|
|
25
|
+
prepend_before_action :forbid_access_if_lacking_session
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This renders a 401 unless the request carries a valid panda token for the current Organization and
|
|
29
|
+
tenant. Skip it where needed:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
skip_before_action :forbid_access_if_lacking_session, only: :launch
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This is easiest with a JavaScript frontend (such as React) that sends the token as a header.
|
|
36
|
+
|
|
37
|
+
## CSRF.
|
|
38
|
+
|
|
39
|
+
PandaPal skips the Rails CSRF check on requests that carry no cookies. Those requests authenticate
|
|
40
|
+
with a panda token instead, and browsers often block cookies in `<iframe>`s anyway.
|
|
41
|
+
|
|
42
|
+
## Secure headers.
|
|
43
|
+
|
|
44
|
+
PandaPal bundles the `secure_headers` gem and applies a default config:
|
|
45
|
+
|
|
46
|
+
- `X-Frame-Options: ALLOWALL`, so Canvas can frame the tool
|
|
47
|
+
- `SameSite=None` cookies, which are `Secure` outside development and test
|
|
48
|
+
- A Content Security Policy limited to `'self'`, with exceptions for Sentry when it's configured
|
|
49
|
+
- In development, extra CSP exceptions for webpack-dev-server (port 3035), the Vite dev server (port
|
|
50
|
+
3036), React DevTools, rack-mini-profiler, and Scout APM (`SCOUT_DEV_TRACE`)
|
|
51
|
+
|
|
52
|
+
To customize it, add your own initializer:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# config/initializers/secure_headers.rb
|
|
56
|
+
SecureHeaders::Configuration.default do |config|
|
|
57
|
+
PandaPal::SecureHeaders.apply_defaults(config) # Optional, but recommended
|
|
58
|
+
# ...
|
|
59
|
+
end
|
|
60
|
+
```
|