castle-rb 3.6.2

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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +157 -0
  3. data/lib/castle-rb.rb +3 -0
  4. data/lib/castle.rb +62 -0
  5. data/lib/castle/api.rb +40 -0
  6. data/lib/castle/api/request.rb +37 -0
  7. data/lib/castle/api/request/build.rb +27 -0
  8. data/lib/castle/api/response.rb +40 -0
  9. data/lib/castle/client.rb +106 -0
  10. data/lib/castle/command.rb +5 -0
  11. data/lib/castle/commands/authenticate.rb +23 -0
  12. data/lib/castle/commands/identify.rb +23 -0
  13. data/lib/castle/commands/impersonate.rb +26 -0
  14. data/lib/castle/commands/review.rb +14 -0
  15. data/lib/castle/commands/track.rb +23 -0
  16. data/lib/castle/configuration.rb +80 -0
  17. data/lib/castle/context/default.rb +40 -0
  18. data/lib/castle/context/merger.rb +14 -0
  19. data/lib/castle/context/sanitizer.rb +23 -0
  20. data/lib/castle/errors.rb +41 -0
  21. data/lib/castle/extractors/client_id.rb +17 -0
  22. data/lib/castle/extractors/headers.rb +51 -0
  23. data/lib/castle/extractors/ip.rb +18 -0
  24. data/lib/castle/failover_auth_response.rb +21 -0
  25. data/lib/castle/header_formatter.rb +9 -0
  26. data/lib/castle/review.rb +11 -0
  27. data/lib/castle/secure_mode.rb +11 -0
  28. data/lib/castle/support/hanami.rb +19 -0
  29. data/lib/castle/support/padrino.rb +19 -0
  30. data/lib/castle/support/rails.rb +13 -0
  31. data/lib/castle/support/sinatra.rb +19 -0
  32. data/lib/castle/utils.rb +55 -0
  33. data/lib/castle/utils/cloner.rb +11 -0
  34. data/lib/castle/utils/merger.rb +23 -0
  35. data/lib/castle/utils/timestamp.rb +12 -0
  36. data/lib/castle/validators/not_supported.rb +16 -0
  37. data/lib/castle/validators/present.rb +16 -0
  38. data/lib/castle/version.rb +5 -0
  39. data/spec/lib/castle/api/request/build_spec.rb +44 -0
  40. data/spec/lib/castle/api/request_spec.rb +59 -0
  41. data/spec/lib/castle/api/response_spec.rb +58 -0
  42. data/spec/lib/castle/api_spec.rb +37 -0
  43. data/spec/lib/castle/client_spec.rb +358 -0
  44. data/spec/lib/castle/command_spec.rb +9 -0
  45. data/spec/lib/castle/commands/authenticate_spec.rb +108 -0
  46. data/spec/lib/castle/commands/identify_spec.rb +87 -0
  47. data/spec/lib/castle/commands/impersonate_spec.rb +106 -0
  48. data/spec/lib/castle/commands/review_spec.rb +24 -0
  49. data/spec/lib/castle/commands/track_spec.rb +113 -0
  50. data/spec/lib/castle/configuration_spec.rb +130 -0
  51. data/spec/lib/castle/context/default_spec.rb +41 -0
  52. data/spec/lib/castle/context/merger_spec.rb +23 -0
  53. data/spec/lib/castle/context/sanitizer_spec.rb +27 -0
  54. data/spec/lib/castle/extractors/client_id_spec.rb +62 -0
  55. data/spec/lib/castle/extractors/headers_spec.rb +89 -0
  56. data/spec/lib/castle/extractors/ip_spec.rb +27 -0
  57. data/spec/lib/castle/header_formatter_spec.rb +25 -0
  58. data/spec/lib/castle/review_spec.rb +19 -0
  59. data/spec/lib/castle/secure_mode_spec.rb +9 -0
  60. data/spec/lib/castle/utils/cloner_spec.rb +18 -0
  61. data/spec/lib/castle/utils/merger_spec.rb +13 -0
  62. data/spec/lib/castle/utils/timestamp_spec.rb +17 -0
  63. data/spec/lib/castle/utils_spec.rb +156 -0
  64. data/spec/lib/castle/validators/not_supported_spec.rb +26 -0
  65. data/spec/lib/castle/validators/present_spec.rb +33 -0
  66. data/spec/lib/castle/version_spec.rb +5 -0
  67. data/spec/lib/castle_spec.rb +66 -0
  68. data/spec/spec_helper.rb +25 -0
  69. metadata +139 -0
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ Command = Struct.new(:path, :data, :method)
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ class Authenticate
6
+ def initialize(context)
7
+ @context = context
8
+ end
9
+
10
+ def build(options = {})
11
+ Castle::Validators::Present.call(options, %i[event])
12
+ context = Castle::Context::Merger.call(@context, options[:context])
13
+ context = Castle::Context::Sanitizer.call(context)
14
+
15
+ Castle::Command.new(
16
+ 'authenticate',
17
+ options.merge(context: context, sent_at: Castle::Utils::Timestamp.call),
18
+ :post
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ class Identify
6
+ def initialize(context)
7
+ @context = context
8
+ end
9
+
10
+ def build(options = {})
11
+ Castle::Validators::NotSupported.call(options, %i[properties])
12
+ context = Castle::Context::Merger.call(@context, options[:context])
13
+ context = Castle::Context::Sanitizer.call(context)
14
+
15
+ Castle::Command.new(
16
+ 'identify',
17
+ options.merge(context: context, sent_at: Castle::Utils::Timestamp.call),
18
+ :post
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ # builder for impersonate command
6
+ class Impersonate
7
+ def initialize(context)
8
+ @context = context
9
+ end
10
+
11
+ def build(options = {})
12
+ Castle::Validators::Present.call(options, %i[user_id])
13
+ context = Castle::Context::Merger.call(@context, options[:context])
14
+ context = Castle::Context::Sanitizer.call(context)
15
+
16
+ Castle::Validators::Present.call(context, %i[user_agent ip])
17
+
18
+ Castle::Command.new(
19
+ 'impersonate',
20
+ options.merge(context: context, sent_at: Castle::Utils::Timestamp.call),
21
+ options[:reset] ? :delete : :post
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ class Review
6
+ class << self
7
+ def build(review_id)
8
+ Castle::Validators::Present.call({ review_id: review_id }, %i[review_id])
9
+ Castle::Command.new("reviews/#{review_id}", nil, :get)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ class Track
6
+ def initialize(context)
7
+ @context = context
8
+ end
9
+
10
+ def build(options = {})
11
+ Castle::Validators::Present.call(options, %i[event])
12
+ context = Castle::Context::Merger.call(@context, options[:context])
13
+ context = Castle::Context::Sanitizer.call(context)
14
+
15
+ Castle::Command.new(
16
+ 'track',
17
+ options.merge(context: context, sent_at: Castle::Utils::Timestamp.call),
18
+ :post
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # manages configuration variables
5
+ class Configuration
6
+ HOST = 'api.castle.io'
7
+ PORT = 443
8
+ URL_PREFIX = 'v1'
9
+ FAILOVER_STRATEGY = :allow
10
+ REQUEST_TIMEOUT = 500 # in milliseconds
11
+ FAILOVER_STRATEGIES = %i[allow deny challenge throw].freeze
12
+
13
+ # @note this value is not assigned as we don't recommend using a whitelist. If you need to use
14
+ # one, this constant is provided as a good default.
15
+ DEFAULT_WHITELIST = %w[
16
+ Accept
17
+ Accept-Charset
18
+ Accept-Datetime
19
+ Accept-Encoding
20
+ Accept-Language
21
+ Cache-Control
22
+ Connection
23
+ Content-Length
24
+ Content-Type
25
+ Host
26
+ Origin
27
+ Pragma
28
+ Referer
29
+ TE
30
+ Upgrade-Insecure-Requests
31
+ X-Castle-Client-Id
32
+ ].freeze
33
+
34
+ attr_accessor :host, :port, :request_timeout, :url_prefix
35
+ attr_reader :api_secret, :whitelisted, :blacklisted, :failover_strategy
36
+
37
+ def initialize
38
+ @formatter = Castle::HeaderFormatter.new
39
+ @request_timeout = REQUEST_TIMEOUT
40
+ self.failover_strategy = FAILOVER_STRATEGY
41
+ self.host = HOST
42
+ self.port = PORT
43
+ self.url_prefix = URL_PREFIX
44
+ self.whitelisted = [].freeze
45
+ self.blacklisted = [].freeze
46
+ self.api_secret = ''
47
+ end
48
+
49
+ def api_secret=(value)
50
+ @api_secret = ENV.fetch('CASTLE_API_SECRET', value).to_s
51
+ end
52
+
53
+ def whitelisted=(value)
54
+ @whitelisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
55
+ end
56
+
57
+ def blacklisted=(value)
58
+ @blacklisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
59
+ end
60
+
61
+ def valid?
62
+ !api_secret.to_s.empty? && !host.to_s.empty? && !port.to_s.empty?
63
+ end
64
+
65
+ def failover_strategy=(value)
66
+ @failover_strategy = FAILOVER_STRATEGIES.detect { |strategy| strategy == value.to_sym }
67
+ raise Castle::ConfigurationError, 'unrecognized failover strategy' if @failover_strategy.nil?
68
+ end
69
+
70
+ private
71
+
72
+ def respond_to_missing?(method_name, _include_private)
73
+ /^(\w+)=$/ =~ method_name
74
+ end
75
+
76
+ def method_missing(setting, *_args)
77
+ raise Castle::ConfigurationError, "there is no such a config #{setting}"
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Context
5
+ class Default
6
+ def initialize(request, cookies = nil)
7
+ @client_id = Extractors::ClientId.new(request, cookies || request.cookies).call
8
+ @headers = Extractors::Headers.new(request).call
9
+ @request_ip = Extractors::IP.new(request).call
10
+ end
11
+
12
+ def call
13
+ defaults.merge!(additional_defaults)
14
+ end
15
+
16
+ private
17
+
18
+ def defaults
19
+ {
20
+ client_id: @client_id,
21
+ active: true,
22
+ origin: 'web',
23
+ headers: @headers,
24
+ ip: @request_ip,
25
+ library: {
26
+ name: 'castle-rb',
27
+ version: Castle::VERSION
28
+ }
29
+ }
30
+ end
31
+
32
+ def additional_defaults
33
+ {}.tap do |result|
34
+ result[:locale] = @headers['Accept-Language'] if @headers['Accept-Language']
35
+ result[:user_agent] = @headers['User-Agent'] if @headers['User-Agent']
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Context
5
+ class Merger
6
+ class << self
7
+ def call(initial_context, request_context)
8
+ main_context = Castle::Utils::Cloner.call(initial_context)
9
+ Castle::Utils::Merger.call(main_context, request_context || {})
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Context
5
+ # removes not proper active flag values
6
+ class Sanitizer
7
+ class << self
8
+ def call(context)
9
+ sanitized_active_mode(context) || {}
10
+ end
11
+
12
+ private
13
+
14
+ def sanitized_active_mode(context)
15
+ return unless context
16
+ return context unless context.key?(:active)
17
+ return context if [true, false].include?(context[:active])
18
+ context.reject { |key| key == :active }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # general error
5
+ class Error < RuntimeError; end
6
+ # Raised when anything is wrong with the request (any unhappy path)
7
+ # This error indicates that either we would wait too long for a response or something
8
+ # else happened somewhere in the middle and we weren't able to get the results
9
+ class RequestError < Castle::Error
10
+ attr_reader :reason
11
+
12
+ # @param reason [Exception] the core exception that causes this error
13
+ def initialize(reason)
14
+ @reason = reason
15
+ end
16
+ end
17
+ # security error
18
+ class SecurityError < Castle::Error; end
19
+ # wrong configuration error
20
+ class ConfigurationError < Castle::Error; end
21
+ # error returned by api
22
+ class ApiError < Castle::Error; end
23
+
24
+ # api error bad request 400
25
+ class BadRequestError < Castle::ApiError; end
26
+ # api error forbidden 403
27
+ class ForbiddenError < Castle::ApiError; end
28
+ # api error not found 404
29
+ class NotFoundError < Castle::ApiError; end
30
+ # api error user unauthorized 419
31
+ class UserUnauthorizedError < Castle::ApiError; end
32
+ # api error invalid param 422
33
+ class InvalidParametersError < Castle::ApiError; end
34
+ # api error unauthorized 401
35
+ class UnauthorizedError < Castle::ApiError; end
36
+ # all internal server errors
37
+ class InternalServerError < Castle::ApiError; end
38
+
39
+ # impersonation command failed
40
+ class ImpersonationFailed < Castle::ApiError; end
41
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Extractors
5
+ # used for extraction of cookies and headers from the request
6
+ class ClientId
7
+ def initialize(request, cookies)
8
+ @request = request
9
+ @cookies = cookies || {}
10
+ end
11
+
12
+ def call
13
+ @request.env['HTTP_X_CASTLE_CLIENT_ID'] || @cookies['__cid'] || ''
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Extractors
5
+ # used for extraction of cookies and headers from the request
6
+ class Headers
7
+ # Headers that we will never scrub, even if they land on the configuration blacklist.
8
+ ALWAYS_INCLUDED_HEADERS = %w[User-Agent].freeze
9
+
10
+ # Headers that will always be scrubbed, even if whitelisted.
11
+ ALWAYS_SCRUBBED_HEADERS = %w[Cookie Authorization].freeze
12
+
13
+ # Rack does not add the HTTP_ prefix to Content-Length for some reason
14
+ CONTENT_LENGTH = 'CONTENT_LENGTH'
15
+
16
+ # Prefix that Rack adds for HTTP headers
17
+ HTTP_HEADER_PREFIX = 'HTTP_'
18
+
19
+ private_constant :ALWAYS_INCLUDED_HEADERS, :ALWAYS_SCRUBBED_HEADERS,
20
+ :CONTENT_LENGTH, :HTTP_HEADER_PREFIX
21
+
22
+ # @param request [Rack::Request]
23
+ def initialize(request)
24
+ @request_env = request.env
25
+ @formatter = HeaderFormatter.new
26
+ end
27
+
28
+ # Serialize HTTP headers
29
+ # @return [Hash]
30
+ def call
31
+ @request_env.keys.each_with_object({}) do |env_header, acc|
32
+ next unless env_header.to_s.start_with?(HTTP_HEADER_PREFIX) || env_header == CONTENT_LENGTH
33
+
34
+ header = @formatter.call(env_header)
35
+
36
+ if ALWAYS_SCRUBBED_HEADERS.include?(header)
37
+ acc[header] = true
38
+ elsif ALWAYS_INCLUDED_HEADERS.include?(header)
39
+ acc[header] = @request_env[env_header]
40
+ elsif Castle.config.blacklisted.include?(header)
41
+ acc[header] = true
42
+ elsif Castle.config.whitelisted.empty? || Castle.config.whitelisted.include?(header)
43
+ acc[header] = @request_env[env_header]
44
+ else
45
+ acc[header] = true
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Extractors
5
+ # used for extraction of ip from the request
6
+ class IP
7
+ def initialize(request)
8
+ @request = request
9
+ end
10
+
11
+ def call
12
+ return @request.env['HTTP_CF_CONNECTING_IP'] if @request.env['HTTP_CF_CONNECTING_IP']
13
+ return @request.remote_ip if @request.respond_to?(:remote_ip)
14
+ @request.ip
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # generate failover authentication response
5
+ class FailoverAuthResponse
6
+ def initialize(user_id, strategy: Castle.config.failover_strategy, reason:)
7
+ @strategy = strategy
8
+ @reason = reason
9
+ @user_id = user_id
10
+ end
11
+
12
+ def generate
13
+ {
14
+ action: @strategy.to_s,
15
+ user_id: @user_id,
16
+ failover: true,
17
+ failover_reason: @reason
18
+ }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ class HeaderFormatter
5
+ def call(header)
6
+ header.to_s.gsub(/^HTTP(?:_|-)/i, '').split(/_|-/).map(&:capitalize).join('-')
7
+ end
8
+ end
9
+ end