From 585ef4f3e4e02de6c1b19a3164e0bad1ccdd66d8 Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Fri, 14 Nov 2025 13:06:20 +1300 Subject: [PATCH] Support custom inbound request sanitization --- Gemfile | 2 +- README.md | 33 ++++++++++- .../scimitar/application_controller.rb | 42 ++++++++++---- app/models/scimitar/engine_configuration.rb | 1 + lib/scimitar/version.rb | 4 +- scimitar.gemspec | 4 +- .../scimitar/application_controller_spec.rb | 55 +++++++++++++++++++ 7 files changed, 122 insertions(+), 19 deletions(-) diff --git a/Gemfile b/Gemfile index bbc2b18..38ba752 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,6 @@ source "https://rubygems.org" # Use a fork version of SDoc; can't use ":git" in ".gemspec" files, so do # it here instead. # -gem 'sdoc', :git => 'https://github.com/pond/sdoc.git', :branch => 'master' +gem 'sdoc', git: 'https://github.com/pond/sdoc.git', branch: 'master' gemspec diff --git a/README.md b/README.md index 20ece83..8273f28 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Some aspects of configuration are handled via a `config/initializers/scimitar.rb Rails.application.config.to_prepare do Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({ # ...see subsections below for configuration options... - end + }) end ``` @@ -184,7 +184,36 @@ Note that Okta has some [curious documentation on its use of `POST` vs `PATCH` f ### Google Workspace note -Using SCIM with Google Workspace might only work for a subset of applications. Since web UIs for major service providers change very often, it doesn't make sense to provide extensive documentation here as it would get out of date quickly; you may have to figure out the setup as best you can using whatever current Google documentation exists for their system. There are [some notes which were relevant around mid-2025](https://github.com/pond/scimitar/issues/142#issuecomment-2699050541) (from when a workarond/fix was incorporated into Scimitar to allow it to work with Google Workspace) which may help you get started. +Using SCIM with Google Workspace might only work for a subset of applications. Since web UIs for major service providers change very often, it doesn't make sense to provide extensive documentation here as it would get out of date quickly; you may have to figure out the setup as best you can using whatever current Google documentation exists for their system. There are [some notes which were relevant around mid-2025](https://github.com/pond/scimitar/issues/142#issuecomment-2699050541) (from when a workaround/fix was incorporated into Scimitar to allow it to work with Google Workspace) which may help you get started. + +### Request content type handling + +The correct content type for SCIM is `application/scim+json`. Scimitar tolerates some variants of this, rewriting things internally so that the request continues to be processed by the rest of the gem (including ending up in subclass code you write) under this media type, with a Rails request format of `:scim`. Sometimes, callers into SCIM endpoints might use a content type that Scimitar rejects. If so, you can configure a custom request sanitizer Proc (with a "z", in keeping with Rails spelling of "sanitize"): + +```ruby +Rails.application.config.to_prepare do + Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({ + + custom_request_sanitizer: Proc.new do | request | + # + # Examine e.g. 'request.media_type' and evaluate to a Symbol: + # + # :success - set the standard content type and ensure the Rails request + # format is :scim; continue processing normally + # + # :preserve - retain the existing content type and Rails request format; + # continue processing normally + # + # :fail - the request format appears to be invalid; generate a 406 + # ("Not Acceptable") response + # + end + + }) +end +``` + +The Proc is passed the [`ActionDispatch::Request`](https://api.rubyonrails.org/classes/ActionDispatch/Request.html) instance for the current request. It **must** evaluable to a Symbol as shown above. Typically, `:preserve` is only for very special use cases where you understand that the request headers and/or format might not match what other parts of Scimitar expect, but have written appropriate custom code elsewhere to deal with that. ### Data models diff --git a/app/controllers/scimitar/application_controller.rb b/app/controllers/scimitar/application_controller.rb index 30fcd50..fb2ac35 100644 --- a/app/controllers/scimitar/application_controller.rb +++ b/app/controllers/scimitar/application_controller.rb @@ -93,19 +93,37 @@ def handle_unexpected_error(exception) # def require_scim scim_mime_type = Mime::Type.lookup_by_extension(:scim).to_s + failure_detail = "Only #{scim_mime_type} type is accepted." + + if Scimitar.engine_configuration.custom_request_sanitizer.is_a?(Proc) + + result = Scimitar.engine_configuration.custom_request_sanitizer.call(request) + case result + when :fail + handle_scim_error(ErrorResponse.new(status: 406, detail: failure_detail)) + when :preserve + # Do nothing + else + request.format = :scim + request.headers['CONTENT_TYPE'] = scim_mime_type + end + + else # "if Scimitar.engine_configuration.custom_request_sanitizer.present?" + + if request.media_type.nil? || request.media_type.empty? + request.format = :scim + request.headers['CONTENT_TYPE'] = scim_mime_type + elsif request.media_type.downcase == scim_mime_type + request.format = :scim + elsif request.format == :scim + request.headers['CONTENT_TYPE'] = scim_mime_type + elsif request.media_type.downcase == 'application/json' && request.user_agent&.start_with?('Google') # https://github.com/pond/scimitar/issues/142 + request.format = :scim + request.headers["CONTENT_TYPE"] = scim_mime_type + else + handle_scim_error(ErrorResponse.new(status: 406, detail: failure_detail)) + end - if request.media_type.nil? || request.media_type.empty? - request.format = :scim - request.headers['CONTENT_TYPE'] = scim_mime_type - elsif request.media_type.downcase == scim_mime_type - request.format = :scim - elsif request.format == :scim - request.headers['CONTENT_TYPE'] = scim_mime_type - elsif request.media_type.downcase == 'application/json' && request.user_agent.start_with?('Google') # https://github.com/pond/scimitar/issues/142 - request.format = :scim - request.headers["CONTENT_TYPE"] = scim_mime_type - else - handle_scim_error(ErrorResponse.new(status: 406, detail: "Only #{scim_mime_type} type is accepted.")) end end diff --git a/app/models/scimitar/engine_configuration.rb b/app/models/scimitar/engine_configuration.rb index 8adfb4f..605d51c 100644 --- a/app/models/scimitar/engine_configuration.rb +++ b/app/models/scimitar/engine_configuration.rb @@ -12,6 +12,7 @@ class EngineConfiguration :basic_authenticator, :token_authenticator, :custom_authenticator, + :custom_request_sanitizer, :application_controller_mixin, :exception_reporter, :optional_value_fields_required, diff --git a/lib/scimitar/version.rb b/lib/scimitar/version.rb index 7d1c414..142b9b2 100644 --- a/lib/scimitar/version.rb +++ b/lib/scimitar/version.rb @@ -3,11 +3,11 @@ module Scimitar # Gem version. If this changes, be sure to re-run "bundle install" or # "bundle update". # - VERSION = '2.13.0' + VERSION = '2.14.0' # Date for VERSION. If this changes, be sure to re-run "bundle install" # or "bundle update". # - DATE = '2025-09-12' + DATE = '2025-11-14' end diff --git a/scimitar.gemspec b/scimitar.gemspec index b398b54..71dd3e1 100644 --- a/scimitar.gemspec +++ b/scimitar.gemspec @@ -34,10 +34,10 @@ Gem::Specification.new do |s| end s.add_development_dependency 'debug', '~> 1.11' - s.add_development_dependency 'rake', '~> 13.2' + s.add_development_dependency 'rake', '~> 13.3' s.add_development_dependency 'pg', '~> 1.6' s.add_development_dependency 'simplecov-rcov', '~> 0.3' - s.add_development_dependency 'rdoc', '~> 6.14' + s.add_development_dependency 'rdoc', '~> 6.15' s.add_development_dependency 'warden', '~> 1.2' s.add_development_dependency 'rspec-rails', '~> 7.1' s.add_development_dependency 'warden-rspec-rails', '~> 0.3' diff --git a/spec/controllers/scimitar/application_controller_spec.rb b/spec/controllers/scimitar/application_controller_spec.rb index 2477244..69af527 100644 --- a/spec/controllers/scimitar/application_controller_spec.rb +++ b/spec/controllers/scimitar/application_controller_spec.rb @@ -435,6 +435,61 @@ def index; end expect(@exception.message).to eql('Only application/scim+json type is accepted.') end end # "context 'and with Google SCIM calls' do" + + context 'and with a custom request sanitizer' do + around :each do | example | + original_configuration = Scimitar.engine_configuration.custom_request_sanitizer + Scimitar.engine_configuration.custom_request_sanitizer = Proc.new do | request | + case request.media_type + when 'application/json+success' + :success + when 'application/json+preserve' + :preserve + else + :fail + end + end + example.run() + ensure + Scimitar.engine_configuration.custom_request_sanitizer = original_configuration + end + + context 'returning "success"' do + it 'reaches the controller action with cleaned up request data' do + request.headers['Content-Type'] = 'application/json+success' + get :index + + expect(@exception).to be_a(RuntimeError) + expect(@exception.message).to eql('Bang') + + expect(request.format == :scim).to eql(true) + expect(request.headers['CONTENT_TYPE']).to eql('application/scim+json') + end + end # "context 'returning "success"' do" + + context 'returning "preserve"' do + it 'reaches the controller action with unmodified request data' do + request.headers['Content-Type'] = 'application/json+preserve' + get :index + + expect(@exception).to be_a(RuntimeError) + expect(@exception.message).to eql('Bang') + + expect(request.format == :html).to eql(true) + expect(request.headers['CONTENT_TYPE']).to eql('application/json+preserve') + end + end # "context 'returning "keep"' do" + + context 'returning "fail"' do + it 'is invoked' do + request.headers['Content-Type'] = 'application/json+fail' + get :index + + expect(@exception).to be_a(Scimitar::ErrorResponse) + expect(@exception.message).to eql('Only application/scim+json type is accepted.') + end + end # "context 'returning "fail"' do" + end # "context 'and with a custom request sanitizer' do" end # "context 'exception reporter' do" end # "context 'error handling' do" end