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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +5 -1
  3. data/app/controllers/two_percent/application_controller.rb +79 -1
  4. data/app/controllers/two_percent/bulk_controller.rb +18 -2
  5. data/app/controllers/two_percent/scim_controller.rb +200 -8
  6. data/app/models/two_percent/application_record.rb +7 -0
  7. data/app/models/two_percent/scim_group.rb +182 -0
  8. data/app/models/two_percent/scim_group_membership.rb +29 -0
  9. data/app/models/two_percent/scim_user.rb +138 -0
  10. data/lib/generators/two_percent/install/install_generator.rb +46 -0
  11. data/lib/generators/two_percent/install/templates/INSTALL_README +84 -0
  12. data/lib/generators/two_percent/install/templates/create_two_percent_scim_group_memberships.rb.erb +20 -0
  13. data/lib/generators/two_percent/install/templates/create_two_percent_scim_groups.rb.erb +24 -0
  14. data/lib/generators/two_percent/install/templates/create_two_percent_scim_users.rb.erb +25 -0
  15. data/lib/generators/two_percent/install/templates/two_percent.rb.erb +85 -0
  16. data/lib/two_percent/bulk_processor.rb +145 -5
  17. data/lib/two_percent/configuration.rb +15 -0
  18. data/lib/two_percent/domain/events/base_event.rb +27 -0
  19. data/lib/two_percent/domain/events/group_events.rb +54 -0
  20. data/lib/two_percent/domain/events/user_events.rb +51 -0
  21. data/lib/two_percent/domain/events.rb +16 -0
  22. data/lib/two_percent/domain.rb +8 -0
  23. data/lib/two_percent/scim/patch_processor.rb +119 -0
  24. data/lib/two_percent/scim/schema.rb +152 -0
  25. data/lib/two_percent/scim.rb +8 -0
  26. data/lib/two_percent/syncable.rb +198 -0
  27. data/lib/two_percent/version.rb +1 -1
  28. data/lib/two_percent.rb +3 -1
  29. metadata +27 -16
  30. data/app/events/two_percent/application_event.rb +0 -7
  31. data/app/events/two_percent/create_event.rb +0 -11
  32. data/app/events/two_percent/delete_event.rb +0 -11
  33. data/app/events/two_percent/replace_event.rb +0 -12
  34. data/app/events/two_percent/update_event.rb +0 -12
  35. 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
+ ===============================================================================
@@ -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(event_handler = EventHandler)
10
+ def dispatch
10
11
  @operations.each do |operation|
11
- resource, id = parse_path(operation[:path])
12
- attrs = { resource: resource, id: id, params: operation[:data] }.compact
13
- event_handler.dispatch(operation[:method], **attrs)
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