two_percent 0.5.0 → 1.1.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +5 -1
  3. data/app/controllers/two_percent/application_controller.rb +79 -1
  4. data/app/controllers/two_percent/bulk_controller.rb +18 -2
  5. data/app/controllers/two_percent/scim_controller.rb +289 -8
  6. data/app/models/two_percent/application_record.rb +7 -0
  7. data/app/models/two_percent/scim_group.rb +182 -0
  8. data/app/models/two_percent/scim_group_membership.rb +29 -0
  9. data/app/models/two_percent/scim_user.rb +138 -0
  10. data/config/routes.rb +5 -0
  11. data/lib/generators/two_percent/install/install_generator.rb +46 -0
  12. data/lib/generators/two_percent/install/templates/INSTALL_README +84 -0
  13. data/lib/generators/two_percent/install/templates/create_two_percent_scim_group_memberships.rb.erb +20 -0
  14. data/lib/generators/two_percent/install/templates/create_two_percent_scim_groups.rb.erb +24 -0
  15. data/lib/generators/two_percent/install/templates/create_two_percent_scim_users.rb.erb +25 -0
  16. data/lib/generators/two_percent/install/templates/two_percent.rb.erb +85 -0
  17. data/lib/two_percent/bulk_processor.rb +145 -5
  18. data/lib/two_percent/configuration.rb +35 -0
  19. data/lib/two_percent/domain/events/base_event.rb +27 -0
  20. data/lib/two_percent/domain/events/group_events.rb +54 -0
  21. data/lib/two_percent/domain/events/user_events.rb +51 -0
  22. data/lib/two_percent/domain/events.rb +16 -0
  23. data/lib/two_percent/domain.rb +8 -0
  24. data/lib/two_percent/scim/patch_processor.rb +119 -0
  25. data/lib/two_percent/scim/schema.rb +152 -0
  26. data/lib/two_percent/scim.rb +8 -0
  27. data/lib/two_percent/syncable.rb +198 -0
  28. data/lib/two_percent/version.rb +1 -1
  29. data/lib/two_percent.rb +3 -1
  30. metadata +27 -16
  31. data/app/events/two_percent/application_event.rb +0 -7
  32. data/app/events/two_percent/create_event.rb +0 -11
  33. data/app/events/two_percent/delete_event.rb +0 -11
  34. data/app/events/two_percent/replace_event.rb +0 -12
  35. data/app/events/two_percent/update_event.rb +0 -12
  36. data/lib/two_percent/event_handler.rb +0 -26
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 533e741957e0c547a769287f8916a72005c839c7db3af317984caf541263bf15
4
- data.tar.gz: 2b43afab484a19828f373069addae52cdabc3150434f267ff488454a566de4c3
3
+ metadata.gz: add03c75e1bf340f38e607daf123b473aec2f2b87b5785dd5a0c0c4b5c29ff6f
4
+ data.tar.gz: f3dda93ee923b646d652be8882db284fa2df08187fe5516c9f57ff2e5a85ebbf
5
5
  SHA512:
6
- metadata.gz: debd817addfe72f031540b9d14a420bd0f9845880f61e5ed77d0cc31c36650362c725281d5629b5b514d39f9634a98cb35253de85b1d8d55eb21d79e6ccd420f
7
- data.tar.gz: 8a0dc3faf0d6a4f1d02b1eb612bc37b693ab1ad2d3bf785c4ddf247acae55d78c7d4c3e506cf6b6b788136fbfab70510be47649f583e55cbc8ab42d9aeeac3a3
6
+ metadata.gz: 4bccbd154aee0eb6cde90ab96efd4eb685f33b4f2a04f304b5e7aef64cc9750641820bf99c2e650fd83c462238dd022d4c9afbfc93bbeb57b9c1dd9a9c947122
7
+ data.tar.gz: 4fe6f68630610ea02f86e48b0265c9e3ddc6b6f0f7636ca0fedd961904760b72842d6410dd759ff17ed776d424f8de7120feeb4a009b94004da5f2a670d0a463
data/Rakefile CHANGED
@@ -5,9 +5,13 @@ require "bundler/setup"
5
5
  require "bundler/gem_tasks"
6
6
 
7
7
  require "rspec/core/rake_task"
8
+
9
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
10
+ load "rails/tasks/engine.rake"
11
+
8
12
  RSpec::Core::RakeTask.new(:spec)
9
13
 
10
14
  require "rubocop/rake_task"
11
15
  RuboCop::RakeTask.new(:rubocop)
12
16
 
13
- task default: %i[rubocop spec]
17
+ task default: %i[rubocop app:db:prepare spec]
@@ -3,9 +3,87 @@
3
3
  module TwoPercent
4
4
  class ApplicationController < ActionController::API
5
5
  before_action :authenticate
6
+ before_action :extract_correlation_id
7
+
8
+ rescue_from ActiveRecord::RecordNotFound, with: :handle_record_not_found
9
+ rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
10
+ rescue_from ArgumentError, with: :handle_bad_request
6
11
 
7
12
  def authenticate
8
- instance_exec(&TwoPercent.config.authenticate)
13
+ result = instance_exec(&TwoPercent.config.authenticate)
14
+
15
+ return if result
16
+
17
+ render_scim_error(
18
+ status: :unauthorized,
19
+ scim_type: nil, # RFC 7644: No scimType for 401
20
+ detail: "Authentication failed"
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def handle_record_not_found(exception)
27
+ # RFC 7644: Error Response with scimType
28
+ render_scim_error(
29
+ status: :not_found,
30
+ scim_type: "noTarget",
31
+ detail: exception.message
32
+ )
33
+ end
34
+
35
+ def handle_validation_error(exception)
36
+ # RFC 7644: 400 Bad Request for invalid data
37
+ scim_type = exception.message.match?(/uniqueness|unique/i) ? "uniqueness" : "invalidValue"
38
+
39
+ render_scim_error(
40
+ status: :bad_request,
41
+ scim_type: scim_type,
42
+ detail: exception.message
43
+ )
44
+ end
45
+
46
+ def handle_bad_request(exception)
47
+ # RFC 7644: 400 Bad Request for malformed requests
48
+ scim_type = exception.message.match?(/schemas/i) ? "invalidValue" : "invalidSyntax"
49
+
50
+ render_scim_error(
51
+ status: :bad_request,
52
+ scim_type: scim_type,
53
+ detail: exception.message
54
+ )
55
+ end
56
+
57
+ def extract_correlation_id
58
+ header_name = TwoPercent.config.correlation_id_header
59
+ @correlation_id = request.headers[header_name] || SecureRandom.uuid
60
+ end
61
+
62
+ # RFC 7644 Section 3.12: SCIM Error Response Format
63
+ #
64
+ # scimType values:
65
+ # - invalidFilter: The specified filter syntax was invalid
66
+ # - tooMany: The specified filter yields many more results than the server is willing to calculate
67
+ # - uniqueness: One or more of the attribute values are already in use or are reserved
68
+ # - mutability: The attempted modification is not compatible with the target attribute's mutability
69
+ # - invalidSyntax: The request body message structure was invalid or did not conform to the request schema
70
+ # - invalidPath: The "path" attribute was invalid or malformed
71
+ # - noTarget: The specified "path" did not yield an attribute or attribute value that could be operated on (404)
72
+ # - invalidValue: A required value was missing, or the value specified was not compatible with the operation
73
+ # - invalidVers: The specified SCIM protocol version is not supported
74
+ # - sensitive: The specified request cannot be completed due to the passing of sensitive information
75
+ #
76
+ def render_scim_error(status:, scim_type:, detail:)
77
+ error_response = {
78
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
79
+ status: Rack::Utils::SYMBOL_TO_STATUS_CODE[status].to_s,
80
+ detail: detail,
81
+ }
82
+
83
+ # RFC 7644: scimType is optional, only include if provided
84
+ error_response[:scimType] = scim_type if scim_type
85
+
86
+ render json: error_response, status: status
9
87
  end
10
88
  end
11
89
  end
@@ -2,16 +2,32 @@
2
2
 
3
3
  module TwoPercent
4
4
  class BulkController < ApplicationController
5
- def _dispatch = processor.dispatch
5
+ def _dispatch
6
+ log_bulk_operation("start")
7
+ processor.dispatch
8
+ log_bulk_operation("complete")
9
+ end
6
10
 
7
11
  private
8
12
 
9
13
  def processor
10
- @processor ||= TwoPercent::BulkProcessor.new(operations)
14
+ @processor ||= TwoPercent::BulkProcessor.new(operations, correlation_id: @correlation_id)
11
15
  end
12
16
 
13
17
  def operations
14
18
  params.require(:Operations).map { _1.permit(:method, :path, :bulkId, data: {}).to_h }
15
19
  end
20
+
21
+ def log_bulk_operation(stage)
22
+ log_data = {
23
+ correlation_id: @correlation_id,
24
+ operation: "bulk",
25
+ operation_count: operations.size,
26
+ stage: stage,
27
+ service: "two_percent",
28
+ }
29
+
30
+ Rails.logger.info(log_data.to_json)
31
+ end
16
32
  end
17
33
  end
@@ -3,27 +3,135 @@
3
3
  module TwoPercent
4
4
  class ScimController < ApplicationController
5
5
  def create
6
- TwoPercent::CreateEvent.create(resource: params[:resource_type], params: scim_params)
6
+ record = with_scim_logging("create") do
7
+ # Persist to two_percent tables first (validates SCIM schema)
8
+ record = persist_scim_record(scim_params)
7
9
 
8
- head :ok
10
+ # Reload with associations for domain event and response
11
+ record = reload_with_members(record)
12
+
13
+ # Publish domain event (not SCIM-specific)
14
+ publish_created_event(record)
15
+
16
+ record
17
+ end
18
+
19
+ # RFC 7644: 201 Created with Location header and resource body
20
+ render json: record.to_scim_representation, status: :created, location: scim_resource_url(record)
9
21
  end
10
22
 
11
23
  def update
12
- TwoPercent::UpdateEvent.create(resource: params[:resource_type], id: params[:id], params: scim_params)
24
+ record = with_scim_logging("update") do
25
+ # Find existing record
26
+ record = find_scim_record(params[:id])
27
+
28
+ # Apply SCIM PATCH operations (RFC 7644 compliance)
29
+ processor = TwoPercent::Scim::PatchProcessor.new(scim_params)
30
+ current_scim_data = record.scim_data || {}
31
+ patched_data = processor.apply_to_hash(current_scim_data)
32
+
33
+ # Persist patched data
34
+ patched_data["id"] = params[:id] # Ensure ID is present
35
+ updated_record = persist_scim_record(patched_data)
13
36
 
14
- head :ok
37
+ # Reload with associations for domain event and response
38
+ updated_record = reload_with_members(updated_record)
39
+
40
+ # Publish domain event with final state
41
+ publish_updated_event(updated_record)
42
+
43
+ updated_record
44
+ end
45
+
46
+ # RFC 7644: 200 OK with updated resource body
47
+ render json: record.to_scim_representation, status: :ok
15
48
  end
16
49
 
17
50
  def replace
18
- TwoPercent::ReplaceEvent.create(resource: params[:resource_type], id: params[:id], params: scim_params)
51
+ # Upsert record (create or replace)
52
+ was_new = !model_class.exists_by_scim_id?(params[:id])
53
+
54
+ record = with_scim_logging("replace") do
55
+ record = upsert_scim_record(params[:id], scim_params)
56
+
57
+ # Reload with associations for domain event and response
58
+ record = reload_with_members(record)
19
59
 
20
- head :ok
60
+ # Publish appropriate domain event
61
+ if was_new
62
+ publish_created_event(record)
63
+ else
64
+ publish_updated_event(record)
65
+ end
66
+
67
+ record
68
+ end
69
+
70
+ # RFC 7644: 201 Created (if new) or 200 OK (if replaced)
71
+ if was_new
72
+ render json: record.to_scim_representation, status: :created, location: scim_resource_url(record)
73
+ else
74
+ render json: record.to_scim_representation, status: :ok
75
+ end
21
76
  end
22
77
 
23
78
  def destroy
24
- TwoPercent::DeleteEvent.create(resource: params[:resource_type], id: params[:id])
79
+ scim_id = with_scim_logging("delete") do
80
+ # Find and destroy record
81
+ record = find_scim_record(params[:id])
82
+ scim_id = record.scim_id
83
+
84
+ # Destroy record
85
+ model_class.destroy_by_scim_id(scim_id)
86
+
87
+ # Publish domain delete event
88
+ publish_deleted_event(scim_id)
89
+
90
+ scim_id
91
+ end
92
+
93
+ # RFC 7644: 204 No Content
94
+ head :no_content
95
+ end
96
+
97
+ def show
98
+ validate_resource_type!
99
+
100
+ record = with_scim_logging("get") do
101
+ # Find record (raises RecordNotFound if not exists)
102
+ record = find_scim_record(params[:id])
25
103
 
26
- head :ok
104
+ # Reload with associations for complete SCIM representation
105
+ reload_with_members(record)
106
+ end
107
+
108
+ # RFC 7644: 200 OK with resource body (no domain events for read operations)
109
+ render json: record.to_scim_representation, status: :ok
110
+ end
111
+
112
+ def index
113
+ validate_resource_type!
114
+ log_scim_operation("list", "start")
115
+
116
+ # Build base query scope
117
+ scope = build_query_scope
118
+
119
+ # Get total count before pagination
120
+ total_count = scope.count
121
+
122
+ # Apply pagination
123
+ paginated_scope = apply_pagination(scope)
124
+
125
+ # Load records with associations
126
+ records = load_records_with_associations(paginated_scope)
127
+
128
+ # Build RFC 7644 ListResponse
129
+ list_response = build_list_response(records, total_count)
130
+
131
+ log_scim_operation("list", "complete")
132
+
133
+ # RFC 7644: 200 OK with ListResponse (no domain events for read operations)
134
+ render json: list_response, status: :ok
27
135
  end
28
136
 
29
137
  private
@@ -31,5 +139,178 @@ module TwoPercent
31
139
  def scim_params
32
140
  params.except(:controller, :action, :resource_type).as_json.with_indifferent_access
33
141
  end
142
+
143
+ def user_resource?
144
+ params[:resource_type] == "Users"
145
+ end
146
+
147
+ def group_resource?
148
+ TwoPercent.config.group_resource_types.include?(params[:resource_type])
149
+ end
150
+
151
+ def validate_resource_type!
152
+ return if user_resource? || group_resource?
153
+
154
+ raise ArgumentError, "Unknown resource type: #{params[:resource_type]}"
155
+ end
156
+
157
+ def persist_scim_record(scim_hash)
158
+ if user_resource?
159
+ TwoPercent::ScimUser.upsert_from_scim(scim_hash, correlation_id: @correlation_id)
160
+ elsif group_resource?
161
+ TwoPercent::ScimGroup.upsert_from_scim(params[:resource_type], scim_hash, correlation_id: @correlation_id)
162
+ else
163
+ raise ArgumentError, "Unknown resource type: #{params[:resource_type]}"
164
+ end
165
+ end
166
+
167
+ def find_scim_record(scim_id)
168
+ record = model_class.find_by_scim_id(scim_id)
169
+ raise ActiveRecord::RecordNotFound, "Resource \"#{scim_id}\" not found" unless record
170
+
171
+ record
172
+ end
173
+
174
+ def upsert_scim_record(scim_id, scim_hash)
175
+ # Ensure scim_id is in the hash
176
+ scim_hash_with_id = scim_hash.merge("id" => scim_id)
177
+ persist_scim_record(scim_hash_with_id)
178
+ end
179
+
180
+ def with_scim_logging(operation)
181
+ log_scim_operation(operation, "start", params[:id])
182
+ record = yield
183
+ scim_id = record.respond_to?(:scim_id) ? record.scim_id : record
184
+ log_scim_operation(operation, "complete", scim_id || params[:id])
185
+ record
186
+ end
187
+
188
+ def log_scim_operation(operation, stage, scim_id = nil)
189
+ log_data = {
190
+ correlation_id: @correlation_id,
191
+ operation: operation,
192
+ resource_type: params[:resource_type],
193
+ stage: stage,
194
+ service: "two_percent",
195
+ }
196
+ log_data[:scim_id] = scim_id if scim_id
197
+
198
+ Rails.logger.info(log_data.to_json)
199
+ end
200
+
201
+ # Domain event publishers
202
+ def publish_created_event(record)
203
+ if user_resource?
204
+ TwoPercent::Domain::Events::UserCreated.create(
205
+ user_attributes: record.to_domain_attributes,
206
+ correlation_id: @correlation_id
207
+ )
208
+ else
209
+ TwoPercent::Domain::Events::GroupCreated.create(
210
+ group_attributes: record.to_domain_attributes,
211
+ resource_type: params[:resource_type],
212
+ correlation_id: @correlation_id
213
+ )
214
+ end
215
+ end
216
+
217
+ def publish_updated_event(record)
218
+ if user_resource?
219
+ TwoPercent::Domain::Events::UserUpdated.create(
220
+ user_attributes: record.to_domain_attributes,
221
+ correlation_id: @correlation_id
222
+ )
223
+ else
224
+ TwoPercent::Domain::Events::GroupUpdated.create(
225
+ group_attributes: record.to_domain_attributes,
226
+ resource_type: params[:resource_type],
227
+ correlation_id: @correlation_id
228
+ )
229
+ end
230
+ end
231
+
232
+ def publish_deleted_event(scim_id)
233
+ if user_resource?
234
+ TwoPercent::Domain::Events::UserDeleted.create(
235
+ user_id: scim_id,
236
+ correlation_id: @correlation_id
237
+ )
238
+ else
239
+ TwoPercent::Domain::Events::GroupDeleted.create(
240
+ group_id: scim_id,
241
+ resource_type: params[:resource_type],
242
+ correlation_id: @correlation_id
243
+ )
244
+ end
245
+ end
246
+
247
+ # Generate SCIM resource URL for Location header (RFC 7644)
248
+ def scim_resource_url(record)
249
+ resource_type = user_resource? ? "Users" : params[:resource_type]
250
+ "#{request.base_url}/scim/#{resource_type}/#{record.scim_id}"
251
+ end
252
+
253
+ # Reload record with associations (users load groups, groups load members)
254
+ def reload_with_members(record)
255
+ model_class.includes(association_name).find(record.id)
256
+ end
257
+
258
+ # Build base query scope with optional filtering
259
+ def build_query_scope
260
+ base_scope = user_resource? ? model_class.all : model_class.where(resource_type: params[:resource_type])
261
+
262
+ # Apply query filtering if present
263
+ scope = if params[:query].present?
264
+ # Sanitize LIKE wildcards (%, _, \) before interpolating into pattern
265
+ sanitized_query = ActiveRecord::Base.sanitize_sql_like(params[:query])
266
+ base_scope.where("LOWER(display_name) LIKE LOWER(?) ESCAPE '\\'", "%#{sanitized_query}%")
267
+ else
268
+ base_scope
269
+ end
270
+
271
+ # Order by id for deterministic pagination results
272
+ # Without ORDER BY, paginated results are non-deterministic and can return duplicates/skip records
273
+ scope.order(:id)
274
+ end
275
+
276
+ # Apply SCIM pagination (RFC 7644 uses 1-based indexing)
277
+ def apply_pagination(scope)
278
+ start_index = (params[:startIndex] || 1).to_i
279
+ count = (params[:count] || 100).to_i
280
+
281
+ # Enforce maximum count limit
282
+ count = [count, 1000].min
283
+
284
+ # Convert SCIM 1-based startIndex to 0-based offset
285
+ offset = [start_index - 1, 0].max
286
+
287
+ scope.offset(offset).limit(count)
288
+ end
289
+
290
+ # Load records with associations
291
+ def load_records_with_associations(scope)
292
+ scope.includes(association_name).to_a
293
+ end
294
+
295
+ # Build RFC 7644 ListResponse format
296
+ def build_list_response(records, total_count)
297
+ start_index = (params[:startIndex] || 1).to_i
298
+
299
+ {
300
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
301
+ totalResults: total_count,
302
+ startIndex: start_index,
303
+ itemsPerPage: records.size,
304
+ Resources: records.map(&:to_scim_representation),
305
+ }
306
+ end
307
+
308
+ def model_class
309
+ user_resource? ? TwoPercent::ScimUser : TwoPercent::ScimGroup
310
+ end
311
+
312
+ def association_name
313
+ user_resource? ? :scim_groups : :scim_users
314
+ end
34
315
  end
35
316
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoPercent
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoPercent
4
+ class ScimGroup < ApplicationRecord
5
+ self.table_name = "two_percent_scim_groups"
6
+ serialize :scim_data, coder: JSON
7
+
8
+ has_many :scim_group_memberships, class_name: "TwoPercent::ScimGroupMembership",
9
+ foreign_key: :scim_group_id, dependent: :destroy
10
+ has_many :scim_users, through: :scim_group_memberships
11
+
12
+ validates :scim_id, presence: true, uniqueness: true
13
+ validates :external_id, presence: true
14
+ validates :display_name, presence: true
15
+ validates :resource_type, presence: true
16
+ validates :scim_data, presence: true
17
+
18
+ scope :active, -> { where(active: true) }
19
+ scope :by_resource_type, ->(type) { where(resource_type: type) }
20
+
21
+ # Creates or updates a group from SCIM data
22
+ #
23
+ # Generates a UUID for the id field if not present (for POST/create operations).
24
+ # Validates the SCIM data against the Group schema before persisting.
25
+ #
26
+ # @param resource_type [String] The resource type (e.g., "Groups", "Departments", "Territories")
27
+ # @param scim_hash [Hash] SCIM Group resource hash conforming to RFC 7643
28
+ # @param correlation_id [String, nil] Optional correlation ID for tracking
29
+ # changes across network hops (e.g., App A -> App B -> App C)
30
+ # @return [TwoPercent::ScimGroup] The persisted group record
31
+ # @raise [TwoPercent::Scim::ValidationError] If SCIM data fails schema validation
32
+ def self.upsert_from_scim(resource_type, scim_hash, correlation_id: nil)
33
+ # Generate SCIM id (stored as scim_id) if not provided (typical for POST operations)
34
+ scim_hash = scim_hash.dup
35
+ scim_hash["id"] ||= SecureRandom.uuid
36
+
37
+ validated_data = TwoPercent::Scim::Schema.validate_group(scim_hash, require_id: true)
38
+ scim_group = find_or_initialize_by(scim_id: scim_hash["id"])
39
+ scim_group.update_from_scim!(resource_type, validated_data, correlation_id: correlation_id)
40
+
41
+ scim_group.replace_members(scim_hash["members"], correlation_id) if scim_hash.key?("members")
42
+
43
+ scim_group
44
+ end
45
+
46
+ def self.find_by_scim_id(scim_id)
47
+ find_by(scim_id: scim_id)
48
+ end
49
+
50
+ def self.exists_by_scim_id?(scim_id)
51
+ exists?(scim_id: scim_id)
52
+ end
53
+
54
+ def self.destroy_by_scim_id(scim_id)
55
+ find_by_scim_id(scim_id)&.destroy
56
+ end
57
+
58
+ # Extracts domain attributes for publishing in domain events
59
+ #
60
+ # Returns key attributes for event payloads.
61
+ # @return [Hash] Domain attributes
62
+ def to_domain_attributes
63
+ {
64
+ scim_id: scim_id,
65
+ external_id: external_id,
66
+ display_name: display_name,
67
+ resource_type: resource_type,
68
+ active: active,
69
+ }.compact
70
+ end
71
+
72
+ # Returns full SCIM representation for HTTP responses
73
+ #
74
+ # @return [Hash] RFC 7644 compliant SCIM Group resource
75
+ def to_scim_representation
76
+ representation = scim_data.merge(
77
+ "id" => scim_id,
78
+ "meta" => {
79
+ "resourceType" => resource_type,
80
+ "created" => created_at.iso8601,
81
+ "lastModified" => updated_at.iso8601,
82
+ }
83
+ )
84
+
85
+ representation["members"] = members_representation if scim_users.loaded?
86
+ representation
87
+ end
88
+
89
+ def update_from_scim!(resource_type, validated_data, correlation_id: nil)
90
+ core_data = validated_data[:core]
91
+ self.scim_data = core_data.merge(validated_data[:extensions])
92
+ self.scim_id = core_data["id"]
93
+ self.external_id = core_data["externalId"]
94
+ self.display_name = core_data["displayName"]
95
+ self.resource_type = resource_type
96
+
97
+ extension_data = validated_data[:extensions]
98
+ self.active = extension_data.dig("urn:ietf:params:scim:schemas:extension:authservice:2.0:Group",
99
+ "active") != false
100
+ self.correlation_id = correlation_id
101
+ save!
102
+ end
103
+
104
+ def replace_members(members_array, correlation_id)
105
+ member_scim_ids = members_array.filter_map { |m| m["value"] }
106
+ existing_users = validate_users_exist!(member_scim_ids)
107
+ existing_user_ids = scim_group_memberships.pluck(:scim_user_id)
108
+
109
+ users_to_add = existing_users.where.not(id: existing_user_ids)
110
+ bulk_insert_memberships(users_to_add, correlation_id) if users_to_add.any?
111
+
112
+ # Bulk delete removed memberships
113
+ users_to_remove_ids = scim_users.where.not(scim_id: member_scim_ids).pluck(:id)
114
+ scim_group_memberships.where(scim_user_id: users_to_remove_ids).delete_all
115
+ end
116
+
117
+ # Extracts a nested attribute from the scim_data JSON
118
+ #
119
+ # @param path [String] Dot-separated path to the attribute (e.g., "displayName")
120
+ # @return [Object, nil] The attribute value or nil if not found
121
+ # @example
122
+ # group.scim_attribute("members.0.value") # => "user-id-123"
123
+ def scim_attribute(path)
124
+ keys = path.split(".")
125
+ scim_data.dig(*keys)
126
+ end
127
+
128
+ private
129
+
130
+ # Validates that all user IDs exist in the database
131
+ #
132
+ # @param member_scim_ids [Array<String>] Array of SCIM user IDs to validate
133
+ # @return [ActiveRecord::Relation] The existing users
134
+ # @raise [ArgumentError] If any users do not exist
135
+ def validate_users_exist!(member_scim_ids)
136
+ existing_users = TwoPercent::ScimUser.where(scim_id: member_scim_ids)
137
+ missing_ids = member_scim_ids - existing_users.pluck(:scim_id)
138
+
139
+ if missing_ids.any?
140
+ raise ArgumentError,
141
+ "Cannot add non-existent users to group: #{missing_ids.join(', ')}"
142
+ end
143
+
144
+ existing_users
145
+ end
146
+
147
+ # Bulk insert memberships for performance
148
+ #
149
+ # @param users_to_add [ActiveRecord::Relation] Users to add as members
150
+ # @param correlation_id [String, nil] Correlation ID for tracking
151
+ def bulk_insert_memberships(users_to_add, correlation_id)
152
+ membership_records = users_to_add.pluck(:id).map do |user_id|
153
+ {
154
+ scim_user_id: user_id,
155
+ scim_group_id: id,
156
+ correlation_id: correlation_id,
157
+ created_at: Time.current,
158
+ updated_at: Time.current,
159
+ }
160
+ end
161
+
162
+ # Skip duplicates (handles race conditions and migration scenarios)
163
+ TwoPercent::ScimGroupMembership.insert_all(
164
+ membership_records,
165
+ unique_by: %i[scim_user_id scim_group_id]
166
+ )
167
+ end
168
+
169
+ # Build SCIM members representation
170
+ #
171
+ # @return [Array<Hash>] Array of member references
172
+ def members_representation
173
+ scim_users.map do |user|
174
+ {
175
+ "value" => user.scim_id,
176
+ "display" => user.display_name,
177
+ "$ref" => "Users/#{user.scim_id}",
178
+ }
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoPercent
4
+ class ScimGroupMembership < ApplicationRecord
5
+ self.table_name = "two_percent_scim_group_memberships"
6
+
7
+ belongs_to :scim_user, class_name: "TwoPercent::ScimUser",
8
+ foreign_key: :scim_user_id
9
+ belongs_to :scim_group, class_name: "TwoPercent::ScimGroup",
10
+ foreign_key: :scim_group_id
11
+
12
+ validates :scim_user_id, presence: true
13
+ validates :scim_group_id, presence: true
14
+ validates :scim_user_id, uniqueness: { scope: :scim_group_id, message: "already a member of this group" }
15
+
16
+ def self.find_or_create_membership(scim_user:, scim_group:, correlation_id: nil)
17
+ find_or_create_by!(
18
+ scim_user_id: scim_user.id,
19
+ scim_group_id: scim_group.id
20
+ ) do |membership|
21
+ membership.correlation_id = correlation_id
22
+ end
23
+ end
24
+
25
+ def self.remove_membership(scim_user:, scim_group:)
26
+ find_by(scim_user_id: scim_user.id, scim_group_id: scim_group.id)&.destroy
27
+ end
28
+ end
29
+ end