scimitar 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8cb5109af12b712e491949d6627588dff6a4d70877253be26c4ee0af3b56c61e
4
- data.tar.gz: 4271bb70e89fd17b97103073bc063de748e261b62b243fa0681b3e2934085f77
3
+ metadata.gz: 4e8493f9f42ec39cc98639167c39e60261400208a011e21d202116d5ca43daff
4
+ data.tar.gz: fb1fe271b42006d0e0054207d873b18ad15851d76597bf78c591f2254eab6688
5
5
  SHA512:
6
- metadata.gz: aed187b7c9cc56fb1b0c3bae86b3274c6753078a71fb4fa6162776c056033edf19c085b830cfc3867acf7ab4e12732a6fe8100d758bd107840edd2e26c068aa0
7
- data.tar.gz: f753d11931026a98c27d27525e88e512ee5a40d1920533243b352f6345ddb285de8efb38d6101afd360156b618365805c4a9bc440941701d7e9cd9cee5488810
6
+ metadata.gz: 4f2d68d767cdf33d97684697466dbca05122783f6a081010f874ef8546cc684c3fe9ca8c72478c6214a7493ffc3d80dfd8a742d03c0748968a32b3dc76980e05
7
+ data.tar.gz: 456146fa9cd91c44208e689ba4ba5663626e4ba8bcd3fbdcd592e78785e5af63449d41e68c465e84f8567352a505d02a200b8fbca36b38b7a9d61bc7784b73b8
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Scimitar
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/scimitar.svg)](https://badge.fury.io/rb/scimitar)
4
- [![Build Status](https://app.travis-ci.com/RIPAGlobal/scimitar.svg?branch=main)](https://app.travis-ci.com/RIPAGlobal/scimitar)
5
- [![License](https://img.shields.io/badge/license-mit-blue.svg)](https://opensource.org/licenses/MIT)
4
+ [![Build Status](https://github.com/pond/scimitar/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/pond/scimitar/actions/)
5
+ [![License](https://img.shields.io/badge/license-mit-blue.svg)](https://github.com/pond/scimitar/blob/main/LICENSE.txt)
6
6
 
7
7
  A SCIM v2 API endpoint implementation for Ruby On Rails.
8
8
 
@@ -58,17 +58,35 @@ All three are provided under the MIT license. Scimitar is too.
58
58
 
59
59
  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.
60
60
 
61
+ 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:
62
+
63
+ ```ruby
64
+ Rails.application.config.to_prepare do
65
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
66
+ # ...see subsections below for configuration options...
67
+ end
68
+ end
69
+ ```
70
+
71
+ 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.
72
+
61
73
  ### Authentication
62
74
 
63
- 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:
75
+ 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`.
76
+
77
+ Please take note of the _Security_ section later for additional information related to authorisation, as well as other security considerations.
78
+
79
+ #### Token-based
80
+
81
+ 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.
64
82
 
65
83
  ```ruby
66
84
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
67
85
  token_authenticator: Proc.new do | token, options |
68
86
 
69
- # This is where you'd write the code to validate `token` - the means by
87
+ # This is where you'd write the code to validate `token`. The means by
70
88
  # which your application issues tokens to SCIM clients, or validates them,
71
- # is outside the scope of the gem; the required mechanisms vary by client.
89
+ # is outside the scope of the gem. The required mechanisms vary by client.
72
90
  # More on this can be found in the 'Security' section later.
73
91
  #
74
92
  SomeLibraryModule.validate_access_token(token)
@@ -77,19 +95,65 @@ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
77
95
  })
78
96
  ```
79
97
 
80
- 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).
98
+ 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).
99
+
100
+ 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).
81
101
 
82
- **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.
102
+ #### Username and password-based
103
+
104
+ For username/passwords, use something like this (again, the code inside the `Proc` is just an illustration):
83
105
 
84
106
  ```ruby
85
- Rails.application.config.to_prepare do
86
- Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
87
- # ...
107
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
108
+ basic_authenticator: Proc.new do | username, password |
109
+
110
+ User.find_by_username(username)&.valid_password?(password) || false
111
+
88
112
  end
89
- end
113
+ })
90
114
  ```
91
115
 
92
- 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.
116
+ 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).
117
+
118
+ #### Custom
119
+
120
+ 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.
121
+
122
+ 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).
123
+
124
+ ```ruby
125
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
126
+ custome_authenticator: Proc.new do
127
+
128
+ # In this example we catch the Warden 'throw' for failed authentication, as
129
+ # well as allowing Warden to successfully find an *authenticated* user, but
130
+ # then fail *authorisation* based on some hypothetical permissions check.
131
+ #
132
+ catch(:warden) do
133
+ response.headers['WWW-Authenticate'] = '...something...'
134
+
135
+ warden = request.env["warden"]
136
+ user = warden.authenticate!(scope: :scim_v2)
137
+ user.can_use_scim? # (just a hypothetical User model method that might check some permissions)
138
+ end or false
139
+
140
+ end
141
+ })
142
+ ```
143
+
144
+ If you _only_ wanted Warden authentication and not further authorisation, that block becomes even simpler:
145
+
146
+ ```ruby
147
+ catch(:warden) do
148
+ response.headers['WWW-Authenticate'] = '...something...'
149
+
150
+ warden = request.env["warden"]
151
+ warden.authenticate!(scope: :scim_v2) # This'll either throw (caught -> block 'nil' -> "or false" -> false), else continue
152
+ true # If we reach this line, Warden didn't throw, so authentication was successful
153
+ end or false
154
+ ```
155
+
156
+ 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.
93
157
 
94
158
  ### Routes
95
159
 
@@ -118,6 +182,10 @@ Internally Scimitar always invokes URL helpers in the controller layer. I.e. any
118
182
 
119
183
  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.
120
184
 
185
+ ### Google Workspace note
186
+
187
+ 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.
188
+
121
189
  ### Data models
122
190
 
123
191
  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".
@@ -130,7 +130,9 @@ module Scimitar
130
130
  end
131
131
 
132
132
  def authenticate
133
- handle_scim_error(Scimitar::AuthenticationError.new) unless authenticated?
133
+ unless authenticated?
134
+ handle_scim_error(Scimitar::AuthenticationError.new) unless self.performed?
135
+ end
134
136
  end
135
137
 
136
138
  def authenticated?
@@ -146,6 +148,10 @@ module Scimitar
146
148
  end
147
149
  end
148
150
 
151
+ result ||= if Scimitar.engine_configuration.custom_authenticator.present?
152
+ instance_exec(&Scimitar.engine_configuration.custom_authenticator)
153
+ end
154
+
149
155
  return result
150
156
  end
151
157
 
@@ -11,6 +11,7 @@ module Scimitar
11
11
  :uses_defaults,
12
12
  :basic_authenticator,
13
13
  :token_authenticator,
14
+ :custom_authenticator,
14
15
  :application_controller_mixin,
15
16
  :exception_reporter,
16
17
  :optional_value_fields_required,
@@ -76,6 +76,22 @@ module Scimitar
76
76
  NEXT_TOKEN = /\A(#{PAREN}|#{STR}|#{OP}|#{WORD})#{SEP}/.freeze
77
77
  IS_OPERATOR = /\A(?:#{OP})\Z/.freeze
78
78
 
79
+ MONGO_SIMPLE_COMPARISON_OPERATORS = {
80
+ "eq" => "$eq", # equal
81
+ "ne" => "$ne", # not equal
82
+ "lt" => "$lt", # less than
83
+ "le" => "$lte", # less than or equal
84
+ "gt" => "$gt", # greater than
85
+ "ge" => "$gte" # greater than or equal
86
+ }.freeze
87
+
88
+ # Present, starts with, ends with, contains
89
+ MONGO_COMPLEX_COMPARISON_OPERATORS = %w[pr sw ew co].freeze
90
+ MONGO_COMBINATION_OPERATORS = {
91
+ "and" => "$and",
92
+ "or" => "$or"
93
+ }.freeze
94
+
79
95
  # Initialise an object.
80
96
  #
81
97
  # +attribute_map+:: See Scimitar::Resources::Mixin and documentation on
@@ -182,6 +198,33 @@ module Scimitar
182
198
  )
183
199
  end
184
200
 
201
+ # Having called #parse, call here to generate a Mongoid query
202
+ # For example, given this input:
203
+ #
204
+ # userType eq "Employee" and (emails eq "a@b.com" or emails eq "a@b.org")
205
+ #
206
+ # this method will return a Mongoid query that looks like this:
207
+ #
208
+ # {
209
+ # "$and" => [
210
+ # { :user_type => { "$eq" => 'Employee' } },
211
+ # { "$or" => [
212
+ # { :emails => { "$eq" => "a@b.com" } },
213
+ # { :emails => { "$eq" => "a@b.org" } }
214
+ # ]
215
+ # ]
216
+ # }
217
+ #
218
+ # Use it with the Mongoid::Criteria#where method to filter results, e.g.:
219
+ #
220
+ # User.where(parser.to_mongoid_query)
221
+ #
222
+ # Returns a Mongoid query that is the gem's
223
+ # best attempt at interpreting the SCIM filter string.
224
+ def to_mongoid_query
225
+ tree_node_to_mongoid_query(tree)
226
+ end
227
+
185
228
  # =======================================================================
186
229
  # PRIVATE INSTANCE METHODS
187
230
  # =======================================================================
@@ -658,6 +701,70 @@ module Scimitar
658
701
  return query
659
702
  end
660
703
 
704
+ # =====================================================================
705
+ # Mongoid query support
706
+ # =====================================================================
707
+
708
+ # Recursively processes an expression tree. Calls itself with nested tree
709
+ # fragments. Handles three cases:
710
+ # 1. Combination operators (and/or) - converted to { "operator" => [condition1, condition2]}
711
+ # 2. Simple comparison operators (eq, ne, lt, le, gt, ge) - converted to { column => { operator => value } }
712
+ # 3. Complex comparison operators (pr, sw, ew, co) - converted to { column => regex }
713
+ # or { column => { "$exists" => true, "$ne" => nil } }
714
+ def tree_node_to_mongoid_query(node)
715
+ op = node[0]
716
+
717
+ if MONGO_COMBINATION_OPERATORS.key?(op)
718
+ components = node.drop(1)
719
+ # Parser works in a way that max number of arguments is 2 here
720
+ # In case the request is A && B && C, it will be parsed as A && (B && C)
721
+ raise "Combination operators expect two arguments: #{node}" unless components.size == 2
722
+
723
+ {
724
+ MONGO_COMBINATION_OPERATORS[op] => [
725
+ tree_node_to_mongoid_query(components[0]),
726
+ tree_node_to_mongoid_query(components[1])
727
+ ]
728
+ }
729
+ elsif MONGO_SIMPLE_COMPARISON_OPERATORS.key?(op) || MONGO_COMPLEX_COMPARISON_OPERATORS.include?(op)
730
+ mongoid_comparison_operation(node)
731
+ else
732
+ raise "Unsupported operator: #{op}"
733
+ end
734
+ end
735
+
736
+ def mongoid_comparison_operation(node)
737
+ raise "No arrays allowed in comparison subnodes: #{node}" if node.any? { |subnode| subnode.is_a?(Array) }
738
+
739
+ column = attribute_map.dig(node[1], :column)
740
+ raise "Unsupported field: #{node[1]}" unless column
741
+
742
+ value = node[2]&.delete("\"")
743
+
744
+ if MONGO_SIMPLE_COMPARISON_OPERATORS.key?(node[0])
745
+ mongoid_simple_comparison_operation(MONGO_SIMPLE_COMPARISON_OPERATORS[node[0]], column, value)
746
+ elsif MONGO_COMPLEX_COMPARISON_OPERATORS.include?(node[0])
747
+ mongoid_complex_comparison_operation(node[0], column, value)
748
+ end
749
+ end
750
+
751
+ def mongoid_simple_comparison_operation(op, column, value)
752
+ { column => { op => value } }
753
+ end
754
+
755
+ def mongoid_complex_comparison_operation(op, column, value)
756
+ case op
757
+ when "pr"
758
+ { column => { "$exists" => true, "$ne" => nil } }
759
+ when "sw"
760
+ { column => /^#{Regexp.escape(value)}/ }
761
+ when "ew"
762
+ { column => /#{Regexp.escape(value)}$/ }
763
+ when "co"
764
+ { column => /#{Regexp.escape(value)}/ }
765
+ end
766
+ end
767
+
661
768
  # Apply a filter to a given base scope. Mandatory named parameters:
662
769
  #
663
770
  # +base_scope+:: Base scope (ActiveRecord::Relation, e.g. User.all)
@@ -990,8 +990,12 @@ module Scimitar
990
990
  end
991
991
 
992
992
  found_data_for_recursion.each do | found_data |
993
+ extension_schema = self.class.scim_resource_type&.schemas&.detect { |schema| schema.id == path_component }
994
+
993
995
  attr_map = if path_component.to_sym == :root
994
996
  with_attr_map
997
+ elsif extension_schema
998
+ with_attr_map.slice(*extension_schema.scim_attributes.map { |attr| attr.name.to_sym })
995
999
  else
996
1000
  with_attr_map[path_component.to_sym]
997
1001
  end
@@ -86,6 +86,18 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
86
86
  # Note that both basic and token authentication can be declared, with the
87
87
  # parameters in the inbound HTTP request determining which is invoked.
88
88
 
89
+ # If you want to support a custom authenticfation method:
90
+ #
91
+ # custom_authenticator: Proc.new do
92
+ # # Custom code here. don't forget to set a WWW-Authenticate header:
93
+ # response.headers['WWW-Authenticate'] = '...something...'
94
+ # # ...and evaluate to 'true' for success or 'false' for failure.
95
+ # end
96
+ #
97
+ # If a basic and/or token authenticator has also been defined, then they're
98
+ # called first. The code will cascade through trying each and only call a
99
+ # custom authenticator if other mechanisms fail to authenticate.
100
+
89
101
  # Scimitar rescues certain error cases and exceptions, in order to return a
90
102
  # JSON response to the API caller. If you want exceptions to also be
91
103
  # reported to a third party system such as sentry.io or raygun.com, you can
@@ -3,11 +3,11 @@ module Scimitar
3
3
  # Gem version. If this changes, be sure to re-run "bundle install" or
4
4
  # "bundle update".
5
5
  #
6
- VERSION = '2.11.0'
6
+ VERSION = '2.12.0'
7
7
 
8
8
  # Date for VERSION. If this changes, be sure to re-run "bundle install"
9
9
  # or "bundle update".
10
10
  #
11
- DATE = '2025-03-05'
11
+ DATE = '2025-08-15'
12
12
 
13
13
  end
@@ -0,0 +1,3 @@
1
+ require 'warden'
2
+
3
+ Rails.application.config.middleware.use Warden::Manager
@@ -11,8 +11,6 @@ RSpec.describe Scimitar::ApplicationController do
11
11
  end
12
12
 
13
13
  controller do
14
- rescue_from StandardError, with: :handle_resource_not_found
15
-
16
14
  def index
17
15
  render json: { 'message' => 'cool, cool!' }, format: :scim
18
16
  end
@@ -61,6 +59,7 @@ RSpec.describe Scimitar::ApplicationController do
61
59
  end
62
60
  end
63
61
 
62
+
64
63
  context 'token authentication' do
65
64
  before do
66
65
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new(
@@ -71,8 +70,6 @@ RSpec.describe Scimitar::ApplicationController do
71
70
  end
72
71
 
73
72
  controller do
74
- rescue_from StandardError, with: :handle_resource_not_found
75
-
76
73
  def index
77
74
  render json: { 'message' => 'cool, cool!' }, format: :scim
78
75
  end
@@ -107,6 +104,134 @@ RSpec.describe Scimitar::ApplicationController do
107
104
  end
108
105
  end
109
106
 
107
+ context 'custom authentication, using Warden as an example' do
108
+ include Warden::Test::Helpers
109
+
110
+ controller do
111
+ def index
112
+ render json: { 'message' => 'cool, cool!' }, format: :scim
113
+ end
114
+ end
115
+
116
+ context 'with standard 401 handling' do
117
+ before do
118
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new(
119
+ custom_authenticator: Proc.new do
120
+ catch(:warden) do
121
+ response.headers['WWW-Authenticate'] = 'SomeOtherValidScheme'
122
+
123
+ warden = request.env['warden']
124
+ warden.authenticate!(scope: :scim_v2)
125
+ true
126
+ end or false
127
+ end
128
+ )
129
+ end
130
+
131
+ context 'when authentication suceeds' do
132
+ it 'renders "success"' do
133
+ expect(warden).to receive(:authenticate!).and_return("A User instance or similar") # ('warden' is the proxy provided by the warden-rspec-rails gem)
134
+
135
+ get :index, params: { format: :scim }
136
+
137
+ expect(response).to be_ok
138
+ expect(JSON.parse(response.body)).to eql({ 'message' => 'cool, cool!' })
139
+ expect(response.headers['WWW-Authenticate']).to eql('SomeOtherValidScheme') # Proves the custom block ran
140
+ end
141
+ end
142
+
143
+ context 'when authentication fails' do # We're kinda just verifying the README.md example code here!
144
+ it 'renders 401' do
145
+ expect(warden).to receive(:authenticate!) { throw(:warden) } # ('warden' is the proxy provided by the warden-rspec-rails gem)
146
+
147
+ get :index, params: { format: :scim }
148
+
149
+ expect(response).to have_http_status(:unauthorized)
150
+ expect(response.headers['WWW-Authenticate']).to eql('SomeOtherValidScheme') # Proves the custom block ran
151
+
152
+ parsed_body = JSON.parse(response.body)
153
+
154
+ expect(parsed_body).to include('schemas' => ['urn:ietf:params:scim:api:messages:2.0:Error'])
155
+ expect(parsed_body).to include('detail' => 'Requires authentication')
156
+ expect(parsed_body).to include('status' => '401')
157
+ end
158
+ end
159
+ end
160
+
161
+ context 'with custom error handling' do
162
+ before do
163
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new(
164
+ custom_authenticator: Proc.new do
165
+ render status: 499, json: { testing: true }
166
+ false
167
+ end
168
+ )
169
+ end
170
+
171
+ context 'when authentication fails' do
172
+ it 'renders the custom response' do
173
+ get :index, params: { format: :scim }
174
+
175
+ expect(response).to have_http_status(499)
176
+
177
+ parsed_body = JSON.parse(response.body)
178
+
179
+ expect(parsed_body).to eql('testing' => true)
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ context 'authentication cascade' do
186
+ before do
187
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new(
188
+ basic_authenticator: -> (username, password) { username == 'A' && password == 'B' },
189
+ token_authenticator: -> (token, options ) { token == 'A' },
190
+ custom_authenticator: -> { params[:let_me_in] == 'A' },
191
+ )
192
+ end
193
+
194
+ controller do
195
+ def index
196
+ render json: { 'message' => 'cool, cool!' }, format: :scim
197
+ end
198
+ end
199
+
200
+ it 'basic first' do
201
+ request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials('A', 'B')
202
+
203
+ get :index, params: { format: :scim }
204
+ expect(response).to be_ok
205
+
206
+ request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials('A', 'Wrong')
207
+
208
+ get :index, params: { format: :scim }
209
+ expect(response).to have_http_status(:unauthorized)
210
+ end
211
+
212
+ it 'token after basic' do
213
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer A'
214
+
215
+ get :index, params: { format: :scim }
216
+ expect(response).to be_ok
217
+
218
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer B'
219
+
220
+ get :index, params: { format: :scim }
221
+ expect(response).to have_http_status(:unauthorized)
222
+ end
223
+
224
+ it 'custom after basic and token' do
225
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer Wrong'
226
+
227
+ get :index, params: { format: :scim, let_me_in: 'A' }
228
+ expect(response).to be_ok
229
+
230
+ get :index, params: { format: :scim, let_me_in: 'Wrong' }
231
+ expect(response).to have_http_status(:unauthorized)
232
+ end
233
+ end
234
+
110
235
  context 'authenticator evaluated within controller context' do
111
236
 
112
237
  # Define a controller with a custom instance method 'valid_token'.
@@ -698,6 +698,54 @@ RSpec.describe Scimitar::Lists::QueryParser do
698
698
  end # "context 'complex cases' do"
699
699
  end # "context '#to_activerecord_query' do"
700
700
 
701
+ context "#to_mongoid_query" do
702
+ context "when simple queries are used" do
703
+ it "generates expected Mongoid eq queries" do
704
+ @instance.parse('name.familyName eq "Doe"')
705
+ query = @instance.to_mongoid_query
706
+
707
+ expect(query).to eql(last_name: { "$eq" => "Doe" })
708
+ end
709
+
710
+ it "generates expected Mongoid ne queries" do
711
+ @instance.parse('name.familyName ne "Doe"')
712
+ query = @instance.to_mongoid_query
713
+
714
+ expect(query).to eql(last_name: { "$ne" => "Doe" })
715
+ end
716
+ end
717
+
718
+ context "when complex comparisons are used" do
719
+ it "generates expected Mongoid pr queries" do
720
+ @instance.parse('name.givenName pr')
721
+ query = @instance.to_mongoid_query
722
+
723
+ expect(query).to eql(first_name: { "$exists" => true, "$ne" => nil })
724
+ end
725
+
726
+ it "generates expected Mongoid co queries" do
727
+ @instance.parse('name.familyName co "Smith"')
728
+ query = @instance.to_mongoid_query
729
+
730
+ expect(query).to eql(last_name: /Smith/)
731
+ end
732
+ end
733
+
734
+ context "when combinations are user" do
735
+ it "generates expected Mongoid queries for AND and OR" do
736
+ @instance.parse('name.givenName eq "Jane" and (name.familyName co "Smith" or name.familyName eq "Doe")')
737
+ query = @instance.to_mongoid_query
738
+
739
+ expect(query).to eql(
740
+ "$and" => [
741
+ { first_name: { "$eq" => "Jane" }},
742
+ { "$or" => [{ last_name: /Smith/ }, { last_name: { "$eq" => "Doe" }}]}
743
+ ]
744
+ )
745
+ end
746
+ end
747
+ end
748
+
701
749
  # ===========================================================================
702
750
  # PRIVATE METHODS
703
751
  # ===========================================================================
@@ -960,12 +960,12 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
960
960
  it_behaves_like 'it handles schema ID value keys without inline attributes', 'add'
961
961
  it_behaves_like 'it handles schema ID value keys without inline attributes', 'replace'
962
962
 
963
- shared_examples 'it handles schema ID value keys with inline attributes' do
963
+ shared_examples 'it handles schema ID value keys with inline attributes' do | operation |
964
964
  it "and performs operation" do
965
965
  payload = {
966
966
  Operations: [
967
967
  {
968
- op: 'add',
968
+ op: operation,
969
969
  value: {
970
970
  'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization' => 'Foo Bar!',
971
971
  'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department' => 'Bar Foo!',
@@ -993,6 +993,110 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
993
993
 
994
994
  it_behaves_like 'it handles schema ID value keys with inline attributes', 'add'
995
995
  it_behaves_like 'it handles schema ID value keys with inline attributes', 'replace'
996
+
997
+ shared_examples 'it handles schema ID value keys of extension schema complex attributes via path' do | operation |
998
+ around :each do | example |
999
+ module ScimSchemaExtensions
1000
+ module User
1001
+ class EnterpisePositionSchema < Scimitar::Schema::Base
1002
+ def self.scim_attributes
1003
+ @scim_attributes ||= [
1004
+ Scimitar::Schema::Attribute.new(name: 'organization', type: 'string'),
1005
+ Scimitar::Schema::Attribute.new(name: 'department', type: 'string'),
1006
+ ]
1007
+ end
1008
+ end
1009
+
1010
+ class EnterpisePositionComplexType < Scimitar::ComplexTypes::Base
1011
+ set_schema EnterpisePositionSchema
1012
+ end
1013
+
1014
+ class EnterpiseManager < Scimitar::Schema::Base
1015
+ def initialize(options = {})
1016
+ super(
1017
+ name: 'EnterpiseManager extension schema',
1018
+ description: 'EnterpiseManager extension for a Enterprise User',
1019
+ id: self.class.id,
1020
+ scim_attributes: self.class.scim_attributes
1021
+ )
1022
+ end
1023
+
1024
+ def self.id
1025
+ 'urn:ietf:params:scim:schemas:extension:enterprise_manager:1.0:User'
1026
+ end
1027
+
1028
+ def self.scim_attributes
1029
+ [
1030
+ Scimitar::Schema::Attribute.new(name: "position", complexType: EnterpisePositionComplexType),
1031
+ ]
1032
+ end
1033
+ end
1034
+ end
1035
+ end
1036
+
1037
+ # change SCIM attributes map temporarily
1038
+ class << MockUser
1039
+ alias_method :original_scim_attributes_map, :scim_attributes_map
1040
+
1041
+ def scim_attributes_map
1042
+ map = original_scim_attributes_map.merge(
1043
+ position: {
1044
+ organization: :organization,
1045
+ department: :department,
1046
+ }
1047
+ )
1048
+ map.delete(:organization)
1049
+ map.delete(:department)
1050
+ map
1051
+ end
1052
+ end
1053
+
1054
+ # change extension schemas on User temporarily
1055
+ @original_extended_schemas = Scimitar::Resources::User.extended_schemas
1056
+ Scimitar::Resources::User.instance_variable_set(:@extended_schemas, [])
1057
+ Scimitar::Resources::User.extend_schema ScimSchemaExtensions::User::EnterpiseManager
1058
+
1059
+ example.run()
1060
+ ensure
1061
+ # restore original setup
1062
+ Scimitar::Resources::User.instance_variable_set(:@extended_schemas, @original_extended_schemas)
1063
+
1064
+ class << MockUser
1065
+ alias_method :scim_attributes_map, :original_scim_attributes_map
1066
+ remove_method :original_scim_attributes_map
1067
+ end
1068
+ end
1069
+
1070
+ it "and performs operation" do
1071
+ payload = {
1072
+ Operations: [
1073
+ {
1074
+ op: operation,
1075
+ path: 'urn:ietf:params:scim:schemas:extension:enterprise_manager:1.0:User:position',
1076
+ value: {
1077
+ 'organization' => 'Foo Bar!',
1078
+ 'department' => 'Bar Foo!'
1079
+ },
1080
+ },
1081
+ ]
1082
+ }
1083
+
1084
+ @u2.update!(organization: 'Old org')
1085
+ payload = spec_helper_hupcase(payload) if force_upper_case
1086
+ patch "/Users/#{@u2.primary_key}", params: payload.merge(format: :scim)
1087
+
1088
+ expect(response.status ).to eql(200)
1089
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
1090
+
1091
+ @u2.reload
1092
+
1093
+ expect(@u2.organization).to eql('Foo Bar!')
1094
+ expect(@u2.department ).to eql('Bar Foo!')
1095
+ end
1096
+ end
1097
+
1098
+ it_behaves_like 'it handles schema ID value keys of extension schema complex attributes via path', 'add'
1099
+ it_behaves_like 'it handles schema ID value keys of extension schema complex attributes via path', 'replace'
996
1100
  end
997
1101
 
998
1102
  it 'which patches "returned: \'never\'" fields' do
data/spec/spec_helper.rb CHANGED
@@ -20,6 +20,8 @@ abort("The Rails environment is running in production mode!") if Rails.env.produ
20
20
 
21
21
  require 'rspec/rails'
22
22
  require 'debug'
23
+ require 'warden'
24
+ require 'warden-rspec-rails'
23
25
  require 'scimitar'
24
26
 
25
27
  # ============================================================================
@@ -32,6 +34,8 @@ RSpec.configure do | config |
32
34
  config.filter_rails_from_backtrace!
33
35
  config.raise_errors_for_deprecations!
34
36
 
37
+ config.include(Warden::Test::ControllerHelpers, type: :controller) # (from the warden-rspec-rails gem)
38
+
35
39
  config.color = true
36
40
  config.tty = true
37
41
  config.order = :random
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scimitar
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.11.0
4
+ version: 2.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - RIPA Global
8
8
  - Andrew David Hodgkinson
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-03-05 00:00:00.000000000 Z
11
+ date: 2025-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '1.10'
33
+ version: '1.11'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '1.10'
40
+ version: '1.11'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rake
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '1.5'
61
+ version: '1.6'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '1.5'
68
+ version: '1.6'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: simplecov-rcov
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -86,14 +86,28 @@ dependencies:
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '6.12'
89
+ version: '6.14'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '6.12'
96
+ version: '6.14'
97
+ - !ruby/object:Gem::Dependency
98
+ name: warden
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.2'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.2'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: rspec-rails
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -108,6 +122,20 @@ dependencies:
108
122
  - - "~>"
109
123
  - !ruby/object:Gem::Version
110
124
  version: '7.1'
125
+ - !ruby/object:Gem::Dependency
126
+ name: warden-rspec-rails
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.3'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.3'
111
139
  - !ruby/object:Gem::Dependency
112
140
  name: doggo
113
141
  requirement: !ruby/object:Gem::Requirement
@@ -213,6 +241,7 @@ files:
213
241
  - spec/apps/dummy/config/initializers/cookies_serializer.rb
214
242
  - spec/apps/dummy/config/initializers/scimitar.rb
215
243
  - spec/apps/dummy/config/initializers/session_store.rb
244
+ - spec/apps/dummy/config/initializers/warden.rb
216
245
  - spec/apps/dummy/config/routes.rb
217
246
  - spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb
218
247
  - spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb
@@ -286,6 +315,7 @@ test_files:
286
315
  - spec/apps/dummy/config/initializers/cookies_serializer.rb
287
316
  - spec/apps/dummy/config/initializers/scimitar.rb
288
317
  - spec/apps/dummy/config/initializers/session_store.rb
318
+ - spec/apps/dummy/config/initializers/warden.rb
289
319
  - spec/apps/dummy/config/routes.rb
290
320
  - spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb
291
321
  - spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb