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 +4 -4
- data/README.md +80 -12
- data/app/controllers/scimitar/application_controller.rb +7 -1
- data/app/models/scimitar/engine_configuration.rb +1 -0
- data/app/models/scimitar/lists/query_parser.rb +107 -0
- data/app/models/scimitar/resources/mixin.rb +4 -0
- data/config/initializers/scimitar.rb +12 -0
- data/lib/scimitar/version.rb +2 -2
- data/spec/apps/dummy/config/initializers/warden.rb +3 -0
- data/spec/controllers/scimitar/application_controller_spec.rb +129 -4
- data/spec/models/scimitar/lists/query_parser_spec.rb +48 -0
- data/spec/requests/active_record_backed_resources_controller_spec.rb +106 -2
- data/spec/spec_helper.rb +4 -0
- metadata +38 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e8493f9f42ec39cc98639167c39e60261400208a011e21d202116d5ca43daff
|
|
4
|
+
data.tar.gz: fb1fe271b42006d0e0054207d873b18ad15851d76597bf78c591f2254eab6688
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f2d68d767cdf33d97684697466dbca05122783f6a081010f874ef8546cc684c3fe9ca8c72478c6214a7493ffc3d80dfd8a742d03c0748968a32b3dc76980e05
|
|
7
|
+
data.tar.gz: 456146fa9cd91c44208e689ba4ba5663626e4ba8bcd3fbdcd592e78785e5af63449d41e68c465e84f8567352a505d02a200b8fbca36b38b7a9d61bc7784b73b8
|
data/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Scimitar
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/scimitar)
|
|
4
|
-
[](https://
|
|
4
|
+
[](https://github.com/pond/scimitar/actions/)
|
|
5
|
+
[](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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
113
|
+
})
|
|
90
114
|
```
|
|
91
115
|
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
data/lib/scimitar/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
11
|
+
DATE = '2025-08-15'
|
|
12
12
|
|
|
13
13
|
end
|
|
@@ -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:
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|