Class: TwoPercent::ScimUser

Inherits:
ApplicationRecord show all
Defined in:
app/models/two_percent/scim_user.rb

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.destroy_by_scim_id(scim_id) ⇒ Object



51
52
53
# File 'app/models/two_percent/scim_user.rb', line 51

def self.destroy_by_scim_id(scim_id)
  find_by_scim_id(scim_id)&.destroy
end

.exists_by_scim_id?(scim_id) ⇒ Boolean

Returns:

  • (Boolean)


47
48
49
# File 'app/models/two_percent/scim_user.rb', line 47

def self.exists_by_scim_id?(scim_id)
  exists?(scim_id: scim_id)
end

.find_by_scim_id(scim_id) ⇒ Object



43
44
45
# File 'app/models/two_percent/scim_user.rb', line 43

def self.find_by_scim_id(scim_id)
  find_by(scim_id: scim_id)
end

.upsert_from_scim(scim_hash, correlation_id: nil) ⇒ TwoPercent::ScimUser

Creates or updates a user from SCIM data

Generates a UUID for the id field if not present (for POST/create operations). Validates the SCIM data against the User schema before persisting.

Parameters:

  • scim_hash (Hash)

    SCIM User resource hash conforming to RFC 7643

  • correlation_id (String, nil) (defaults to: nil)

    Optional correlation ID for tracking changes across network hops (e.g., App A -> App B -> App C)

Returns:

Raises:

  • (TwoPercent::Scim::ValidationError)

    If SCIM data fails schema validation



28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'app/models/two_percent/scim_user.rb', line 28

def self.upsert_from_scim(scim_hash, correlation_id: nil)
  # Generate SCIM id (stored as scim_id) if not provided (typical for POST operations)
  scim_hash = scim_hash.dup
  scim_hash["id"] ||= SecureRandom.uuid

  # Extract groups before validation to prevent storage in scim_data JSONB
  # Groups are synced to join table only (single source of truth)
  groups = scim_hash.delete("groups")

  validated_data = TwoPercent::Scim::Schema.validate_user(scim_hash, require_id: true)
  scim_user = find_or_initialize_by(scim_id: scim_hash["id"])
  scim_user.update_from_scim!(validated_data, groups, correlation_id: correlation_id)
  scim_user
end

Instance Method Details

#extension_attributes(schema_urn = nil) ⇒ Object



161
162
163
164
165
166
167
# File 'app/models/two_percent/scim_user.rb', line 161

def extension_attributes(schema_urn = nil)
  if schema_urn
    scim_data[schema_urn] || {}
  else
    scim_data.select { |k, _| k.start_with?("urn:ietf:params:scim:schemas:extension:") }
  end
end

#groups_representationArray<Hash>

Build SCIM groups representation dynamically from join table Per RFC 7643 Section 4.1.2, User.groups is read-only and must reflect Group memberships

Returns:

  • (Array<Hash>)

    Array of group references with SCIM-compliant attributes



96
97
98
99
100
101
102
103
104
105
# File 'app/models/two_percent/scim_user.rb', line 96

def groups_representation
  scim_groups.map do |group|
    {
      "value" => group.scim_id,
      "display" => group.display_name,
      "$ref" => "#{group.resource_type}/#{group.scim_id}",
      "type" => group.resource_type,
    }
  end
end

#scim_attribute(path) ⇒ Object?

Extracts a nested attribute from the scim_data JSON

Examples:

user.scim_attribute("emails.0.value") # => "user@example.com"

Parameters:

  • path (String)

    Dot-separated path to the attribute (e.g., "name.givenName")

Returns:

  • (Object, nil)

    The attribute value or nil if not found



156
157
158
159
# File 'app/models/two_percent/scim_user.rb', line 156

def scim_attribute(path)
  keys = path.split(".")
  scim_data.dig(*keys)
end

#sync_groups(groups_data) ⇒ Object

Syncs user's group memberships from SCIM groups array

Accepts a groups array from SCIM User payload and updates associations. This enables bulk sync operations where clients send User.groups arrays.

Examples:

sync_groups([{"value" => "group-123", "display" => "Engineering"}])
sync_groups([]) # Clears all group memberships

Parameters:

  • groups_data (Array<Hash>)

    Array of group references with "value" (scim_id)



142
143
144
145
146
147
148
# File 'app/models/two_percent/scim_user.rb', line 142

def sync_groups(groups_data)
  return if groups_data.nil?

  group_ids = groups_data.filter_map { |g| g["value"] }
  groups = TwoPercent::ScimGroup.where(scim_id: group_ids)
  self.scim_groups = groups
end

#to_domain_attributesHash

Extracts domain attributes for publishing in domain events

Returns key attributes for event payloads (thin events - no associations). Group memberships are queried separately by consumers who need them.

Returns:

  • (Hash)

    Domain attributes



61
62
63
64
65
66
67
68
69
70
# File 'app/models/two_percent/scim_user.rb', line 61

def to_domain_attributes
  {
    scim_id: scim_id,
    external_id: external_id,
    user_name: user_name,
    display_name: display_name,
    email: email,
    active: active,
  }.compact
end

#to_scim_representationHash

Returns full SCIM representation for HTTP responses

Returns:

  • (Hash)

    RFC 7644 compliant SCIM User resource



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'app/models/two_percent/scim_user.rb', line 75

def to_scim_representation
  representation = scim_data.merge(
    "id" => scim_id,
    "meta" => {
      "resourceType" => "User",
      "created" => created_at.iso8601,
      "lastModified" => updated_at.iso8601,
    }
  )

  # Build groups dynamically from join table (RFC 7643: User.groups is read-only)
  # Single source of truth is join table - only included when association is loaded
  representation["groups"] = groups_representation if scim_groups.loaded?

  representation
end

#update_from_scim!(validated_data, groups_data, correlation_id: nil) ⇒ Object

Updates user attributes from validated SCIM data

NOTE: This method processes the User.groups array from the request payload and syncs group memberships, which is a deviation from strict RFC 7643 compliance (User.groups is specified as read-only in Section 4.1.2). This behavior is intentionally maintained for compatibility with existing SCIM clients that rely on this functionality for bulk sync operations.

Parameters:

  • validated_data (Hash)

    Validated SCIM data with :core and :extensions keys

  • groups_data (Array<Hash>, nil)

    Groups array extracted before validation

  • correlation_id (String, nil) (defaults to: nil)

    Optional correlation ID for tracking



118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'app/models/two_percent/scim_user.rb', line 118

def update_from_scim!(validated_data, groups_data, correlation_id: nil)
  core_data = validated_data[:core]
  self.scim_data = core_data.merge(validated_data[:extensions])
  self.scim_id = core_data["id"]
  self.external_id = core_data["externalId"]
  self.user_name = core_data["userName"]
  self.display_name = core_data["displayName"]
  self.email = core_data.dig("emails", 0, "value")
  self.active = core_data.fetch("active", true)
  self.correlation_id = correlation_id
  save!
  # Sync groups to join table only (never stored in scim_data)
  sync_groups(groups_data) if groups_data
end