two_percent 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74667df371bad7a8aae259ac6337d7bc1fa5625fd26811ec376f15bc2fa0434b
4
- data.tar.gz: 0e75d901f0d746f04d1b154663e086c2e68659bd6076c73dc770f2e22d024c92
3
+ metadata.gz: add03c75e1bf340f38e607daf123b473aec2f2b87b5785dd5a0c0c4b5c29ff6f
4
+ data.tar.gz: f3dda93ee923b646d652be8882db284fa2df08187fe5516c9f57ff2e5a85ebbf
5
5
  SHA512:
6
- metadata.gz: 7373a151278578cc9bb7f709cf546412f1896b8f27f21f133f5c8660e9e32fb4b218b2426ad8c885265a933f96e4b4f669305330ea261ec3d451accb5adc0c4e
7
- data.tar.gz: d45edaefccd729706e618aedcbd9e1f95053541fafbe9f290d357a139eb270abbc44d5366eaa472ed7858c2c1b2c469759124a609da27de52b0e9c99d54715b6
6
+ metadata.gz: 4bccbd154aee0eb6cde90ab96efd4eb685f33b4f2a04f304b5e7aef64cc9750641820bf99c2e650fd83c462238dd022d4c9afbfc93bbeb57b9c1dd9a9c947122
7
+ data.tar.gz: 4fe6f68630610ea02f86e48b0265c9e3ddc6b6f0f7636ca0fedd961904760b72842d6410dd759ff17ed776d424f8de7120feeb4a009b94004da5f2a670d0a463
@@ -3,108 +3,137 @@
3
3
  module TwoPercent
4
4
  class ScimController < ApplicationController
5
5
  def create
6
- log_scim_operation("create", "start")
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
- # Persist to two_percent tables first (validates SCIM schema)
9
- record = persist_scim_record(scim_params)
10
+ # Reload with associations for domain event and response
11
+ record = reload_with_members(record)
10
12
 
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)
13
+ # Publish domain event (not SCIM-specific)
14
+ publish_created_event(record)
16
15
 
17
- log_scim_operation("create", "complete", record.scim_id)
16
+ record
17
+ end
18
18
 
19
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
20
+ render json: record.to_scim_representation, status: :created, location: scim_resource_url(record)
22
21
  end
23
22
 
24
23
  def update
25
- log_scim_operation("update", "start")
26
-
27
- # Find existing record
28
- record = find_scim_record(params[:id])
24
+ record = with_scim_logging("update") do
25
+ # Find existing record
26
+ record = find_scim_record(params[:id])
29
27
 
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)
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)
34
32
 
35
- # Persist patched data
36
- patched_data["id"] = params[:id] # Ensure ID is present
37
- updated_record = persist_scim_record(patched_data)
33
+ # Persist patched data
34
+ patched_data["id"] = params[:id] # Ensure ID is present
35
+ updated_record = persist_scim_record(patched_data)
38
36
 
39
- # Reload with associations for domain event and response
40
- updated_record = reload_with_members(updated_record)
37
+ # Reload with associations for domain event and response
38
+ updated_record = reload_with_members(updated_record)
41
39
 
42
- # Publish domain event with final state
43
- publish_updated_event(updated_record)
40
+ # Publish domain event with final state
41
+ publish_updated_event(updated_record)
44
42
 
45
- log_scim_operation("update", "complete", record.scim_id)
43
+ updated_record
44
+ end
46
45
 
47
46
  # RFC 7644: 200 OK with updated resource body
48
- render json: updated_record.to_scim_representation, status: :ok
47
+ render json: record.to_scim_representation, status: :ok
49
48
  end
50
49
 
51
50
  def replace
52
- log_scim_operation("replace", "start")
53
-
54
51
  # 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
52
+ was_new = !model_class.exists_by_scim_id?(params[:id])
61
53
 
62
- record = upsert_scim_record(params[:id], scim_params)
54
+ record = with_scim_logging("replace") do
55
+ record = upsert_scim_record(params[:id], scim_params)
63
56
 
64
- # Reload with associations for domain event and response
65
- record = reload_with_members(record)
57
+ # Reload with associations for domain event and response
58
+ record = reload_with_members(record)
66
59
 
67
- # Publish appropriate domain event
68
- if was_new
69
- publish_created_event(record)
70
- else
71
- publish_updated_event(record)
72
- end
60
+ # Publish appropriate domain event
61
+ if was_new
62
+ publish_created_event(record)
63
+ else
64
+ publish_updated_event(record)
65
+ end
73
66
 
74
- log_scim_operation("replace", "complete", record.scim_id)
67
+ record
68
+ end
75
69
 
76
70
  # RFC 7644: 201 Created (if new) or 200 OK (if replaced)
77
71
  if was_new
78
- response.headers["Location"] = scim_resource_url(record)
79
- render json: record.to_scim_representation, status: :created
72
+ render json: record.to_scim_representation, status: :created, location: scim_resource_url(record)
80
73
  else
81
74
  render json: record.to_scim_representation, status: :ok
82
75
  end
83
76
  end
84
77
 
85
78
  def destroy
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
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
91
83
 
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
84
+ # Destroy record
85
+ model_class.destroy_by_scim_id(scim_id)
98
86
 
99
- # Publish domain delete event
100
- publish_deleted_event(scim_id)
87
+ # Publish domain delete event
88
+ publish_deleted_event(scim_id)
101
89
 
102
- log_scim_operation("delete", "complete", scim_id)
90
+ scim_id
91
+ end
103
92
 
104
93
  # RFC 7644: 204 No Content
105
94
  head :no_content
106
95
  end
107
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])
103
+
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
135
+ end
136
+
108
137
  private
109
138
 
110
139
  def scim_params
@@ -116,7 +145,13 @@ module TwoPercent
116
145
  end
117
146
 
118
147
  def group_resource?
119
- %w[Groups Departments Territories Roles Titles].include?(params[:resource_type])
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]}"
120
155
  end
121
156
 
122
157
  def persist_scim_record(scim_hash)
@@ -130,15 +165,7 @@ module TwoPercent
130
165
  end
131
166
 
132
167
  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
-
168
+ record = model_class.find_by_scim_id(scim_id)
142
169
  raise ActiveRecord::RecordNotFound, "Resource \"#{scim_id}\" not found" unless record
143
170
 
144
171
  record
@@ -150,6 +177,14 @@ module TwoPercent
150
177
  persist_scim_record(scim_hash_with_id)
151
178
  end
152
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
+
153
188
  def log_scim_operation(operation, stage, scim_id = nil)
154
189
  log_data = {
155
190
  correlation_id: @correlation_id,
@@ -217,11 +252,65 @@ module TwoPercent
217
252
 
218
253
  # Reload record with associations (users load groups, groups load members)
219
254
  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
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
225
314
  end
226
315
  end
227
316
  end
@@ -30,7 +30,7 @@ module TwoPercent
30
30
  # @return [TwoPercent::ScimGroup] The persisted group record
31
31
  # @raise [TwoPercent::Scim::ValidationError] If SCIM data fails schema validation
32
32
  def self.upsert_from_scim(resource_type, scim_hash, correlation_id: nil)
33
- # Generate ID if not present (for POST/create operations)
33
+ # Generate SCIM id (stored as scim_id) if not provided (typical for POST operations)
34
34
  scim_hash = scim_hash.dup
35
35
  scim_hash["id"] ||= SecureRandom.uuid
36
36
 
@@ -26,7 +26,7 @@ module TwoPercent
26
26
  # @return [TwoPercent::ScimUser] The persisted user record
27
27
  # @raise [TwoPercent::Scim::ValidationError] If SCIM data fails schema validation
28
28
  def self.upsert_from_scim(scim_hash, correlation_id: nil)
29
- # Generate ID if not present (for POST/create operations)
29
+ # Generate SCIM id (stored as scim_id) if not provided (typical for POST operations)
30
30
  scim_hash = scim_hash.dup
31
31
  scim_hash["id"] ||= SecureRandom.uuid
32
32
 
data/config/routes.rb CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  TwoPercent::Engine.routes.draw do
4
4
  post "/Bulk" => "bulk#_dispatch"
5
+
6
+ # GET routes must come before POST to ensure proper precedence
7
+ get "/:resource_type/:id" => "scim#show"
8
+ get "/:resource_type" => "scim#index"
9
+
5
10
  post "/:resource_type" => "scim#create"
6
11
  patch "/:resource_type/:id" => "scim#update"
7
12
  put "/:resource_type/:id" => "scim#replace"
@@ -53,6 +53,26 @@ module TwoPercent
53
53
  #
54
54
  config_accessor :logger
55
55
 
56
+ #
57
+ # Group resource types that TwoPercent will accept and process.
58
+ # Defaults to ["Groups"] which is the standard SCIM 2.0 group resource type.
59
+ #
60
+ # To support additional company-specific group types (like Departments, Territories),
61
+ # add them to this array in your initializer:
62
+ #
63
+ # TwoPercent.configure do |config|
64
+ # config.group_resource_types = %w[Groups Departments Territories]
65
+ # end
66
+ #
67
+ # All configured types will:
68
+ # - Accept POST/PUT/PATCH/DELETE/GET operations at /scim/:resource_type
69
+ # - Store data in the same scim_groups table with resource_type column
70
+ # - Publish domain events with the resource_type included
71
+ #
72
+ config_accessor :group_resource_types do
73
+ %w[Groups]
74
+ end
75
+
56
76
  #
57
77
  # HTTP header name for correlation ID tracking
58
78
  # Defaults to "X-Correlation-Id" (common microservices pattern)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TwoPercent
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
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.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carlos Palhares