diff --git a/app/controllers/live_event_subscriptions_controller.rb b/app/controllers/live_event_subscriptions_controller.rb new file mode 100644 index 0000000000000..c5608d36074c8 --- /dev/null +++ b/app/controllers/live_event_subscriptions_controller.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +# +# Copyright (C) 2026 - present Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# + +# @API Live Event Subscriptions +# +# Read-only API for auditing Live Event subscriptions configured for an account. +# Subscriptions define which Canvas events are forwarded to external consumers +# (e.g., via SQS or HTTPS) and in which format (live-event or caliper). +# +# Sensitive fields such as AWS keys and secrets are masked in the response. +# +# @model LiveEventSubscription +# { +# "id": "LiveEventSubscription", +# "description": "A Live Event subscription that forwards Canvas events to an external endpoint.", +# "properties": { +# "Id": { +# "description": "The unique identifier for the subscription.", +# "example": "71d6dfba-0547-477d-b41d-db8cb528c6d1", +# "type": "string" +# }, +# "ContextId": { +# "description": "The id of the context (account) for this subscription.", +# "example": "10000000000001", +# "type": "string" +# }, +# "ContextType": { +# "description": "The type of context. Typically 'root_account'.", +# "example": "root_account", +# "type": "string" +# }, +# "EventTypes": { +# "description": "Array of event type names this subscription listens for.", +# "example": ["course_created", "enrollment_created"], +# "type": "array", +# "items": { "type": "string" } +# }, +# "Format": { +# "description": "Delivery format. Either 'live-event' or 'caliper'.", +# "example": "live-event", +# "type": "string" +# }, +# "TransportType": { +# "description": "Transport mechanism. Either 'sqs' or 'https'.", +# "example": "sqs", +# "type": "string" +# }, +# "TransportMetadata": { +# "description": "Transport configuration. Sensitive values are masked.", +# "example": { "Url": "https://sqs.us-east-1.amazonaws.com/123456789012/my-queue" }, +# "type": "object" +# }, +# "State": { +# "description": "The state of the subscription.", +# "example": "Active", +# "type": "string" +# } +# } +# } +class LiveEventSubscriptionsController < ApplicationController + before_action :require_account_context + before_action { require_feature_enabled :live_event_subscriptions_api } + before_action :require_root_account + before_action :require_manage_data_services + before_action :verify_service_configured + + SENSITIVE_KEY_PATTERN = /(?:_|\b)(?:key|secret|credential|token|password)(?:_|\b)/i + + # @API List Live Event Subscriptions + # + # Returns all live event subscriptions for the specified root account. + # Must be called on a root account. Sub-accounts are not currently + # supported (the account_id parameter is preserved for future use). + # + # Sensitive fields (AWS keys, secrets, etc.) are masked in the response. + # + # @returns [LiveEventSubscription] + def index + response = Services::LiveEventsSubscriptionService.index(jwt_body) + render_masked_response(response) + rescue StandardError => e + handle_service_error(e) + end + + # @API Show a Live Event Subscription + # + # Returns a single live event subscription by ID. + # + # Sensitive fields (AWS keys, secrets, etc.) are masked in the response. + # + # @returns LiveEventSubscription + def show + response = Services::LiveEventsSubscriptionService.show(jwt_body, params.require(:id)) + render_masked_response(response) + rescue StandardError => e + handle_service_error(e) + end + + private + + def require_root_account + return if @context.root_account? + + render json: { error: "Live event subscriptions are only available for root accounts" }, + status: :bad_request + end + + def jwt_body + { + sub: "#{@context.global_id}:#{@context.uuid}", + DeveloperKey: "internal", + RootAccountId: @context.global_id, + RootAccountUUID: @context.uuid + } + end + + def require_manage_data_services + authorized_action(@context, @current_user, :manage_data_services) + end + + def verify_service_configured + return if Services::LiveEventsSubscriptionService.available? + + render json: { error: "Live Events Subscription service not configured" }, + status: :service_unavailable + end + + def handle_service_error(error) + Canvas::Errors.capture_exception(:live_event_subscriptions, error, :warn) + render json: { error: "Unable to communicate with Live Events Subscription service" }, + status: :bad_gateway + end + + def render_masked_response(service_response) + body = JSON.parse(service_response.body) + masked = mask_sensitive_fields(body) + render json: masked, status: service_response.code + rescue JSON::ParserError + render json: { error: "Unexpected response from subscription service" }, + status: :bad_gateway + end + + def mask_sensitive_fields(obj) + case obj + when Hash + obj.each_with_object({}) do |(key, value), result| + result[key] = if sensitive_key?(key) + mask_value(value) + elsif value.is_a?(String) && url_like?(value) + mask_url(value) + else + mask_sensitive_fields(value) + end + end + when Array + obj.map { |item| mask_sensitive_fields(item) } + else + obj + end + end + + def sensitive_key?(key) + SENSITIVE_KEY_PATTERN.match?(key.to_s) + end + + def url_like?(value) + value.match?(%r{\Ahttps?://}i) + end + + def mask_url(url) + uri = URI.parse(url) + + if uri.userinfo + uri.user = mask_string(uri.user) if uri.user + uri.password = mask_string(uri.password) if uri.password + end + + if uri.query + masked_params = URI.decode_www_form(uri.query).map do |key, value| + [key, mask_string(value)] + end + uri.query = URI.encode_www_form(masked_params) + end + + uri.to_s + rescue URI::InvalidURIError, ArgumentError + mask_string(url) + end + + def mask_value(value) + case value + when String + mask_string(value) + when Hash + value.transform_values { |v| mask_value(v) } + when Array + value.map { |v| mask_value(v) } + else + value + end + end + + def mask_string(str) + return str if str.nil? || str.length <= 4 + + "#{str[0..1]}******#{str[-2..]}" + end +end diff --git a/config/feature_flags/00_standard.yml b/config/feature_flags/00_standard.yml index e92e1cd583c88..e1aa24f47e54f 100644 --- a/config/feature_flags/00_standard.yml +++ b/config/feature_flags/00_standard.yml @@ -416,3 +416,18 @@ enable_kafka_events: display_name: Emit Events to Kafka description: Allow emitting events to Kafka applies_to: SiteAdmin +live_event_subscriptions_api: + type: setting + state: hidden + display_name: Live Event Subscriptions API + description: |- + Read-only API endpoints for auditing live event + subscriptions configured for a root account. + Sensitive fields such as AWS keys and secrets + are masked in the response. + applies_to: RootAccount + environments: + development: + state: allowed_on + ci: + state: allowed_on diff --git a/config/routes.rb b/config/routes.rb index bcd99ee005d1a..a1d768c840c77 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2164,6 +2164,11 @@ get "accounts/:account_id/developer_keys/lookup_utids", action: :lookup_utids end + scope(controller: :live_event_subscriptions) do + get "accounts/:account_id/live_event_subscriptions", action: :index, as: "account_live_event_subscriptions" + get "accounts/:account_id/live_event_subscriptions/:id", action: :show, as: "account_live_event_subscription" + end + scope(controller: "lti/registrations") do get "accounts/:account_id/lti_registrations", action: :list post "accounts/:account_id/lti_registrations", action: :create diff --git a/spec/apis/v1/live_event_subscriptions_api_spec.rb b/spec/apis/v1/live_event_subscriptions_api_spec.rb new file mode 100644 index 0000000000000..227090bdd9cdd --- /dev/null +++ b/spec/apis/v1/live_event_subscriptions_api_spec.rb @@ -0,0 +1,459 @@ +# frozen_string_literal: true + +# +# Copyright (C) 2026 - present Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# + +require_relative "../api_spec_helper" + +describe LiveEventSubscriptionsController, type: :request do + let(:root_account) { Account.default } + let(:admin) { account_admin_user(account: root_account, active_all: true) } + let(:sub_account) { root_account.sub_accounts.create!(name: "Sub Account") } + + let(:sample_subscription) do + { + "Id" => "71d6dfba-0547-477d-b41d-db8cb528c6d1", + "ContextId" => root_account.global_id.to_s, + "ContextType" => "root_account", + "EventTypes" => ["course_created", "enrollment_created"], + "Format" => "live-event", + "TransportType" => "sqs", + "TransportMetadata" => { "Url" => "https://sqs.us-east-1.amazonaws.com/123456789012/my-queue" }, + "State" => "Active", + "aws_access_key_id" => "AKIAIOSFODNN7EXAMPLE", + "aws_secret_access_key" => "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + end + + let(:service_response) do + instance_double(HTTParty::Response, body: [sample_subscription].to_json, code: 200) + end + + let(:show_service_response) do + instance_double(HTTParty::Response, body: sample_subscription.to_json, code: 200) + end + + before do + root_account.enable_feature!(:live_event_subscriptions_api) + allow(DynamicSettings).to receive(:find).and_call_original + allow(DynamicSettings).to receive(:find) + .with("live-events-subscription-service", default_ttl: 5.minutes) + .and_return({ "app-host" => "http://live-event-service" }) + allow(CanvasSecurity::ServicesJwt).to receive_messages( + encryption_secret: "setecastronomy92" * 2, + signing_secret: "donttell" * 10 + ) + end + + context "when the feature flag is disabled" do + before do + root_account.disable_feature!(:live_event_subscriptions_api) + user_session(admin) + end + + it "returns 404 for index" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: root_account.id.to_s }) + assert_status(404) + end + + it "returns 404 for show" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions/some-id", + { controller: "live_event_subscriptions", + action: "show", + format: "json", + account_id: root_account.id.to_s, + id: "some-id" }) + assert_status(404) + end + end + + describe "GET /api/v1/accounts/:account_id/live_event_subscriptions (index)" do + def api_list_subscriptions(account = root_account, opts = {}) + api_call(:get, + "/api/v1/accounts/#{account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: account.id.to_s }, + {}, + {}, + opts) + end + + context "as an account admin" do + before do + user_session(admin) + allow(HTTParty).to receive(:send).and_return(service_response) + end + + it "returns subscriptions for the root account" do + json = api_list_subscriptions + expect(json).to be_an(Array) + expect(json.length).to eq(1) + expect(json.first["Id"]).to eq("71d6dfba-0547-477d-b41d-db8cb528c6d1") + expect(json.first["EventTypes"]).to eq(["course_created", "enrollment_created"]) + end + + it "masks sensitive fields" do + json = api_list_subscriptions + sub = json.first + expect(sub["aws_access_key_id"]).to eq("AK******LE") + expect(sub["aws_secret_access_key"]).to eq("wJ******EY") + end + + it "does not mask non-sensitive fields" do + json = api_list_subscriptions + sub = json.first + expect(sub["TransportType"]).to eq("sqs") + expect(sub["Format"]).to eq("live-event") + expect(sub["State"]).to eq("Active") + expect(sub["TransportMetadata"]["Url"]).to eq("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue") + end + + it "rejects sub-account requests with 400" do + sub_account_admin = account_admin_user(account: sub_account, active_all: true) + user_session(sub_account_admin) + raw_api_call(:get, + "/api/v1/accounts/#{sub_account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: sub_account.id.to_s }) + assert_status(400) + end + end + + context "as a non-admin user" do + let(:regular_user) { user_model } + + before do + user_session(regular_user) + end + + it "returns 401 unauthorized" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: root_account.id.to_s }) + assert_status(401) + end + end + + context "when service is not configured" do + before do + user_session(admin) + allow(DynamicSettings).to receive(:find) + .with("live-events-subscription-service", default_ttl: 5.minutes) + .and_return({}) + end + + it "returns 503 service unavailable" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: root_account.id.to_s }) + assert_status(503) + end + end + + context "when the subscription service times out" do + before do + user_session(admin) + allow(HTTParty).to receive(:send).and_raise(Timeout::Error.new("execution expired")) + end + + it "returns 502 bad gateway" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: root_account.id.to_s }) + assert_status(502) + end + end + + context "when the subscription service raises a network error" do + before do + user_session(admin) + allow(HTTParty).to receive(:send).and_raise(Errno::ECONNREFUSED.new("Connection refused")) + end + + it "returns 502 bad gateway" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: root_account.id.to_s }) + assert_status(502) + end + end + + context "when the subscription service returns non-JSON" do + before do + user_session(admin) + bad_response = instance_double(HTTParty::Response, body: "Internal Error", code: 500) + allow(HTTParty).to receive(:send).and_return(bad_response) + end + + it "returns 502 with structured error" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: root_account.id.to_s }) + assert_status(502) + json = JSON.parse(response.body) + expect(json["error"]).to include("Unexpected response") + end + end + + context "when the subscription service returns a non-200 status" do + before do + user_session(admin) + error_response = instance_double(HTTParty::Response, + body: { "error" => "not found" }.to_json, + code: 404) + allow(HTTParty).to receive(:send).and_return(error_response) + end + + it "forwards the status code from the service" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions", + { controller: "live_event_subscriptions", + action: "index", + format: "json", + account_id: root_account.id.to_s }) + assert_status(404) + end + end + end + + describe "GET /api/v1/accounts/:account_id/live_event_subscriptions/:id (show)" do + let(:subscription_id) { "71d6dfba-0547-477d-b41d-db8cb528c6d1" } + + def api_show_subscription(account = root_account, id = subscription_id, opts = {}) + api_call(:get, + "/api/v1/accounts/#{account.id}/live_event_subscriptions/#{id}", + { controller: "live_event_subscriptions", + action: "show", + format: "json", + account_id: account.id.to_s, + id: }, + {}, + {}, + opts) + end + + context "as an account admin" do + before do + user_session(admin) + allow(HTTParty).to receive(:send).and_return(show_service_response) + end + + it "returns the subscription" do + json = api_show_subscription + expect(json["Id"]).to eq(subscription_id) + expect(json["EventTypes"]).to eq(["course_created", "enrollment_created"]) + end + + it "masks sensitive fields" do + json = api_show_subscription + expect(json["aws_access_key_id"]).to eq("AK******LE") + expect(json["aws_secret_access_key"]).to eq("wJ******EY") + end + + it "preserves non-sensitive fields" do + json = api_show_subscription + expect(json["ContextType"]).to eq("root_account") + expect(json["TransportType"]).to eq("sqs") + expect(json["TransportMetadata"]["Url"]).to eq("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue") + end + end + + context "as a non-admin user" do + let(:regular_user) { user_model } + + before do + user_session(regular_user) + end + + it "returns 401 unauthorized" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions/#{subscription_id}", + { controller: "live_event_subscriptions", + action: "show", + format: "json", + account_id: root_account.id.to_s, + id: subscription_id }) + assert_status(401) + end + end + + context "with a sub-account" do + before do + sub_account_admin = account_admin_user(account: sub_account, active_all: true) + user_session(sub_account_admin) + end + + it "rejects with 400" do + raw_api_call(:get, + "/api/v1/accounts/#{sub_account.id}/live_event_subscriptions/#{subscription_id}", + { controller: "live_event_subscriptions", + action: "show", + format: "json", + account_id: sub_account.id.to_s, + id: subscription_id }) + assert_status(400) + end + end + + context "when the subscription service times out" do + before do + user_session(admin) + allow(HTTParty).to receive(:send).and_raise(Timeout::Error.new("execution expired")) + end + + it "returns 502 bad gateway" do + raw_api_call(:get, + "/api/v1/accounts/#{root_account.id}/live_event_subscriptions/#{subscription_id}", + { controller: "live_event_subscriptions", + action: "show", + format: "json", + account_id: root_account.id.to_s, + id: subscription_id }) + assert_status(502) + end + end + end + + describe "credential masking" do + before do + user_session(admin) + end + + it "masks strings with only 4 characters by leaving them as-is" do + controller = LiveEventSubscriptionsController.new + expect(controller.send(:mask_string, "ABCD")).to eq("ABCD") + end + + it "masks strings with 5+ characters" do + controller = LiveEventSubscriptionsController.new + expect(controller.send(:mask_string, "ABCDE")).to eq("AB******DE") + end + + it "masks nested sensitive values in hashes" do + controller = LiveEventSubscriptionsController.new + input = { "credentials" => { "api_key" => "my-secret-key-123" } } + result = controller.send(:mask_sensitive_fields, input) + expect(result["credentials"]).to eq({ "api_key" => "my******23" }) + end + + it "masks sensitive values in arrays" do + controller = LiveEventSubscriptionsController.new + input = { "secret_tokens" => ["token-abc-123", "token-xyz-789"] } + result = controller.send(:mask_sensitive_fields, input) + expect(result["secret_tokens"]).to eq(["to******23", "to******89"]) + end + + it "handles nil values gracefully" do + controller = LiveEventSubscriptionsController.new + expect(controller.send(:mask_string, nil)).to be_nil + end + + it "passes through non-string sensitive values unchanged" do + controller = LiveEventSubscriptionsController.new + input = { "token_enabled" => true, "key_count" => 42 } + result = controller.send(:mask_sensitive_fields, input) + expect(result["token_enabled"]).to be(true) + expect(result["key_count"]).to eq(42) + end + + it "masks empty string as-is" do + controller = LiveEventSubscriptionsController.new + expect(controller.send(:mask_string, "")).to eq("") + end + end + + describe "URL masking" do + let(:controller) { LiveEventSubscriptionsController.new } + + it "preserves base URL without query params or auth" do + url = "https://sqs.us-east-1.amazonaws.com/123456789012/my-queue" + expect(controller.send(:mask_url, url)).to eq(url) + end + + it "masks basic auth credentials in URL" do + url = "https://myuser:mysecretpass@example.com/webhook" + result = controller.send(:mask_url, url) + expect(result).to eq("https://my******er:my******ss@example.com/webhook") + end + + it "masks query parameter values" do + url = "https://example.com/webhook?token=adfasfdasfs&api_key=secret123" + result = controller.send(:mask_url, url) + expect(result).to include("token=ad******fs") + expect(result).to include("api_key=se******23") + expect(result).to include("https://example.com/webhook?") + end + + it "masks both basic auth and query params" do + url = "https://admin:password@example.com/events?token=abc12345" + result = controller.send(:mask_url, url) + expect(result).to include("ad******in") + expect(result).to include("pa******rd") + expect(result).to include("token=ab******45") + end + + it "leaves short query param values as-is" do + url = "https://example.com/webhook?v=1&ok=yes" + result = controller.send(:mask_url, url) + expect(result).to include("v=1") + expect(result).to include("ok=yes") + end + + it "handles malformed percent-encoding in query params" do + url = "https://example.com/hook?token=abc%ZZdef" + result = controller.send(:mask_url, url) + expect(result).to eq("ht******ef") + end + + it "is applied to URL values in hash fields automatically" do + input = { + "TransportMetadata" => { + "Url" => "https://example.com/hook?token=mysecrettoken123" + } + } + result = controller.send(:mask_sensitive_fields, input) + expect(result["TransportMetadata"]["Url"]).to include("https://example.com/hook?") + expect(result["TransportMetadata"]["Url"]).to include("token=my******23") + end + end +end