devise_scim 0.1.11

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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +124 -0
  3. data/CHANGELOG.md +47 -0
  4. data/CODE_OF_CONDUCT.md +11 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +348 -0
  7. data/Rakefile +21 -0
  8. data/app/controllers/devise_scim/application_controller.rb +69 -0
  9. data/app/controllers/devise_scim/groups_controller.rb +67 -0
  10. data/app/controllers/devise_scim/resource_types_controller.rb +43 -0
  11. data/app/controllers/devise_scim/schemas_controller.rb +55 -0
  12. data/app/controllers/devise_scim/service_provider_controller.rb +34 -0
  13. data/app/controllers/devise_scim/users_controller.rb +281 -0
  14. data/docs/contributing.md +163 -0
  15. data/docs/custom_adapter.md +456 -0
  16. data/docs/idp_setup.md +335 -0
  17. data/docs/multi_tenant.md +328 -0
  18. data/docs/testing.md +444 -0
  19. data/lib/devise_scim/auth/base_strategy.rb +16 -0
  20. data/lib/devise_scim/auth/oauth_strategy.rb +28 -0
  21. data/lib/devise_scim/auth/token_strategy.rb +25 -0
  22. data/lib/devise_scim/concerns/scim_group_identifiable.rb +21 -0
  23. data/lib/devise_scim/concerns/scim_tenant.rb +41 -0
  24. data/lib/devise_scim/configuration.rb +92 -0
  25. data/lib/devise_scim/engine.rb +15 -0
  26. data/lib/devise_scim/filter/arel_visitor.rb +77 -0
  27. data/lib/devise_scim/filter/parser.rb +190 -0
  28. data/lib/devise_scim/middleware/authenticator.rb +51 -0
  29. data/lib/devise_scim/minitest.rb +57 -0
  30. data/lib/devise_scim/models/scim_tenant.rb +14 -0
  31. data/lib/devise_scim/models/scim_tenant_user.rb +15 -0
  32. data/lib/devise_scim/routing.rb +43 -0
  33. data/lib/devise_scim/rspec/factories.rb +17 -0
  34. data/lib/devise_scim/rspec/scim_helpers.rb +43 -0
  35. data/lib/devise_scim/rspec/shared_examples/discovery_endpoints.rb +94 -0
  36. data/lib/devise_scim/rspec/shared_examples/groups_endpoint.rb +148 -0
  37. data/lib/devise_scim/rspec/shared_examples/users_endpoint.rb +301 -0
  38. data/lib/devise_scim/rspec.rb +7 -0
  39. data/lib/devise_scim/scim/error.rb +59 -0
  40. data/lib/devise_scim/scim/group.rb +66 -0
  41. data/lib/devise_scim/scim/list_response.rb +32 -0
  42. data/lib/devise_scim/scim/patch_operation.rb +55 -0
  43. data/lib/devise_scim/scim/user.rb +161 -0
  44. data/lib/devise_scim/scim_adapter.rb +84 -0
  45. data/lib/devise_scim/version.rb +5 -0
  46. data/lib/devise_scim.rb +48 -0
  47. data/lib/generators/devise_scim/adapter_generator.rb +17 -0
  48. data/lib/generators/devise_scim/install_generator.rb +117 -0
  49. data/lib/generators/devise_scim/templates/add_scim_to_tenant.rb.tt +17 -0
  50. data/lib/generators/devise_scim/templates/add_scim_to_users.rb.tt +15 -0
  51. data/lib/generators/devise_scim/templates/application_scim_adapter.rb.tt +34 -0
  52. data/lib/generators/devise_scim/templates/create_scim_tenant_users.rb.tt +22 -0
  53. data/lib/generators/devise_scim/templates/create_scim_tenants.rb.tt +18 -0
  54. data/lib/generators/devise_scim/templates/devise_scim.rb.tt +53 -0
  55. data/sig/devise_scim.rbs +4 -0
  56. metadata +146 -0
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ # Groups controller delegates all model interaction to the host app's ScimAdapter subclass.
5
+ # The gem handles the SCIM protocol layer; the adapter handles group lifecycle.
6
+ class GroupsController < ApplicationController
7
+ def index
8
+ render_scim(Scim::ListResponse.new(resources: []))
9
+ end
10
+
11
+ def create
12
+ scim_g = Scim::Group.from_h(parsed_body)
13
+ adapter = scim_adapter_for(nil, scim_g)
14
+ adapter.handle_group_create
15
+ render_scim(adapter.group_to_scim, status: :created)
16
+ rescue NotImplementedError => e
17
+ render_scim(Scim::Error.server_error(e.message), status: :internal_server_error)
18
+ end
19
+
20
+ def show
21
+ scim_g = build_scim_group_for_id(params[:id])
22
+ adapter = scim_adapter_for(nil, scim_g)
23
+ render_scim(adapter.group_to_scim)
24
+ rescue NotImplementedError => e
25
+ render_scim(Scim::Error.server_error(e.message), status: :internal_server_error)
26
+ end
27
+
28
+ def replace
29
+ scim_g = Scim::Group.from_h(parsed_body)
30
+ adapter = scim_adapter_for(nil, scim_g)
31
+ adapter.handle_group_update
32
+ render_scim(adapter.group_to_scim)
33
+ rescue NotImplementedError => e
34
+ render_scim(Scim::Error.server_error(e.message), status: :internal_server_error)
35
+ end
36
+
37
+ def update
38
+ scim_g = Scim::Group.from_h(parsed_body)
39
+ adapter = scim_adapter_for(nil, scim_g)
40
+ adapter.handle_group_update
41
+ render_scim(adapter.group_to_scim)
42
+ rescue NotImplementedError => e
43
+ render_scim(Scim::Error.server_error(e.message), status: :internal_server_error)
44
+ end
45
+
46
+ def destroy
47
+ scim_g = build_scim_group_for_id(params[:id])
48
+ adapter = scim_adapter_for(nil, scim_g)
49
+ adapter.handle_group_destroy
50
+ head :no_content
51
+ end
52
+
53
+ private
54
+
55
+ def parsed_body
56
+ @parsed_body ||= JSON.parse(request.body.read)
57
+ rescue JSON::ParserError
58
+ {}
59
+ end
60
+
61
+ def build_scim_group_for_id(id)
62
+ g = Scim::Group.new
63
+ g.id = id
64
+ g
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class ResourceTypesController < ApplicationController
5
+ LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
6
+ RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
7
+
8
+ def index
9
+ types = [user_resource_type]
10
+ types << group_resource_type if DeviseScim.configuration.enable_groups
11
+ payload = {
12
+ "schemas" => [LIST_SCHEMA],
13
+ "totalResults" => types.size,
14
+ "Resources" => types
15
+ }
16
+ render_scim(payload)
17
+ end
18
+
19
+ private
20
+
21
+ def user_resource_type
22
+ prefix = DeviseScim.configuration.route_prefix
23
+ {
24
+ "schemas" => [RESOURCE_TYPE_SCHEMA],
25
+ "id" => "User",
26
+ "name" => "User",
27
+ "endpoint" => "#{prefix}/Users",
28
+ "schema" => Scim::USER_SCHEMA
29
+ }
30
+ end
31
+
32
+ def group_resource_type
33
+ prefix = DeviseScim.configuration.route_prefix
34
+ {
35
+ "schemas" => [RESOURCE_TYPE_SCHEMA],
36
+ "id" => "Group",
37
+ "name" => "Group",
38
+ "endpoint" => "#{prefix}/Groups",
39
+ "schema" => Scim::GROUP_SCHEMA
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class SchemasController < ApplicationController
5
+ LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
6
+ SCHEMA_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema"
7
+
8
+ def index
9
+ schemas = [user_schema]
10
+ schemas << group_schema if DeviseScim.configuration.enable_groups
11
+ payload = {
12
+ "schemas" => [LIST_SCHEMA],
13
+ "totalResults" => schemas.size,
14
+ "Resources" => schemas
15
+ }
16
+ render_scim(payload)
17
+ end
18
+
19
+ private
20
+
21
+ def user_schema
22
+ {
23
+ "schemas" => [SCHEMA_SCHEMA],
24
+ "id" => Scim::USER_SCHEMA,
25
+ "name" => "User",
26
+ "description" => "User Account",
27
+ "attributes" => user_attributes
28
+ }
29
+ end
30
+
31
+ def group_schema
32
+ {
33
+ "schemas" => [SCHEMA_SCHEMA],
34
+ "id" => Scim::GROUP_SCHEMA,
35
+ "name" => "Group",
36
+ "description" => "Group",
37
+ "attributes" => [
38
+ { "name" => "displayName", "type" => "string", "multiValued" => false, "required" => true },
39
+ { "name" => "members", "type" => "complex", "multiValued" => true, "required" => false }
40
+ ]
41
+ }
42
+ end
43
+
44
+ def user_attributes
45
+ [
46
+ { "name" => "userName", "type" => "string", "multiValued" => false, "required" => true },
47
+ { "name" => "name", "type" => "complex", "multiValued" => false, "required" => false },
48
+ { "name" => "emails", "type" => "complex", "multiValued" => true, "required" => false },
49
+ { "name" => "phoneNumbers", "type" => "complex", "multiValued" => true, "required" => false },
50
+ { "name" => "active", "type" => "boolean", "multiValued" => false, "required" => false },
51
+ { "name" => "externalId", "type" => "string", "multiValued" => false, "required" => false }
52
+ ]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class ServiceProviderController < ApplicationController
5
+ def show
6
+ render_scim(service_provider_config)
7
+ end
8
+
9
+ private
10
+
11
+ def service_provider_config
12
+ {
13
+ "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
14
+ "patch" => { "supported" => true },
15
+ "bulk" => { "supported" => false, "maxOperations" => 0, "maxPayloadSize" => 0 },
16
+ "filter" => { "supported" => true, "maxResults" => 200 },
17
+ "changePassword" => { "supported" => false },
18
+ "sort" => { "supported" => false },
19
+ "etag" => { "supported" => false },
20
+ "authenticationSchemes" => auth_schemes
21
+ }
22
+ end
23
+
24
+ def auth_schemes
25
+ if DeviseScim.configuration.auth_method == :oauth
26
+ [{ "type" => "oauthbearertoken", "name" => "OAuth Bearer Token",
27
+ "description" => "OAuth2 client_credentials grant" }]
28
+ else
29
+ [{ "type" => "oauthbearertoken", "name" => "Bearer Token",
30
+ "description" => "Static bearer token authentication" }]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class UsersController < ApplicationController # rubocop:disable Metrics/ClassLength
5
+ def index
6
+ scope = apply_filter(tenant_scope)
7
+ scim_users = scope.map { |u| scim_adapter_for(u, Scim::User.new).to_scim }
8
+ render_scim(Scim::ListResponse.new(resources: scim_users))
9
+ end
10
+
11
+ def create
12
+ scim_u = Scim::User.from_h(parsed_body)
13
+ user = multi_tenant? ? multi_tenant_create(scim_u) : single_tenant_create(scim_u)
14
+ render_scim(scim_adapter_for(user, Scim::User.new).to_scim, status: :created)
15
+ end
16
+
17
+ def show
18
+ render_scim(scim_adapter_for(find_user!(params[:id]), Scim::User.new).to_scim)
19
+ end
20
+
21
+ def replace
22
+ user = find_user!(params[:id])
23
+ scim_u = Scim::User.from_h(parsed_body)
24
+ adapter = scim_adapter_for(user, scim_u)
25
+ user.assign_attributes(adapter.attributes_for_update)
26
+ user.save!
27
+ render_scim(scim_adapter_for(user, Scim::User.new).to_scim)
28
+ end
29
+
30
+ def update
31
+ user = find_user!(params[:id])
32
+ ops = Scim::PatchOperation.parse_request(parsed_body)
33
+ apply_patch(user, ops)
34
+ user.save!
35
+ render_scim(scim_adapter_for(user, Scim::User.new).to_scim)
36
+ end
37
+
38
+ def destroy
39
+ handle_deprovision(find_user!(params[:id]))
40
+ end
41
+
42
+ private
43
+
44
+ def parsed_body
45
+ @parsed_body ||= JSON.parse(request.body.read)
46
+ rescue JSON::ParserError
47
+ {}
48
+ end
49
+
50
+ def apply_filter(scope)
51
+ return scope unless params[:filter].present?
52
+
53
+ ast = Filter::Parser.parse(params[:filter])
54
+ Filter::ArelVisitor.new(devise_model).apply(ast, scope)
55
+ rescue Filter::Parser::ParseError => e
56
+ raise InvalidFilter, e.message
57
+ end
58
+
59
+ def find_user!(id)
60
+ user = tenant_scope.find_by("#{devise_model.table_name}.id" => id)
61
+ raise NotFound, "User #{id} not found" unless user
62
+
63
+ user
64
+ end
65
+
66
+ # ── single-tenant ──────────────────────────────────────────────────────────
67
+
68
+ def single_tenant_create(scim_u)
69
+ existing = find_by_uid_or_email(scim_u)
70
+
71
+ if existing.nil?
72
+ build_and_save_user(scim_u)
73
+ elsif deprovisioned?(existing)
74
+ reprovision_user(existing, scim_u)
75
+ else
76
+ raise Conflict, "User already exists"
77
+ end
78
+ end
79
+
80
+ def build_and_save_user(scim_u)
81
+ user = devise_model.new
82
+ adapter = scim_adapter_for(user, scim_u)
83
+ user.assign_attributes(adapter.attributes_for_create)
84
+ set_scim_meta(user, scim_u.external_id)
85
+ user.save!
86
+ adapter.after_provision
87
+ user
88
+ end
89
+
90
+ def reprovision_user(user, scim_u)
91
+ user.scim_active = true if col?(:scim_active)
92
+ user.scim_deprovisioned_at = nil if col?(:scim_deprovisioned_at)
93
+ user.scim_source = "scim" if col?(:scim_source)
94
+ adapter = scim_adapter_for(user, scim_u)
95
+ user.assign_attributes(adapter.attributes_for_update)
96
+ user.save!
97
+ adapter.after_provision
98
+ user
99
+ end
100
+
101
+ def find_by_uid_or_email(scim_u)
102
+ email = scim_u.user_name || scim_u.primary_email
103
+ uid = scim_u.external_id
104
+
105
+ if uid && col?(:scim_uid)
106
+ devise_model.find_by(scim_uid: uid) || devise_model.find_by(email: email)
107
+ else
108
+ devise_model.find_by(email: email)
109
+ end
110
+ end
111
+
112
+ # ── multi-tenant ───────────────────────────────────────────────────────────
113
+
114
+ def multi_tenant_create(scim_u)
115
+ email = scim_u.user_name || scim_u.primary_email
116
+ existing = devise_model.find_by(email: email)
117
+
118
+ if existing.nil?
119
+ create_and_assign_user(scim_u)
120
+ else
121
+ active_join = tenant_join_for(existing)
122
+ raise Conflict, "User already belongs to this tenant" if active_join
123
+
124
+ other_join = other_tenant_join_for(existing)
125
+ if other_join
126
+ handle_exclusivity_conflict(existing, scim_u, other_join)
127
+ else
128
+ claim_user(existing, scim_u)
129
+ end
130
+ existing
131
+ end
132
+ end
133
+
134
+ def create_and_assign_user(scim_u)
135
+ user = devise_model.new
136
+ adapter = scim_adapter_for(user, scim_u)
137
+ user.assign_attributes(adapter.attributes_for_create)
138
+ user.scim_source = "scim" if col?(:scim_source)
139
+ user.save!
140
+ assign_to_tenant(user, scim_u.external_id)
141
+ adapter.after_provision
142
+ user
143
+ end
144
+
145
+ def claim_user(user, scim_u)
146
+ user.scim_source = "scim" if col?(:scim_source)
147
+ user.save! if user.changed?
148
+ assign_to_tenant(user, scim_u.external_id, claimed_now: true)
149
+ scim_adapter_for(user, scim_u).after_provision
150
+ end
151
+
152
+ def handle_exclusivity_conflict(user, scim_u, other_join)
153
+ config = DeviseScim.configuration
154
+ if config.user_exclusivity == :multiple
155
+ assign_to_tenant(user, scim_u.external_id)
156
+ scim_adapter_for(user, scim_u).after_provision
157
+ elsif config.exclusivity_conflict == :reassign
158
+ other_join.update!(active: false)
159
+ assign_to_tenant(user, scim_u.external_id)
160
+ scim_adapter_for(user, scim_u).after_provision
161
+ else
162
+ raise Conflict, "User already belongs to another tenant"
163
+ end
164
+ end
165
+
166
+ def tenant_join_for(user)
167
+ ScimTenantUser.find_by("user_id" => user.id,
168
+ tenant_fk_column => current_scim_tenant.id,
169
+ "active" => true)
170
+ end
171
+
172
+ def other_tenant_join_for(user)
173
+ ScimTenantUser.where("user_id" => user.id, "active" => true)
174
+ .where.not(tenant_fk_column => current_scim_tenant.id)
175
+ .first
176
+ end
177
+
178
+ def assign_to_tenant(user, external_id, claimed_now: false)
179
+ attrs = {
180
+ "user_id" => user.id,
181
+ tenant_fk_column => current_scim_tenant.id,
182
+ "scim_uid" => external_id,
183
+ "provisioned_at" => Time.current,
184
+ "active" => true
185
+ }
186
+ attrs["scim_claimed_at"] = Time.current if claimed_now
187
+ ScimTenantUser.create!(attrs)
188
+ end
189
+
190
+ # ── deprovision ────────────────────────────────────────────────────────────
191
+
192
+ def handle_deprovision(user)
193
+ config = DeviseScim.configuration
194
+ source = col?(:scim_source) ? user.scim_source : "scim"
195
+
196
+ if source != "scim"
197
+ case config.deprovision_manual_users
198
+ when false
199
+ Rails.logger.warn "[DeviseScim] Skipping deprovision of manual user #{user.id}"
200
+ return render_scim(scim_adapter_for(user, Scim::User.new).to_scim)
201
+ when :error
202
+ raise Conflict, "Cannot deprovision a manually-created user"
203
+ end
204
+ end
205
+
206
+ perform_deprovision(user, config)
207
+ head :no_content
208
+ end
209
+
210
+ def perform_deprovision(user, config)
211
+ if multi_tenant?
212
+ ScimTenantUser.find_by("user_id" => user.id,
213
+ tenant_fk_column => current_scim_tenant.id)
214
+ &.update!(active: false)
215
+ end
216
+ if config.soft_delete
217
+ user.scim_deprovisioned_at = Time.current if col?(:scim_deprovisioned_at)
218
+ user.scim_active = false if col?(:scim_active)
219
+ user.save! if user.changed?
220
+ end
221
+ scim_adapter_for(user, Scim::User.new).after_deprovision
222
+ end
223
+
224
+ # ── patch ──────────────────────────────────────────────────────────────────
225
+
226
+ def apply_patch(user, ops)
227
+ ops.each { |op| apply_op(user, op) }
228
+ end
229
+
230
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
231
+ def apply_op(user, patch_op)
232
+ return apply_valuemap(user, patch_op.value) unless patch_op.raw_path
233
+
234
+ case [patch_op.attribute&.downcase, patch_op.sub_attribute&.downcase]
235
+ when ["active", nil]
236
+ user.scim_active = patch_op.value if col?(:scim_active)
237
+ when ["username", nil]
238
+ user.email = patch_op.value
239
+ when ["emails", nil]
240
+ primary = Array(patch_op.value).find { |e| e["primary"] } || Array(patch_op.value).first
241
+ user.email = primary["value"] if primary
242
+ when %w[name givenname]
243
+ user.first_name = patch_op.value if col?(:first_name)
244
+ when %w[name familyname]
245
+ user.last_name = patch_op.value if col?(:last_name)
246
+ end
247
+ end
248
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
249
+
250
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
251
+ def apply_valuemap(user, value)
252
+ return unless value.is_a?(Hash)
253
+
254
+ value.each do |key, val|
255
+ case key.downcase
256
+ when "active" then user.scim_active = val if col?(:scim_active)
257
+ when "username" then user.email = val
258
+ when "name"
259
+ user.first_name = val["givenName"] if val["givenName"] && col?(:first_name)
260
+ user.last_name = val["familyName"] if val["familyName"] && col?(:last_name)
261
+ end
262
+ end
263
+ end
264
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
265
+
266
+ # ── helpers ────────────────────────────────────────────────────────────────
267
+
268
+ def set_scim_meta(user, external_id)
269
+ user.scim_uid = external_id if col?(:scim_uid)
270
+ user.scim_source = "scim" if col?(:scim_source)
271
+ end
272
+
273
+ def col?(name)
274
+ devise_model.column_names.include?(name.to_s)
275
+ end
276
+
277
+ def deprovisioned?(user)
278
+ col?(:scim_active) && user.scim_active == false
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,163 @@
1
+ # Contributing Guide
2
+
3
+ ## Dev Setup
4
+
5
+ ```bash
6
+ git clone https://github.com/vertigo-prime/devise_scim.git
7
+ cd devise_scim
8
+ bundle install
9
+ bundle exec rspec # should report ~285 examples, 0 failures
10
+ ```
11
+
12
+ The gem uses [Combustion](https://github.com/pat/combustion) — there is no separate test Rails app to create or maintain. `Combustion.initialize! :all` in `spec/rails_helper.rb` boots a full Rails environment from `spec/internal/` in-process. SQLite is used for the test database; no setup is needed beyond `bundle install`.
13
+
14
+ ---
15
+
16
+ ## Test Configurations
17
+
18
+ The gem must work correctly under three distinct configurations. The spec suite exercises all three via `before`/`after` blocks that temporarily reconfigure the gem and reset after each example or context.
19
+
20
+ **Single-tenant token auth:**
21
+
22
+ ```ruby
23
+ DeviseScim.configure do |c|
24
+ c.tenancy = :single
25
+ c.auth_method = :token
26
+ c.token = "test-token"
27
+ end
28
+ ```
29
+
30
+ **Single-tenant OAuth:**
31
+
32
+ ```ruby
33
+ DeviseScim.configure do |c|
34
+ c.tenancy = :single
35
+ c.auth_method = :oauth
36
+ end
37
+ ```
38
+
39
+ Requires Doorkeeper in the Gemfile. OAuth specs stub or use Doorkeeper's test helpers to issue access tokens.
40
+
41
+ **Multi-tenant:**
42
+
43
+ ```ruby
44
+ DeviseScim.configure do |c|
45
+ c.tenancy = :multi
46
+ end
47
+ ```
48
+
49
+ Multi-tenant specs create `DeviseScim::ScimTenant` records and pass their tokens via the `Authorization` header.
50
+
51
+ Always call `DeviseScim.reset_configuration!` in an `after` block (or use the provided RSpec helpers) to avoid leaking configuration between examples.
52
+
53
+ ---
54
+
55
+ ## Running Checks
56
+
57
+ All three checks must pass before submitting a PR:
58
+
59
+ ```bash
60
+ # Run the full test suite
61
+ bundle exec rspec
62
+
63
+ # Lint — fix safe autocorrections first, then review the rest
64
+ bundle exec rubocop --autocorrect
65
+ bundle exec rubocop
66
+
67
+ # Static security analysis
68
+ bundle exec brakeman --force --no-pager
69
+ ```
70
+
71
+ The CI workflow (`.github/workflows/main.yml`) runs all three. A PR with failing Brakeman output will not be merged regardless of test results.
72
+
73
+ ---
74
+
75
+ ## Test App Structure
76
+
77
+ `spec/internal/` is the Combustion test application. It is a minimal Rails app that exists solely to host the engine during tests.
78
+
79
+ **Key files:**
80
+
81
+ - `spec/internal/db/schema.rb` — defines all tables: `users`, `scim_tenants`, `scim_tenant_users`, `scim_groups`. This schema is loaded via `config.before(:suite)` in `spec/rails_helper.rb`, ensuring AR specs see the correct tables even when the generator spec has overwritten the in-memory connection.
82
+ - `spec/internal/config/routes.rb` — mounts three `scim_for` variants: the default at `/scim/v2`, a groups-enabled variant at `/scim_groups`, and an OAuth-enabled variant at `/scim_oauth`. The routing spec verifies all three.
83
+ - `spec/internal/app/models/user.rb` — the Devise user model used throughout request specs.
84
+ - `spec/internal/app/models/scim_group.rb` — the group model used by groups endpoint specs.
85
+ - `spec/internal/config/initializers/warden.rb` — patches Devise's `configure_warden!` to be a no-op (Warden middleware is not fully instantiated in the test environment).
86
+
87
+ > [!WARNING]
88
+ > Do not modify `spec/internal/db/schema.rb` without updating all request specs that depend on the table structure. Adding a column that does not exist in production schemas will cause false-positive test passes. Removing a column used by a spec will cause failures that are hard to attribute.
89
+
90
+ ---
91
+
92
+ ## PR Conventions
93
+
94
+ **Commit messages:**
95
+
96
+ - Imperative mood, present tense: "Add OAuth token rotation" not "Added OAuth token rotation".
97
+ - First line under 72 characters.
98
+ - Body explains **why**, not what — the diff shows what changed.
99
+ - One logical change per commit; one logical change per PR.
100
+
101
+ **For SCIM protocol changes:** link to the relevant RFC section in the PR description. RFC 7643 covers the data model; RFC 7644 covers the protocol (endpoints, filter syntax, PATCH operations). Example: "Per RFC 7644 §3.4.2.2, the `pr` operator should match non-null values."
102
+
103
+ **Test requirements:**
104
+
105
+ - New features must include request specs in `spec/requests/`.
106
+ - Bug fixes must include a regression test that fails before the fix and passes after.
107
+ - Filter system changes (new operators, new attribute mappings) need both a `spec/filter/parser_spec.rb` example and a `spec/filter/arel_visitor_spec.rb` example.
108
+
109
+ ---
110
+
111
+ ## Extending the Filter System
112
+
113
+ The filter pipeline is two files:
114
+
115
+ **`lib/devise_scim/filter/parser.rb`** — tokenizer + recursive descent AST builder.
116
+
117
+ - The tokenizer uses explicit `m = regex.match(s)` calls rather than `String#gsub`, because `gsub` clobbers `$&` even with a String pattern argument, which interferes with the match data.
118
+ - The AST node types are `Comparison`, `Conjunction`, `Disjunction`, and `AttrPath` (all `Struct`s defined at the top of the file).
119
+ - `Parser.parse(str)` is the public entry point. It raises `Filter::Parser::ParseError` (a subclass of `DeviseScim::InvalidFilter`) on syntax errors.
120
+
121
+ **`lib/devise_scim/filter/arel_visitor.rb`** — maps AST nodes to Arel conditions.
122
+
123
+ - `SCIM_TO_AR` is the attribute mapping hash. To support a new SCIM attribute, add an entry: `"newScimAttr" => "ar_column_name"`.
124
+ - All conditions are built with Arel node methods (`col.eq`, `col.matches`, etc.) — never with string interpolation or `where("column = ?", val)`. This is a hard requirement enforced by Brakeman and code review.
125
+ - `ArelVisitor#apply(ast, scope)` returns a new scope with the Arel condition appended via `where`.
126
+
127
+ **Adding a new operator** (e.g., a hypothetical `contains-all`):
128
+
129
+ 1. Add the operator string to `COMP_OPS` in `parser.rb`.
130
+ 2. Add a tokenizer branch in the `elsif` chain that emits a `:op` token.
131
+ 3. Add a `when` branch in `visit_comparison` in `arel_visitor.rb` that returns an Arel condition.
132
+ 4. Add examples to both spec files.
133
+
134
+ ---
135
+
136
+ ## Adding a New Endpoint
137
+
138
+ 1. **Add route** in `lib/devise_scim/routing.rb` inside the appropriate `draw_*` method, or add a new private method and call it from `scim_for`.
139
+
140
+ 2. **Add controller action** in `app/controllers/devise_scim/`. Inherit from `DeviseScim::ApplicationController` to get `tenant_scope`, `current_scim_tenant`, `render_scim`, and the error rescue chain.
141
+
142
+ 3. **Add request spec** in `spec/requests/`. Test at minimum: unauthenticated request (401), valid request (2xx), and any error paths (404, 409, 400).
143
+
144
+ 4. **Update discovery endpoints** if the new endpoint represents a new resource type or capability:
145
+ - `ServiceProviderConfig` (`app/controllers/devise_scim/service_provider_controller.rb`) — update `supported` flags for filter, sort, etag, etc.
146
+ - `Schemas` (`app/controllers/devise_scim/schemas_controller.rb`) — add a schema entry if introducing a new SCIM resource type.
147
+ - `ResourceTypes` (`app/controllers/devise_scim/resource_types_controller.rb`) — add an entry for the new endpoint.
148
+
149
+ Discovery endpoint responses are static; update them when the protocol surface changes, not just when implementation details change.
150
+
151
+ ---
152
+
153
+ ## Security
154
+
155
+ **SQL injection prevention:** all database queries use Arel node methods or parameterized AR queries. The `ArelVisitor` never interpolates user input into strings. The `tenant_scope` helper builds conditions entirely from Arel table/column references. Never introduce `where("#{col} = ?", val)` or equivalent — use `@table[col_name].eq(val)` instead.
156
+
157
+ **Timing-safe token comparison:** single-tenant Bearer token authentication uses `ActiveSupport::SecurityUtils.secure_compare(raw, config.token)`, which prevents timing-oracle attacks. Do not replace this with `==`.
158
+
159
+ **BCrypt for token storage:** multi-tenant token digests are stored with `BCrypt::Password.create(raw)`. Do not store raw tokens. Do not use a weaker digest (SHA, MD5) as a "performance optimization".
160
+
161
+ **Brakeman must stay clean:** run `bundle exec brakeman --force --no-pager` before every PR. A new warning blocks the PR. If you believe a warning is a false positive, add a Brakeman ignore entry with a comment explaining why, and include it in the PR.
162
+
163
+ **Reporting security issues:** do not open a public GitHub issue for security vulnerabilities. Email the maintainer directly at `devise_scim@vinson.pro`. Include a description of the vulnerability, steps to reproduce, and your assessment of impact. You will receive a response within 72 hours.