two_percent 1.2.0 → 1.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1f5dcfb8d7346d8f08d3b4a10b9d9e53f89a31717b03e202653deaaadedeb81
4
- data.tar.gz: 10345fe74093b552ef36c20710412fa84b0b826ffd6ea22803fb49eea73105f1
3
+ metadata.gz: 4ac252e6553e3c0c261a4040213a21c79a68676d11a5f62acd3b6400bc391f44
4
+ data.tar.gz: 4bd46dfb3b2017945cff1e9abca2549634f4d4d68facc8290a5e2345b4264ad1
5
5
  SHA512:
6
- metadata.gz: 596d23c098e8053360b2d5bf79f683f37cb0f476ca74ef50d3e6c45767871ebc178e8d5222cac35327c804a2b2ec8f6ce461035534c8bc8930c2079b0345bee0
7
- data.tar.gz: 4991c89a19e03a7c6ab431cc52e6cc6e629a8a3b462bf4d9b5e577a0ece8e30d35d4df7d6d84c01bed40ff939cfbdedddea8e448bebe305368b6cbbe40db119b
6
+ metadata.gz: b3b842f8a44b3b5c58ebc4d9597468a70c970a70550d3a3b64d67f54c88dc67484eb9b30599cc4274db5674584318535fc38a84060b930c25661044938407ef3
7
+ data.tar.gz: bfe00f93654f7848d59d43610d70cb018a93f65d13663988a066761fa8291e65014712b54306d7211dfb28fb0f3fe6ae50ca99faa13c71ec3faf75d64f7e630f
@@ -8,6 +8,7 @@ module TwoPercent
8
8
  rescue_from ActiveRecord::RecordNotFound, with: :handle_record_not_found
9
9
  rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
10
10
  rescue_from ArgumentError, with: :handle_bad_request
11
+ rescue_from TwoPercent::ReadOnlyAttributeError, with: :handle_read_only_attribute
11
12
 
12
13
  def authenticate
13
14
  result = instance_exec(&TwoPercent.config.authenticate)
@@ -54,6 +55,15 @@ module TwoPercent
54
55
  )
55
56
  end
56
57
 
58
+ def handle_read_only_attribute(exception)
59
+ # RFC 7644 Section 3.5.2: Read-only attribute modification attempt
60
+ render_scim_error(
61
+ status: :bad_request,
62
+ scim_type: "mutability",
63
+ detail: exception.message
64
+ )
65
+ end
66
+
57
67
  def extract_correlation_id
58
68
  header_name = TwoPercent.config.correlation_id_header
59
69
  @correlation_id = request.headers[header_name] || SecureRandom.uuid
@@ -2,6 +2,8 @@
2
2
 
3
3
  module TwoPercent
4
4
  class ScimController < ApplicationController
5
+ include TwoPercent::ValidatesUserGroupsPatch
6
+
5
7
  def create
6
8
  record = with_scim_logging("create") do
7
9
  # Persist to two_percent tables first (validates SCIM schema)
@@ -25,6 +27,14 @@ module TwoPercent
25
27
  # Find existing record
26
28
  record = find_scim_record(params[:id])
27
29
 
30
+ # Sync scim_data["members"] from join table for Groups to ensure data consistency
31
+ # Must happen BEFORE PatchProcessor reads scim_data to ensure PATCH operations
32
+ # are applied to current members, not stale/empty data
33
+ record.scim_data["members"] = record.members_for_patch if group_resource?
34
+
35
+ # Validate RFC 7643 read-only attributes before processing
36
+ validate_patch_operations!(params[:resource_type], scim_params) if user_resource?
37
+
28
38
  # Apply SCIM PATCH operations (RFC 7644 compliance)
29
39
  processor = TwoPercent::Scim::PatchProcessor.new(scim_params)
30
40
  current_scim_data = record.scim_data || {}
@@ -34,8 +44,10 @@ module TwoPercent
34
44
  patched_data["id"] = params[:id] # Ensure ID is present
35
45
  updated_record = persist_scim_record(patched_data)
36
46
 
37
- # Reload with associations for domain event and response
38
- updated_record = reload_with_members(updated_record)
47
+ # Reload associations for response
48
+ # Users: always reload (cheap - few groups per user)
49
+ # Groups: conditionally reload based on config (expensive - thousands of members)
50
+ updated_record = reload_with_members(updated_record) if user_resource? || should_reload_members?(updated_record)
39
51
 
40
52
  # Publish domain event with final state
41
53
  publish_updated_event(updated_record)
@@ -255,6 +267,16 @@ module TwoPercent
255
267
  model_class.includes(association_name).find(record.id)
256
268
  end
257
269
 
270
+ # Check if group members should be reloaded for PATCH response
271
+ # Only applies to groups - users always reload via separate check
272
+ # Skip reload if: members already loaded, or config disables it
273
+ def should_reload_members?(record)
274
+ return false unless group_resource?
275
+ return false if record.scim_users.loaded?
276
+
277
+ TwoPercent.config.include_members_in_patch_response
278
+ end
279
+
258
280
  # Build base query scope with optional filtering
259
281
  def build_query_scope
260
282
  base_scope = user_resource? ? model_class.all : model_class.where(resource_type: params[:resource_type])
@@ -10,7 +10,7 @@ module TwoPercent
10
10
  has_many :scim_users, through: :scim_group_memberships
11
11
 
12
12
  validates :scim_id, presence: true, uniqueness: true
13
- validates :external_id, presence: true
13
+ validates :external_id, presence: true, uniqueness: { scope: :resource_type }
14
14
  validates :display_name, presence: true
15
15
  validates :resource_type, presence: true
16
16
  validates :scim_data, presence: true
@@ -34,13 +34,21 @@ module TwoPercent
34
34
  scim_hash = scim_hash.dup
35
35
  scim_hash["id"] ||= SecureRandom.uuid
36
36
 
37
+ # Extract members before validation to prevent storage in scim_data JSONB
38
+ members = scim_hash.delete("members")
39
+
37
40
  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
 
41
- scim_group.replace_members(scim_hash["members"]) if scim_hash.key?("members")
42
+ # Wrap in transaction to ensure rollback on member validation failure
43
+ transaction do
44
+ scim_group = find_or_initialize_by(scim_id: scim_hash["id"])
45
+ scim_group.update_from_scim!(resource_type, validated_data, correlation_id: correlation_id)
46
+
47
+ # Sync members to join table only (never stored in scim_data)
48
+ scim_group.replace_members(members) if members
42
49
 
43
- scim_group
50
+ scim_group
51
+ end
44
52
  end
45
53
 
46
54
  def self.find_by_scim_id(scim_id)
@@ -58,6 +66,7 @@ module TwoPercent
58
66
  # Extracts domain attributes for publishing in domain events
59
67
  #
60
68
  # Returns key attributes for event payloads.
69
+ # Members are NOT included - consumers should query TwoPercent models directly for current state.
61
70
  # @return [Hash] Domain attributes
62
71
  def to_domain_attributes
63
72
  {
@@ -103,15 +112,21 @@ module TwoPercent
103
112
 
104
113
  def replace_members(members_array)
105
114
  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
115
 
109
- users_to_add = existing_users.where.not(id: existing_user_ids)
110
- bulk_insert_memberships(users_to_add) if users_to_add.any?
116
+ # Get current member scim_ids efficiently (just IDs, no full records)
117
+ current_member_scim_ids = scim_group_memberships
118
+ .joins(:scim_user)
119
+ .pluck("two_percent_scim_users.scim_id")
120
+
121
+ # Calculate diff in Ruby (cheap for ID arrays)
122
+ scim_ids_to_add = member_scim_ids - current_member_scim_ids
123
+ scim_ids_to_remove = current_member_scim_ids - member_scim_ids
111
124
 
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
125
+ # Only validate and add NEW members (not existing ones)
126
+ add_members_by_scim_id(scim_ids_to_add) if scim_ids_to_add.any?
127
+
128
+ # Only remove members that need removing
129
+ remove_members_by_scim_id(scim_ids_to_remove) if scim_ids_to_remove.any?
115
130
  end
116
131
 
117
132
  # Extracts a nested attribute from the scim_data JSON
@@ -125,30 +140,80 @@ module TwoPercent
125
140
  scim_data.dig(*keys)
126
141
  end
127
142
 
143
+ # Build SCIM members representation from join table
144
+ # Optimized to bypass ActiveRecord and load only needed columns
145
+ #
146
+ # @return [Array<Hash>] Array of member references
147
+ def members_representation
148
+ scim_group_memberships
149
+ .joins(:scim_user)
150
+ .pluck("two_percent_scim_users.scim_id", "two_percent_scim_users.display_name")
151
+ .map do |scim_id, display_name|
152
+ {
153
+ "value" => scim_id,
154
+ "display" => display_name,
155
+ "$ref" => "Users/#{scim_id}",
156
+ }
157
+ end
158
+ end
159
+
160
+ # Build members array with value field only (for PatchProcessor)
161
+ # Uses pluck to avoid loading full AR objects
162
+ #
163
+ # @return [Array<Hash>] Array of member values
164
+ def members_for_patch
165
+ scim_group_memberships
166
+ .joins(:scim_user)
167
+ .pluck("two_percent_scim_users.scim_id")
168
+ .map { |id| { "value" => id } }
169
+ end
170
+
128
171
  private
129
172
 
130
- # Validates that all user IDs exist in the database
173
+ # Add members by SCIM IDs, validating they exist
174
+ #
175
+ # @param scim_ids_to_add [Array<String>] SCIM IDs of users to add
176
+ def add_members_by_scim_id(scim_ids_to_add)
177
+ return if scim_ids_to_add.empty?
178
+
179
+ users_to_add = validate_and_fetch_users(scim_ids_to_add)
180
+ bulk_insert_new_memberships(users_to_add)
181
+ end
182
+
183
+ # Remove members by SCIM IDs (direct, no JOIN needed)
131
184
  #
132
- # @param member_scim_ids [Array<String>] Array of SCIM user IDs to validate
185
+ # @param scim_ids_to_remove [Array<String>] SCIM IDs of users to remove
186
+ def remove_members_by_scim_id(scim_ids_to_remove)
187
+ return if scim_ids_to_remove.empty?
188
+
189
+ # Direct delete using SCIM IDs via subquery
190
+ scim_group_memberships
191
+ .where(scim_user_id: TwoPercent::ScimUser.where(scim_id: scim_ids_to_remove).select(:id))
192
+ .delete_all
193
+ end
194
+
195
+ # Validate users exist and return them
196
+ #
197
+ # @param scim_ids [Array<String>] SCIM IDs to validate
133
198
  # @return [ActiveRecord::Relation] The existing users
134
199
  # @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)
200
+ def validate_and_fetch_users(scim_ids)
201
+ users = TwoPercent::ScimUser.where(scim_id: scim_ids)
202
+ missing_ids = scim_ids - users.pluck(:scim_id)
138
203
 
139
204
  if missing_ids.any?
140
205
  raise ArgumentError,
141
206
  "Cannot add non-existent users to group: #{missing_ids.join(', ')}"
142
207
  end
143
208
 
144
- existing_users
209
+ users
145
210
  end
146
211
 
147
- # Bulk insert memberships for performance
212
+ # Bulk insert memberships for the given users
148
213
  #
149
- # @param users_to_add [ActiveRecord::Relation] Users to add as members
150
- def bulk_insert_memberships(users_to_add)
151
- membership_records = users_to_add.pluck(:id).map do |user_id|
214
+ # @param users [ActiveRecord::Relation] Users to add as members
215
+ def bulk_insert_new_memberships(users)
216
+ membership_records = users.pluck(:id).map do |user_id|
152
217
  {
153
218
  scim_user_id: user_id,
154
219
  scim_group_id: id,
@@ -157,24 +222,10 @@ module TwoPercent
157
222
  }
158
223
  end
159
224
 
160
- # Skip duplicates (handles race conditions and migration scenarios)
161
225
  TwoPercent::ScimGroupMembership.insert_all(
162
226
  membership_records,
163
227
  unique_by: %i[scim_user_id scim_group_id]
164
228
  )
165
229
  end
166
-
167
- # Build SCIM members representation
168
- #
169
- # @return [Array<Hash>] Array of member references
170
- def members_representation
171
- scim_users.map do |user|
172
- {
173
- "value" => user.scim_id,
174
- "display" => user.display_name,
175
- "$ref" => "Users/#{user.scim_id}",
176
- }
177
- end
178
- end
179
230
  end
180
231
  end
@@ -10,7 +10,7 @@ module TwoPercent
10
10
  has_many :scim_groups, through: :scim_group_memberships
11
11
 
12
12
  validates :scim_id, presence: true, uniqueness: true
13
- validates :external_id, presence: true
13
+ validates :external_id, presence: true, uniqueness: true
14
14
  validates :scim_data, presence: true
15
15
 
16
16
  scope :active, -> { where(active: true) }
@@ -30,9 +30,13 @@ module TwoPercent
30
30
  scim_hash = scim_hash.dup
31
31
  scim_hash["id"] ||= SecureRandom.uuid
32
32
 
33
+ # Extract groups before validation to prevent storage in scim_data JSONB
34
+ # Groups are synced to join table only (single source of truth)
35
+ groups = scim_hash.delete("groups")
36
+
33
37
  validated_data = TwoPercent::Scim::Schema.validate_user(scim_hash, require_id: true)
34
38
  scim_user = find_or_initialize_by(scim_id: scim_hash["id"])
35
- scim_user.update_from_scim!(validated_data, correlation_id: correlation_id)
39
+ scim_user.update_from_scim!(validated_data, groups, correlation_id: correlation_id)
36
40
  scim_user
37
41
  end
38
42
 
@@ -50,29 +54,26 @@ module TwoPercent
50
54
 
51
55
  # Extracts domain attributes for publishing in domain events
52
56
  #
53
- # Returns key attributes for event payloads.
54
- # Includes associated group memberships if loaded.
57
+ # Returns key attributes for event payloads (thin events - no associations).
58
+ # Group memberships are queried separately by consumers who need them.
55
59
  #
56
60
  # @return [Hash] Domain attributes
57
61
  def to_domain_attributes
58
- attributes = {
62
+ {
59
63
  scim_id: scim_id,
60
64
  external_id: external_id,
61
65
  user_name: user_name,
62
66
  display_name: display_name,
63
67
  email: email,
64
68
  active: active,
65
- }
66
-
67
- attributes[:groups] = group_memberships_attributes if scim_groups.loaded? || scim_groups.any?
68
- attributes.compact
69
+ }.compact
69
70
  end
70
71
 
71
72
  # Returns full SCIM representation for HTTP responses
72
73
  #
73
74
  # @return [Hash] RFC 7644 compliant SCIM User resource
74
75
  def to_scim_representation
75
- scim_data.merge(
76
+ representation = scim_data.merge(
76
77
  "id" => scim_id,
77
78
  "meta" => {
78
79
  "resourceType" => "User",
@@ -80,9 +81,41 @@ module TwoPercent
80
81
  "lastModified" => updated_at.iso8601,
81
82
  }
82
83
  )
84
+
85
+ # Build groups dynamically from join table (RFC 7643: User.groups is read-only)
86
+ # Single source of truth is join table - only included when association is loaded
87
+ representation["groups"] = groups_representation if scim_groups.loaded?
88
+
89
+ representation
83
90
  end
84
91
 
85
- def update_from_scim!(validated_data, correlation_id: nil)
92
+ # Build SCIM groups representation dynamically from join table
93
+ # Per RFC 7643 Section 4.1.2, User.groups is read-only and must reflect Group memberships
94
+ #
95
+ # @return [Array<Hash>] Array of group references with SCIM-compliant attributes
96
+ def groups_representation
97
+ scim_groups.map do |group|
98
+ {
99
+ "value" => group.scim_id,
100
+ "display" => group.display_name,
101
+ "$ref" => "#{group.resource_type}/#{group.scim_id}",
102
+ "type" => group.resource_type,
103
+ }
104
+ end
105
+ end
106
+
107
+ # Updates user attributes from validated SCIM data
108
+ #
109
+ # NOTE: This method processes the User.groups array from the request payload
110
+ # and syncs group memberships, which is a deviation from strict RFC 7643 compliance
111
+ # (User.groups is specified as read-only in Section 4.1.2). This behavior is
112
+ # intentionally maintained for compatibility with existing SCIM clients that rely
113
+ # on this functionality for bulk sync operations.
114
+ #
115
+ # @param validated_data [Hash] Validated SCIM data with :core and :extensions keys
116
+ # @param groups_data [Array<Hash>, nil] Groups array extracted before validation
117
+ # @param correlation_id [String, nil] Optional correlation ID for tracking
118
+ def update_from_scim!(validated_data, groups_data, correlation_id: nil)
86
119
  core_data = validated_data[:core]
87
120
  self.scim_data = core_data.merge(validated_data[:extensions])
88
121
  self.scim_id = core_data["id"]
@@ -93,11 +126,21 @@ module TwoPercent
93
126
  self.active = core_data.fetch("active", true)
94
127
  self.correlation_id = correlation_id
95
128
  save!
96
- sync_groups(core_data["groups"]) if core_data["groups"]
129
+ # Sync groups to join table only (never stored in scim_data)
130
+ sync_groups(groups_data) if groups_data
97
131
  end
98
132
 
133
+ # Syncs user's group memberships from SCIM groups array
134
+ #
135
+ # Accepts a groups array from SCIM User payload and updates associations.
136
+ # This enables bulk sync operations where clients send User.groups arrays.
137
+ #
138
+ # @param groups_data [Array<Hash>] Array of group references with "value" (scim_id)
139
+ # @example
140
+ # sync_groups([{"value" => "group-123", "display" => "Engineering"}])
141
+ # sync_groups([]) # Clears all group memberships
99
142
  def sync_groups(groups_data)
100
- return if groups_data.blank?
143
+ return if groups_data.nil?
101
144
 
102
145
  group_ids = groups_data.filter_map { |g| g["value"] }
103
146
  groups = TwoPercent::ScimGroup.where(scim_id: group_ids)
@@ -18,28 +18,56 @@ module TwoPercent
18
18
  end
19
19
 
20
20
  def copy_migrations
21
+ copy_users_migration
22
+ copy_groups_migration
23
+ copy_memberships_migration
24
+ copy_users_index_migration
25
+ copy_groups_index_migration
26
+ end
27
+
28
+ def copy_initializer
29
+ template "two_percent.rb.erb", "config/initializers/two_percent.rb"
30
+ end
31
+
32
+ def show_readme
33
+ readme "INSTALL_README" if behavior == :invoke
34
+ end
35
+
36
+ private
37
+
38
+ def copy_users_migration
21
39
  migration_template(
22
40
  "create_two_percent_scim_users.rb.erb",
23
41
  "db/migrate/create_two_percent_scim_users.rb"
24
42
  )
43
+ end
25
44
 
45
+ def copy_groups_migration
26
46
  migration_template(
27
47
  "create_two_percent_scim_groups.rb.erb",
28
48
  "db/migrate/create_two_percent_scim_groups.rb"
29
49
  )
50
+ end
30
51
 
52
+ def copy_memberships_migration
31
53
  migration_template(
32
54
  "create_two_percent_scim_group_memberships.rb.erb",
33
55
  "db/migrate/create_two_percent_scim_group_memberships.rb"
34
56
  )
35
57
  end
36
58
 
37
- def copy_initializer
38
- template "two_percent.rb.erb", "config/initializers/two_percent.rb"
59
+ def copy_users_index_migration
60
+ migration_template(
61
+ "add_unique_index_to_scim_users_external_id.rb.erb",
62
+ "db/migrate/add_unique_index_to_scim_users_external_id.rb"
63
+ )
39
64
  end
40
65
 
41
- def show_readme
42
- readme "INSTALL_README" if behavior == :invoke
66
+ def copy_groups_index_migration
67
+ migration_template(
68
+ "add_unique_composite_index_to_scim_groups.rb.erb",
69
+ "db/migrate/add_unique_composite_index_to_scim_groups.rb"
70
+ )
43
71
  end
44
72
  end
45
73
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddUniqueCompositeIndexToScimGroups < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_index :two_percent_scim_groups,
6
+ [:resource_type, :external_id],
7
+ unique: true,
8
+ name: "index_scim_groups_on_resource_and_external_id",
9
+ if_not_exists: true
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddUniqueIndexToScimUsersExternalId < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_index :two_percent_scim_users, :external_id, unique: true, if_not_exists: true
6
+ end
7
+ end
@@ -2,8 +2,10 @@
2
2
 
3
3
  module TwoPercent
4
4
  class BulkProcessor
5
+ include TwoPercent::ValidatesUserGroupsPatch
6
+
5
7
  def initialize(operations, correlation_id: nil)
6
- @operations = operations
8
+ @operations = operations.map(&:with_indifferent_access)
7
9
  @correlation_id = correlation_id
8
10
  end
9
11
 
@@ -50,39 +52,25 @@ module TwoPercent
50
52
  end
51
53
 
52
54
  def persist_create(resource_type, data)
53
- if resource_type == "Users"
54
- TwoPercent::ScimUser.upsert_from_scim(data, correlation_id: @correlation_id)
55
- else
56
- TwoPercent::ScimGroup.upsert_from_scim(resource_type, data, correlation_id: @correlation_id)
57
- end
55
+ upsert_record(resource_type, data)
58
56
  end
59
57
 
60
58
  def persist_patch(resource_type, id, data)
61
- # PATCH - apply operations to existing resource
62
59
  record = find_record(resource_type, id)
60
+ validate_patch_operations!(resource_type, data) if resource_type == "Users"
61
+
62
+ current_scim_data = prepare_scim_data_for_patch(record, resource_type)
63
63
 
64
- # Apply SCIM PATCH operations (RFC 7644 compliance)
65
64
  processor = TwoPercent::Scim::PatchProcessor.new(data)
66
- current_scim_data = record.scim_data || {}
67
65
  patched_data = processor.apply_to_hash(current_scim_data)
66
+ patched_data["id"] = id
68
67
 
69
- # Persist patched data
70
- patched_data["id"] = id # Ensure ID is present
71
- if resource_type == "Users"
72
- TwoPercent::ScimUser.upsert_from_scim(patched_data, correlation_id: @correlation_id)
73
- else
74
- TwoPercent::ScimGroup.upsert_from_scim(resource_type, patched_data, correlation_id: @correlation_id)
75
- end
68
+ upsert_record(resource_type, patched_data)
76
69
  end
77
70
 
78
71
  def persist_update(resource_type, id, data)
79
- # PUT - replace entire resource
80
72
  data_with_id = data.merge("id" => id)
81
- if resource_type == "Users"
82
- TwoPercent::ScimUser.upsert_from_scim(data_with_id, correlation_id: @correlation_id)
83
- else
84
- TwoPercent::ScimGroup.upsert_from_scim(resource_type, data_with_id, correlation_id: @correlation_id)
85
- end
73
+ upsert_record(resource_type, data_with_id)
86
74
  end
87
75
 
88
76
  def persist_delete(resource_type, id)
@@ -149,6 +137,26 @@ module TwoPercent
149
137
  end
150
138
  end
151
139
 
140
+ def prepare_scim_data_for_patch(record, resource_type)
141
+ current_scim_data = record.scim_data || {}
142
+ return current_scim_data if resource_type == "Users"
143
+
144
+ # Sync scim_data["members"] from join table for Groups to ensure data consistency
145
+ # Must happen BEFORE PatchProcessor reads scim_data to ensure PATCH operations
146
+ # are applied to current members, not stale/empty data
147
+
148
+ current_scim_data["members"] = record.members_for_patch
149
+ current_scim_data
150
+ end
151
+
152
+ def upsert_record(resource_type, data)
153
+ if resource_type == "Users"
154
+ TwoPercent::ScimUser.upsert_from_scim(data, correlation_id: @correlation_id)
155
+ else
156
+ TwoPercent::ScimGroup.upsert_from_scim(resource_type, data, correlation_id: @correlation_id)
157
+ end
158
+ end
159
+
152
160
  def find_record(resource_type, scim_id)
153
161
  record =
154
162
  if resource_type == "Users"
@@ -86,5 +86,21 @@ module TwoPercent
86
86
  #
87
87
  config_accessor :correlation_id_header, default: "X-Correlation-Id"
88
88
 
89
+ #
90
+ # Performance optimization: Skip members in Group PATCH responses
91
+ # RFC 7644 compliance (PATCH responses SHOULD return full resource)
92
+ #
93
+ # For Groups with large member lists, loading members can impact performance.
94
+ # Set to false to skip member loading in Group PATCH responses only:
95
+ #
96
+ # TwoPercent.configure do |config|
97
+ # config.include_members_in_patch_response = false
98
+ # end
99
+ #
100
+ # User PATCH responses always include groups. GET requests always include
101
+ # members/groups regardless of this setting.
102
+ #
103
+ config_accessor :include_members_in_patch_response, default: true
104
+
89
105
  class ConfigurationError < StandardError; end
90
106
  end
@@ -21,7 +21,7 @@ module TwoPercent
21
21
  when "replace"
22
22
  apply_replace(result, operation[:path], operation[:value])
23
23
  when "remove"
24
- apply_remove(result, operation[:path])
24
+ apply_remove(result, operation[:path], operation[:value])
25
25
  else
26
26
  raise ArgumentError, "Unknown PATCH operation: #{operation[:op]}"
27
27
  end
@@ -90,17 +90,26 @@ module TwoPercent
90
90
  end
91
91
  end
92
92
 
93
- def apply_remove(hash, path)
93
+ def apply_remove(hash, path, value = nil)
94
94
  return if path.nil? || path.empty?
95
95
 
96
96
  keys = path.split(".")
97
97
  target = navigate_to_parent(hash, keys[0..-2])
98
98
  last_key = keys.last
99
99
 
100
- # Special handling for members array - set to empty array instead of deleting
101
- # This ensures upsert_from_scim can sync memberships to empty state
100
+ # Special handling for members array on Group resources
102
101
  if last_key == "members"
103
- target[last_key] = []
102
+ if value.nil? || (value.is_a?(Array) && value.empty?)
103
+ # No value or empty array means remove all
104
+ target[last_key] = []
105
+ elsif target[last_key].is_a?(Array)
106
+ # Value provided: remove specific items by filtering
107
+ values_to_remove = Array(value).filter_map { |v| v["value"] || v[:value] }
108
+ target[last_key] = target[last_key].reject do |item|
109
+ item_value = item["value"] || item[:value]
110
+ values_to_remove.include?(item_value)
111
+ end
112
+ end
104
113
  else
105
114
  target.delete(last_key)
106
115
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoPercent
4
+ # Validates PATCH operations against RFC 7643 read-only attributes
5
+ #
6
+ # Ensures User.groups attribute cannot be modified via PATCH operations,
7
+ # as per RFC 7643 Section 4.1.2. Group memberships must be managed via
8
+ # PATCH operations on the Group resource itself.
9
+ module ValidatesUserGroupsPatch
10
+ # Validate PATCH operations against RFC 7643 read-only attributes
11
+ # @param resource_type [String] The SCIM resource type (e.g., "Users", "Groups")
12
+ # @param patch_request [Hash] The PATCH request payload
13
+ # @raise [TwoPercent::ReadOnlyAttributeError] if attempting to modify read-only User.groups
14
+ def validate_patch_operations!(resource_type, patch_request)
15
+ return unless resource_type == "Users"
16
+
17
+ patch_request = patch_request.with_indifferent_access
18
+ operations = patch_request[:Operations]
19
+ return unless operations.is_a?(Array)
20
+
21
+ operations.each { |operation| validate_operation_path!(operation.with_indifferent_access) }
22
+ end
23
+
24
+ private
25
+
26
+ # Validate a single PATCH operation path for read-only attributes
27
+ # @param operation [Hash] A single PATCH operation
28
+ # @raise [TwoPercent::ReadOnlyAttributeError] if attempting to modify User.groups
29
+ def validate_operation_path!(operation)
30
+ path = operation[:path]
31
+ value = operation[:value]
32
+
33
+ # Check path-based operations (e.g., {op: "add", path: "groups", value: [...]})
34
+ if path
35
+ base_path = path.split(/[.\[]/).first
36
+ raise_groups_read_only_error if base_path == "groups"
37
+ end
38
+
39
+ # Check pathless operations (e.g., {op: "replace", value: {active: true, groups: [...]}})
40
+ return unless value.is_a?(Hash)
41
+
42
+ value = value.with_indifferent_access
43
+ raise_groups_read_only_error if value[:groups]
44
+ end
45
+
46
+ def raise_groups_read_only_error
47
+ # RFC 7643 Section 4.1.2: User.groups is read-only
48
+ # RFC 7644 Section 3.5.2: Return 400 with scimType="mutability"
49
+ raise TwoPercent::ReadOnlyAttributeError,
50
+ "Attribute 'groups' is read-only per SCIM RFC 7643. Manage group membership via PATCH /scim/Groups/{id}"
51
+ end
52
+ end
53
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TwoPercent
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/two_percent.rb CHANGED
@@ -5,11 +5,14 @@ require "aether_observatory"
5
5
  require "two_percent/version"
6
6
  require "two_percent/configuration"
7
7
  require "two_percent/domain"
8
+ require "two_percent/validates_user_groups_patch"
8
9
  require "two_percent/bulk_processor"
9
10
  require "two_percent/scim"
10
11
  require "two_percent/syncable"
11
-
12
12
  module TwoPercent
13
+ # Custom exception for SCIM RFC 7643 read-only attribute violations
14
+ class ReadOnlyAttributeError < StandardError; end
15
+
13
16
  # Logger used by TwoPercent. Defaults to Rails.logger
14
17
  def self.logger
15
18
  config.logger || Rails.logger
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: two_percent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carlos Palhares
@@ -63,6 +63,8 @@ files:
63
63
  - config/routes.rb
64
64
  - lib/generators/two_percent/install/install_generator.rb
65
65
  - lib/generators/two_percent/install/templates/INSTALL_README
66
+ - lib/generators/two_percent/install/templates/add_unique_composite_index_to_scim_groups.rb.erb
67
+ - lib/generators/two_percent/install/templates/add_unique_index_to_scim_users_external_id.rb.erb
66
68
  - lib/generators/two_percent/install/templates/create_two_percent_scim_group_memberships.rb.erb
67
69
  - lib/generators/two_percent/install/templates/create_two_percent_scim_groups.rb.erb
68
70
  - lib/generators/two_percent/install/templates/create_two_percent_scim_users.rb.erb
@@ -81,6 +83,7 @@ files:
81
83
  - lib/two_percent/scim/patch_processor.rb
82
84
  - lib/two_percent/scim/schema.rb
83
85
  - lib/two_percent/syncable.rb
86
+ - lib/two_percent/validates_user_groups_patch.rb
84
87
  - lib/two_percent/version.rb
85
88
  homepage: https://github.com/powerhome/power-tools
86
89
  licenses: