panda_pal 5.19.1 → 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.
@@ -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
+ ```