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 +4 -4
- data/app/controllers/two_percent/scim_controller.rb +166 -77
- data/app/models/two_percent/scim_group.rb +1 -1
- data/app/models/two_percent/scim_user.rb +1 -1
- data/config/routes.rb +5 -0
- data/lib/two_percent/configuration.rb +20 -0
- data/lib/two_percent/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: add03c75e1bf340f38e607daf123b473aec2f2b87b5785dd5a0c0c4b5c29ff6f
|
|
4
|
+
data.tar.gz: f3dda93ee923b646d652be8882db284fa2df08187fe5516c9f57ff2e5a85ebbf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
+
# Reload with associations for domain event and response
|
|
11
|
+
record = reload_with_members(record)
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
+
record
|
|
17
|
+
end
|
|
18
18
|
|
|
19
19
|
# RFC 7644: 201 Created with Location header and resource body
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
37
|
+
# Reload with associations for domain event and response
|
|
38
|
+
updated_record = reload_with_members(updated_record)
|
|
41
39
|
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
# Publish domain event with final state
|
|
41
|
+
publish_updated_event(updated_record)
|
|
44
42
|
|
|
45
|
-
|
|
43
|
+
updated_record
|
|
44
|
+
end
|
|
46
45
|
|
|
47
46
|
# RFC 7644: 200 OK with updated resource body
|
|
48
|
-
render json:
|
|
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 =
|
|
54
|
+
record = with_scim_logging("replace") do
|
|
55
|
+
record = upsert_scim_record(params[:id], scim_params)
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
# Reload with associations for domain event and response
|
|
58
|
+
record = reload_with_members(record)
|
|
66
59
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
87
|
+
# Publish domain delete event
|
|
88
|
+
publish_deleted_event(scim_id)
|
|
101
89
|
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
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)
|
data/lib/two_percent/version.rb
CHANGED