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 +4 -4
- data/app/controllers/two_percent/application_controller.rb +10 -0
- data/app/controllers/two_percent/scim_controller.rb +24 -2
- data/app/models/two_percent/scim_group.rb +87 -36
- data/app/models/two_percent/scim_user.rb +56 -13
- data/lib/generators/two_percent/install/install_generator.rb +32 -4
- data/lib/generators/two_percent/install/templates/add_unique_composite_index_to_scim_groups.rb.erb +11 -0
- data/lib/generators/two_percent/install/templates/add_unique_index_to_scim_users_external_id.rb.erb +7 -0
- data/lib/two_percent/bulk_processor.rb +30 -22
- data/lib/two_percent/configuration.rb +16 -0
- data/lib/two_percent/scim/patch_processor.rb +14 -5
- data/lib/two_percent/validates_user_groups_patch.rb +53 -0
- data/lib/two_percent/version.rb +1 -1
- data/lib/two_percent.rb +4 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ac252e6553e3c0c261a4040213a21c79a68676d11a5f62acd3b6400bc391f44
|
|
4
|
+
data.tar.gz: 4bd46dfb3b2017945cff1e9abca2549634f4d4d68facc8290a5e2345b4264ad1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
136
|
-
|
|
137
|
-
missing_ids =
|
|
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
|
-
|
|
209
|
+
users
|
|
145
210
|
end
|
|
146
211
|
|
|
147
|
-
# Bulk insert memberships for
|
|
212
|
+
# Bulk insert memberships for the given users
|
|
148
213
|
#
|
|
149
|
-
# @param
|
|
150
|
-
def
|
|
151
|
-
membership_records =
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
38
|
-
|
|
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
|
|
42
|
-
|
|
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
|
data/lib/generators/two_percent/install/templates/add_unique_composite_index_to_scim_groups.rb.erb
ADDED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/two_percent/version.rb
CHANGED
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.
|
|
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:
|