attio-ruby 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +164 -0
- data/.simplecov +17 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +333 -0
- data/INTEGRATION_TEST_STATUS.md +149 -0
- data/LICENSE +21 -0
- data/README.md +638 -0
- data/Rakefile +8 -0
- data/attio-ruby.gemspec +61 -0
- data/docs/CODECOV_SETUP.md +34 -0
- data/examples/basic_usage.rb +149 -0
- data/examples/oauth_flow.rb +843 -0
- data/examples/oauth_flow_README.md +84 -0
- data/examples/typed_records_example.rb +167 -0
- data/examples/webhook_server.rb +463 -0
- data/lib/attio/api_resource.rb +539 -0
- data/lib/attio/builders/name_builder.rb +181 -0
- data/lib/attio/client.rb +160 -0
- data/lib/attio/errors.rb +126 -0
- data/lib/attio/internal/record.rb +359 -0
- data/lib/attio/oauth/client.rb +219 -0
- data/lib/attio/oauth/scope_validator.rb +162 -0
- data/lib/attio/oauth/token.rb +158 -0
- data/lib/attio/resources/attribute.rb +332 -0
- data/lib/attio/resources/comment.rb +114 -0
- data/lib/attio/resources/company.rb +224 -0
- data/lib/attio/resources/entry.rb +208 -0
- data/lib/attio/resources/list.rb +196 -0
- data/lib/attio/resources/meta.rb +113 -0
- data/lib/attio/resources/note.rb +213 -0
- data/lib/attio/resources/object.rb +66 -0
- data/lib/attio/resources/person.rb +294 -0
- data/lib/attio/resources/task.rb +147 -0
- data/lib/attio/resources/thread.rb +99 -0
- data/lib/attio/resources/typed_record.rb +98 -0
- data/lib/attio/resources/webhook.rb +224 -0
- data/lib/attio/resources/workspace_member.rb +136 -0
- data/lib/attio/util/configuration.rb +166 -0
- data/lib/attio/util/id_extractor.rb +115 -0
- data/lib/attio/util/webhook_signature.rb +175 -0
- data/lib/attio/version.rb +6 -0
- data/lib/attio/webhook/event.rb +114 -0
- data/lib/attio/webhook/signature_verifier.rb +73 -0
- data/lib/attio.rb +123 -0
- metadata +402 -0
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
# Utility classes for the Attio gem
|
5
|
+
module Util
|
6
|
+
# Configuration management for the Attio gem
|
7
|
+
class Configuration
|
8
|
+
# Raised when configuration validation fails
|
9
|
+
class ConfigurationError < ::Attio::InvalidRequestError; end
|
10
|
+
|
11
|
+
# Settings that must be configured
|
12
|
+
REQUIRED_SETTINGS = %i[api_key].freeze
|
13
|
+
# Optional settings with defaults
|
14
|
+
OPTIONAL_SETTINGS = %i[
|
15
|
+
api_base
|
16
|
+
api_version
|
17
|
+
timeout
|
18
|
+
open_timeout
|
19
|
+
max_retries
|
20
|
+
logger
|
21
|
+
debug
|
22
|
+
ca_bundle_path
|
23
|
+
verify_ssl_certs
|
24
|
+
use_faraday
|
25
|
+
].freeze
|
26
|
+
|
27
|
+
# All available configuration settings
|
28
|
+
ALL_SETTINGS = (REQUIRED_SETTINGS + OPTIONAL_SETTINGS).freeze
|
29
|
+
|
30
|
+
# Default values for optional settings
|
31
|
+
DEFAULT_SETTINGS = {
|
32
|
+
api_base: "https://api.attio.com",
|
33
|
+
api_version: "v2",
|
34
|
+
timeout: 30,
|
35
|
+
open_timeout: 10,
|
36
|
+
max_retries: 3,
|
37
|
+
logger: nil,
|
38
|
+
debug: false,
|
39
|
+
ca_bundle_path: nil,
|
40
|
+
verify_ssl_certs: true,
|
41
|
+
use_faraday: true
|
42
|
+
}.freeze
|
43
|
+
|
44
|
+
attr_reader(*ALL_SETTINGS)
|
45
|
+
|
46
|
+
def initialize
|
47
|
+
@mutex = Mutex.new
|
48
|
+
@configured = false
|
49
|
+
reset_without_lock!
|
50
|
+
end
|
51
|
+
|
52
|
+
# Reset configuration to defaults
|
53
|
+
# @return [void]
|
54
|
+
def reset!
|
55
|
+
@mutex.synchronize do
|
56
|
+
reset_without_lock!
|
57
|
+
@configured = false
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def configure
|
62
|
+
raise ConfigurationError, "Configuration has already been finalized" if frozen?
|
63
|
+
|
64
|
+
@mutex.synchronize do
|
65
|
+
yield(self) if block_given?
|
66
|
+
validate!
|
67
|
+
@configured = true
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Call this to make configuration immutable
|
72
|
+
def finalize!
|
73
|
+
@mutex.synchronize do
|
74
|
+
validate!
|
75
|
+
freeze unless frozen?
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def validate!
|
80
|
+
REQUIRED_SETTINGS.each do |setting|
|
81
|
+
value = instance_variable_get("@#{setting}")
|
82
|
+
if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
83
|
+
raise ConfigurationError, "#{setting} must be configured"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
raise ConfigurationError, "timeout must be positive" if @timeout && @timeout <= 0
|
88
|
+
raise ConfigurationError, "open_timeout must be positive" if @open_timeout && @open_timeout <= 0
|
89
|
+
raise ConfigurationError, "max_retries must be non-negative" if @max_retries&.negative?
|
90
|
+
|
91
|
+
true
|
92
|
+
end
|
93
|
+
|
94
|
+
# Convert configuration to hash
|
95
|
+
# @return [Hash] Configuration settings as a hash
|
96
|
+
def to_h
|
97
|
+
ALL_SETTINGS.each_with_object({}) do |setting, hash|
|
98
|
+
hash[setting] = instance_variable_get("@#{setting}")
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def apply_env_vars!
|
103
|
+
raise ConfigurationError, "Cannot modify frozen configuration" if frozen?
|
104
|
+
|
105
|
+
@mutex.synchronize do
|
106
|
+
@api_key = ENV.fetch("ATTIO_API_KEY", @api_key)
|
107
|
+
@api_base = ENV.fetch("ATTIO_API_BASE", @api_base)
|
108
|
+
@api_version = ENV.fetch("ATTIO_API_VERSION", @api_version)
|
109
|
+
@timeout = ENV.fetch("ATTIO_TIMEOUT", @timeout).to_i if ENV.key?("ATTIO_TIMEOUT")
|
110
|
+
@open_timeout = ENV.fetch("ATTIO_OPEN_TIMEOUT", @open_timeout).to_i if ENV.key?("ATTIO_OPEN_TIMEOUT")
|
111
|
+
@max_retries = ENV.fetch("ATTIO_MAX_RETRIES", @max_retries).to_i if ENV.key?("ATTIO_MAX_RETRIES")
|
112
|
+
@debug = ENV.fetch("ATTIO_DEBUG", @debug).to_s.downcase == "true" if ENV.key?("ATTIO_DEBUG")
|
113
|
+
@ca_bundle_path = ENV.fetch("ATTIO_CA_BUNDLE_PATH", @ca_bundle_path) if ENV.key?("ATTIO_CA_BUNDLE_PATH")
|
114
|
+
@verify_ssl_certs = ENV.fetch("ATTIO_VERIFY_SSL_CERTS", @verify_ssl_certs).to_s.downcase != "false" if ENV.key?("ATTIO_VERIFY_SSL_CERTS")
|
115
|
+
@use_faraday = ENV.fetch("ATTIO_USE_FARADAY", @use_faraday).to_s.downcase != "false" if ENV.key?("ATTIO_USE_FARADAY")
|
116
|
+
|
117
|
+
if ENV.key?("ATTIO_LOGGER")
|
118
|
+
logger_class = ENV["ATTIO_LOGGER"]
|
119
|
+
@logger = (logger_class == "STDOUT") ? Logger.new($stdout) : nil
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# Create a new configuration with merged options
|
125
|
+
# @param options [Hash] Options to merge
|
126
|
+
# @return [Configuration] New configuration instance
|
127
|
+
def merge(options)
|
128
|
+
dup.tap do |config|
|
129
|
+
options.each do |key, value|
|
130
|
+
if ALL_SETTINGS.include?(key.to_sym)
|
131
|
+
config.instance_variable_set("@#{key}", value)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Create a duplicate configuration
|
138
|
+
# @return [Configuration] Duplicate configuration instance
|
139
|
+
def dup
|
140
|
+
self.class.new.tap do |config|
|
141
|
+
ALL_SETTINGS.each do |setting|
|
142
|
+
config.instance_variable_set("@#{setting}", instance_variable_get("@#{setting}"))
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Setters - only work before configuration is frozen
|
148
|
+
ALL_SETTINGS.each do |setting|
|
149
|
+
define_method("#{setting}=") do |value|
|
150
|
+
raise ConfigurationError, "Cannot modify frozen configuration" if frozen?
|
151
|
+
# Don't synchronize here - it's already synchronized in configure
|
152
|
+
instance_variable_set("@#{setting}", value)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
private
|
157
|
+
|
158
|
+
def reset_without_lock!
|
159
|
+
DEFAULT_SETTINGS.each do |key, value|
|
160
|
+
instance_variable_set("@#{key}", value)
|
161
|
+
end
|
162
|
+
@api_key = nil
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
module Util
|
5
|
+
# Centralized ID extraction utility for handling various ID formats
|
6
|
+
# across different Attio resources
|
7
|
+
class IdExtractor
|
8
|
+
class << self
|
9
|
+
# Extract an ID from various formats
|
10
|
+
# @param id [String, Hash, nil] The ID in various formats
|
11
|
+
# @param key [Symbol, String] The key to extract from a hash ID
|
12
|
+
# @return [String, nil] The extracted ID
|
13
|
+
def extract(id, key = nil)
|
14
|
+
return nil if id.nil?
|
15
|
+
|
16
|
+
case id
|
17
|
+
when String
|
18
|
+
id
|
19
|
+
when Hash
|
20
|
+
extract_from_hash(id, key)
|
21
|
+
else
|
22
|
+
id.to_s if id.respond_to?(:to_s)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Extract a specific ID type from a potentially nested structure
|
27
|
+
# @param id [String, Hash, nil] The ID structure
|
28
|
+
# @param resource_type [Symbol] The resource type (:record, :webhook, :attribute, etc.)
|
29
|
+
# @return [String, nil] The extracted ID
|
30
|
+
def extract_for_resource(id, resource_type)
|
31
|
+
return nil if id.nil?
|
32
|
+
|
33
|
+
key = resource_key_map[resource_type]
|
34
|
+
return id if id.is_a?(String) && key.nil?
|
35
|
+
|
36
|
+
extract(id, key)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Normalize an ID structure to a consistent format
|
40
|
+
# @param id [String, Hash, nil] The ID to normalize
|
41
|
+
# @param resource_type [Symbol] The resource type
|
42
|
+
# @return [Hash, String, nil] The normalized ID
|
43
|
+
def normalize(id, resource_type)
|
44
|
+
return nil if id.nil?
|
45
|
+
|
46
|
+
extracted = extract_for_resource(id, resource_type)
|
47
|
+
return nil if extracted.nil?
|
48
|
+
|
49
|
+
# For resources that need hash format
|
50
|
+
if hash_format_resources.include?(resource_type)
|
51
|
+
key = resource_key_map[resource_type]
|
52
|
+
key ? {key => extracted} : extracted
|
53
|
+
else
|
54
|
+
extracted
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def extract_from_hash(hash, key = nil)
|
61
|
+
return nil unless hash.is_a?(Hash)
|
62
|
+
|
63
|
+
if key
|
64
|
+
# Try both symbol and string keys
|
65
|
+
hash[key] || hash[key.to_s] || hash[key.to_sym]
|
66
|
+
else
|
67
|
+
# Try common ID keys in order of preference
|
68
|
+
common_keys.each do |k|
|
69
|
+
value = hash[k] || hash[k.to_s]
|
70
|
+
return value if value
|
71
|
+
end
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def resource_key_map
|
77
|
+
@resource_key_map ||= {
|
78
|
+
record: :record_id,
|
79
|
+
workspace_member: :workspace_member_id,
|
80
|
+
webhook: :webhook_id,
|
81
|
+
attribute: :attribute_id,
|
82
|
+
object: :object_id,
|
83
|
+
list: :list_id,
|
84
|
+
note: :note_id,
|
85
|
+
comment: :comment_id,
|
86
|
+
task: :task_id,
|
87
|
+
entry: :entry_id,
|
88
|
+
thread: :thread_id
|
89
|
+
}.freeze
|
90
|
+
end
|
91
|
+
|
92
|
+
def hash_format_resources
|
93
|
+
@hash_format_resources ||= %i[record].freeze
|
94
|
+
end
|
95
|
+
|
96
|
+
def common_keys
|
97
|
+
@common_keys ||= %i[
|
98
|
+
id
|
99
|
+
record_id
|
100
|
+
workspace_member_id
|
101
|
+
webhook_id
|
102
|
+
attribute_id
|
103
|
+
object_id
|
104
|
+
list_id
|
105
|
+
note_id
|
106
|
+
comment_id
|
107
|
+
task_id
|
108
|
+
entry_id
|
109
|
+
thread_id
|
110
|
+
].freeze
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "base64"
|
5
|
+
require "time"
|
6
|
+
|
7
|
+
module Attio
|
8
|
+
module Util
|
9
|
+
# Verifies webhook signatures from Attio to ensure authenticity
|
10
|
+
class WebhookSignature
|
11
|
+
# HTTP header containing the webhook signature
|
12
|
+
SIGNATURE_HEADER = "x-attio-signature"
|
13
|
+
# HTTP header containing the request timestamp
|
14
|
+
TIMESTAMP_HEADER = "x-attio-timestamp"
|
15
|
+
TOLERANCE_SECONDS = 300 # 5 minutes
|
16
|
+
|
17
|
+
class << self
|
18
|
+
# Verify webhook signature (raises exception on failure)
|
19
|
+
def verify!(payload:, signature:, timestamp:, secret:, tolerance: TOLERANCE_SECONDS)
|
20
|
+
validate_inputs!(payload, signature, timestamp, secret)
|
21
|
+
|
22
|
+
# Check timestamp to prevent replay attacks
|
23
|
+
verify_timestamp!(timestamp, tolerance)
|
24
|
+
|
25
|
+
# Calculate expected signature
|
26
|
+
expected_signature = calculate_signature(payload, timestamp, secret)
|
27
|
+
|
28
|
+
# Constant-time comparison to prevent timing attacks
|
29
|
+
raise SignatureVerificationError, "Invalid signature" unless secure_compare(signature, expected_signature)
|
30
|
+
rescue => e
|
31
|
+
raise SignatureVerificationError, "Webhook signature verification failed: #{e.message}"
|
32
|
+
end
|
33
|
+
|
34
|
+
# Verify webhook signature (returns boolean)
|
35
|
+
def verify(payload:, signature:, timestamp:, secret:, tolerance: TOLERANCE_SECONDS)
|
36
|
+
verify!(payload: payload, signature: signature, timestamp: timestamp, secret: secret, tolerance: tolerance)
|
37
|
+
true
|
38
|
+
rescue SignatureVerificationError
|
39
|
+
false
|
40
|
+
end
|
41
|
+
|
42
|
+
# Calculate signature for a payload
|
43
|
+
def calculate_signature(payload, timestamp, secret)
|
44
|
+
# Ensure payload is a string
|
45
|
+
payload_string = payload.is_a?(String) ? payload : JSON.generate(payload)
|
46
|
+
|
47
|
+
# Create the signed payload
|
48
|
+
signed_payload = "#{timestamp}.#{payload_string}"
|
49
|
+
|
50
|
+
# Calculate HMAC
|
51
|
+
hmac = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
|
52
|
+
|
53
|
+
# Return in the format Attio uses
|
54
|
+
"v1=#{hmac}"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Extract signature from headers
|
58
|
+
def extract_from_headers(headers)
|
59
|
+
signature = headers[SIGNATURE_HEADER] || headers[SIGNATURE_HEADER.upcase] || headers[SIGNATURE_HEADER.tr("-", "_").upcase]
|
60
|
+
timestamp = headers[TIMESTAMP_HEADER] || headers[TIMESTAMP_HEADER.upcase] || headers[TIMESTAMP_HEADER.tr("-", "_").upcase]
|
61
|
+
|
62
|
+
raise SignatureVerificationError, "Missing signature header: #{SIGNATURE_HEADER}" unless signature
|
63
|
+
raise SignatureVerificationError, "Missing timestamp header: #{TIMESTAMP_HEADER}" unless timestamp
|
64
|
+
|
65
|
+
{
|
66
|
+
signature: signature,
|
67
|
+
timestamp: timestamp
|
68
|
+
}
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def validate_inputs!(payload, signature, timestamp, secret)
|
74
|
+
raise ArgumentError, "Payload cannot be nil" if payload.nil?
|
75
|
+
raise ArgumentError, "Signature cannot be nil or empty" if signature.nil? || signature.empty?
|
76
|
+
raise ArgumentError, "Timestamp cannot be nil or empty" if timestamp.nil? || timestamp.to_s.empty?
|
77
|
+
raise ArgumentError, "Secret cannot be nil or empty" if secret.nil? || secret.empty?
|
78
|
+
end
|
79
|
+
|
80
|
+
def verify_timestamp!(timestamp, tolerance)
|
81
|
+
timestamp_int = timestamp.to_i
|
82
|
+
current_time = Time.now.to_i
|
83
|
+
|
84
|
+
if timestamp_int < (current_time - tolerance)
|
85
|
+
raise SignatureVerificationError, "Timestamp too old"
|
86
|
+
end
|
87
|
+
|
88
|
+
if timestamp_int > (current_time + tolerance)
|
89
|
+
raise SignatureVerificationError, "Timestamp too far in the future"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def secure_compare(a, b)
|
94
|
+
return false unless a.bytesize == b.bytesize
|
95
|
+
|
96
|
+
# Use constant-time comparison
|
97
|
+
res = 0
|
98
|
+
a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
|
99
|
+
res == 0
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Helper class for webhook handlers
|
104
|
+
class Handler
|
105
|
+
attr_reader :secret
|
106
|
+
|
107
|
+
def initialize(secret)
|
108
|
+
@secret = secret
|
109
|
+
validate_secret!
|
110
|
+
end
|
111
|
+
|
112
|
+
# Verify a request
|
113
|
+
def verify_request(request)
|
114
|
+
headers = extract_headers(request)
|
115
|
+
body = extract_body(request)
|
116
|
+
|
117
|
+
signature_data = WebhookSignature.extract_from_headers(headers)
|
118
|
+
|
119
|
+
WebhookSignature.verify!(
|
120
|
+
payload: body,
|
121
|
+
signature: signature_data[:signature],
|
122
|
+
timestamp: signature_data[:timestamp],
|
123
|
+
secret: secret
|
124
|
+
)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Parse and verify a request
|
128
|
+
def parse_and_verify(request)
|
129
|
+
verify_request(request)
|
130
|
+
|
131
|
+
body = extract_body(request)
|
132
|
+
JSON.parse(body, symbolize_names: true)
|
133
|
+
rescue JSON::ParserError => e
|
134
|
+
raise SignatureVerificationError, "Invalid JSON payload: #{e.message}"
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
def validate_secret!
|
140
|
+
raise ArgumentError, "Webhook secret is required" if secret.nil? || secret.empty?
|
141
|
+
end
|
142
|
+
|
143
|
+
def extract_headers(request)
|
144
|
+
case request
|
145
|
+
when Hash
|
146
|
+
request[:headers] || request["headers"] || {}
|
147
|
+
when defined?(Rack::Request) && Rack::Request
|
148
|
+
request.env.select { |k, _| k.start_with?("HTTP_") }.transform_keys { |k| k.sub(/^HTTP_/, "").downcase }
|
149
|
+
when defined?(ActionDispatch::Request) && ActionDispatch::Request
|
150
|
+
request.headers.to_h
|
151
|
+
else
|
152
|
+
raise ArgumentError, "Unsupported request type: #{request.class}"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def extract_body(request)
|
157
|
+
case request
|
158
|
+
when Hash
|
159
|
+
request[:body] || request["body"] || ""
|
160
|
+
when defined?(Rack::Request) && Rack::Request
|
161
|
+
request.body.rewind
|
162
|
+
request.body.read
|
163
|
+
when defined?(ActionDispatch::Request) && ActionDispatch::Request
|
164
|
+
request.raw_post
|
165
|
+
else
|
166
|
+
raise ArgumentError, "Unsupported request type: #{request.class}"
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# Raised when webhook signature verification fails
|
172
|
+
class SignatureVerificationError < StandardError; end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
module WebhookUtils
|
5
|
+
# Represents a webhook event payload
|
6
|
+
class Event
|
7
|
+
attr_reader :id, :type, :occurred_at, :data, :raw_data
|
8
|
+
|
9
|
+
def initialize(payload)
|
10
|
+
@raw_data = payload.is_a?(String) ? JSON.parse(payload) : payload
|
11
|
+
@id = @raw_data["id"] || @raw_data[:id]
|
12
|
+
@type = @raw_data["type"] || @raw_data[:type]
|
13
|
+
@occurred_at = parse_timestamp(@raw_data["occurred_at"] || @raw_data[:occurred_at])
|
14
|
+
@data = @raw_data["data"] || @raw_data[:data] || {}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get the object type from the event data
|
18
|
+
def object_type
|
19
|
+
@data["object"] || @data[:object]
|
20
|
+
end
|
21
|
+
|
22
|
+
# Get the record from the event data
|
23
|
+
def record
|
24
|
+
@data["record"] || @data[:record]
|
25
|
+
end
|
26
|
+
|
27
|
+
# Get the record ID
|
28
|
+
def record_id
|
29
|
+
record_data = record
|
30
|
+
return nil unless record_data
|
31
|
+
|
32
|
+
record_data["id"] || record_data[:id]
|
33
|
+
end
|
34
|
+
|
35
|
+
# Get the record data
|
36
|
+
def record_data
|
37
|
+
record || {}
|
38
|
+
end
|
39
|
+
|
40
|
+
# Get changes from updated events
|
41
|
+
def changes
|
42
|
+
@data["changes"] || @data[:changes]
|
43
|
+
end
|
44
|
+
|
45
|
+
# Add present? method to match Rails expectations
|
46
|
+
def present?
|
47
|
+
true # Events are always present if they exist
|
48
|
+
end
|
49
|
+
|
50
|
+
# Check if this is a record event
|
51
|
+
def record_event?
|
52
|
+
type&.start_with?("record.")
|
53
|
+
end
|
54
|
+
|
55
|
+
# Check if this is a created event
|
56
|
+
def created_event?
|
57
|
+
type&.end_with?(".created")
|
58
|
+
end
|
59
|
+
|
60
|
+
# Check if this is an updated event
|
61
|
+
def updated_event?
|
62
|
+
type&.end_with?(".updated")
|
63
|
+
end
|
64
|
+
|
65
|
+
# Check if this is a deleted event
|
66
|
+
def deleted_event?
|
67
|
+
type&.end_with?(".deleted")
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check if this is a list entry event
|
71
|
+
def list_entry_event?
|
72
|
+
type&.start_with?("list_entry.")
|
73
|
+
end
|
74
|
+
|
75
|
+
# Check if this is a note event
|
76
|
+
def note_event?
|
77
|
+
type&.start_with?("note.")
|
78
|
+
end
|
79
|
+
|
80
|
+
# Check if this is a task event
|
81
|
+
def task_event?
|
82
|
+
type&.start_with?("task.")
|
83
|
+
end
|
84
|
+
|
85
|
+
# Convert to hash
|
86
|
+
def to_h
|
87
|
+
{
|
88
|
+
id: id,
|
89
|
+
type: type,
|
90
|
+
occurred_at: occurred_at&.iso8601,
|
91
|
+
object_type: object_type,
|
92
|
+
record_id: record_id,
|
93
|
+
record_data: record_data,
|
94
|
+
changes: changes,
|
95
|
+
data: data
|
96
|
+
}.compact
|
97
|
+
end
|
98
|
+
|
99
|
+
# Convert event back to JSON
|
100
|
+
def to_json(*args)
|
101
|
+
@raw_data.to_json(*args)
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
def parse_timestamp(timestamp_str)
|
107
|
+
return nil unless timestamp_str
|
108
|
+
Time.parse(timestamp_str)
|
109
|
+
rescue ArgumentError
|
110
|
+
nil
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
|
5
|
+
module Attio
|
6
|
+
module WebhookUtils
|
7
|
+
# Verifies webhook signatures to ensure payloads are from Attio
|
8
|
+
class SignatureVerifier
|
9
|
+
TOLERANCE = 300 # 5 minutes in seconds
|
10
|
+
|
11
|
+
def initialize(secret)
|
12
|
+
@secret = secret
|
13
|
+
end
|
14
|
+
|
15
|
+
# Verify the webhook signature
|
16
|
+
# @param payload [String] The raw request body
|
17
|
+
# @param signature_header [String] The signature header from the request
|
18
|
+
# @param tolerance [Integer] Maximum age of timestamp in seconds
|
19
|
+
# @return [Boolean] True if signature is valid
|
20
|
+
def verify(payload, signature_header, tolerance: TOLERANCE)
|
21
|
+
timestamp, signature = parse_signature_header(signature_header)
|
22
|
+
return false unless timestamp && signature
|
23
|
+
|
24
|
+
# Check timestamp tolerance
|
25
|
+
current_time = Time.now.to_i
|
26
|
+
if (current_time - timestamp.to_i).abs > tolerance
|
27
|
+
return false
|
28
|
+
end
|
29
|
+
|
30
|
+
# Generate expected signature
|
31
|
+
signed_payload = "#{timestamp}.#{payload}"
|
32
|
+
expected_signature = OpenSSL::HMAC.hexdigest("SHA256", @secret, signed_payload)
|
33
|
+
|
34
|
+
# Compare signatures securely
|
35
|
+
secure_compare(signature, expected_signature)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# Parse the signature header format: "t=timestamp v1=signature"
|
41
|
+
def parse_signature_header(header)
|
42
|
+
return [nil, nil] unless header
|
43
|
+
|
44
|
+
timestamp = nil
|
45
|
+
signature = nil
|
46
|
+
|
47
|
+
header.split(/[,\s]+/).each do |element|
|
48
|
+
key, value = element.split("=", 2)
|
49
|
+
case key
|
50
|
+
when "t"
|
51
|
+
timestamp = value
|
52
|
+
when "v1"
|
53
|
+
signature = value
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
[timestamp, signature]
|
58
|
+
end
|
59
|
+
|
60
|
+
# Secure string comparison to prevent timing attacks
|
61
|
+
def secure_compare(a, b)
|
62
|
+
return false unless a.bytesize == b.bytesize
|
63
|
+
|
64
|
+
l = a.unpack("C*")
|
65
|
+
r = b.unpack("C*")
|
66
|
+
result = 0
|
67
|
+
|
68
|
+
l.zip(r) { |x, y| result |= x ^ y }
|
69
|
+
result == 0
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|