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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ RSpec.shared_examples "a SCIM Groups endpoint" do |_options = {}|
5
+ include DeviseScim::RSpec::ScimHelpers
6
+
7
+ let(:_scim_test_token) { "scim-test-token-#{SecureRandom.hex(8)}" }
8
+ let(:_scim_headers) { scim_auth_headers(_scim_test_token) }
9
+
10
+ before do
11
+ DeviseScim.configure do |c|
12
+ c.tenancy = :single
13
+ c.auth_method = :token
14
+ c.token = _scim_test_token
15
+ c.enable_groups = true
16
+ end
17
+ end
18
+
19
+ after { DeviseScim.reset_configuration! }
20
+
21
+ # ── GET /Groups ──────────────────────────────────────────────────────────────
22
+
23
+ describe "GET /Groups" do
24
+ it "returns 200 with an empty ListResponse" do
25
+ get "#{scim_prefix}/Groups", headers: _scim_headers
26
+ expect(response).to have_http_status(:ok)
27
+ body = scim_json(response.body)
28
+ expect(body["schemas"]).to include(DeviseScim::Scim::LIST_RESPONSE_SCHEMA)
29
+ expect(body["Resources"]).to eq([])
30
+ end
31
+
32
+ it "sets Content-Type to application/scim+json" do
33
+ get "#{scim_prefix}/Groups", headers: _scim_headers
34
+ expect(response.content_type).to include("application/scim+json")
35
+ end
36
+ end
37
+
38
+ # ── POST /Groups ─────────────────────────────────────────────────────────────
39
+
40
+ describe "POST /Groups" do
41
+ let(:_group_payload) do
42
+ { "schemas" => [DeviseScim::Scim::GROUP_SCHEMA], "displayName" => "Test Group" }
43
+ end
44
+
45
+ context "when the adapter implements group_to_scim" do
46
+ let(:_group_scim_obj) do
47
+ grp = DeviseScim::Scim::Group.new
48
+ grp.id = "test-grp-1"
49
+ grp.display_name = "Test Group"
50
+ grp
51
+ end
52
+ let(:_adapter_spy) do
53
+ instance_double(DeviseScim::ScimAdapter,
54
+ handle_group_create: nil,
55
+ group_to_scim: _group_scim_obj)
56
+ end
57
+
58
+ before { allow(DeviseScim::ScimAdapter).to receive(:new).and_return(_adapter_spy) }
59
+
60
+ it "calls handle_group_create on the adapter" do
61
+ post "#{scim_prefix}/Groups", params: _group_payload.to_json, headers: _scim_headers
62
+ expect(_adapter_spy).to have_received(:handle_group_create)
63
+ end
64
+
65
+ it "returns 201 with the group representation" do
66
+ post "#{scim_prefix}/Groups", params: _group_payload.to_json, headers: _scim_headers
67
+ expect(response).to have_http_status(:created)
68
+ body = scim_json(response.body)
69
+ expect(body["displayName"]).to eq("Test Group")
70
+ end
71
+ end
72
+
73
+ context "when the adapter does not implement group_to_scim (default)" do
74
+ it "returns 500" do
75
+ post "#{scim_prefix}/Groups", params: _group_payload.to_json, headers: _scim_headers
76
+ expect(response).to have_http_status(:internal_server_error)
77
+ end
78
+ end
79
+ end
80
+
81
+ # ── GET /Groups/:id ──────────────────────────────────────────────────────────
82
+
83
+ describe "GET /Groups/:id" do
84
+ let(:_group_scim_obj) do
85
+ grp = DeviseScim::Scim::Group.new
86
+ grp.id = "grp-42"
87
+ grp.display_name = "My Group"
88
+ grp
89
+ end
90
+ let(:_adapter_spy) do
91
+ instance_double(DeviseScim::ScimAdapter, group_to_scim: _group_scim_obj)
92
+ end
93
+
94
+ before { allow(DeviseScim::ScimAdapter).to receive(:new).and_return(_adapter_spy) }
95
+
96
+ it "returns 200 with the group serialised by the adapter" do
97
+ get "#{scim_prefix}/Groups/grp-42", headers: _scim_headers
98
+ expect(response).to have_http_status(:ok)
99
+ body = scim_json(response.body)
100
+ expect(body["id"]).to eq("grp-42")
101
+ end
102
+ end
103
+
104
+ # ── PATCH /Groups/:id ────────────────────────────────────────────────────────
105
+
106
+ describe "PATCH /Groups/:id" do
107
+ let(:_group_scim_obj) do
108
+ grp = DeviseScim::Scim::Group.new
109
+ grp.id = "grp-42"
110
+ grp.display_name = "Updated Group"
111
+ grp
112
+ end
113
+ let(:_adapter_spy) do
114
+ instance_double(DeviseScim::ScimAdapter,
115
+ handle_group_update: nil,
116
+ group_to_scim: _group_scim_obj)
117
+ end
118
+
119
+ before { allow(DeviseScim::ScimAdapter).to receive(:new).and_return(_adapter_spy) }
120
+
121
+ it "calls handle_group_update on the adapter and returns 200" do
122
+ payload = {
123
+ "schemas" => ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
124
+ "Operations" => [{ "op" => "replace", "value" => { "displayName" => "Updated Group" } }]
125
+ }
126
+ patch "#{scim_prefix}/Groups/grp-42", params: payload.to_json, headers: _scim_headers
127
+ expect(response).to have_http_status(:ok)
128
+ expect(_adapter_spy).to have_received(:handle_group_update)
129
+ end
130
+ end
131
+
132
+ # ── DELETE /Groups/:id ───────────────────────────────────────────────────────
133
+
134
+ describe "DELETE /Groups/:id" do
135
+ let(:_adapter_spy) do
136
+ instance_double(DeviseScim::ScimAdapter, handle_group_destroy: nil)
137
+ end
138
+
139
+ before { allow(DeviseScim::ScimAdapter).to receive(:new).and_return(_adapter_spy) }
140
+
141
+ it "calls handle_group_destroy on the adapter and returns 204" do
142
+ delete "#{scim_prefix}/Groups/grp-42", headers: _scim_headers
143
+ expect(response).to have_http_status(:no_content)
144
+ expect(_adapter_spy).to have_received(:handle_group_destroy)
145
+ end
146
+ end
147
+ end
148
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ RSpec.shared_examples "a SCIM Users endpoint" do |options = {}|
5
+ include DeviseScim::RSpec::ScimHelpers
6
+
7
+ let(:_scim_model) { options[:devise_model] || raise(ArgumentError, "devise_model: is required") }
8
+ let(:_scim_test_token) { "scim-test-token-#{SecureRandom.hex(8)}" }
9
+ let(:_scim_headers) { scim_auth_headers(_scim_test_token) }
10
+ let(:_scim_email) { "scim.user@example.com" }
11
+
12
+ before do
13
+ DeviseScim.configure do |c|
14
+ c.tenancy = :single
15
+ c.auth_method = :token
16
+ c.token = _scim_test_token
17
+ end
18
+ end
19
+
20
+ after { DeviseScim.reset_configuration! }
21
+
22
+ # ── GET /Users ─────────────────────────────────────────────────────────────
23
+
24
+ describe "GET /Users" do
25
+ let!(:_listed_user) { _scim_model.create!(email: _scim_email) }
26
+
27
+ it "returns 200 with ListResponse schema" do
28
+ get "#{scim_prefix}/Users", headers: _scim_headers
29
+ expect(response).to have_http_status(:ok)
30
+ body = scim_json(response.body)
31
+ expect(body["schemas"]).to include(DeviseScim::Scim::LIST_RESPONSE_SCHEMA)
32
+ expect(body).to have_key("totalResults")
33
+ expect(body).to have_key("Resources")
34
+ end
35
+
36
+ it "includes the user in Resources" do
37
+ get "#{scim_prefix}/Users", headers: _scim_headers
38
+ usernames = scim_json(response.body)["Resources"].map { |u| u["userName"] }
39
+ expect(usernames).to include(_scim_email)
40
+ end
41
+
42
+ it "sets Content-Type to application/scim+json" do
43
+ get "#{scim_prefix}/Users", headers: _scim_headers
44
+ expect(response.content_type).to include("application/scim+json")
45
+ end
46
+
47
+ context "with filter" do
48
+ before { _scim_model.create!(email: "other@example.com") }
49
+
50
+ it "filters by userName eq" do
51
+ get "#{scim_prefix}/Users?filter=userName eq \"#{_scim_email}\"", headers: _scim_headers
52
+ body = scim_json(response.body)
53
+ expect(body["totalResults"]).to eq(1)
54
+ expect(body["Resources"].first["userName"]).to eq(_scim_email)
55
+ end
56
+
57
+ it "returns 400 with invalidFilter scimType for a bad filter" do
58
+ get "#{scim_prefix}/Users?filter=!!!bad", headers: _scim_headers
59
+ expect(response).to have_http_status(:bad_request)
60
+ body = scim_json(response.body)
61
+ expect(body["scimType"]).to eq("invalidFilter")
62
+ end
63
+ end
64
+ end
65
+
66
+ # ── POST /Users ─────────────────────────────────────────────────────────────
67
+
68
+ describe "POST /Users" do
69
+ let(:_create_payload) { scim_user_payload(user_name: _scim_email) }
70
+
71
+ it "creates a user and returns 201" do
72
+ expect do
73
+ post "#{scim_prefix}/Users", params: _create_payload.to_json, headers: _scim_headers
74
+ end.to change(_scim_model, :count).by(1)
75
+ expect(response).to have_http_status(:created)
76
+ body = scim_json(response.body)
77
+ expect(body["userName"]).to eq(_scim_email)
78
+ end
79
+
80
+ it "sets scim_source to 'scim' on the created user" do
81
+ post "#{scim_prefix}/Users", params: _create_payload.to_json, headers: _scim_headers
82
+ user = _scim_model.find_by(email: _scim_email)
83
+ expect(user.scim_source).to eq("scim") if user.respond_to?(:scim_source)
84
+ end
85
+
86
+ it "returns 409 when the user already exists and is active" do
87
+ _scim_model.create!(email: _scim_email, scim_source: "scim")
88
+ post "#{scim_prefix}/Users", params: _create_payload.to_json, headers: _scim_headers
89
+ expect(response).to have_http_status(:conflict)
90
+ end
91
+
92
+ it "re-provisions a deprovisioned SCIM user" do
93
+ existing = _scim_model.create!(email: _scim_email, scim_source: "scim", scim_active: false)
94
+ post "#{scim_prefix}/Users", params: _create_payload.to_json, headers: _scim_headers
95
+ expect(response).to have_http_status(:created)
96
+ expect(existing.reload.scim_active).to be(true)
97
+ end
98
+ end
99
+
100
+ # ── GET /Users/:id ───────────────────────────────────────────────────────────
101
+
102
+ describe "GET /Users/:id" do
103
+ let!(:_shown_user) { _scim_model.create!(email: _scim_email) }
104
+
105
+ it "returns the user by id" do
106
+ get "#{scim_prefix}/Users/#{_shown_user.id}", headers: _scim_headers
107
+ expect(response).to have_http_status(:ok)
108
+ body = scim_json(response.body)
109
+ expect(body["id"]).to eq(_shown_user.id.to_s)
110
+ expect(body["userName"]).to eq(_scim_email)
111
+ end
112
+
113
+ it "returns 404 with SCIM error for an unknown id" do
114
+ get "#{scim_prefix}/Users/0", headers: _scim_headers
115
+ expect(response).to have_http_status(:not_found)
116
+ body = scim_json(response.body)
117
+ expect(body["status"]).to eq("404")
118
+ expect(body["schemas"]).to include(DeviseScim::Scim::ERROR_SCHEMA)
119
+ end
120
+ end
121
+
122
+ # ── PUT /Users/:id ───────────────────────────────────────────────────────────
123
+
124
+ describe "PUT /Users/:id" do
125
+ let!(:_replaced_user) { _scim_model.create!(email: "old@example.com") }
126
+
127
+ it "replaces user attributes and returns 200" do
128
+ payload = scim_user_payload(user_name: "updated@example.com")
129
+ put "#{scim_prefix}/Users/#{_replaced_user.id}", params: payload.to_json, headers: _scim_headers
130
+ expect(response).to have_http_status(:ok)
131
+ expect(_replaced_user.reload.email).to eq("updated@example.com")
132
+ end
133
+ end
134
+
135
+ # ── PATCH /Users/:id ─────────────────────────────────────────────────────────
136
+
137
+ describe "PATCH /Users/:id" do
138
+ let!(:_patched_user) { _scim_model.create!(email: _scim_email, scim_active: true) }
139
+
140
+ it "applies a replace op to active" do
141
+ payload = scim_patch_payload(scim_replace_op("active", false))
142
+ patch "#{scim_prefix}/Users/#{_patched_user.id}", params: payload.to_json, headers: _scim_headers
143
+ expect(response).to have_http_status(:ok)
144
+ expect(_patched_user.reload.scim_active).to be(false) if _patched_user.respond_to?(:scim_active)
145
+ end
146
+
147
+ it "applies a replace op to userName" do
148
+ payload = scim_patch_payload(scim_replace_op("userName", "patched@example.com"))
149
+ patch "#{scim_prefix}/Users/#{_patched_user.id}", params: payload.to_json, headers: _scim_headers
150
+ expect(response).to have_http_status(:ok)
151
+ expect(_patched_user.reload.email).to eq("patched@example.com")
152
+ end
153
+
154
+ it "applies a remove op without error" do
155
+ payload = scim_patch_payload(scim_remove_op("active"))
156
+ patch "#{scim_prefix}/Users/#{_patched_user.id}", params: payload.to_json, headers: _scim_headers
157
+ expect(response).to have_http_status(:ok)
158
+ end
159
+ end
160
+
161
+ # ── DELETE /Users/:id ────────────────────────────────────────────────────────
162
+
163
+ describe "DELETE /Users/:id" do
164
+ let!(:_scim_sourced_user) { _scim_model.create!(email: _scim_email, scim_source: "scim") }
165
+
166
+ it "soft-deactivates the user and returns 204" do
167
+ delete "#{scim_prefix}/Users/#{_scim_sourced_user.id}", headers: _scim_headers
168
+ expect(response).to have_http_status(:no_content)
169
+ reloaded = _scim_sourced_user.reload
170
+ expect(reloaded.scim_active).to be(false) if reloaded.respond_to?(:scim_active)
171
+ expect(reloaded.scim_deprovisioned_at).not_to be_nil if reloaded.respond_to?(:scim_deprovisioned_at)
172
+ end
173
+
174
+ context "with deprovision_manual_users: false (default)" do
175
+ let!(:_manual_user) { _scim_model.create!(email: "manual@example.com", scim_source: nil) }
176
+
177
+ it "returns 200 and skips deprovisioning the manual user" do
178
+ delete "#{scim_prefix}/Users/#{_manual_user.id}", headers: _scim_headers
179
+ expect(response).to have_http_status(:ok)
180
+ expect(_manual_user.reload.scim_active).to be(true) if _manual_user.respond_to?(:scim_active)
181
+ end
182
+ end
183
+
184
+ context "with deprovision_manual_users: :error" do
185
+ before { DeviseScim.configure { |c| c.deprovision_manual_users = :error } }
186
+
187
+ let!(:_manual_user) { _scim_model.create!(email: "manual@example.com", scim_source: nil) }
188
+
189
+ it "returns 409 for a manually-created user" do
190
+ delete "#{scim_prefix}/Users/#{_manual_user.id}", headers: _scim_headers
191
+ expect(response).to have_http_status(:conflict)
192
+ end
193
+ end
194
+ end
195
+
196
+ # ── Re-provisioning ──────────────────────────────────────────────────────────
197
+
198
+ describe "re-provisioning" do
199
+ it "POST after DELETE re-enables the deprovisioned user" do
200
+ user = _scim_model.create!(email: _scim_email, scim_source: "scim")
201
+ delete "#{scim_prefix}/Users/#{user.id}", headers: _scim_headers
202
+ expect(response).to have_http_status(:no_content)
203
+
204
+ post "#{scim_prefix}/Users",
205
+ params: scim_user_payload(user_name: _scim_email).to_json,
206
+ headers: _scim_headers
207
+ expect(response).to have_http_status(:created)
208
+ expect(user.reload.scim_active).to be(true)
209
+ end
210
+ end
211
+
212
+ # ── Authentication ───────────────────────────────────────────────────────────
213
+
214
+ describe "authentication" do
215
+ it "returns 401 with SCIM error body when no auth is provided" do
216
+ get "#{scim_prefix}/Users"
217
+ expect(response).to have_http_status(:unauthorized)
218
+ body = scim_json(response.body)
219
+ expect(body["schemas"]).to include(DeviseScim::Scim::ERROR_SCHEMA)
220
+ expect(body["status"]).to eq("401")
221
+ end
222
+
223
+ it "returns 401 for an invalid token" do
224
+ get "#{scim_prefix}/Users", headers: scim_auth_headers("invalid-token")
225
+ expect(response).to have_http_status(:unauthorized)
226
+ end
227
+ end
228
+
229
+ # ── Multi-tenant ─────────────────────────────────────────────────────────────
230
+
231
+ context "multi-tenant" do
232
+ let!(:_mt_tenant) do
233
+ DeviseScim::ScimTenant.create!(name: "Shared Example Org", auth_method: "token", active: true)
234
+ end
235
+ let(:_mt_token) { _mt_tenant.rotate_token! }
236
+ let(:_mt_headers) { scim_auth_headers(_mt_token) }
237
+
238
+ before do
239
+ _mt_token
240
+ DeviseScim.configure { |c| c.tenancy = :multi }
241
+ end
242
+
243
+ it "claims an existing manual user and sets scim_claimed_at on the join record" do
244
+ manual = _scim_model.create!(email: "manual@example.com")
245
+ post "#{scim_prefix}/Users",
246
+ params: scim_user_payload(user_name: "manual@example.com").to_json,
247
+ headers: _mt_headers
248
+ expect(response).to have_http_status(:created)
249
+ join = DeviseScim::ScimTenantUser.find_by(user_id: manual.id)
250
+ expect(join).not_to be_nil
251
+ expect(join.scim_claimed_at).not_to be_nil
252
+ end
253
+
254
+ it "returns 404 for a user not assigned to this tenant" do
255
+ other_user = _scim_model.create!(email: "other@example.com")
256
+ get "#{scim_prefix}/Users/#{other_user.id}", headers: _mt_headers
257
+ expect(response).to have_http_status(:not_found)
258
+ end
259
+
260
+ context "with user_exclusivity: :one_to_one" do
261
+ let!(:_other_tenant) do
262
+ t = DeviseScim::ScimTenant.create!(name: "Other Org", auth_method: "token", active: true)
263
+ t.rotate_token!
264
+ t
265
+ end
266
+
267
+ before { DeviseScim.configure { |c| c.user_exclusivity = :one_to_one } }
268
+
269
+ it "returns 409 when user belongs to another tenant (exclusivity_conflict: :error)" do
270
+ DeviseScim.configure { |c| c.exclusivity_conflict = :error }
271
+ existing = _scim_model.create!(email: "taken@example.com")
272
+ DeviseScim::ScimTenantUser.create!(
273
+ scim_tenant_id: _other_tenant.id, user_id: existing.id,
274
+ active: true, provisioned_at: Time.current
275
+ )
276
+ post "#{scim_prefix}/Users",
277
+ params: scim_user_payload(user_name: "taken@example.com").to_json,
278
+ headers: _mt_headers
279
+ expect(response).to have_http_status(:conflict)
280
+ end
281
+
282
+ it "reassigns the user when exclusivity_conflict: :reassign" do
283
+ DeviseScim.configure { |c| c.exclusivity_conflict = :reassign }
284
+ existing = _scim_model.create!(email: "taken@example.com")
285
+ old_join = DeviseScim::ScimTenantUser.create!(
286
+ scim_tenant_id: _other_tenant.id, user_id: existing.id,
287
+ active: true, provisioned_at: Time.current
288
+ )
289
+ post "#{scim_prefix}/Users",
290
+ params: scim_user_payload(user_name: "taken@example.com").to_json,
291
+ headers: _mt_headers
292
+ expect(response).to have_http_status(:created)
293
+ expect(old_join.reload.active).to be(false)
294
+ new_join = DeviseScim::ScimTenantUser.find_by(user_id: existing.id, scim_tenant_id: _mt_tenant.id)
295
+ expect(new_join).not_to be_nil
296
+ expect(new_join.active).to be(true)
297
+ end
298
+ end
299
+ end
300
+ end
301
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "devise_scim/rspec/scim_helpers"
4
+ require "devise_scim/rspec/factories"
5
+ require "devise_scim/rspec/shared_examples/users_endpoint"
6
+ require "devise_scim/rspec/shared_examples/groups_endpoint"
7
+ require "devise_scim/rspec/shared_examples/discovery_endpoints"
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DeviseScim
6
+ module Scim
7
+ ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
8
+
9
+ class Error
10
+ attr_reader :status, :detail, :scim_type
11
+
12
+ def initialize(status:, detail:, scim_type: nil)
13
+ @status = status
14
+ @detail = detail
15
+ @scim_type = scim_type
16
+ end
17
+
18
+ def to_h
19
+ h = {
20
+ "schemas" => [ERROR_SCHEMA],
21
+ "status" => status.to_s,
22
+ "detail" => detail
23
+ }
24
+ h["scimType"] = scim_type if scim_type
25
+ h
26
+ end
27
+
28
+ def to_json(*)
29
+ to_h.to_json
30
+ end
31
+
32
+ class << self
33
+ def unauthorized(detail = "Unauthorized")
34
+ new(status: 401, detail: detail)
35
+ end
36
+
37
+ def not_found(detail = "Resource not found")
38
+ new(status: 404, detail: detail)
39
+ end
40
+
41
+ def conflict(detail = "Resource already exists", scim_type: "uniqueness")
42
+ new(status: 409, detail: detail, scim_type: scim_type)
43
+ end
44
+
45
+ def bad_request(detail, scim_type: "invalidValue")
46
+ new(status: 400, detail: detail, scim_type: scim_type)
47
+ end
48
+
49
+ def unprocessable(detail)
50
+ new(status: 422, detail: detail)
51
+ end
52
+
53
+ def server_error(detail = "Internal server error")
54
+ new(status: 500, detail: detail)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DeviseScim
6
+ module Scim
7
+ GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
8
+
9
+ # rubocop:disable Lint/StructNewOverride
10
+ Member = Struct.new(:value, :display, :ref, keyword_init: true)
11
+ # rubocop:enable Lint/StructNewOverride
12
+
13
+ class Group
14
+ SCHEMAS = [GROUP_SCHEMA].freeze
15
+
16
+ attr_accessor :id, :external_id, :display_name, :members, :meta
17
+
18
+ def self.from_h(hash)
19
+ group = new
20
+ group.id = hash["id"]
21
+ group.external_id = hash["externalId"]
22
+ group.display_name = hash["displayName"]
23
+ group.members = Array(hash["members"]).map do |entry|
24
+ Member.new(value: entry["value"], display: entry["display"], ref: entry["$ref"])
25
+ end
26
+ group
27
+ end
28
+
29
+ def to_h
30
+ h = {
31
+ "schemas" => SCHEMAS,
32
+ "id" => id,
33
+ "externalId" => external_id,
34
+ "displayName" => display_name,
35
+ "members" => (members || []).map { |member| serialize_member(member) }
36
+ }.compact
37
+ h["schemas"] = SCHEMAS
38
+ h["members"] = (members || []).map { |member| serialize_member(member) }
39
+ h["meta"] = serialize_meta if meta
40
+ h
41
+ end
42
+
43
+ def to_json(*)
44
+ to_h.to_json
45
+ end
46
+
47
+ private
48
+
49
+ def serialize_member(member)
50
+ { "value" => member.value, "display" => member.display, "$ref" => member.ref }.compact
51
+ end
52
+
53
+ def serialize_meta
54
+ {
55
+ "resourceType" => meta.resource_type || "Group",
56
+ "created" => iso8601_or_raw(meta.created),
57
+ "lastModified" => iso8601_or_raw(meta.last_modified)
58
+ }.compact
59
+ end
60
+
61
+ def iso8601_or_raw(value)
62
+ value.respond_to?(:iso8601) ? value.iso8601 : value
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DeviseScim
6
+ module Scim
7
+ LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
8
+
9
+ class ListResponse
10
+ def initialize(resources:, total_results: nil, start_index: 1, items_per_page: 100)
11
+ @resources = resources
12
+ @total_results = total_results || resources.size
13
+ @start_index = start_index
14
+ @items_per_page = items_per_page
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ "schemas" => [LIST_RESPONSE_SCHEMA],
20
+ "totalResults" => @total_results,
21
+ "startIndex" => @start_index,
22
+ "itemsPerPage" => @items_per_page,
23
+ "Resources" => @resources.map { |r| r.respond_to?(:to_h) ? r.to_h : r }
24
+ }
25
+ end
26
+
27
+ def to_json(*)
28
+ to_h.to_json
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Scim
5
+ PATCH_OP_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
6
+
7
+ class PatchOperation
8
+ VALID_OPS = %w[add remove replace].freeze
9
+
10
+ attr_reader :op, :raw_path, :value, :attribute, :filter, :sub_attribute
11
+
12
+ def self.parse(hash)
13
+ operation = hash["op"]&.downcase
14
+ raise ArgumentError, "Invalid op '#{hash["op"]}'; must be one of: #{VALID_OPS.join(", ")}" unless VALID_OPS.include?(operation)
15
+
16
+ new(operation: operation, path: hash["path"], value: hash["value"])
17
+ end
18
+
19
+ def self.parse_request(body)
20
+ ops = body["Operations"] || body["operations"] || []
21
+ ops.map { |op_hash| parse(op_hash) }
22
+ end
23
+
24
+ def initialize(operation:, path:, value: nil)
25
+ @op = operation
26
+ @raw_path = path
27
+ @value = value
28
+ parse_path(path)
29
+ end
30
+
31
+ private
32
+
33
+ # Handles three path forms:
34
+ # "active" → attribute: "active"
35
+ # "name.givenName" → attribute: "name", sub_attribute: "givenName"
36
+ # "emails[type eq \"work\"].value" → attribute: "emails", filter: "...", sub_attribute: "value"
37
+ # "emails[type eq \"work\"]" → attribute: "emails", filter: "..."
38
+ def parse_path(path)
39
+ return unless path
40
+
41
+ if (match = path.match(/\A(\w+)\[(.+?)\](?:\.(\w+))?\z/))
42
+ @attribute = match[1]
43
+ @filter = match[2]
44
+ @sub_attribute = match[3]
45
+ elsif path.include?(".")
46
+ parts = path.split(".", 2)
47
+ @attribute = parts[0]
48
+ @sub_attribute = parts[1]
49
+ else
50
+ @attribute = path
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end