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,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TwoPercent
|
|
4
|
+
module Domain
|
|
5
|
+
module Events
|
|
6
|
+
# Domain event: User was created
|
|
7
|
+
class UserCreated < BaseEvent
|
|
8
|
+
event_name "user.created"
|
|
9
|
+
|
|
10
|
+
attribute :user_attributes # Domain attributes, not SCIM
|
|
11
|
+
|
|
12
|
+
def user_id
|
|
13
|
+
user_attributes[:scim_id]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Apply this event to a domain model class
|
|
17
|
+
def apply_to_model(model_class)
|
|
18
|
+
model_class.syncable_model.sync_created(user_attributes, model_class)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Domain event: User was updated
|
|
23
|
+
class UserUpdated < BaseEvent
|
|
24
|
+
event_name "user.updated"
|
|
25
|
+
|
|
26
|
+
attribute :user_attributes
|
|
27
|
+
|
|
28
|
+
def user_id
|
|
29
|
+
user_attributes[:scim_id]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Apply this event to a domain model class
|
|
33
|
+
def apply_to_model(model_class)
|
|
34
|
+
model_class.syncable_model.sync_updated(user_attributes, model_class)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Domain event: User was deleted
|
|
39
|
+
class UserDeleted < BaseEvent
|
|
40
|
+
event_name "user.deleted"
|
|
41
|
+
|
|
42
|
+
attribute :user_id # Just the ID for deletion
|
|
43
|
+
|
|
44
|
+
# Apply this event to a domain model class
|
|
45
|
+
def apply_to_model(model_class)
|
|
46
|
+
model_class.syncable_model.sync_deleted(user_id, model_class)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TwoPercent
|
|
4
|
+
module Domain
|
|
5
|
+
# Domain events (provider-agnostic)
|
|
6
|
+
module Events
|
|
7
|
+
autoload :BaseEvent, "two_percent/domain/events/base_event"
|
|
8
|
+
autoload :UserCreated, "two_percent/domain/events/user_events"
|
|
9
|
+
autoload :UserUpdated, "two_percent/domain/events/user_events"
|
|
10
|
+
autoload :UserDeleted, "two_percent/domain/events/user_events"
|
|
11
|
+
autoload :GroupCreated, "two_percent/domain/events/group_events"
|
|
12
|
+
autoload :GroupUpdated, "two_percent/domain/events/group_events"
|
|
13
|
+
autoload :GroupDeleted, "two_percent/domain/events/group_events"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TwoPercent
|
|
4
|
+
module Scim
|
|
5
|
+
# SCIM RFC 7644 PATCH operation processor
|
|
6
|
+
# Handles add, replace, remove operations on SCIM resources
|
|
7
|
+
class PatchProcessor
|
|
8
|
+
attr_reader :operations
|
|
9
|
+
|
|
10
|
+
def initialize(patch_request)
|
|
11
|
+
@operations = parse_operations(patch_request)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply_to_hash(scim_hash)
|
|
15
|
+
result = scim_hash.deep_dup
|
|
16
|
+
|
|
17
|
+
operations.each do |operation|
|
|
18
|
+
case operation[:op].downcase
|
|
19
|
+
when "add"
|
|
20
|
+
apply_add(result, operation[:path], operation[:value])
|
|
21
|
+
when "replace"
|
|
22
|
+
apply_replace(result, operation[:path], operation[:value])
|
|
23
|
+
when "remove"
|
|
24
|
+
apply_remove(result, operation[:path])
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, "Unknown PATCH operation: #{operation[:op]}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def parse_operations(patch_request)
|
|
36
|
+
ops = patch_request["Operations"] || patch_request[:Operations]
|
|
37
|
+
raise ArgumentError, "PATCH request must contain 'Operations' array" unless ops.is_a?(Array)
|
|
38
|
+
|
|
39
|
+
ops.flat_map do |op|
|
|
40
|
+
derive_operation(op)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def derive_operation(operation)
|
|
45
|
+
# Handle nested value hashes by flattening to path notation
|
|
46
|
+
case operation.fetch("value") { operation[:value] }
|
|
47
|
+
when Hash
|
|
48
|
+
operation.fetch("value") { operation[:value] }.flat_map do |key, value|
|
|
49
|
+
path = [operation.fetch("path") { operation[:path] }, key].compact.join(".")
|
|
50
|
+
derive_operation(
|
|
51
|
+
"op" => operation.fetch("op") { operation[:op] },
|
|
52
|
+
"path" => path,
|
|
53
|
+
"value" => value
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
[{
|
|
58
|
+
op: operation.fetch("op") { operation[:op] },
|
|
59
|
+
path: operation.fetch("path") { operation[:path] },
|
|
60
|
+
value: operation.fetch("value") { operation[:value] },
|
|
61
|
+
}]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def apply_add(hash, path, value)
|
|
66
|
+
if path.nil? || path.empty?
|
|
67
|
+
# No path means add to root
|
|
68
|
+
hash.merge!(value) if value.is_a?(Hash)
|
|
69
|
+
else
|
|
70
|
+
keys = path.split(".")
|
|
71
|
+
target = navigate_to_parent(hash, keys[0..-2])
|
|
72
|
+
last_key = keys.last
|
|
73
|
+
|
|
74
|
+
target[last_key] = if target[last_key].is_a?(Array)
|
|
75
|
+
(target[last_key] + [value]).flatten
|
|
76
|
+
else
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def apply_replace(hash, path, value)
|
|
83
|
+
if path.nil? || path.empty?
|
|
84
|
+
# No path means replace root attributes
|
|
85
|
+
hash.merge!(value) if value.is_a?(Hash)
|
|
86
|
+
else
|
|
87
|
+
keys = path.split(".")
|
|
88
|
+
target = navigate_to_parent(hash, keys[0..-2])
|
|
89
|
+
target[keys.last] = value
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def apply_remove(hash, path)
|
|
94
|
+
return if path.nil? || path.empty?
|
|
95
|
+
|
|
96
|
+
keys = path.split(".")
|
|
97
|
+
target = navigate_to_parent(hash, keys[0..-2])
|
|
98
|
+
last_key = keys.last
|
|
99
|
+
|
|
100
|
+
# Special handling for members array - set to empty array instead of deleting
|
|
101
|
+
# This ensures upsert_from_scim can sync memberships to empty state
|
|
102
|
+
if last_key == "members"
|
|
103
|
+
target[last_key] = []
|
|
104
|
+
else
|
|
105
|
+
target.delete(last_key)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def navigate_to_parent(hash, keys)
|
|
110
|
+
return hash if keys.empty?
|
|
111
|
+
|
|
112
|
+
keys.reduce(hash) do |current, key|
|
|
113
|
+
current[key] ||= {}
|
|
114
|
+
current[key]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TwoPercent
|
|
4
|
+
module Scim
|
|
5
|
+
# SCIM Schema definition based on RFC 7644
|
|
6
|
+
class Schema
|
|
7
|
+
CORE_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
|
8
|
+
CORE_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
|
9
|
+
EXTENSION_SCHEMA = "urn:ietf:params:scim:schemas:extension:authservice:2.0:User"
|
|
10
|
+
|
|
11
|
+
# Core User attributes per RFC 7644 Section 4.1
|
|
12
|
+
CORE_USER_ATTRIBUTES = %w[
|
|
13
|
+
id
|
|
14
|
+
externalId
|
|
15
|
+
userName
|
|
16
|
+
displayName
|
|
17
|
+
name
|
|
18
|
+
emails
|
|
19
|
+
phoneNumbers
|
|
20
|
+
addresses
|
|
21
|
+
photos
|
|
22
|
+
userType
|
|
23
|
+
title
|
|
24
|
+
active
|
|
25
|
+
groups
|
|
26
|
+
meta
|
|
27
|
+
schemas
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Core Group attributes per RFC 7644 Section 4.2
|
|
31
|
+
CORE_GROUP_ATTRIBUTES = %w[
|
|
32
|
+
id
|
|
33
|
+
externalId
|
|
34
|
+
displayName
|
|
35
|
+
members
|
|
36
|
+
meta
|
|
37
|
+
schemas
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Extension attributes (custom per IDP)
|
|
41
|
+
EXTENSION_USER_ATTRIBUTES = %w[
|
|
42
|
+
department
|
|
43
|
+
territory
|
|
44
|
+
territoryAbbr
|
|
45
|
+
role
|
|
46
|
+
mfaRequired
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
def self.validate_user(scim_hash, require_id: true)
|
|
50
|
+
# Accept either core schema or extension schemas
|
|
51
|
+
validate_schemas_present(scim_hash)
|
|
52
|
+
|
|
53
|
+
# Only require id for updates, not creation
|
|
54
|
+
required_attrs = require_id ? %w[id externalId] : %w[externalId]
|
|
55
|
+
validate_required_attributes(scim_hash, required_attrs)
|
|
56
|
+
validate_attribute_types(scim_hash)
|
|
57
|
+
|
|
58
|
+
# Return validated data with schemas normalized
|
|
59
|
+
normalize_user(scim_hash)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.validate_group(scim_hash, require_id: true)
|
|
63
|
+
# Accept either core schema or extension schemas
|
|
64
|
+
validate_schemas_present(scim_hash)
|
|
65
|
+
|
|
66
|
+
# Only require id for updates, not creation
|
|
67
|
+
required_attrs = require_id ? %w[id displayName] : %w[displayName]
|
|
68
|
+
validate_required_attributes(scim_hash, required_attrs)
|
|
69
|
+
|
|
70
|
+
normalize_group(scim_hash)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.normalize_user(scim_hash)
|
|
74
|
+
{
|
|
75
|
+
core: extract_core_attributes(scim_hash, CORE_USER_ATTRIBUTES),
|
|
76
|
+
extensions: extract_extensions(scim_hash),
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.normalize_group(scim_hash)
|
|
81
|
+
{
|
|
82
|
+
core: extract_core_attributes(scim_hash, CORE_GROUP_ATTRIBUTES),
|
|
83
|
+
extensions: extract_extensions(scim_hash),
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.extract_core_attributes(scim_hash, allowed_attrs)
|
|
88
|
+
scim_hash.slice(*allowed_attrs)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.extract_extensions(scim_hash)
|
|
92
|
+
scim_hash.select { |key, _| key.start_with?("urn:ietf:params:scim:schemas:extension:") }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.validate_schemas(scim_hash, required_schemas)
|
|
96
|
+
schemas = scim_hash["schemas"] || []
|
|
97
|
+
missing = required_schemas - schemas
|
|
98
|
+
|
|
99
|
+
return unless missing.any?
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "Missing required schemas: #{missing.join(', ')}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.validate_schemas_present(scim_hash)
|
|
105
|
+
schemas = scim_hash["schemas"] || []
|
|
106
|
+
|
|
107
|
+
return unless schemas.empty?
|
|
108
|
+
|
|
109
|
+
raise ArgumentError, "schemas attribute is required"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.validate_required_attributes(scim_hash, required_attrs)
|
|
113
|
+
missing = required_attrs.select { |attr| scim_hash[attr].nil? }
|
|
114
|
+
|
|
115
|
+
return unless missing.any?
|
|
116
|
+
|
|
117
|
+
raise ArgumentError, "Missing required attributes: #{missing.join(', ')}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.validate_attribute_types(scim_hash)
|
|
121
|
+
# Validate complex attribute structures
|
|
122
|
+
validate_name_structure(scim_hash["name"]) if scim_hash["name"]
|
|
123
|
+
validate_multi_valued(scim_hash["emails"], %w[value type]) if scim_hash["emails"]
|
|
124
|
+
validate_multi_valued(scim_hash["phoneNumbers"], %w[value type]) if scim_hash["phoneNumbers"]
|
|
125
|
+
validate_multi_valued(scim_hash["addresses"], %w[type]) if scim_hash["addresses"]
|
|
126
|
+
validate_multi_valued(scim_hash["photos"], %w[value type]) if scim_hash["photos"]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.validate_name_structure(name)
|
|
130
|
+
return unless name.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
valid_keys = %w[formatted familyName givenName middleName honorificPrefix honorificSuffix]
|
|
133
|
+
invalid = name.keys - valid_keys
|
|
134
|
+
|
|
135
|
+
return unless invalid.any?
|
|
136
|
+
|
|
137
|
+
raise ArgumentError, "Invalid name attributes: #{invalid.join(', ')}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.validate_multi_valued(array, required_keys)
|
|
141
|
+
return unless array.is_a?(Array)
|
|
142
|
+
|
|
143
|
+
array.each_with_index do |item, idx|
|
|
144
|
+
raise ArgumentError, "Multi-valued attribute item #{idx} must be an object" unless item.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
missing = required_keys - item.keys
|
|
147
|
+
raise ArgumentError, "Multi-valued attribute item #{idx} missing: #{missing.join(', ')}" if missing.any?
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TwoPercent
|
|
4
|
+
# Syncable concern for syncing SCIM data to domain models
|
|
5
|
+
#
|
|
6
|
+
# This concern provides one-way synchronization from SCIM to your domain models,
|
|
7
|
+
# ensuring SCIM remains the source of truth for identity data.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# class User < ApplicationRecord
|
|
11
|
+
# include TwoPercent::Syncable
|
|
12
|
+
#
|
|
13
|
+
# syncable_as :user, scim_id_column: :scim_id do |scim_attrs|
|
|
14
|
+
# {
|
|
15
|
+
# first_name: scim_attrs.dig(:name, :givenName),
|
|
16
|
+
# last_name: scim_attrs.dig(:name, :familyName),
|
|
17
|
+
# email: scim_attrs[:email],
|
|
18
|
+
# active: scim_attrs[:active]
|
|
19
|
+
# }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# class Group < ApplicationRecord
|
|
24
|
+
# include TwoPercent::Syncable
|
|
25
|
+
#
|
|
26
|
+
# syncable_as :group, scim_id_column: :scim_id do |scim_attrs|
|
|
27
|
+
# { name: scim_attrs[:display_name], active: scim_attrs[:active] }
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# This provides:
|
|
32
|
+
# - user.scim_user => linked ScimUser record
|
|
33
|
+
# - user.refresh_from_scim => pull latest data from SCIM
|
|
34
|
+
# - User.sync_from_scim_event(event) => sync from SCIM domain events
|
|
35
|
+
#
|
|
36
|
+
module Syncable
|
|
37
|
+
# Encapsulates type-specific SCIM synchronization logic
|
|
38
|
+
#
|
|
39
|
+
# This object replaces case statements and conditionals in the Syncable concern
|
|
40
|
+
# by encapsulating all knowledge about User vs Group differences.
|
|
41
|
+
#
|
|
42
|
+
class Model
|
|
43
|
+
attr_reader :scim_model_class, :scim_id_column, :resource_type, :options, :attribute_mapper_block
|
|
44
|
+
|
|
45
|
+
def initialize(scim_model_class:, scim_id_column:, resource_type:, **options, &block)
|
|
46
|
+
@scim_model_class = scim_model_class
|
|
47
|
+
@scim_id_column = scim_id_column
|
|
48
|
+
@resource_type = resource_type
|
|
49
|
+
@options = options
|
|
50
|
+
@attribute_mapper_block = block
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Setup association and validations on the domain model class
|
|
54
|
+
#
|
|
55
|
+
# @param domain_model_class [Class] The ActiveRecord model including Syncable
|
|
56
|
+
def setup_association(domain_model_class)
|
|
57
|
+
if scim_model_class == TwoPercent::ScimUser
|
|
58
|
+
setup_user_syncable(domain_model_class)
|
|
59
|
+
else
|
|
60
|
+
setup_group_syncable(domain_model_class)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Setup ScimUser association and validations
|
|
65
|
+
#
|
|
66
|
+
# @param domain_model_class [Class] The ActiveRecord model including Syncable
|
|
67
|
+
def setup_user_syncable(domain_model_class)
|
|
68
|
+
domain_model_class.belongs_to :scim_user,
|
|
69
|
+
class_name: "TwoPercent::ScimUser",
|
|
70
|
+
foreign_key: scim_id_column,
|
|
71
|
+
primary_key: "scim_id",
|
|
72
|
+
optional: true
|
|
73
|
+
|
|
74
|
+
domain_model_class.validates scim_id_column, uniqueness: true, allow_nil: true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Setup ScimGroup association and validations
|
|
78
|
+
#
|
|
79
|
+
# @param domain_model_class [Class] The ActiveRecord model including Syncable
|
|
80
|
+
def setup_group_syncable(domain_model_class)
|
|
81
|
+
domain_model_class.belongs_to :scim_group,
|
|
82
|
+
class_name: "TwoPercent::ScimGroup",
|
|
83
|
+
foreign_key: scim_id_column,
|
|
84
|
+
primary_key: "scim_id",
|
|
85
|
+
optional: true
|
|
86
|
+
|
|
87
|
+
domain_model_class.validates scim_id_column, uniqueness: true, allow_nil: true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Sync created event to domain model
|
|
91
|
+
#
|
|
92
|
+
# @param attributes [Hash] SCIM attributes from event
|
|
93
|
+
# @param domain_model_class [Class] The domain model class
|
|
94
|
+
# @return [ActiveRecord::Base] The synced record
|
|
95
|
+
def sync_created(attributes, domain_model_class)
|
|
96
|
+
sync_upsert(attributes, domain_model_class)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Sync updated event to domain model
|
|
100
|
+
#
|
|
101
|
+
# @param attributes [Hash] SCIM attributes from event
|
|
102
|
+
# @param domain_model_class [Class] The domain model class
|
|
103
|
+
# @return [ActiveRecord::Base] The synced record
|
|
104
|
+
def sync_updated(attributes, domain_model_class)
|
|
105
|
+
sync_upsert(attributes, domain_model_class)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Sync deleted event to domain model
|
|
109
|
+
#
|
|
110
|
+
# @param scim_id [String] SCIM ID of deleted resource
|
|
111
|
+
# @param domain_model_class [Class] The domain model class
|
|
112
|
+
# @return [ActiveRecord::Base, nil] The destroyed record
|
|
113
|
+
def sync_deleted(scim_id, domain_model_class)
|
|
114
|
+
record = domain_model_class.find_by(scim_id_column => scim_id)
|
|
115
|
+
record&.destroy
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Shared logic for created/updated events
|
|
121
|
+
def sync_upsert(attributes, domain_model_class)
|
|
122
|
+
scim_id = attributes[:scim_id]
|
|
123
|
+
return unless scim_id
|
|
124
|
+
|
|
125
|
+
unless attribute_mapper_block
|
|
126
|
+
raise ArgumentError, "No attribute mapper block provided. Define one in syncable_as."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
record = domain_model_class.find_or_initialize_by(scim_id_column => scim_id)
|
|
130
|
+
mapped_attrs = attribute_mapper_block.call(attributes)
|
|
131
|
+
record.assign_attributes(mapped_attrs)
|
|
132
|
+
record.save! if record.changed?
|
|
133
|
+
record
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
extend ActiveSupport::Concern
|
|
138
|
+
|
|
139
|
+
included do
|
|
140
|
+
class_attribute :syncable_model
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
class_methods do
|
|
144
|
+
# Configure this model as syncable with SCIM
|
|
145
|
+
#
|
|
146
|
+
# @param type [Symbol] :user or :group
|
|
147
|
+
# @param scim_id_column [Symbol] Column storing SCIM ID (default: :scim_id)
|
|
148
|
+
# @param options [Hash] Additional configuration options
|
|
149
|
+
# @option options [String] :resource_type Resource type for groups (default: "Groups")
|
|
150
|
+
# @param block [Proc] Required block for custom attribute mapping from SCIM to domain
|
|
151
|
+
#
|
|
152
|
+
def syncable_as(type, scim_id_column: :scim_id, **options, &block)
|
|
153
|
+
scim_model_class = type == :user ? TwoPercent::ScimUser : TwoPercent::ScimGroup
|
|
154
|
+
|
|
155
|
+
self.syncable_model = Model.new(
|
|
156
|
+
scim_model_class: scim_model_class,
|
|
157
|
+
scim_id_column: scim_id_column,
|
|
158
|
+
resource_type: type,
|
|
159
|
+
**options,
|
|
160
|
+
&block
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
syncable_model.setup_association(self)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Sync from a SCIM domain event
|
|
167
|
+
#
|
|
168
|
+
# Uses polymorphic dispatch - events know how to apply themselves
|
|
169
|
+
#
|
|
170
|
+
# @param event [TwoPercent::Domain::Events::Base] Domain event
|
|
171
|
+
# @return [ActiveRecord::Base, nil] The affected record, if any
|
|
172
|
+
#
|
|
173
|
+
def sync_from_scim_event(event)
|
|
174
|
+
event.apply_to_model(self)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Instance methods
|
|
179
|
+
|
|
180
|
+
# Refresh this record from SCIM data
|
|
181
|
+
def refresh_from_scim
|
|
182
|
+
model = self.class.syncable_model
|
|
183
|
+
association_name = model.scim_model_class == TwoPercent::ScimUser ? :scim_user : :scim_group
|
|
184
|
+
scim_record = public_send(association_name)
|
|
185
|
+
|
|
186
|
+
return unless scim_record
|
|
187
|
+
|
|
188
|
+
unless model.attribute_mapper_block
|
|
189
|
+
raise ArgumentError, "No attribute mapper block provided. Define one in syncable_as."
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
attrs = scim_record.to_domain_attributes
|
|
193
|
+
mapped_attrs = model.attribute_mapper_block.call(attrs)
|
|
194
|
+
assign_attributes(mapped_attrs)
|
|
195
|
+
save! if changed?
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
data/lib/two_percent/version.rb
CHANGED
data/lib/two_percent.rb
CHANGED
|
@@ -4,8 +4,10 @@ require "aether_observatory"
|
|
|
4
4
|
|
|
5
5
|
require "two_percent/version"
|
|
6
6
|
require "two_percent/configuration"
|
|
7
|
-
require "two_percent/
|
|
7
|
+
require "two_percent/domain"
|
|
8
8
|
require "two_percent/bulk_processor"
|
|
9
|
+
require "two_percent/scim"
|
|
10
|
+
require "two_percent/syncable"
|
|
9
11
|
|
|
10
12
|
module TwoPercent
|
|
11
13
|
# Logger used by TwoPercent. Defaults to Rails.logger
|
metadata
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: two_percent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carlos Palhares
|
|
8
8
|
- Katie Edgar
|
|
9
9
|
- Dan Smith
|
|
10
|
-
autorequire:
|
|
11
10
|
bindir: bin
|
|
12
11
|
cert_chain: []
|
|
13
|
-
date:
|
|
12
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
14
13
|
dependencies:
|
|
15
14
|
- !ruby/object:Gem::Dependency
|
|
16
15
|
name: aether_observatory
|
|
@@ -40,8 +39,10 @@ dependencies:
|
|
|
40
39
|
- - ">="
|
|
41
40
|
- !ruby/object:Gem::Version
|
|
42
41
|
version: '6.1'
|
|
43
|
-
description:
|
|
44
|
-
|
|
42
|
+
description: TwoPercent is a SCIM 2.0 Rails Engine with partial RFC 7644 protocol
|
|
43
|
+
compliance for IdP provisioning. Supports POST/PUT/PATCH/DELETE/Bulk operations
|
|
44
|
+
for Users and Groups, publishes domain events via AetherObservatory, and integrates
|
|
45
|
+
seamlessly with Rails applications.
|
|
45
46
|
email:
|
|
46
47
|
- carlos.palhares@powerhrg.com
|
|
47
48
|
- katie.weber@powerhrg.com
|
|
@@ -55,18 +56,31 @@ files:
|
|
|
55
56
|
- app/controllers/two_percent/application_controller.rb
|
|
56
57
|
- app/controllers/two_percent/bulk_controller.rb
|
|
57
58
|
- app/controllers/two_percent/scim_controller.rb
|
|
58
|
-
- app/
|
|
59
|
-
- app/
|
|
60
|
-
- app/
|
|
61
|
-
- app/
|
|
62
|
-
- app/events/two_percent/update_event.rb
|
|
59
|
+
- app/models/two_percent/application_record.rb
|
|
60
|
+
- app/models/two_percent/scim_group.rb
|
|
61
|
+
- app/models/two_percent/scim_group_membership.rb
|
|
62
|
+
- app/models/two_percent/scim_user.rb
|
|
63
63
|
- config/routes.rb
|
|
64
|
+
- lib/generators/two_percent/install/install_generator.rb
|
|
65
|
+
- lib/generators/two_percent/install/templates/INSTALL_README
|
|
66
|
+
- lib/generators/two_percent/install/templates/create_two_percent_scim_group_memberships.rb.erb
|
|
67
|
+
- lib/generators/two_percent/install/templates/create_two_percent_scim_groups.rb.erb
|
|
68
|
+
- lib/generators/two_percent/install/templates/create_two_percent_scim_users.rb.erb
|
|
69
|
+
- lib/generators/two_percent/install/templates/two_percent.rb.erb
|
|
64
70
|
- lib/tasks/two_percent_tasks.rake
|
|
65
71
|
- lib/two_percent.rb
|
|
66
72
|
- lib/two_percent/bulk_processor.rb
|
|
67
73
|
- lib/two_percent/configuration.rb
|
|
74
|
+
- lib/two_percent/domain.rb
|
|
75
|
+
- lib/two_percent/domain/events.rb
|
|
76
|
+
- lib/two_percent/domain/events/base_event.rb
|
|
77
|
+
- lib/two_percent/domain/events/group_events.rb
|
|
78
|
+
- lib/two_percent/domain/events/user_events.rb
|
|
68
79
|
- lib/two_percent/engine.rb
|
|
69
|
-
- lib/two_percent/
|
|
80
|
+
- lib/two_percent/scim.rb
|
|
81
|
+
- lib/two_percent/scim/patch_processor.rb
|
|
82
|
+
- lib/two_percent/scim/schema.rb
|
|
83
|
+
- lib/two_percent/syncable.rb
|
|
70
84
|
- lib/two_percent/version.rb
|
|
71
85
|
homepage: https://github.com/powerhome/power-tools
|
|
72
86
|
licenses:
|
|
@@ -76,7 +90,6 @@ metadata:
|
|
|
76
90
|
source_code_uri: https://github.com/powerhome/power-tools
|
|
77
91
|
changelog_uri: https://github.com/powerhome/power-tools/blob/main/packages/two_percent/docs/CHANGELOG.md
|
|
78
92
|
rubygems_mfa_required: 'true'
|
|
79
|
-
post_install_message:
|
|
80
93
|
rdoc_options: []
|
|
81
94
|
require_paths:
|
|
82
95
|
- lib
|
|
@@ -91,9 +104,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
91
104
|
- !ruby/object:Gem::Version
|
|
92
105
|
version: '0'
|
|
93
106
|
requirements: []
|
|
94
|
-
rubygems_version: 3.
|
|
95
|
-
signing_key:
|
|
107
|
+
rubygems_version: 3.6.9
|
|
96
108
|
specification_version: 4
|
|
97
|
-
summary:
|
|
98
|
-
to observers
|
|
109
|
+
summary: SCIM 2.0 Rails Engine for identity provisioning with domain event publishing
|
|
99
110
|
test_files: []
|