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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TwoPercent
|
|
4
|
+
class ScimUser < ApplicationRecord
|
|
5
|
+
self.table_name = "two_percent_scim_users"
|
|
6
|
+
serialize :scim_data, coder: JSON
|
|
7
|
+
|
|
8
|
+
has_many :scim_group_memberships, class_name: "TwoPercent::ScimGroupMembership",
|
|
9
|
+
foreign_key: :scim_user_id, dependent: :destroy
|
|
10
|
+
has_many :scim_groups, through: :scim_group_memberships
|
|
11
|
+
|
|
12
|
+
validates :scim_id, presence: true, uniqueness: true
|
|
13
|
+
validates :external_id, presence: true
|
|
14
|
+
validates :scim_data, presence: true
|
|
15
|
+
|
|
16
|
+
scope :active, -> { where(active: true) }
|
|
17
|
+
|
|
18
|
+
# Creates or updates a user from SCIM data
|
|
19
|
+
#
|
|
20
|
+
# Generates a UUID for the id field if not present (for POST/create operations).
|
|
21
|
+
# Validates the SCIM data against the User schema before persisting.
|
|
22
|
+
#
|
|
23
|
+
# @param scim_hash [Hash] SCIM User resource hash conforming to RFC 7643
|
|
24
|
+
# @param correlation_id [String, nil] Optional correlation ID for tracking
|
|
25
|
+
# changes across network hops (e.g., App A -> App B -> App C)
|
|
26
|
+
# @return [TwoPercent::ScimUser] The persisted user record
|
|
27
|
+
# @raise [TwoPercent::Scim::ValidationError] If SCIM data fails schema validation
|
|
28
|
+
def self.upsert_from_scim(scim_hash, correlation_id: nil)
|
|
29
|
+
# Generate ID if not present (for POST/create operations)
|
|
30
|
+
scim_hash = scim_hash.dup
|
|
31
|
+
scim_hash["id"] ||= SecureRandom.uuid
|
|
32
|
+
|
|
33
|
+
validated_data = TwoPercent::Scim::Schema.validate_user(scim_hash, require_id: true)
|
|
34
|
+
scim_user = find_or_initialize_by(scim_id: scim_hash["id"])
|
|
35
|
+
scim_user.update_from_scim!(validated_data, correlation_id: correlation_id)
|
|
36
|
+
scim_user
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.find_by_scim_id(scim_id)
|
|
40
|
+
find_by(scim_id: scim_id)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.exists_by_scim_id?(scim_id)
|
|
44
|
+
exists?(scim_id: scim_id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.destroy_by_scim_id(scim_id)
|
|
48
|
+
find_by_scim_id(scim_id)&.destroy
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Extracts domain attributes for publishing in domain events
|
|
52
|
+
#
|
|
53
|
+
# Returns key attributes for event payloads.
|
|
54
|
+
# Includes associated group memberships if loaded.
|
|
55
|
+
#
|
|
56
|
+
# @return [Hash] Domain attributes
|
|
57
|
+
def to_domain_attributes
|
|
58
|
+
attributes = {
|
|
59
|
+
scim_id: scim_id,
|
|
60
|
+
external_id: external_id,
|
|
61
|
+
user_name: user_name,
|
|
62
|
+
display_name: display_name,
|
|
63
|
+
email: email,
|
|
64
|
+
active: active,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
attributes[:groups] = group_memberships_attributes if scim_groups.loaded? || scim_groups.any?
|
|
68
|
+
attributes.compact
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns full SCIM representation for HTTP responses
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] RFC 7644 compliant SCIM User resource
|
|
74
|
+
def to_scim_representation
|
|
75
|
+
scim_data.merge(
|
|
76
|
+
"id" => scim_id,
|
|
77
|
+
"meta" => {
|
|
78
|
+
"resourceType" => "User",
|
|
79
|
+
"created" => created_at.iso8601,
|
|
80
|
+
"lastModified" => updated_at.iso8601,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def update_from_scim!(validated_data, correlation_id: nil)
|
|
86
|
+
core_data = validated_data[:core]
|
|
87
|
+
self.scim_data = core_data.merge(validated_data[:extensions])
|
|
88
|
+
self.scim_id = core_data["id"]
|
|
89
|
+
self.external_id = core_data["externalId"]
|
|
90
|
+
self.user_name = core_data["userName"]
|
|
91
|
+
self.display_name = core_data["displayName"]
|
|
92
|
+
self.email = core_data.dig("emails", 0, "value")
|
|
93
|
+
self.active = core_data.fetch("active", true)
|
|
94
|
+
self.correlation_id = correlation_id
|
|
95
|
+
save!
|
|
96
|
+
sync_groups(core_data["groups"]) if core_data["groups"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sync_groups(groups_data)
|
|
100
|
+
return if groups_data.blank?
|
|
101
|
+
|
|
102
|
+
group_ids = groups_data.filter_map { |g| g["value"] }
|
|
103
|
+
groups = TwoPercent::ScimGroup.where(scim_id: group_ids)
|
|
104
|
+
self.scim_groups = groups
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Extracts a nested attribute from the scim_data JSON
|
|
108
|
+
#
|
|
109
|
+
# @param path [String] Dot-separated path to the attribute (e.g., "name.givenName")
|
|
110
|
+
# @return [Object, nil] The attribute value or nil if not found
|
|
111
|
+
# @example
|
|
112
|
+
# user.scim_attribute("emails.0.value") # => "user@example.com"
|
|
113
|
+
def scim_attribute(path)
|
|
114
|
+
keys = path.split(".")
|
|
115
|
+
scim_data.dig(*keys)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extension_attributes(schema_urn = nil)
|
|
119
|
+
if schema_urn
|
|
120
|
+
scim_data[schema_urn] || {}
|
|
121
|
+
else
|
|
122
|
+
scim_data.select { |k, _| k.start_with?("urn:ietf:params:scim:schemas:extension:") }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def group_memberships_attributes
|
|
129
|
+
scim_groups.map do |group|
|
|
130
|
+
{
|
|
131
|
+
scim_id: group.scim_id,
|
|
132
|
+
display_name: group.display_name,
|
|
133
|
+
resource_type: group.resource_type,
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
|
|
7
|
+
module TwoPercent
|
|
8
|
+
module Generators
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
desc "Installs TwoPercent SCIM integration with migrations and initializer"
|
|
15
|
+
|
|
16
|
+
def self.next_migration_number(path)
|
|
17
|
+
ActiveRecord::Generators::Base.next_migration_number(path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def copy_migrations
|
|
21
|
+
migration_template(
|
|
22
|
+
"create_two_percent_scim_users.rb.erb",
|
|
23
|
+
"db/migrate/create_two_percent_scim_users.rb"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
migration_template(
|
|
27
|
+
"create_two_percent_scim_groups.rb.erb",
|
|
28
|
+
"db/migrate/create_two_percent_scim_groups.rb"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
migration_template(
|
|
32
|
+
"create_two_percent_scim_group_memberships.rb.erb",
|
|
33
|
+
"db/migrate/create_two_percent_scim_group_memberships.rb"
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def copy_initializer
|
|
38
|
+
template "two_percent.rb.erb", "config/initializers/two_percent.rb"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def show_readme
|
|
42
|
+
readme "INSTALL_README" if behavior == :invoke
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
TwoPercent SCIM Integration Installed Successfully!
|
|
3
|
+
===============================================================================
|
|
4
|
+
|
|
5
|
+
Next Steps:
|
|
6
|
+
|
|
7
|
+
1. Run migrations to create SCIM tables:
|
|
8
|
+
|
|
9
|
+
rails db:migrate
|
|
10
|
+
|
|
11
|
+
2. Configure authentication in config/initializers/two_percent.rb:
|
|
12
|
+
|
|
13
|
+
Replace the NotImplementedError with your authentication logic
|
|
14
|
+
(bearer token, BasicAuth, API key, etc.)
|
|
15
|
+
|
|
16
|
+
3. Mount SCIM routes in config/routes.rb:
|
|
17
|
+
|
|
18
|
+
mount TwoPercent::Engine => "/scim"
|
|
19
|
+
|
|
20
|
+
Or mount at a custom path:
|
|
21
|
+
mount TwoPercent::Engine => "/api/scim/v2"
|
|
22
|
+
|
|
23
|
+
4. Subscribe to domain events:
|
|
24
|
+
|
|
25
|
+
TwoPercent publishes domain events when SCIM resources change:
|
|
26
|
+
|
|
27
|
+
- TwoPercent::Domain::Events::UserCreated
|
|
28
|
+
- TwoPercent::Domain::Events::UserUpdated
|
|
29
|
+
- TwoPercent::Domain::Events::UserDeleted
|
|
30
|
+
- TwoPercent::Domain::Events::GroupCreated
|
|
31
|
+
- TwoPercent::Domain::Events::GroupUpdated
|
|
32
|
+
- TwoPercent::Domain::Events::GroupDeleted
|
|
33
|
+
|
|
34
|
+
Subscribe using ActiveSupport::Notifications or integrate using the
|
|
35
|
+
Syncable concern (see step 5)
|
|
36
|
+
|
|
37
|
+
5. (Option A) Subscribe to events manually:
|
|
38
|
+
|
|
39
|
+
ActiveSupport::Notifications.subscribe(/TwoPercent::Domain::Events/) do |name, start, finish, id, payload|
|
|
40
|
+
event = payload[:event]
|
|
41
|
+
case event
|
|
42
|
+
when TwoPercent::Domain::Events::UserCreated
|
|
43
|
+
User.create!(scim_id: event.user_attributes[:scim_id], ...)
|
|
44
|
+
when TwoPercent::Domain::Events::UserUpdated
|
|
45
|
+
user = User.find_by(scim_id: event.user_attributes[:scim_id])
|
|
46
|
+
user&.update!(event.user_attributes.slice(...))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
6. (Option B) Use Syncable concern for automatic sync:
|
|
51
|
+
|
|
52
|
+
class User < ApplicationRecord
|
|
53
|
+
include TwoPercent::Syncable
|
|
54
|
+
|
|
55
|
+
syncable_as :user, scim_id_column: :scim_id do |scim_attrs|
|
|
56
|
+
{
|
|
57
|
+
first_name: scim_attrs.dig(:name, :givenName),
|
|
58
|
+
last_name: scim_attrs.dig(:name, :familyName),
|
|
59
|
+
email: scim_attrs[:email],
|
|
60
|
+
active: scim_attrs[:active]
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Then subscribe to events:
|
|
66
|
+
ActiveSupport::Notifications.subscribe(/TwoPercent::Domain::Events/) do |name, start, finish, id, payload|
|
|
67
|
+
event = payload[:event]
|
|
68
|
+
User.sync_from_scim_event(event) if event.is_a?(TwoPercent::Domain::Events::Base)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
7. (Option C) Query SCIM models directly:
|
|
72
|
+
|
|
73
|
+
scim_user = TwoPercent::ScimUser.find_by_scim_id("user-123")
|
|
74
|
+
attrs = scim_user.to_domain_attributes
|
|
75
|
+
email = scim_user.scim_data["email"]
|
|
76
|
+
|
|
77
|
+
===============================================================================
|
|
78
|
+
|
|
79
|
+
For detailed documentation and examples, see:
|
|
80
|
+
https://github.com/powerhome/power-tools/tree/main/packages/two_percent
|
|
81
|
+
|
|
82
|
+
Questions or issues? Open an issue on GitHub.
|
|
83
|
+
|
|
84
|
+
===============================================================================
|
data/lib/generators/two_percent/install/templates/create_two_percent_scim_group_memberships.rb.erb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateTwoPercentScimGroupMemberships < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :two_percent_scim_group_memberships do |t|
|
|
6
|
+
t.integer :scim_user_id, null: false
|
|
7
|
+
t.integer :scim_group_id, null: false
|
|
8
|
+
t.string :correlation_id
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
|
|
12
|
+
t.index :scim_user_id
|
|
13
|
+
t.index :scim_group_id
|
|
14
|
+
t.index [:scim_user_id, :scim_group_id], unique: true, name: "index_scim_memberships_on_user_and_group"
|
|
15
|
+
t.index :correlation_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Note: No explicit foreign keys for Percona/MySQL compatibility
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateTwoPercentScimGroups < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :two_percent_scim_groups do |t|
|
|
6
|
+
t.string :scim_id, null: false
|
|
7
|
+
t.string :external_id, null: false
|
|
8
|
+
t.string :display_name, null: false
|
|
9
|
+
t.string :resource_type, null: false
|
|
10
|
+
t.boolean :active, default: true
|
|
11
|
+
t.text :scim_data, limit: 16_777_215 # MEDIUMTEXT for MySQL
|
|
12
|
+
t.string :correlation_id
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
|
|
16
|
+
t.index :scim_id, unique: true
|
|
17
|
+
t.index :external_id
|
|
18
|
+
t.index [:resource_type, :external_id], name: "index_scim_groups_on_resource_and_external_id"
|
|
19
|
+
t.index :resource_type
|
|
20
|
+
t.index :active
|
|
21
|
+
t.index :correlation_id
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateTwoPercentScimUsers < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :two_percent_scim_users do |t|
|
|
6
|
+
t.string :scim_id, null: false
|
|
7
|
+
t.string :external_id, null: false
|
|
8
|
+
t.string :user_name
|
|
9
|
+
t.string :display_name
|
|
10
|
+
t.string :email
|
|
11
|
+
t.boolean :active, default: true
|
|
12
|
+
t.text :scim_data, limit: 16_777_215 # MEDIUMTEXT for MySQL
|
|
13
|
+
t.string :correlation_id
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
|
|
17
|
+
t.index :scim_id, unique: true
|
|
18
|
+
t.index :external_id
|
|
19
|
+
t.index :user_name
|
|
20
|
+
t.index :email
|
|
21
|
+
t.index :active
|
|
22
|
+
t.index :correlation_id
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Rails.application.config.to_prepare do
|
|
4
|
+
TwoPercent.configure do |config|
|
|
5
|
+
# ============================================================
|
|
6
|
+
# Authentication (Required)
|
|
7
|
+
# ============================================================
|
|
8
|
+
# Define how to authenticate SCIM requests
|
|
9
|
+
# This should return truthy value for authenticated requests
|
|
10
|
+
#
|
|
11
|
+
# Example with bearer token:
|
|
12
|
+
# config.authenticate = ->(request) do
|
|
13
|
+
# token = request.headers["Authorization"]&.remove("Bearer ")
|
|
14
|
+
# token == Rails.application.credentials.scim_token
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Example with BasicAuth:
|
|
18
|
+
# config.authenticate = ->(request) do
|
|
19
|
+
# ActionController::HttpAuthentication::Basic.authenticate(request) do |username, password|
|
|
20
|
+
# username == "scim_user" && password == Rails.application.credentials.scim_password
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
config.authenticate = ->(request) do
|
|
25
|
+
# TODO: Implement your authentication logic here
|
|
26
|
+
# Return true for authenticated requests, false otherwise
|
|
27
|
+
raise NotImplementedError, "TwoPercent authentication not configured"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ============================================================
|
|
31
|
+
# Integration with Your Application
|
|
32
|
+
# ============================================================
|
|
33
|
+
# TwoPercent publishes domain events when SCIM resources change.
|
|
34
|
+
# Choose your integration approach:
|
|
35
|
+
#
|
|
36
|
+
# Option 1: Subscribe to domain events (recommended)
|
|
37
|
+
# ActiveSupport::Notifications.subscribe(/TwoPercent::Domain::Events/) do |name, start, finish, id, payload|
|
|
38
|
+
# event = payload[:event]
|
|
39
|
+
# case event
|
|
40
|
+
# when TwoPercent::Domain::Events::UserCreated
|
|
41
|
+
# User.create!(
|
|
42
|
+
# scim_id: event.user_attributes[:scim_id],
|
|
43
|
+
# email: event.user_attributes[:email],
|
|
44
|
+
# first_name: event.user_attributes.dig(:name, :givenName)
|
|
45
|
+
# )
|
|
46
|
+
# when TwoPercent::Domain::Events::UserUpdated
|
|
47
|
+
# user = User.find_by(scim_id: event.user_attributes[:scim_id])
|
|
48
|
+
# user&.update!(email: event.user_attributes[:email])
|
|
49
|
+
# end
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# Option 2: Use the Syncable concern (declarative)
|
|
53
|
+
# class User < ApplicationRecord
|
|
54
|
+
# include TwoPercent::Syncable
|
|
55
|
+
#
|
|
56
|
+
# syncable_as :user, scim_id_column: :scim_id do |scim_attrs|
|
|
57
|
+
# {
|
|
58
|
+
# first_name: scim_attrs.dig(:name, :givenName),
|
|
59
|
+
# last_name: scim_attrs.dig(:name, :familyName),
|
|
60
|
+
# email: scim_attrs[:email],
|
|
61
|
+
# active: scim_attrs[:active]
|
|
62
|
+
# }
|
|
63
|
+
# end
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
# # Then subscribe to events and sync automatically:
|
|
67
|
+
# ActiveSupport::Notifications.subscribe(/TwoPercent::Domain::Events/) do |name, start, finish, id, payload|
|
|
68
|
+
# event = payload[:event]
|
|
69
|
+
# User.sync_from_scim_event(event) if event.is_a?(TwoPercent::Domain::Events::Base)
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# Option 3: Query SCIM models directly
|
|
73
|
+
# scim_user = TwoPercent::ScimUser.find_by_scim_id("user-123")
|
|
74
|
+
# email = scim_user.scim_data["email"]
|
|
75
|
+
# attrs = scim_user.to_domain_attributes
|
|
76
|
+
#
|
|
77
|
+
# Events published:
|
|
78
|
+
# - TwoPercent::Domain::Events::UserCreated
|
|
79
|
+
# - TwoPercent::Domain::Events::UserUpdated
|
|
80
|
+
# - TwoPercent::Domain::Events::UserDeleted
|
|
81
|
+
# - TwoPercent::Domain::Events::GroupCreated
|
|
82
|
+
# - TwoPercent::Domain::Events::GroupUpdated
|
|
83
|
+
# - TwoPercent::Domain::Events::GroupDeleted
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -2,15 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module TwoPercent
|
|
4
4
|
class BulkProcessor
|
|
5
|
-
def initialize(operations)
|
|
5
|
+
def initialize(operations, correlation_id: nil)
|
|
6
6
|
@operations = operations
|
|
7
|
+
@correlation_id = correlation_id
|
|
7
8
|
end
|
|
8
9
|
|
|
9
|
-
def dispatch
|
|
10
|
+
def dispatch
|
|
10
11
|
@operations.each do |operation|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
resource_type, id = parse_path(operation[:path])
|
|
13
|
+
|
|
14
|
+
# Persist data to two_percent tables first (wrapped in transaction for bulk integrity)
|
|
15
|
+
ActiveRecord::Base.transaction do
|
|
16
|
+
record = persist_bulk_operation(operation[:method], resource_type, id, operation[:data])
|
|
17
|
+
|
|
18
|
+
# Publish domain events based on operation
|
|
19
|
+
# Note: DELETE operations don't return a record, but still need to publish events
|
|
20
|
+
if record || operation[:method] == "DELETE"
|
|
21
|
+
publish_domain_event(operation[:method], resource_type, record,
|
|
22
|
+
id)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
14
25
|
end
|
|
15
26
|
end
|
|
16
27
|
|
|
@@ -21,5 +32,134 @@ module TwoPercent
|
|
|
21
32
|
|
|
22
33
|
[resource_type, id]
|
|
23
34
|
end
|
|
35
|
+
|
|
36
|
+
def persist_bulk_operation(method, resource_type, id, data)
|
|
37
|
+
case method
|
|
38
|
+
when "POST"
|
|
39
|
+
persist_create(resource_type, data)
|
|
40
|
+
when "PATCH"
|
|
41
|
+
persist_patch(resource_type, id, data)
|
|
42
|
+
when "PUT"
|
|
43
|
+
persist_update(resource_type, id, data)
|
|
44
|
+
when "DELETE"
|
|
45
|
+
persist_delete(resource_type, id)
|
|
46
|
+
nil # No record to return for deletes
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "Unknown HTTP method: #{method}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
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
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def persist_patch(resource_type, id, data)
|
|
61
|
+
# PATCH - apply operations to existing resource
|
|
62
|
+
record = find_record(resource_type, id)
|
|
63
|
+
|
|
64
|
+
# Apply SCIM PATCH operations (RFC 7644 compliance)
|
|
65
|
+
processor = TwoPercent::Scim::PatchProcessor.new(data)
|
|
66
|
+
current_scim_data = record.scim_data || {}
|
|
67
|
+
patched_data = processor.apply_to_hash(current_scim_data)
|
|
68
|
+
|
|
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
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def persist_update(resource_type, id, data)
|
|
79
|
+
# PUT - replace entire resource
|
|
80
|
+
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
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def persist_delete(resource_type, id)
|
|
89
|
+
if resource_type == "Users"
|
|
90
|
+
TwoPercent::ScimUser.destroy_by_scim_id(id)
|
|
91
|
+
else
|
|
92
|
+
TwoPercent::ScimGroup.destroy_by_scim_id(id)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def publish_domain_event(method, resource_type, record, id)
|
|
97
|
+
case method
|
|
98
|
+
when "POST"
|
|
99
|
+
publish_created_event(resource_type, record)
|
|
100
|
+
when "PATCH", "PUT"
|
|
101
|
+
publish_updated_event(resource_type, record)
|
|
102
|
+
when "DELETE"
|
|
103
|
+
publish_deleted_event(resource_type, id)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def publish_created_event(resource_type, record)
|
|
108
|
+
if resource_type == "Users"
|
|
109
|
+
TwoPercent::Domain::Events::UserCreated.create(
|
|
110
|
+
user_attributes: record.to_domain_attributes,
|
|
111
|
+
correlation_id: @correlation_id
|
|
112
|
+
)
|
|
113
|
+
else
|
|
114
|
+
TwoPercent::Domain::Events::GroupCreated.create(
|
|
115
|
+
group_attributes: record.to_domain_attributes,
|
|
116
|
+
resource_type: resource_type,
|
|
117
|
+
correlation_id: @correlation_id
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def publish_updated_event(resource_type, record)
|
|
123
|
+
if resource_type == "Users"
|
|
124
|
+
TwoPercent::Domain::Events::UserUpdated.create(
|
|
125
|
+
user_attributes: record.to_domain_attributes,
|
|
126
|
+
correlation_id: @correlation_id
|
|
127
|
+
)
|
|
128
|
+
else
|
|
129
|
+
TwoPercent::Domain::Events::GroupUpdated.create(
|
|
130
|
+
group_attributes: record.to_domain_attributes,
|
|
131
|
+
resource_type: resource_type,
|
|
132
|
+
correlation_id: @correlation_id
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def publish_deleted_event(resource_type, id)
|
|
138
|
+
if resource_type == "Users"
|
|
139
|
+
TwoPercent::Domain::Events::UserDeleted.create(
|
|
140
|
+
user_id: id,
|
|
141
|
+
correlation_id: @correlation_id
|
|
142
|
+
)
|
|
143
|
+
else
|
|
144
|
+
TwoPercent::Domain::Events::GroupDeleted.create(
|
|
145
|
+
group_id: id,
|
|
146
|
+
resource_type: resource_type,
|
|
147
|
+
correlation_id: @correlation_id
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def find_record(resource_type, scim_id)
|
|
153
|
+
record =
|
|
154
|
+
if resource_type == "Users"
|
|
155
|
+
TwoPercent::ScimUser.find_by_scim_id(scim_id)
|
|
156
|
+
else
|
|
157
|
+
TwoPercent::ScimGroup.find_by_scim_id(scim_id)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
raise ActiveRecord::RecordNotFound, "Resource \"#{scim_id}\" not found" unless record
|
|
161
|
+
|
|
162
|
+
record
|
|
163
|
+
end
|
|
24
164
|
end
|
|
25
165
|
end
|
|
@@ -52,4 +52,19 @@ module TwoPercent
|
|
|
52
52
|
# `TwoPercent.logger` will default to Rails.logger
|
|
53
53
|
#
|
|
54
54
|
config_accessor :logger
|
|
55
|
+
|
|
56
|
+
#
|
|
57
|
+
# HTTP header name for correlation ID tracking
|
|
58
|
+
# Defaults to "X-Correlation-Id" (common microservices pattern)
|
|
59
|
+
# Set to your IdP's correlation header (e.g., "SCIM-Request-ID")
|
|
60
|
+
#
|
|
61
|
+
# I.e.:
|
|
62
|
+
#
|
|
63
|
+
# TwoPercent.configure do |config|
|
|
64
|
+
# config.correlation_id_header = "SCIM-Request-ID"
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
config_accessor :correlation_id_header, default: "X-Correlation-Id"
|
|
68
|
+
|
|
69
|
+
class ConfigurationError < StandardError; end
|
|
55
70
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TwoPercent
|
|
4
|
+
module Domain
|
|
5
|
+
module Events
|
|
6
|
+
# Base class for domain events
|
|
7
|
+
# These are domain-focused, not SCIM-specific
|
|
8
|
+
class BaseEvent < AetherObservatory::EventBase
|
|
9
|
+
event_prefix "two_percent.domain"
|
|
10
|
+
|
|
11
|
+
attribute :correlation_id
|
|
12
|
+
|
|
13
|
+
# Apply this event to a domain model class
|
|
14
|
+
#
|
|
15
|
+
# Events know how to apply themselves to domain models, implementing
|
|
16
|
+
# the "tell, don't ask" principle and avoiding case statements.
|
|
17
|
+
#
|
|
18
|
+
# @param model_class [Class] The domain model class including Syncable
|
|
19
|
+
# @return [ActiveRecord::Base, nil] The affected record, if any
|
|
20
|
+
# @abstract Override in subclasses to implement event-specific logic
|
|
21
|
+
def apply_to_model(model_class)
|
|
22
|
+
raise NotImplementedError, "#{self.class} must implement #apply_to_model"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TwoPercent
|
|
4
|
+
module Domain
|
|
5
|
+
module Events
|
|
6
|
+
# Domain event: Group was created
|
|
7
|
+
class GroupCreated < BaseEvent
|
|
8
|
+
event_name "group.created"
|
|
9
|
+
|
|
10
|
+
attribute :group_attributes # Domain attributes, not SCIM
|
|
11
|
+
attribute :resource_type # Groups, Departments, etc.
|
|
12
|
+
|
|
13
|
+
def group_id
|
|
14
|
+
group_attributes[:scim_id]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Apply this event to a domain model class
|
|
18
|
+
def apply_to_model(model_class)
|
|
19
|
+
model_class.syncable_model.sync_created(group_attributes, model_class)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Domain event: Group was updated
|
|
24
|
+
class GroupUpdated < BaseEvent
|
|
25
|
+
event_name "group.updated"
|
|
26
|
+
|
|
27
|
+
attribute :group_attributes
|
|
28
|
+
attribute :resource_type
|
|
29
|
+
|
|
30
|
+
def group_id
|
|
31
|
+
group_attributes[:scim_id]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Apply this event to a domain model class
|
|
35
|
+
def apply_to_model(model_class)
|
|
36
|
+
model_class.syncable_model.sync_updated(group_attributes, model_class)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Domain event: Group was deleted
|
|
41
|
+
class GroupDeleted < BaseEvent
|
|
42
|
+
event_name "group.deleted"
|
|
43
|
+
|
|
44
|
+
attribute :group_id # Just the ID for deletion
|
|
45
|
+
attribute :resource_type
|
|
46
|
+
|
|
47
|
+
# Apply this event to a domain model class
|
|
48
|
+
def apply_to_model(model_class)
|
|
49
|
+
model_class.syncable_model.sync_deleted(group_id, model_class)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|