diff --git a/CHANGELOG.md b/CHANGELOG.md index f054969..32ec15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 2.12.0 (2025-08-15) + +Fixes: + +* `PATCH` could fail to work as expected with extension schemas using complex type attributes. The problem arose if using a single `path` that pointed to the complex attribute with a Hash (object) value containing field-value pairs, rather than `path`s pointing all the way down to individual attribute fields with simple values - see [#160](https://github.com/pond/scimitar/pull/160) - thanks to `@aerodynamik` +* Works around an issue with Google Workspace - fixes [#142](https://github.com/pond/scimitar/issues/142) via [#161](https://github.com/pond/scimitar/pull/161) - thanks to `@kewnt` + +Features: + +* MongoDB support through Mongoid queries; see [#159](https://github.com/pond/scimitar/pull/159) for more - thanks to `@andriisereda-st` +* Proper support for fully custom authorisation, fixing [#158](https://github.com/pond/scimitar/issues/158) via [#162](https://github.com/pond/scimitar/pull/162) - thanks to `@NobodysNightmare` + # 2.11.0 (2025-03-05) Maintenance: diff --git a/README.md b/README.md index 6ff33a0..7d68ccc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Gem Version](https://badge.fury.io/rb/scimitar.svg)](https://badge.fury.io/rb/scimitar) [![Build Status](https://github.com/pond/scimitar/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/pond/scimitar/actions/) -[![License](https://img.shields.io/badge/license-mit-blue.svg)](https://opensource.org/licenses/MIT) +[![License](https://img.shields.io/badge/license-mit-blue.svg)](https://github.com/pond/scimitar/blob/main/LICENSE.txt) A SCIM v2 API endpoint implementation for Ruby On Rails. @@ -58,17 +58,35 @@ All three are provided under the MIT license. Scimitar is too. Scimitar is best used with Rails and ActiveRecord, but it can be used with other persistence back-ends too - you just have to do more of the work in controllers using Scimitar's lower level controller subclasses, rather than relying on Scimitar's higher level ActiveRecord abstractions. +Some aspects of configuration are handled via a `config/initializers/scimitar.rb` file. It is **strongly recommended** that you wrap Scimitar configuration with `Rails.application.config.to_prepare do...` so that any changes you make to configuration during local development are reflected via auto-reload, rather than requiring a server restart: + +```ruby +Rails.application.config.to_prepare do + Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({ + # ...see subsections below for configuration options... + end +end +``` + +In general, Scimitar's own development and tests assume this approach. If you choose to put the configuration directly into an initializer file without the `to_prepare` wrapper, you will be at a _slightly_ higher risk of tripping over unrecognised Scimitar bugs; please make sure that your own application test coverage is reasonably comprehensive. + ### Authentication -Noting the _Security_ section later - to set up an authentication method, create a `config/initializers/scimitar.rb` in your Rails application and define a token-based authenticator and/or a username-password authenticator in the [engine configuration section documented in the sample file](https://github.com/pond/scimitar/blob/main/config/initializers/scimitar.rb). For example: +You can define a token-based authenticator, a basic username-password authenticator or a custom authenticator in the [engine configuration section documented in the sample file](https://github.com/pond/scimitar/blob/main/config/initializers/scimitar.rb) and examples of these are given in the sub-sections below. In all cases, it boils down to a `Proc` that you define which is invoked for every handled request. Your `Proc` code executes as if it were an instance method of an ApplicationController subclass which is handling a `before_action` callback in the normal Rails fashion, so it has full access to all the usual Rails objects such as `request` and `response`. + +Please take note of the _Security_ section later for additional information related to authorisation, as well as other security considerations. + +#### Token-based + +The `Proc` shown below must evaluate to `true` or `false`. The way you do that is up to you; in the examples below, code within the `Proc.new` block is just for illustration. ```ruby Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({ token_authenticator: Proc.new do | token, options | - # This is where you'd write the code to validate `token` - the means by + # This is where you'd write the code to validate `token`. The means by # which your application issues tokens to SCIM clients, or validates them, - # is outside the scope of the gem; the required mechanisms vary by client. + # is outside the scope of the gem. The required mechanisms vary by client. # More on this can be found in the 'Security' section later. # SomeLibraryModule.validate_access_token(token) @@ -77,19 +95,65 @@ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({ }) ``` -When it comes to token access, Scimitar neither enforces nor presumes any kind of encoding for bearer tokens. You can use anything you like, including encoding/encrypting JWTs if you so wish - https://rubygems.org/gems/jwt may be useful. The way in which a client might integrate with your SCIM service varies by client and you will have to check documentation to see how a token gets conveyed to that client in the first place (e.g. a full OAuth flow with your application, or just a static token generated in some UI which an administrator copies and pastes into their client's SCIM configuration UI). +Scimitar returns either a 401 error if your block evaluated to `false`, else consider the request authenticated and set HTTP header `WWW-Authenticate` to a value of **`Bearer`(( in the response per [RFC 7644](https://tools.ietf.org/html/rfc7644#section-2). + +Scimitar neither enforces nor presumes any kind of encoding for bearer tokens. You can use anything you like, including encoding/encrypting JWTs if you so wish - https://rubygems.org/gems/jwt may be useful. The way in which a client might integrate with your SCIM service varies by client and you will have to check documentation to see how a token gets conveyed to that client in the first place (e.g. a full OAuth flow with your application, or just a static token generated in some UI which an administrator copies and pastes into their client's SCIM configuration UI). -**Strongly recommended:** You should wrap any Scimitar configuration with `Rails.application.config.to_prepare do...` so that any changes you make to configuration during local development are reflected via auto-reload, rather than requiring a server restart. +#### Username and password-based + +For username/passwords, use something like this (again, the code inside the `Proc` is just an illustration): ```ruby -Rails.application.config.to_prepare do - Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({ - # ... +Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({ + basic_authenticator: Proc.new do | username, password | + + User.find_by_username(username)&.valid_password?(password) || false + end -end +}) ``` -In general, Scimitar's own development and tests assume this approach. If you choose to put the configuration directly into an initializer file without the `to_prepare` wrapper, you will be at a _slightly_ higher risk of tripping over unrecognised Scimitar bugs; please make sure that your own application test coverage is reasonably comprehensive. +Scimitar returns either a 401 error if your block evaluated to `false`, else consider the request authenticated and set HTTP header `WWW-Authenticate` to a value of **`Basic`** in the response per [RFC 7644](https://tools.ietf.org/html/rfc7644#section-2). + +#### Custom + +To fully take over authentication, you can supply a custom authenticator. If the authentication mechanism you're using does not already do so, **you become responsible for setting an appropriate value for the `WWW-Authenticate` header** in your response to indicate the appropriate authentication type. Scimitar won't do that itself, since it doesn't know what approach your custom code is using. + +Here's an example where Warden is being used for authentication, with Warden storing the authenticated information under a scope of `scim_v2` in this case, so the user could be later read back using `warden.user(:scim_v2)` (though you can use any Warden scope name you want, of course, or not use any authentication scope at all). + +```ruby +Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({ + custome_authenticator: Proc.new do + + # In this example we catch the Warden 'throw' for failed authentication, as + # well as allowing Warden to successfully find an *authenticated* user, but + # then fail *authorisation* based on some hypothetical permissions check. + # + catch(:warden) do + response.headers['WWW-Authenticate'] = '...something...' + + warden = request.env["warden"] + user = warden.authenticate!(scope: :scim_v2) + user.can_use_scim? # (just a hypothetical User model method that might check some permissions) + end or false + + end +}) +``` + +If you _only_ wanted Warden authentication and not further authorisation, that block becomes even simpler: + +```ruby +catch(:warden) do + response.headers['WWW-Authenticate'] = '...something...' + + warden = request.env["warden"] + warden.authenticate!(scope: :scim_v2) # This'll either throw (caught -> block 'nil' -> "or false" -> false), else continue + true # If we reach this line, Warden didn't throw, so authentication was successful +end or false +``` + +Scimitar handles `false` responses for you, rendering a `401` response, _but_ you can override that for even more customised behaviour if you want. Should your Proc have already responded, either by calling `handle_scim_error` and passing it an instance of an `Exception` - e.g. the `Scimitar::ErrorResponse` subclass of `Exception` initialised with `status: , detail: "error message"`) - or by any other means, then Scimitar won't render its standard 401 at all and your code becomes responsible for the returned JSON payload. ### Routes @@ -118,6 +182,10 @@ Internally Scimitar always invokes URL helpers in the controller layer. I.e. any Note that Okta has some [curious documentation on its use of `POST` vs `PATCH` for Groups](https://developer.okta.com/docs/api/openapi/okta-scim/guides/scim-20/#update-a-specific-group-name), which per [this Scimitar issue](https://github.com/pond/scimitar/issues/153#issuecomment-2468897194) has caused at least one person some trouble. Defining the routes for both verbs as shown above (though in that issue's case, it was for the Group resource) _does_ still seem to work, but take care if integrating with Okta to try and at least manually test, if not auto-test, `PATCH`/`PUT` operations initiated from Okta's side, just to make sure. +### 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. + ### Data models Scimitar assumes that each SCIM resource maps to a single corresponding class in your system. This might be an abstraction over more complex underpinings, but either way, a 1:1 relationship is expected. For example, a SCIM User might map to a User ActiveRecord model in your Rails application, while a SCIM Group might map to some custom class called Team which operates on a more complex set of data "under the hood". diff --git a/app/controllers/scimitar/application_controller.rb b/app/controllers/scimitar/application_controller.rb index d88be3f..f0f8bfd 100644 --- a/app/controllers/scimitar/application_controller.rb +++ b/app/controllers/scimitar/application_controller.rb @@ -140,11 +140,15 @@ def authenticated? authenticate_with_http_basic do |username, password| instance_exec(username, password, &Scimitar.engine_configuration.basic_authenticator) end - elsif Scimitar.engine_configuration.token_authenticator.present? + end + + result ||= if Scimitar.engine_configuration.token_authenticator.present? authenticate_with_http_token do |token, options| instance_exec(token, options, &Scimitar.engine_configuration.token_authenticator) end - elsif Scimitar.engine_configuration.custom_authenticator.present? + end + + result ||= if Scimitar.engine_configuration.custom_authenticator.present? instance_exec(&Scimitar.engine_configuration.custom_authenticator) end diff --git a/config/initializers/scimitar.rb b/config/initializers/scimitar.rb index a4701f0..91559b2 100644 --- a/config/initializers/scimitar.rb +++ b/config/initializers/scimitar.rb @@ -86,6 +86,18 @@ # Note that both basic and token authentication can be declared, with the # parameters in the inbound HTTP request determining which is invoked. + # If you want to support a custom authenticfation method: + # + # custom_authenticator: Proc.new do + # # Custom code here. don't forget to set a WWW-Authenticate header: + # response.headers['WWW-Authenticate'] = '...something...' + # # ...and evaluate to 'true' for success or 'false' for failure. + # end + # + # If a basic and/or token authenticator has also been defined, then they're + # called first. The code will cascade through trying each and only call a + # custom authenticator if other mechanisms fail to authenticate. + # Scimitar rescues certain error cases and exceptions, in order to return a # JSON response to the API caller. If you want exceptions to also be # reported to a third party system such as sentry.io or raygun.com, you can diff --git a/lib/scimitar/version.rb b/lib/scimitar/version.rb index 3a8ff60..5624e93 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.11.0' + VERSION = '2.12.0' # Date for VERSION. If this changes, be sure to re-run "bundle install" # or "bundle update". # - DATE = '2025-03-05' + DATE = '2025-08-15' end diff --git a/spec/controllers/scimitar/application_controller_spec.rb b/spec/controllers/scimitar/application_controller_spec.rb index 02b3083..874ab1a 100644 --- a/spec/controllers/scimitar/application_controller_spec.rb +++ b/spec/controllers/scimitar/application_controller_spec.rb @@ -11,8 +11,6 @@ end controller do - rescue_from StandardError, with: :handle_resource_not_found - def index render json: { 'message' => 'cool, cool!' }, format: :scim end @@ -61,6 +59,7 @@ def index end end + context 'token authentication' do before do Scimitar.engine_configuration = Scimitar::EngineConfiguration.new( @@ -71,8 +70,6 @@ def index end controller do - rescue_from StandardError, with: :handle_resource_not_found - def index render json: { 'message' => 'cool, cool!' }, format: :scim end @@ -185,6 +182,56 @@ def index end end + context 'authentication cascade' do + before do + Scimitar.engine_configuration = Scimitar::EngineConfiguration.new( + basic_authenticator: -> (username, password) { username == 'A' && password == 'B' }, + token_authenticator: -> (token, options ) { token == 'A' }, + custom_authenticator: -> { params[:let_me_in] == 'A' }, + ) + end + + controller do + def index + render json: { 'message' => 'cool, cool!' }, format: :scim + end + end + + it 'basic first' do + request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials('A', 'B') + + get :index, params: { format: :scim } + expect(response).to be_ok + + request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials('A', 'Wrong') + + get :index, params: { format: :scim } + expect(response).to have_http_status(:unauthorized) + end + + it 'token after basic' do + request.env['HTTP_AUTHORIZATION'] = 'Bearer A' + + get :index, params: { format: :scim } + expect(response).to be_ok + + request.env['HTTP_AUTHORIZATION'] = 'Bearer B' + + get :index, params: { format: :scim } + expect(response).to have_http_status(:unauthorized) + end + + it 'custom after basic and token' do + request.env['HTTP_AUTHORIZATION'] = 'Bearer Wrong' + + get :index, params: { format: :scim, let_me_in: 'A' } + expect(response).to be_ok + + get :index, params: { format: :scim, let_me_in: 'Wrong' } + expect(response).to have_http_status(:unauthorized) + end + end + context 'authenticator evaluated within controller context' do # Define a controller with a custom instance method 'valid_token'.