Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
90 changes: 79 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand All @@ -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: <HTTP status code>, 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

Expand Down Expand Up @@ -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".
Expand Down
8 changes: 6 additions & 2 deletions app/controllers/scimitar/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions config/initializers/scimitar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/scimitar/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 51 additions & 4 deletions spec/controllers/scimitar/application_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +59,7 @@ def index
end
end


context 'token authentication' do
before do
Scimitar.engine_configuration = Scimitar::EngineConfiguration.new(
Expand All @@ -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
Expand Down Expand Up @@ -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'.
Expand Down