PandaPal

LTI Configuration

A standard Canvas LTI may have a configuration similar to the one below

# config/initializers/lti.rb
PandaPal.lti_options = { title: 'Teacher Reports' }
PandaPal.lti_properties = {oauth_compliant: 'true'}
# Environments reflect those used by Canvas
PandaPal.lti_environments = { domain: ENV['PROD_DOMAIN'], beta_domain: ENV['BETA_DOMAIN'], test_domain: ENV['TEST_DOMAIN'] }
PandaPal.lti_custom_params = { custom_canvas_role: '$Canvas.membership.roles' }
# :user_navigation, :course_navigation for user context and course context endpoints respectively
PandaPal::stage_navigation(:account_navigation, {
  enabled: true,
  # url: :account_index, # Optional if using lti_nav
  text: 'Teacher Reports',
  visibility: 'admins',
})

Configuration data for an installation can be set by creating a PandaPal::Organization record. Due to the nature of the data segregation, once created, the name of the organization should not be changed (and will raise a validation error).

Canvas Installation

As of version 5.5.0, LTIs can be installed into Canvas via the console:

org.install_lti(
  host: "https://your_lti.herokuapp.com",
  context: "account/self", # (Optional) Or "account/3", "course/1", etc
  exists: :error, # (Optional) Action to take if an LTI with the same Key already exists. Options are :error, :replace, :duplicate, :update
  version: "v1p3", # (Optional, default `v1p3`) LTI Version. Accepts `v1p0` or `v1p3`.
  dedicated_deployment: false, # (Optional) If true, the Organization will be updated to link to a single deployment rather then to the general LTI Key. (experimental, LTI 1.3 only)
)

LTI 1.3 Configuration

LTI 1.3 has some additional configuration steps required to setup an LTI:

  1. If you're running Canvas locally, make sure the config/redis.yml and config/dynamic_settings.yml files exist in Canvas.
  2. Also make sure config/security.yml is present and set development.lti_iss to 'http://localhost:3000' (where 3000 is the port you're running Canvas on).
  3. In prod, you'll need to generate a RSA Private Key for the LTI to use. You can set the LTI_PRIVATE_KEY ENV variable, or manually set PandaPal.lti_private_key = OpenSSL::PKey::RSA.new(key).
  4. Make sure you have Redis installed and linked correctly
  5. Your PandaPal::Organization's key should be CLIENT_ID/DEPLOYMENT_ID (which can be found in Canvas). If a Deployment ID is not given, the key should just be CLIENT_ID.

Launch URL property

LTI Spec: The launch_url contains the URL to which the LTI Launch is to be sent. The secure_launch_url is the URL to use if secure http is required. One of either the launch_url or the secure_launch_url must be specified.

Bridge is now validating that this launch URL exists and requires it per the LTI spec. As of PandaPal 3.1.0, you now have 6 options to use this. Use one of these 6 options in PandaPal.lti_options hash.

  1. Use option secure_launch_url: 'http://domain.com/path' when a full static secure domain is needed.
  2. Use option secure_launch_path: '/path' when you need the secure options and want the host to be dynamic and just need to provide a path.
  3. Use option launch_url: 'http://domain.com/path' when a full static domain is needed.
  4. Use option launch_path: '/path' when you don't need the secure options and want the host to be dynamic and just need to provide a path.
  5. Leave this property off, and you will get the dynamic host with the root path ('http://appdomain.com/') by default.
  6. If you really do not want this property use the option launch_url: false for it to be left off.

Task Scheduling

PandaPal includes an integration with sidekiq-scheduler. You can define tasks on an Organization class Stub like so:

# <your_app>/app/models/organization_extension.rb
module OrganizationExtension
  extend ActiveSupport::Concern

  # Will invoke CanvasSyncStarterWorker.perform_async() according to the cron schedule
  scheduled_task '0 15 05 * * *', :identifier, worker: CanvasSyncStarterWorker

  # Will invoke the method 'organization_method' on the Organization
  scheduled_task '0 15 05 * * *', :organization_method_and_identifier

  # If you need to invoke the same method on multiple schedules
  scheduled_task '0 15 05 * * *', :identifier, worker: :organization_method

  # You can also use a block
  scheduled_task '0 15 05 * * *', :identifier do
    # Do Stuff
  end

  # You can use a Proc (called in the context of the Organization) to determine the schedule
  scheduled_task -> { settings[:cron] }, :identifier

  # You can specify a timezone. If a TZ is not coded and settings[:timezone] is present, it will be appended automatically
  scheduled_task '0 15 05 * * * America/Denver', :identifier, worker: :organization_method

  # Setting settings[:task_schedules][:identifier] will override the code cron schedule. Setting it to false will disable the Task
  # :identifer values _must_ be unique, but can be nil, in which case they will be determined by where (lineno etc) scheduled_task is called
end

Organization Attributes

id: Primary Key name: Name of the organization. Used to on requests to select the tenant key: Key field from CanvasLMS secret: Secret field from CanvasLMS canvas_account_id: ID of the corresponding Canvas account. settings: Hash of settings for this Organization salesforce_id: ID of this organization in Salesforce

XML for an installation can be generated by visiting /lti/config in your application.

Generated API Methods

It's common to need to manually trigger logic during UAT. PandaPal 5.6.0+ adds a feature to enable clients to do this themselves. This is done by a deployer accessing the console and creating a PandaPal::ApiCall. This can be done like so:

org.create_api(<<~RUBY, expiration: 30.days.from_now, uses: 10)
  arg1 = p[:some_param] # `p` is an in-scope variable that is a Hash of the params sent to the triggering request.
  PandaPal::Organization.current.trigger_canvas_sync()
  { foo: "bar" } # Will be returned to the client. Can be anything that is JSON serializable.
RUBY
# OR
org.create_api(:symbol_of_method_on_organization, expiration: 30.days.from_now, uses: 10)

This will return a URL like such: /panda_pal/call_logic?some_param=TODO&token=JWT.TOKEN.IS.HERE that can be either GET'd or POST'd. The URL generator will attempt to determine which params the logic accepts and insert them as param=TODO in the generated URL.

When triggered, the return value of the code will be wrapped and sent to the client:

{
  "status": "ok",
  "uses_remaining": 9,
  "result": {
    "foo": "bar",
  }
}

Revoking

A Call URI may be revoked by deleting the PandaPal::ApiCall object. uses: and logic are stored in the DB (and can thus be altered), but expiration: is stored in the JWT and is thus immutable.

Routing

The following routes should be added to the routes.rb file of the implementing LTI. (substituting account_navigation with the other staged navigation routes, if necessary)

# config/routes.rb
mount PandaPal::Engine, at: '/lti'
root to: 'panda_pal/lti#launch'

# Add Launch Endpoints:
lti_nav account_navigation: 'accounts#launch', auto_launch: false # (LTI <1.3 Default)
# -- OR --
scope '/organizations/:organization_id' do
  lti_nav account_navigation: 'accounts#launch_landing', auto_launch: true # (LTI 1.3 Default)
  lti_nav account_navigation: 'accounts#launch_landing' # Automatically sets auto_launch to true because :organization_id is part of the path
  # ...
end

auto_launch: Setting to true will tell PandaPal to handle all of the launch details and session creation, and then pass off to the defined action. Setting it to false indicates that the defined action handles launch validation and setup itself (this has been the legacy approach). Because auto_launch: false is most similar to the previous behavior, it is the default for LTI 1.0/1.1 LTIs. For LTI 1.3 LTIs, auto_launch: true is the default. If not specified and :organization_id is detected in the Route Path, auto_launch will be set to true

Implementating data segregation

This engine uses Apartment to keep data segregated between installations of the implementing LTI tool. By default, it does this by inspecting the path of the request, and matching URLs containing orgs or organizations, followed by the numeric ID of the organization (path such as /organizations/123/accounts). As such, all URLs should be built on top of a routing scope, such as the example below.

scope '/organizations/:organization_id' do
  resources :accounts, only: :index do
    collection do
      get 'teachers' => 'accounts#teachers'
    end
  end
end

This can be overriden by creating an initializer, and adding a custom elevator

Rails.application.config.middleware.delete 'Apartment::Elevators::Generic'
Rails.application.config.middleware.use 'Apartment::Elevators::Generic', lambda { |request|
  if match = request.path.match(/\/(?:orgs|organizations)\/(\d+)/)
    PandaPal::Organization.find_by(id: match[1]).try(:name)
  end
}

It is also possible to switch tenants by implementing around_action :switch_tenant in the controller. However, it should be noted that calling switch_tenant must be used in an around_action hook (or given a block), and is only intended to be used for LTI launches, and not subsequent requests.

Rake tasks and jobs

Delayed Job Support has been removed. This allows each project to assess it's need to handle background jobs.

The Apartment Gem makes it so background jobs need to be run within the current tenant. If using sidekiq, there is a gem that does this automatically called apartment-sidkiq. If Delayed Jobs, see how it's done in version 1 of PandaPal.

For rake tasks, the current tenant will be set to public, and thus special handling must be taken when running the task or queueing jobs as part of it.

One potential solution is to create a method with switches between tenants, and invoke the job for each individual organization as necessary.

# config/initializers/global_methods.rb
def switch_tenant(tenant, &block)
  if block_given?
    Apartment::Tenant.switch(tenant, &block)
  else
    Apartment::Tenant.switch! tenant
  end
end
# lib/tasks/lti_tasks.rake
namespace :lti_tasks do
  desc 'some rake task'
  task some_rake_task: :environment do
    PandaPal::Organization.all.each do |org|
      switch_tenant(org.name) do
        SomeJob.perform_later
      end
    end
  end
end

Controller helper methods

Controllers will automatically have access to a number of methods that can be helpful to implement. A list with descriptions is provided below.

  • validate_launch! - should be used in any tool launch points, and will verify that the launch request received from Canvas is legitimate based on the saved key and secret.
  • current_session - PandaPal provides support to use database persisted sessions, rather than the standard rails variety, as these play better when the tool is launched in an iframe. The session_key attribute from the returned object needs to be served to the client at some point during the launch and during subsequent requests. The benefit from this is that sessions will exist entirely in the frame used to launch the tool, and will not be visible should the user be accessing the tool in other contexts simultaneously. See section below for more information about this method.
  • current_session_data - Shortcut method to access the data hash given by the above method.
  • current_organization - Used to return the organization related to the currently selected tenant. See section below for more information about this method.
  • session_changed? - returns true if the session given by current_session has been modified during the request.
  • save_session - Saves the session given by current_session to the database.

Sessions and current_session

Because of the ever-increasing security around <iframe>s and cookies, a custom session implementation has been rolled into PandaPal. The implementation is mostly passive, but requires some changes and caveats as opposed to the native Rails implementation.

The current session object can be accessed from any controller using current_session. This object isn't the actual data container (as session is in Rails). Session data can be accessed and modified using current_session.data[:key] or current_session_data[:key].

Because cookies are unreliable, it becomes the responsibility of the LTI developer to ensure that a custom "cookie" is passed to the frontend with each response and returned to the backend with each request, otherwise the backend won't be able to access the current session. There are a few ways to do this.

Generally, the method for passing the session "cookie" to the frontend looks something like:

<meta name="session_key" content="<%= current_session.session_key %>">

Returning to the backend

The session_key can be passed to the backend in multiple ways:

  1. (Recommended) Via the Authorization Header (usually defining it as a default in your AJAX/XHR/fetch library): http Authorization: token=SESSION_KEY_HERE
  2. Via a session_key parameter in a POST request (Useful for native HTML <form>s).
  3. (Used for Dev, but not highly discouraged in Prod) via a GET/URL Query parameter: ?session_key=SESSION_KEY_HERE.

The session_key does not contain any secrets itself, so it is safe to pass to the frontend, but it is encouraged to keep it away from the end-user as much as possible because it should not be shared.

Redirecting and Multi-Page Applications

Special instructions must be followed when using link_to or redirect_to to ensure that the Session is passed correctly:

Add a session_token: (notice the use of _token as opposed to _key!) parameter when using link_to or redirect_to:

  link_to "Link Name", somewhere_else_path(session_token: link_nonce)
  redirect_to somewhere_else_path(session_token: link_nonce)

You can also use the redirect_with_session_to helper, which will automatically add organization_id: and session_token: params:

  redirect_with_session_to :somewhere_else_path, other_param: 1

For each request (not each call), link_nonce generates a nonce and stores it in the Session. The generated value can be used for at-most-one future request. This means that browser back-forward navigation will not work (if it actually ever worked for iframe-based LTIs in the first place).

Persisting the session

The session is automatically saved using an after_action callback. This callback is poised to run last, but if that causes issues (eg some other callback running afterwards and needing to modify the session), skip_after_action :auto_save_session can be used and a custom implementation can be supplemented.

Session cleanup

Over time, the sessions table will become bloated with sessions that are no longer active. If sidekiq-scheduler and TaskScheduling are enabled, cleanup will be performed automatically. Otherwise you should schedule, rake panda_pal:clean_sessions to run periodically (recommend weekly).

Organizations and current_organization

Similar to current_session, current_organization can be returned with a number of methods, shown below

Rake Tasks

panda_pal:org:new

Opens $EDITOR and creates a new Organization. The opened editor is prefilled with all of the available Settings with optional settings pre-commented.

panda_pal:org:dev

Similar to panda_pal:org:new, but is somewhat preconfigured for development environments.

You can create a ~/pandapalrc.yml file to specify additional defaults:

lti_host: http://localhost:5000
settings:
  canvas:
    base_url: http://localhost:3000
    api_token: Uo5yckRQuPl96qVxgt2IVxDAa5oSUo1dqbb2dBVOnpId18aTmjSXdYkn4BN9JF2k

NB: settings entries present in ~/pandapalrc.yml that are not defined in the settings_structure will be ignored.

panda_pal:org:edit[name]

Similar to panda_pal:org:new but for editing an existing Organization.

panda_pal:org:install[orgname, http://url.of.lti]

Install the LTI into Canvas. Must specify the Organization and the host of the LTI

panda_pal:org:reinstall[orgname, http://url.of.lti]

Same as panda_pal:org:install, but first deletes an existing installation

Security

Tool Launches

Any time a tool launch request is received, we should always validate that it originated from within Canvas and that it wasn't spoofed. The easiest way to do this is to call validate_launch! in any controller action that is expecting to receive launch requests.

Validate active session is present

It is easier to implement this if a purely javascript frontend is used (such as React). Any controller actions that are not designated as launch points, or externally available API endpoints (such as in the case where an endpoint should be exposed outside the context of a tool launch, and has its own authentication mechanisms in place) should implement a line similar to the one below in application_controller.rb

# app/controllers/application_controller.rb
prepend_before_action :forbid_access_if_lacking_session

This will render an unauthorized message any time a request is received and a valid session is not present. It can be overridden on a action-by-action basis by using skip_before_action.

skip_before_action :forbid_access_if_lacking_session, only: :launch

Secure Headers

PandaPal bundles the secure_headers Gem and provides a default config. The PandaPal default works for really basic apps, but you may need to provide a custom configuration. This can be done by creating a secure_headers.rb initializer in you App like so:

SecureHeaders::Configuration.default do |config|
  PandaPal::SecureHeaders.apply_defaults(config) # Optional, but recommended
  # ...
end

Validating settings in your LTI.

You can specify a structure that you would like to have enforced for your settings.

In your panda_pal initializer (i.e. config/initializers/panda_pal.rb or config/initializers/lti.rb)

You can specify options that can include a structure for your settings. If specified, PandaPal will enforce this structure on any new / updated organizations.

PandaPal.lti_options = {
  title: 'LBS Gradebook',
  settings_structure: {
    allow_additional: true, # Allow additional properties that aren't included in the :properties Hash.
    allow_additional: { type: 'String' }, # You can also set :allow_additional to a settings specification that will be used to validate each additional setting
    validate: ->(value, spec, **kwargs) {
      # kwargs currently includes:
      #   :errors => [An array to push errors to]
      #   :path => [An array representation of the current path in the settings object]

      # To add errors, you may:
      # Push strings to the kwargs[:errors] Array:
      kwargs[:errors] << "Your error message at <path>" unless value < 10
      # Or return a string or string array:
      value.valid? ? nil : "Your error message at <path>" # <path> will be replaced with the actual path that the error occurred at
    },
    properties: {
      canvas_api_token: { type: 'String', required: true, },
      catalog: { # :validate, :allow_additional, :properties keys are all supported at this level as well
        type: 'Hash',
        required: false,
        validate: -> (*args) {},
        allow_additional: false,
        properties: {

        },
      }
    }
  },
}

Legacy Settings Structure:

Here is an example options specification:

PandaPal.lti_options = {
  title: 'LBS Gradebook',
  settings_structure: YAML.load("
    canvas:
      is_required: true
      data_type: Hash
      api_token:
        is_required: true
        data_type: String
      base_url:
        is_required: true
        data_type: String
    reports:
      is_required: true
      data_type: Hash
      active_term_allowance:
      submissions_report_time_length:
        is_required: true
        data_type: ActiveSupport::Duration
      recheck_wait:
        data_type: ActiveSupport::Duration
      max_recheck_time:
        is_required: true
  ").deep_symbolize_keys
}

(This loads the structure in from YAML, but you can specify a hash directly if you prefer.)

Each data attribute can have two children attributes to describe desired structure:

is_required: If specified, and specified as true, the parent attribute is required in the settings hash.

data_type: If specified, and a settings hash contains this attribute, attribute data type will be compared to the specified data type. If you are not sure how to derive your class data type, you can use class to determine data type. For example: 30.minutes.class.to_s => "ActiveSupport::Duration"

Bridge vs Canvas

As of 3.2.0, the LTI XML config can have subtle differences based on whether your platform is bridge, or canvas. This is determined by PandaPal.lti_options[:platform]. Set this to platform: 'canvas.instructure.com' (default) OR platform: 'bridgeapp.com'

Development:

Running Specs:

Initialize the Specs DB: cd spec/dummy; bundle exec rake db:drop; bundle exec rake db:create; bundle exec rake db:schema:load Then bundle exec rspec

Safari Support

Safari is weird (it blocks cookies in <iframe>s unless the site has set a cookie outside of an <iframe> first), and you'll run into issues with Rails-native Sessions and CSRF because of that.

This means that safari will likely refuse to send info about your rails session back to the LTI, and the application will start up a new session each time the browser navigates. This likely means a new session each time the LTI launches.

PandaPal 5

It has been a constant struggle to force safari to store and allow access to a rails session while the application is embedded in Canvas.

As of PandaPal 5, a session cookie is no longer required by panda_pal.

See the Section on Sessions and current_session

You will want to watch out for a few scenarios: 1) Make sure you are using redirect_with_session_to if you need to redirect and have your PandaPal session_key persisted server side. 2) Use the Authorization header with token={session_key} to send your PandaPal session info into api calls. 3) If you use link_to and navigate in your LTI (apps that are not single page) make sure you include the link_nonce like so: ruby link_to "Link Name", somewhere_else_path(arg, session_token: link_nonce) NB: As of PandaPal 5.2.6, you can instead use ruby link_to "Name", url_with_session(:somewhere_else_path, arg, kwarg: 1)

Persistent sessions have session_tokens as a way to safely communicate a session key in a way that is hopefully not too persistent in case it is logged somewhere. Options for communicating session_token - :nonce (default) - each nonce is good for exactly one communication with the backend server. Once the nonce is used, it is no longer valid. :fixed_ip - each session_token is good until it expires. It must be used from the same ip the LTI launched from. :expiring - this is the least secure. Each token is good until it expires.

For :fixed_ip and :expiring tokens you can override the default expiration period of 15 minutes.

See the following example of how to override the link_nonce_type and token expiration length.

class ApplicationController < ActionController::Base link_nonce_type :fixed_ip def session_expiration_period_minutes 120 end ... end

Previous Safari Instructions

Safari is weird and you'll potentially run into issues getting POST requests to properly validate CSRF if you don't do the following:

  • Make sure you have both a launch controller and your normal controllers. The launch controller should call before_action :validate_launch! and then redirect to your other controller.
  • Make sure your other controller calls before_action :forbid_access_if_lacking_session

This will allow PandaPal to apply an iframe cookie fix that will allow CSRF validation to work.

Migrating Major Versions

Upgrading to version 3

Before upgrading save existing settings somewhere safe in case you need to restore them for whatever reason.

panda_pal v3 introduces an encrypted settings hash. This should provide more security in the case where a customer may have gained access to Organization information in the database. Settings cannot be decrypted without knowing the secret decryption key. For panda_pal, we are relying on the secret_key_base to be set (typically in config/secrets.yml), if that is not available we are falling back to directly use ENV['SECRET_KEY_BASE']. For production environments, config/secrets.yml should not have a plain-text secret, it should be referencing an ENV variable. Make sure your secret is not plain-text committed to a repository!

The secret key is used to encrypt / decrypt the settings hash as necessary.

Before upgrading to version 3, you should confirm that the secret key is set to a consistent value (if the value is lost your settings will be hosed).

You should also rollback any local encryption you have done on the settings hash. The settings hash should just be plainly visible when you upgrade to V3. Otherwise the panda_pal migrations will probably fail.

Once you have upgraded your gem, you will need to run migrations. Before doing that, I would store off your unencrypted settings (just in case).

rake db:migrate

If all goes well, you should be set! Log into console, and verify that you can still access settings:

PandaPal::Organization.first.settings

should show unencrypted settings in your console.

If anything goes wrong, you should be able to rollback the change:

rake db:rollback STEP=2

If you need to give up on the change, just make sure to change your gem version for panda_pal to be < 3.

Upgrading from PandaPal 4 to 5:

If your tool is setup according to a pretty standard pattern (see pace_plans, canvas_group_enrollment, etc), you shouldn't have to do anything to upgrade.

You will want to make sure that IF your launch controller is redirecting, it is using "redirect_with_session_to".

Here is an example launch / account controller setup, assuming an account launch.

class LaunchController < ApplicationController
  # We don't verify CSRF on launch because the LTI launch is done via a POST
  # request, and Canvas wouldn't know anything about the CSRF
  skip_before_action :verify_authenticity_token
  skip_before_action :forbid_access_if_lacking_session # We don't have a session yet
  around_action :switch_tenant
  before_action :validate_launch!
  before_action :handle_launch

  def handle_launch
    current_session_data[:canvas_user_id] = params[:custom_canvas_user_id]
    current_session_data[:canvas_course_id] = params[:custom_canvas_course_id]
  end

  def 
    redirect_with_session_to :accounts_url
  end
end

class AccountController < ApplicationController
  prepend_before_action :forbid_access_if_lacking_session

  def index
  end
end