scimitar 2.8.0 → 2.10.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/README.md +23 -18
  4. data/app/controllers/scimitar/application_controller.rb +4 -5
  5. data/app/controllers/scimitar/resource_types_controller.rb +7 -1
  6. data/app/controllers/scimitar/schemas_controller.rb +361 -1
  7. data/app/models/scimitar/engine_configuration.rb +3 -1
  8. data/app/models/scimitar/lists/query_parser.rb +10 -10
  9. data/app/models/scimitar/resource_type.rb +4 -6
  10. data/app/models/scimitar/resources/base.rb +37 -6
  11. data/app/models/scimitar/resources/mixin.rb +15 -10
  12. data/app/models/scimitar/schema/base.rb +1 -1
  13. data/config/initializers/scimitar.rb +41 -0
  14. data/lib/scimitar/engine.rb +50 -12
  15. data/lib/scimitar/support/utilities.rb +8 -3
  16. data/lib/scimitar/version.rb +2 -2
  17. data/spec/apps/dummy/app/models/mock_user.rb +11 -3
  18. data/spec/apps/dummy/config/initializers/scimitar.rb +29 -1
  19. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +1 -0
  20. data/spec/apps/dummy/db/schema.rb +1 -0
  21. data/spec/controllers/scimitar/resource_types_controller_spec.rb +8 -4
  22. data/spec/controllers/scimitar/schemas_controller_spec.rb +342 -54
  23. data/spec/models/scimitar/lists/query_parser_spec.rb +5 -0
  24. data/spec/models/scimitar/resources/base_spec.rb +11 -11
  25. data/spec/models/scimitar/resources/base_validation_spec.rb +1 -1
  26. data/spec/models/scimitar/resources/mixin_spec.rb +31 -12
  27. data/spec/requests/active_record_backed_resources_controller_spec.rb +86 -2
  28. data/spec/requests/engine_spec.rb +75 -0
  29. data/spec/spec_helper.rb +1 -1
  30. metadata +21 -21
@@ -35,14 +35,45 @@ module Scimitar
35
35
  @errors = ActiveModel::Errors.new(self)
36
36
  end
37
37
 
38
+ # Scimitar has at present a general limitation in handling schema IDs,
39
+ # which just involves stripping them and requiring attributes across all
40
+ # extension schemas to be overall unique.
41
+ #
42
+ # This method takes an options payload for the initializer and strips out
43
+ # *recognised* schema IDs, so that the resulting attribute data matches
44
+ # the resource attribute map.
45
+ #
46
+ # +attributes+:: Attributes to assign via initializer; typically a POST
47
+ # payload of attributes that has been run through Rails
48
+ # strong parameters for safety.
49
+ #
50
+ # Returns a new object of the same class as +options+ with recognised
51
+ # schema IDs removed.
52
+ #
38
53
  def flatten_extension_attributes(options)
39
- flattened = options.dup
40
- self.class.extended_schemas.each do |extended_schema|
41
- if extension_attrs = flattened.delete(extended_schema.id)
42
- flattened.merge!(extension_attrs)
54
+ flattened = options.class.new
55
+ lower_case_schema_ids = self.class.extended_schemas.map do | schema |
56
+ schema.id.downcase()
57
+ end
58
+
59
+ options.each do | key, value |
60
+ path = Scimitar::Support::Utilities::path_str_to_array(
61
+ self.class.extended_schemas,
62
+ key
63
+ )
64
+
65
+ if path.first.include?(':') && lower_case_schema_ids.include?(path.first.downcase)
66
+ path.shift()
67
+ end
68
+
69
+ if path.empty?
70
+ flattened.merge!(value)
71
+ else
72
+ flattened[path.join('.')] = value
43
73
  end
44
74
  end
45
- flattened
75
+
76
+ return flattened
46
77
  end
47
78
 
48
79
  # Can be used to extend an existing resource type's schema. For example:
@@ -127,7 +158,7 @@ module Scimitar
127
158
  hash.with_indifferent_access.each_pair do |attr_name, attr_value|
128
159
  scim_attribute = self.class.complex_scim_attributes[attr_name].try(:first)
129
160
 
130
- if scim_attribute && scim_attribute.complexType
161
+ if scim_attribute&.complexType
131
162
  if scim_attribute.multiValued
132
163
  self.send("#{attr_name}=", attr_value&.map {|attr_for_each_item| complex_type_from_hash(scim_attribute, attr_for_each_item)})
133
164
  else
@@ -139,11 +139,12 @@ module Scimitar
139
139
  # # ...
140
140
  # groups: [
141
141
  # {
142
- # list: :users, # <-- i.e. Team.users,
142
+ # list: :users, # <-- i.e. Team.users,
143
143
  # using: {
144
144
  # value: :id, # <-- i.e. Team.users[n].id
145
145
  # display: :full_name # <-- i.e. Team.users[n].full_name
146
146
  # },
147
+ # class: Team, # Optional; see below
147
148
  # find_with: -> (scim_list_entry) {...} # See below
148
149
  # }
149
150
  # ],
@@ -159,7 +160,10 @@ module Scimitar
159
160
  # example above, "find_with"'s Proc might look at a SCIM entry value which
160
161
  # is expected to be a user ID and find that User. The mapped set of User
161
162
  # data thus found would be written back with "#users=", due to the ":list"
162
- # key declaring the method name ":users".
163
+ # key declaring the method name ":users". The optional "class" key is
164
+ # recommended but not really *needed* unless the configuration option
165
+ # Scimitar::EngineConfiguration::schema_list_from_attribute_mappings is
166
+ # defined; see documentation of that option for more information.
163
167
  #
164
168
  # Note that you can only use either:
165
169
  #
@@ -176,7 +180,8 @@ module Scimitar
176
180
  # == scim_mutable_attributes
177
181
  #
178
182
  # Define this method to return a Set (preferred) or Array of names of
179
- # attributes which may be written in the mixing-in class.
183
+ # attributes which may be written in the mixing-in class. The names MUST be
184
+ # expressed as Symbols, *not* Strings.
180
185
  #
181
186
  # If you return +nil+, it is assumed that +any+ attribute mapped by
182
187
  # ::scim_attributes_map which has a write accessor will be eligible for
@@ -291,7 +296,7 @@ module Scimitar
291
296
  # the result in an instance variable.
292
297
  #
293
298
  def scim_mutable_attributes
294
- @scim_mutable_attributes ||= self.class.scim_mutable_attributes()
299
+ @scim_mutable_attributes ||= self.class.scim_mutable_attributes()&.map(&:to_sym)
295
300
 
296
301
  if @scim_mutable_attributes.nil?
297
302
  @scim_mutable_attributes = Set.new
@@ -468,7 +473,7 @@ module Scimitar
468
473
  path_str = operation['path' ]
469
474
  value = operation['value']
470
475
 
471
- unless ['add', 'remove', 'replace'].include?(nature)
476
+ unless %w[add remove replace].include?(nature)
472
477
  raise Scimitar::InvalidSyntaxError.new("Unrecognised PATCH \"op\" value of \"#{nature}\"")
473
478
  end
474
479
 
@@ -608,7 +613,7 @@ module Scimitar
608
613
  built_dynamic_list = false
609
614
  mapped_array = attrs_map_or_leaf_value.map do |value|
610
615
  if ! value.is_a?(Hash)
611
- raise 'Bad attribute map: Array contains someting other than mapping Hash(es)'
616
+ raise 'Bad attribute map: Array contains something other than mapping Hash(es)'
612
617
 
613
618
  elsif value.key?(:match) # Static map
614
619
  static_hash = { value[:match] => value[:with] }
@@ -736,7 +741,7 @@ module Scimitar
736
741
  # +path+:: Array of SCIM attribute names giving a
737
742
  # path into the SCIM schema where
738
743
  # iteration has reached. Used to find the
739
- # schema attribute definiton and check
744
+ # schema attribute definition and check
740
745
  # mutability before writing.
741
746
  #
742
747
  def from_scim_backend!(
@@ -1083,7 +1088,7 @@ module Scimitar
1083
1088
  # associated collection or clearing a local model attribute
1084
1089
  # directly to "nil").
1085
1090
  #
1086
- if handled == false
1091
+ unless handled
1087
1092
  current_data_at_path[matched_index] = nil
1088
1093
  compact_after = true
1089
1094
  end
@@ -1285,7 +1290,7 @@ module Scimitar
1285
1290
  end
1286
1291
  end
1287
1292
 
1288
- if handled == false
1293
+ unless handled
1289
1294
  altering_hash[path_component] = []
1290
1295
  end
1291
1296
 
@@ -1440,7 +1445,7 @@ module Scimitar
1440
1445
  # { value: :work_email }
1441
1446
  #
1442
1447
  # If there was a SCIM entry with a type of something unrecognised,
1443
- # such as 'holday', then +nil+ would be returned since there is no
1448
+ # such as 'holiday', then +nil+ would be returned since there is no
1444
1449
  # matching attribute map entry.
1445
1450
  #
1446
1451
  # Note that the <tt>:with_attr_map</tt> array can contain dynamic
@@ -36,7 +36,7 @@ module Scimitar
36
36
  scim_attributes.map { |scim_attribute| scim_attribute.clone }
37
37
  end
38
38
 
39
- # Find a given attribute this schema, travelling down a path to any
39
+ # Find a given attribute of this schema, travelling down a path to any
40
40
  # sub-attributes within. Given that callers might be dealing with paths
41
41
  # into actual SCIM data, array indices for multi-value attributes are
42
42
  # allowed (as integers) and simply skipped - only the names are of
@@ -106,6 +106,47 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
106
106
  # whatever that means for you receiving system in your model code.
107
107
  #
108
108
  # optional_value_fields_required: false
109
+
110
+ # The SCIM standard `/Schemas` endpoint lists, by default, all known schema
111
+ # definitions with the mutabilty (read-write, read-only, write-only) state
112
+ # described by those definitions, and includes all defined attributes. For
113
+ # user-defined schema, this will typically exactly match your underlying
114
+ # mapped attribute and model capability - it wouldn't make sense to define
115
+ # your own schema that misrepresented the implementation! For core SCIM RFC
116
+ # schema, though, you might want to only list actually mapped attributes.
117
+ # Further, if you happen to have a non-compliant implementation especially
118
+ # in relation to mutability of some attributes, you may want to report that
119
+ # accurately in the '/Schemas' list, for auto-discovery purposes. To switch
120
+ # to a significantly slower but more accurate render method for the list,
121
+ # driven by your resource subclasses and their attribute maps, set:
122
+ #
123
+ # schema_list_from_attribute_mappings: [...array...]
124
+ #
125
+ # ...where you provide an Array of *models*, your classes that include the
126
+ # Scimitar::Resources::Mixin module and, therefore, define an attribute map
127
+ # translating SCIM schema attributes into actual implemented data. These
128
+ # must *uniquely* describe, via the Scimitar resources they each declare in
129
+ # their Scimitar::Resources::Mixin::scim_resource_type implementation, the
130
+ # set of schemas and extended schemas you want to render. Should resources
131
+ # share schema, the '/Schemas' endpoint will fail since it cannot determine
132
+ # which model attribute map it should use and it needs the map in order to
133
+ # resolve the differences (if any) between what the schema might say, and
134
+ # what the actual underlying model supports.
135
+ #
136
+ # It is further _very_ _strongly_ _recommended_ that, for any
137
+ # +scim_attributes_map+ containing a collection which has "list:" key (for
138
+ # an associative array of zero or more entities; the Groups to which a User
139
+ # might belong is a good example) then you should also specify the "class:"
140
+ # key, giving the class used for objects in that associated collection. The
141
+ # class *must* include Scimitar::Resources::Mixin, since its own attribute
142
+ # map is consulted in order to render the part of the schema describing
143
+ # those associated properties in the owning resource. If you don't do this,
144
+ # and if you're using ActiveRecord, then Scimitar attempts association
145
+ # reflection to determine the collection class - but that's more fragile
146
+ # than just being told the exact class in the attribute map. No matter how
147
+ # this class is determined, though, it must be possible to create a simple
148
+ # instance with +new+ and no parameters, since that's needed in order to
149
+ # call Scimitar::Resources::Mixin#scim_mutable_attributes.
109
150
  })
110
151
 
111
152
  end
@@ -15,8 +15,24 @@ module Scimitar
15
15
  JSON.parse(body)
16
16
  end
17
17
 
18
+ # Return an Array of all supported default and custom resource classes.
19
+ # See also :add_custom_resource and :set_default_resources.
20
+ #
18
21
  def self.resources
19
- default_resources + custom_resources
22
+ self.default_resources() + self.custom_resources()
23
+ end
24
+
25
+ # Returns a flat array of instances of all resource schema included in the
26
+ # resource classes returned by ::resources.
27
+ #
28
+ def self.schemas
29
+ self.resources().map(&:schemas).flatten.uniq.map(&:new)
30
+ end
31
+
32
+ # Returns the list of custom resources, if any.
33
+ #
34
+ def self.custom_resources
35
+ @custom_resources ||= []
20
36
  end
21
37
 
22
38
  # Can be used to add a new resource type which is not provided by the gem.
@@ -37,7 +53,7 @@ module Scimitar
37
53
  # Scimitar::Engine.add_custom_resource Scim::Resources::ShinyResource
38
54
  #
39
55
  def self.add_custom_resource(resource)
40
- custom_resources << resource
56
+ self.custom_resources() << resource
41
57
  end
42
58
 
43
59
  # Resets the resource list to default. This is really only intended for use
@@ -47,23 +63,45 @@ module Scimitar
47
63
  @custom_resources = []
48
64
  end
49
65
 
50
- # Returns the list of custom resources, if any.
51
- #
52
- def self.custom_resources
53
- @custom_resources ||= []
54
- end
55
-
56
- # Returns the default resources added in this gem:
66
+ # Returns the default resources added in this gem - by default, these are:
57
67
  #
58
68
  # * Scimitar::Resources::User
59
69
  # * Scimitar::Resources::Group
60
70
  #
71
+ # ...but if an implementation does not e.g. support Group, it can
72
+ # be overridden via ::set_default_resources to help with service
73
+ # auto-discovery.
74
+ #
61
75
  def self.default_resources
62
- [ Resources::User, Resources::Group ]
76
+ @standard_default_resources = [ Resources::User, Resources::Group ]
77
+ @default_resources ||= @standard_default_resources.dup()
63
78
  end
64
79
 
65
- def self.schemas
66
- resources.map(&:schemas).flatten.uniq.map(&:new)
80
+ # Override the resources returned by ::default_resources.
81
+ #
82
+ # +resource_array+:: An Array containing one or both of
83
+ # Scimitar::Resources::User and/or
84
+ # Scimitar::Resources::Group, and nothing else.
85
+ #
86
+ def self.set_default_resources(resource_array)
87
+ self.default_resources()
88
+ unrecognised_resources = resource_array - @standard_default_resources
89
+
90
+ if unrecognised_resources.any?
91
+ raise "Scimitar::Engine.set_default_resources: Only #{@standard_default_resources.map(&:name).join(', ')} are supported"
92
+ elsif resource_array.empty?
93
+ raise 'Scimitar::Engine.set_default_resources: At least one resource must be given'
94
+ end
95
+
96
+ @default_resources = resource_array
97
+ end
98
+
99
+ # Resets the default resource list. This is really only intended for use
100
+ # during testing, to avoid one test polluting another.
101
+ #
102
+ def self.reset_default_resources
103
+ self.default_resources()
104
+ @default_resources = @standard_default_resources
67
105
  end
68
106
 
69
107
  end
@@ -57,9 +57,10 @@ module Scimitar
57
57
  # <tt>scim_resource_type.extended_schemas</tt> value. The
58
58
  # Array should be empty if there are no extensions.
59
59
  #
60
- # +path_str+:: Path string, e.g. <tt>"password"</tt>, <tt>"name.givenName"</tt>,
60
+ # +path_str+:: Path String, e.g. <tt>"password"</tt>, <tt>"name.givenName"</tt>,
61
61
  # <tt>"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"</tt> (special case),
62
62
  # <tt>"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization"</tt>
63
+ # (if given a Symbol, it'll be converted to a String).
63
64
  #
64
65
  # Returns an array of components, e.g. <tt>["password"]</tt>, <tt>["name",
65
66
  # "givenName"]</tt>,
@@ -74,6 +75,7 @@ module Scimitar
74
75
  # path-free payload.
75
76
  #
76
77
  def self.path_str_to_array(schemas, path_str)
78
+ path_str = path_str.to_s
77
79
  components = []
78
80
 
79
81
  # Note the ":" separating the schema ID (URN) from the attribute.
@@ -84,11 +86,14 @@ module Scimitar
84
86
  # particular, https://tools.ietf.org/html/rfc7644#page-35.
85
87
  #
86
88
  if path_str.include?(':')
89
+ lower_case_path_str = path_str.downcase()
90
+
87
91
  schemas.each do |schema|
88
- attributes_after_schema_id = path_str.downcase.split(schema.id.downcase + ':').drop(1)
92
+ lower_case_schema_id = schema.id.downcase()
93
+ attributes_after_schema_id = lower_case_path_str.split(lower_case_schema_id + ':').drop(1)
89
94
 
90
95
  if attributes_after_schema_id.empty?
91
- components += [schema.id]
96
+ components += [schema.id] if lower_case_path_str == lower_case_schema_id
92
97
  else
93
98
  attributes_after_schema_id.each do |component|
94
99
  components += [schema.id] + component.split('.')
@@ -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.8.0'
6
+ VERSION = '2.10.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 = '2024-06-13'
11
+ DATE = '2024-10-22'
12
12
 
13
13
  end
@@ -18,6 +18,7 @@ class MockUser < ActiveRecord::Base
18
18
  work_phone_number
19
19
  organization
20
20
  department
21
+ manager
21
22
  mock_groups
22
23
  }
23
24
 
@@ -48,6 +49,7 @@ class MockUser < ActiveRecord::Base
48
49
  externalId: :scim_uid,
49
50
  userName: :username,
50
51
  password: :password,
52
+ active: :is_active,
51
53
  name: {
52
54
  givenName: :first_name,
53
55
  familyName: :last_name
@@ -80,8 +82,11 @@ class MockUser < ActiveRecord::Base
80
82
  }
81
83
  },
82
84
  ],
83
- groups: [ # NB read-only, so no :find_with key
85
+ groups: [
84
86
  {
87
+ # Read-only, so no :find_with key. There's no 'class' specified here
88
+ # either, to help test the "/Schemas" endpoint's reflection code.
89
+ #
85
90
  list: :mock_groups,
86
91
  using: {
87
92
  value: :id,
@@ -89,7 +94,6 @@ class MockUser < ActiveRecord::Base
89
94
  }
90
95
  }
91
96
  ],
92
- active: :is_active,
93
97
 
94
98
  # Custom extension schema - see configuration in
95
99
  # "spec/apps/dummy/config/initializers/scimitar.rb".
@@ -97,6 +101,9 @@ class MockUser < ActiveRecord::Base
97
101
  organization: :organization,
98
102
  department: :department,
99
103
  primaryEmail: :scim_primary_email,
104
+
105
+ manager: :manager,
106
+
100
107
  userGroups: [
101
108
  {
102
109
  list: :mock_groups,
@@ -130,7 +137,8 @@ class MockUser < ActiveRecord::Base
130
137
  }
131
138
  end
132
139
 
133
- # reader
140
+ # Custom attribute reader
141
+ #
134
142
  def scim_primary_email
135
143
  work_email_address
136
144
  end
@@ -33,10 +33,13 @@ Rails.application.config.to_prepare do
33
33
 
34
34
  module ScimSchemaExtensions
35
35
  module User
36
+
37
+ # This "looks like" part of the standard Enterprise extension.
38
+ #
36
39
  class Enterprise < Scimitar::Schema::Base
37
40
  def initialize(options = {})
38
41
  super(
39
- name: 'ExtendedUser',
42
+ name: 'EnterpriseExtendedUser',
40
43
  description: 'Enterprise extension for a User',
41
44
  id: self.class.id,
42
45
  scim_attributes: self.class.scim_attributes
@@ -55,8 +58,33 @@ Rails.application.config.to_prepare do
55
58
  ]
56
59
  end
57
60
  end
61
+
62
+ # In https://github.com/RIPAGlobal/scimitar/issues/122 we learn that with
63
+ # more than one extension, things can go wrong - so now we test with two.
64
+ #
65
+ class Manager < Scimitar::Schema::Base
66
+ def initialize(options = {})
67
+ super(
68
+ name: 'ManagementExtendedUser',
69
+ description: 'Management extension for a User',
70
+ id: self.class.id,
71
+ scim_attributes: self.class.scim_attributes
72
+ )
73
+ end
74
+
75
+ def self.id
76
+ 'urn:ietf:params:scim:schemas:extension:manager:1.0:User'
77
+ end
78
+
79
+ def self.scim_attributes
80
+ [
81
+ Scimitar::Schema::Attribute.new(name: 'manager', type: 'string')
82
+ ]
83
+ end
84
+ end
58
85
  end
59
86
  end
60
87
 
61
88
  Scimitar::Resources::User.extend_schema ScimSchemaExtensions::User::Enterprise
89
+ Scimitar::Resources::User.extend_schema ScimSchemaExtensions::User::Manager
62
90
  end
@@ -19,6 +19,7 @@ class CreateMockUsers < ActiveRecord::Migration[6.1]
19
19
  #
20
20
  t.text :organization
21
21
  t.text :department
22
+ t.text :manager
22
23
  end
23
24
  end
24
25
  end
@@ -41,6 +41,7 @@ ActiveRecord::Schema[7.1].define(version: 2021_03_08_044214) do
41
41
  t.text "work_phone_number"
42
42
  t.text "organization"
43
43
  t.text "department"
44
+ t.text "manager"
44
45
  end
45
46
 
46
47
  add_foreign_key "mock_groups_users", "mock_groups"
@@ -9,11 +9,15 @@ RSpec.describe Scimitar::ResourceTypesController do
9
9
  it 'renders the resource type for user' do
10
10
  get :index, format: :scim
11
11
  response_hash = JSON.parse(response.body)
12
- expected_response = [ Scimitar::Resources::User.resource_type(scim_resource_type_url(name: 'User', test: 1)),
13
- Scimitar::Resources::Group.resource_type(scim_resource_type_url(name: 'Group', test: 1))
14
- ].to_json
12
+ expected_response = {
13
+ schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
14
+ totalResults: 2,
15
+ Resources: [
16
+ Scimitar::Resources::User.resource_type(scim_resource_type_url(name: 'User', test: 1)),
17
+ Scimitar::Resources::Group.resource_type(scim_resource_type_url(name: 'Group', test: 1))
18
+ ]
19
+ }.to_json
15
20
 
16
- response_hash = JSON.parse(response.body)
17
21
  expect(response_hash).to eql(JSON.parse(expected_response))
18
22
  end
19
23