two_percent 0.5.0 → 1.0.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 (35) 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 +200 -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/lib/generators/two_percent/install/install_generator.rb +46 -0
  11. data/lib/generators/two_percent/install/templates/INSTALL_README +84 -0
  12. data/lib/generators/two_percent/install/templates/create_two_percent_scim_group_memberships.rb.erb +20 -0
  13. data/lib/generators/two_percent/install/templates/create_two_percent_scim_groups.rb.erb +24 -0
  14. data/lib/generators/two_percent/install/templates/create_two_percent_scim_users.rb.erb +25 -0
  15. data/lib/generators/two_percent/install/templates/two_percent.rb.erb +85 -0
  16. data/lib/two_percent/bulk_processor.rb +145 -5
  17. data/lib/two_percent/configuration.rb +15 -0
  18. data/lib/two_percent/domain/events/base_event.rb +27 -0
  19. data/lib/two_percent/domain/events/group_events.rb +54 -0
  20. data/lib/two_percent/domain/events/user_events.rb +51 -0
  21. data/lib/two_percent/domain/events.rb +16 -0
  22. data/lib/two_percent/domain.rb +8 -0
  23. data/lib/two_percent/scim/patch_processor.rb +119 -0
  24. data/lib/two_percent/scim/schema.rb +152 -0
  25. data/lib/two_percent/scim.rb +8 -0
  26. data/lib/two_percent/syncable.rb +198 -0
  27. data/lib/two_percent/version.rb +1 -1
  28. data/lib/two_percent.rb +3 -1
  29. metadata +27 -16
  30. data/app/events/two_percent/application_event.rb +0 -7
  31. data/app/events/two_percent/create_event.rb +0 -11
  32. data/app/events/two_percent/delete_event.rb +0 -11
  33. data/app/events/two_percent/replace_event.rb +0 -12
  34. data/app/events/two_percent/update_event.rb +0 -12
  35. 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: 74667df371bad7a8aae259ac6337d7bc1fa5625fd26811ec376f15bc2fa0434b
4
+ data.tar.gz: 0e75d901f0d746f04d1b154663e086c2e68659bd6076c73dc770f2e22d024c92
5
5
  SHA512:
6
- metadata.gz: debd817addfe72f031540b9d14a420bd0f9845880f61e5ed77d0cc31c36650362c725281d5629b5b514d39f9634a98cb35253de85b1d8d55eb21d79e6ccd420f
7
- data.tar.gz: 8a0dc3faf0d6a4f1d02b1eb612bc37b693ab1ad2d3bf785c4ddf247acae55d78c7d4c3e506cf6b6b788136fbfab70510be47649f583e55cbc8ab42d9aeeac3a3
6
+ metadata.gz: 7373a151278578cc9bb7f709cf546412f1896b8f27f21f133f5c8660e9e32fb4b218b2426ad8c885265a933f96e4b4f669305330ea261ec3d451accb5adc0c4e
7
+ data.tar.gz: d45edaefccd729706e618aedcbd9e1f95053541fafbe9f290d357a139eb270abbc44d5366eaa472ed7858c2c1b2c469759124a609da27de52b0e9c99d54715b6
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,106 @@
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
+ log_scim_operation("create", "start")
7
7
 
8
- head :ok
8
+ # Persist to two_percent tables first (validates SCIM schema)
9
+ record = persist_scim_record(scim_params)
10
+
11
+ # Reload with associations for domain event and response
12
+ record = reload_with_members(record)
13
+
14
+ # Publish domain event (not SCIM-specific)
15
+ publish_created_event(record)
16
+
17
+ log_scim_operation("create", "complete", record.scim_id)
18
+
19
+ # RFC 7644: 201 Created with Location header and resource body
20
+ response.headers["Location"] = scim_resource_url(record)
21
+ render json: record.to_scim_representation, status: :created
9
22
  end
10
23
 
11
24
  def update
12
- TwoPercent::UpdateEvent.create(resource: params[:resource_type], id: params[:id], params: scim_params)
25
+ log_scim_operation("update", "start")
26
+
27
+ # Find existing record
28
+ record = find_scim_record(params[:id])
29
+
30
+ # Apply SCIM PATCH operations (RFC 7644 compliance)
31
+ processor = TwoPercent::Scim::PatchProcessor.new(scim_params)
32
+ current_scim_data = record.scim_data || {}
33
+ patched_data = processor.apply_to_hash(current_scim_data)
13
34
 
14
- head :ok
35
+ # Persist patched data
36
+ patched_data["id"] = params[:id] # Ensure ID is present
37
+ updated_record = persist_scim_record(patched_data)
38
+
39
+ # Reload with associations for domain event and response
40
+ updated_record = reload_with_members(updated_record)
41
+
42
+ # Publish domain event with final state
43
+ publish_updated_event(updated_record)
44
+
45
+ log_scim_operation("update", "complete", record.scim_id)
46
+
47
+ # RFC 7644: 200 OK with updated resource body
48
+ render json: updated_record.to_scim_representation, status: :ok
15
49
  end
16
50
 
17
51
  def replace
18
- TwoPercent::ReplaceEvent.create(resource: params[:resource_type], id: params[:id], params: scim_params)
52
+ log_scim_operation("replace", "start")
53
+
54
+ # Upsert record (create or replace)
55
+ was_new =
56
+ if user_resource?
57
+ !TwoPercent::ScimUser.exists_by_scim_id?(params[:id])
58
+ else
59
+ !TwoPercent::ScimGroup.exists_by_scim_id?(params[:id])
60
+ end
19
61
 
20
- head :ok
62
+ record = upsert_scim_record(params[:id], scim_params)
63
+
64
+ # Reload with associations for domain event and response
65
+ record = reload_with_members(record)
66
+
67
+ # Publish appropriate domain event
68
+ if was_new
69
+ publish_created_event(record)
70
+ else
71
+ publish_updated_event(record)
72
+ end
73
+
74
+ log_scim_operation("replace", "complete", record.scim_id)
75
+
76
+ # RFC 7644: 201 Created (if new) or 200 OK (if replaced)
77
+ if was_new
78
+ response.headers["Location"] = scim_resource_url(record)
79
+ render json: record.to_scim_representation, status: :created
80
+ else
81
+ render json: record.to_scim_representation, status: :ok
82
+ end
21
83
  end
22
84
 
23
85
  def destroy
24
- TwoPercent::DeleteEvent.create(resource: params[:resource_type], id: params[:id])
86
+ log_scim_operation("delete", "start")
87
+
88
+ # Find and destroy record
89
+ record = find_scim_record(params[:id])
90
+ scim_id = record.scim_id
91
+
92
+ # Destroy record
93
+ if user_resource?
94
+ TwoPercent::ScimUser.destroy_by_scim_id(scim_id)
95
+ else
96
+ TwoPercent::ScimGroup.destroy_by_scim_id(scim_id)
97
+ end
25
98
 
26
- head :ok
99
+ # Publish domain delete event
100
+ publish_deleted_event(scim_id)
101
+
102
+ log_scim_operation("delete", "complete", scim_id)
103
+
104
+ # RFC 7644: 204 No Content
105
+ head :no_content
27
106
  end
28
107
 
29
108
  private
@@ -31,5 +110,118 @@ module TwoPercent
31
110
  def scim_params
32
111
  params.except(:controller, :action, :resource_type).as_json.with_indifferent_access
33
112
  end
113
+
114
+ def user_resource?
115
+ params[:resource_type] == "Users"
116
+ end
117
+
118
+ def group_resource?
119
+ %w[Groups Departments Territories Roles Titles].include?(params[:resource_type])
120
+ end
121
+
122
+ def persist_scim_record(scim_hash)
123
+ if user_resource?
124
+ TwoPercent::ScimUser.upsert_from_scim(scim_hash, correlation_id: @correlation_id)
125
+ elsif group_resource?
126
+ TwoPercent::ScimGroup.upsert_from_scim(params[:resource_type], scim_hash, correlation_id: @correlation_id)
127
+ else
128
+ raise ArgumentError, "Unknown resource type: #{params[:resource_type]}"
129
+ end
130
+ end
131
+
132
+ def find_scim_record(scim_id)
133
+ record =
134
+ if user_resource?
135
+ TwoPercent::ScimUser.find_by_scim_id(scim_id)
136
+ elsif group_resource?
137
+ TwoPercent::ScimGroup.find_by_scim_id(scim_id)
138
+ else
139
+ raise ArgumentError, "Unknown resource type: #{params[:resource_type]}"
140
+ end
141
+
142
+ raise ActiveRecord::RecordNotFound, "Resource \"#{scim_id}\" not found" unless record
143
+
144
+ record
145
+ end
146
+
147
+ def upsert_scim_record(scim_id, scim_hash)
148
+ # Ensure scim_id is in the hash
149
+ scim_hash_with_id = scim_hash.merge("id" => scim_id)
150
+ persist_scim_record(scim_hash_with_id)
151
+ end
152
+
153
+ def log_scim_operation(operation, stage, scim_id = nil)
154
+ log_data = {
155
+ correlation_id: @correlation_id,
156
+ operation: operation,
157
+ resource_type: params[:resource_type],
158
+ stage: stage,
159
+ service: "two_percent",
160
+ }
161
+ log_data[:scim_id] = scim_id if scim_id
162
+
163
+ Rails.logger.info(log_data.to_json)
164
+ end
165
+
166
+ # Domain event publishers
167
+ def publish_created_event(record)
168
+ if user_resource?
169
+ TwoPercent::Domain::Events::UserCreated.create(
170
+ user_attributes: record.to_domain_attributes,
171
+ correlation_id: @correlation_id
172
+ )
173
+ else
174
+ TwoPercent::Domain::Events::GroupCreated.create(
175
+ group_attributes: record.to_domain_attributes,
176
+ resource_type: params[:resource_type],
177
+ correlation_id: @correlation_id
178
+ )
179
+ end
180
+ end
181
+
182
+ def publish_updated_event(record)
183
+ if user_resource?
184
+ TwoPercent::Domain::Events::UserUpdated.create(
185
+ user_attributes: record.to_domain_attributes,
186
+ correlation_id: @correlation_id
187
+ )
188
+ else
189
+ TwoPercent::Domain::Events::GroupUpdated.create(
190
+ group_attributes: record.to_domain_attributes,
191
+ resource_type: params[:resource_type],
192
+ correlation_id: @correlation_id
193
+ )
194
+ end
195
+ end
196
+
197
+ def publish_deleted_event(scim_id)
198
+ if user_resource?
199
+ TwoPercent::Domain::Events::UserDeleted.create(
200
+ user_id: scim_id,
201
+ correlation_id: @correlation_id
202
+ )
203
+ else
204
+ TwoPercent::Domain::Events::GroupDeleted.create(
205
+ group_id: scim_id,
206
+ resource_type: params[:resource_type],
207
+ correlation_id: @correlation_id
208
+ )
209
+ end
210
+ end
211
+
212
+ # Generate SCIM resource URL for Location header (RFC 7644)
213
+ def scim_resource_url(record)
214
+ resource_type = user_resource? ? "Users" : params[:resource_type]
215
+ "#{request.base_url}/scim/#{resource_type}/#{record.scim_id}"
216
+ end
217
+
218
+ # Reload record with associations (users load groups, groups load members)
219
+ def reload_with_members(record)
220
+ if user_resource?
221
+ TwoPercent::ScimUser.includes(:scim_groups).find(record.id)
222
+ else
223
+ TwoPercent::ScimGroup.includes(:scim_users).find(record.id)
224
+ end
225
+ end
34
226
  end
35
227
  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 ID if not present (for POST/create 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