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.
- checksums.yaml +4 -4
- data/Rakefile +5 -1
- data/app/controllers/two_percent/application_controller.rb +79 -1
- data/app/controllers/two_percent/bulk_controller.rb +18 -2
- data/app/controllers/two_percent/scim_controller.rb +200 -8
- data/app/models/two_percent/application_record.rb +7 -0
- data/app/models/two_percent/scim_group.rb +182 -0
- data/app/models/two_percent/scim_group_membership.rb +29 -0
- data/app/models/two_percent/scim_user.rb +138 -0
- data/lib/generators/two_percent/install/install_generator.rb +46 -0
- data/lib/generators/two_percent/install/templates/INSTALL_README +84 -0
- data/lib/generators/two_percent/install/templates/create_two_percent_scim_group_memberships.rb.erb +20 -0
- data/lib/generators/two_percent/install/templates/create_two_percent_scim_groups.rb.erb +24 -0
- data/lib/generators/two_percent/install/templates/create_two_percent_scim_users.rb.erb +25 -0
- data/lib/generators/two_percent/install/templates/two_percent.rb.erb +85 -0
- data/lib/two_percent/bulk_processor.rb +145 -5
- data/lib/two_percent/configuration.rb +15 -0
- data/lib/two_percent/domain/events/base_event.rb +27 -0
- data/lib/two_percent/domain/events/group_events.rb +54 -0
- data/lib/two_percent/domain/events/user_events.rb +51 -0
- data/lib/two_percent/domain/events.rb +16 -0
- data/lib/two_percent/domain.rb +8 -0
- data/lib/two_percent/scim/patch_processor.rb +119 -0
- data/lib/two_percent/scim/schema.rb +152 -0
- data/lib/two_percent/scim.rb +8 -0
- data/lib/two_percent/syncable.rb +198 -0
- data/lib/two_percent/version.rb +1 -1
- data/lib/two_percent.rb +3 -1
- metadata +27 -16
- data/app/events/two_percent/application_event.rb +0 -7
- data/app/events/two_percent/create_event.rb +0 -11
- data/app/events/two_percent/delete_event.rb +0 -11
- data/app/events/two_percent/replace_event.rb +0 -12
- data/app/events/two_percent/update_event.rb +0 -12
- data/lib/two_percent/event_handler.rb +0 -26
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74667df371bad7a8aae259ac6337d7bc1fa5625fd26811ec376f15bc2fa0434b
|
|
4
|
+
data.tar.gz: 0e75d901f0d746f04d1b154663e086c2e68659bd6076c73dc770f2e22d024c92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
6
|
+
log_scim_operation("create", "start")
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|