end_point_blank 0.12.0 → 0.13.1

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.
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../page"
4
+
5
+ module EndPointBlank
6
+ module Management
7
+ # The management API's resources, each reached from a {Client} (or a
8
+ # {ManagedClient} view) rather than built directly.
9
+ #
10
+ # Every method answers what the API sent, decoded from JSON into Hashes
11
+ # with String keys: a single resource's +data+, a {Page} for a list, and
12
+ # <tt>{"id" => ..., "deleted" => true}</tt> for a delete. Optional keyword
13
+ # arguments left nil are not sent.
14
+ module Resources
15
+ # Shared plumbing: paths, list pages and auto-paging.
16
+ class Base
17
+ MAX_LIMIT = 100
18
+
19
+ def initialize(transport, prefix = "")
20
+ @transport = transport
21
+ @prefix = prefix
22
+ end
23
+
24
+ def inspect
25
+ "#<#{self.class.name} prefix=#{@prefix.inspect}>"
26
+ end
27
+
28
+ # One path segment, percent-escaped. An empty id is refused, so it can
29
+ # never turn a "get one" into a "list all"; so is an id of dots only
30
+ # (".", ".."), a dot-segment that any proxy or URL normalizer may
31
+ # resolve into a different route (DELETE /clients/c1/grants/.. into
32
+ # DELETE /clients/c1). Percent-encoding the dots would not help:
33
+ # normalizers decode %2E first. Real ids are UUIDs.
34
+ def self.escape(segment)
35
+ value = segment.to_s
36
+ raise ArgumentError, "an id must be a non-empty String" if value.empty?
37
+ raise ArgumentError, "an id must not be only dots" if value.each_char.all?(".")
38
+
39
+ value.b.gsub(/[^A-Za-z0-9\-._~]/n) { |char| format("%%%02X", char.ord) }
40
+ end
41
+
42
+ private
43
+
44
+ # "/segment/escaped-id/..." under this resource's prefix. Every
45
+ # argument is one path segment (see {.escape}).
46
+ def path(*segments)
47
+ @prefix + segments.map { |segment| "/#{escape(segment)}" }.join
48
+ end
49
+
50
+ def escape(segment)
51
+ self.class.escape(segment)
52
+ end
53
+
54
+ def get_data(path, query = nil)
55
+ data(@transport.request("GET", path, query: query))
56
+ end
57
+
58
+ def post_data(path, body, idempotency_key)
59
+ data(@transport.request("POST", path, body: body, idempotency_key: idempotency_key))
60
+ end
61
+
62
+ def patch_data(path, body)
63
+ data(@transport.request("PATCH", path, body: body))
64
+ end
65
+
66
+ def delete_data(path)
67
+ data(@transport.request("DELETE", path))
68
+ end
69
+
70
+ def data(body)
71
+ body.is_a?(Hash) && body.key?("data") ? body["data"] : body
72
+ end
73
+
74
+ # One page of a list. +filters+ with a nil value are not sent.
75
+ def list_page(path, limit, after, filters = {})
76
+ check_limit(limit)
77
+ query = compact(filters.merge(limit: limit, after: after))
78
+ Page.from_body(@transport.request("GET", path, query: query))
79
+ end
80
+
81
+ # Every item of a list, fetching pages as it goes. With a block,
82
+ # yields each item and answers nil; without one, answers an
83
+ # Enumerator (lazy: no request is made until it is iterated).
84
+ def each_item(path, limit, filters = {}, &block)
85
+ check_limit(limit)
86
+ enumerator = Enumerator.new { |yielder| each_page_item(yielder, path, limit, filters) }
87
+ return enumerator unless block
88
+
89
+ enumerator.each(&block)
90
+ nil
91
+ end
92
+
93
+ def each_page_item(yielder, path, limit, filters)
94
+ after = nil
95
+ loop do
96
+ page = list_page(path, limit, after, filters)
97
+ page.each { |item| yielder << item }
98
+ break unless page.next_page?
99
+
100
+ after = page.next_cursor
101
+ end
102
+ end
103
+
104
+ def check_limit(limit)
105
+ return if limit.nil?
106
+ return if limit.is_a?(Integer) && limit.between?(1, MAX_LIMIT)
107
+
108
+ raise ArgumentError, "limit must be an Integer from 1 to #{MAX_LIMIT}, got #{limit.inspect}"
109
+ end
110
+
111
+ def compact(hash)
112
+ hash.reject { |_key, value| value.nil? }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module EndPointBlank
6
+ module Management
7
+ module Resources
8
+ # +/clients+: the organizations you serve as their provider, as client
9
+ # invites (pending and accepted), and managed clients you run for your
10
+ # customers.
11
+ #
12
+ # A client: <tt>{"id", "name", "status" ("pending"/"accepted"),
13
+ # "invite_code" (write keys, while pending), "accepted_at", "managed",
14
+ # "claimed_at", "client_organization", "inserted_at", "updated_at"}</tt>;
15
+ # {#get} adds +contacts+ and +pre_assignments+.
16
+ class Clients < Base
17
+ # GET /clients. @return [Page]
18
+ def list(limit: nil, after: nil)
19
+ list_page(path("clients"), limit, after)
20
+ end
21
+
22
+ # Every client, across pages. @return [Enumerator, nil]
23
+ def each(limit: nil, &block)
24
+ each_item(path("clients"), limit, &block)
25
+ end
26
+
27
+ # GET /clients/:id. @return [Hash]
28
+ def get(id)
29
+ get_data(path("clients", id))
30
+ end
31
+
32
+ # POST /clients.
33
+ #
34
+ # @param contacts [Array<Hash>, nil] your contacts to share, each
35
+ # <tt>{email:, first_name:, last_name:, title: nil, phone_number: nil}</tt>
36
+ # @param packages [Array<Hash>, nil] API packages to assign when the
37
+ # client accepts, each <tt>{api_package_id:, environment_id:}</tt>
38
+ # @param grants [Array<Hash>, nil] direct grants to make when it
39
+ # accepts, each <tt>{target_application_id:, environment_id:,
40
+ # target_endpoint_id: nil}</tt>
41
+ # @param managed [Boolean, nil] true creates a managed client (no
42
+ # packages or grants with it; assign those afterwards)
43
+ # @return [Hash] the new client; +invite_code+ is what the client
44
+ # accepts with
45
+ # rubocop:disable Metrics/ParameterLists
46
+ def create(name:, contacts: nil, packages: nil, grants: nil, managed: nil, idempotency_key: nil)
47
+ body = compact(name: name, contacts: contacts, packages: packages, grants: grants, managed: managed)
48
+ post_data(path("clients"), body, idempotency_key)
49
+ end
50
+ # rubocop:enable Metrics/ParameterLists
51
+
52
+ # Invites a client: {#create} without +managed+.
53
+ def invite(name:, contacts: nil, packages: nil, grants: nil, idempotency_key: nil)
54
+ create(name: name, contacts: contacts, packages: packages, grants: grants,
55
+ idempotency_key: idempotency_key)
56
+ end
57
+
58
+ # Creates a managed client: {#create} with <tt>managed: true</tt>. Set
59
+ # it up with {Client#for_managed_client}.
60
+ def create_managed(name:, contacts: nil, idempotency_key: nil)
61
+ create(name: name, contacts: contacts, managed: true, idempotency_key: idempotency_key)
62
+ end
63
+
64
+ # DELETE /clients/:id. Refused with +managed_client_has_credentials+
65
+ # for a managed client that still holds credentials.
66
+ # @return [Hash] <tt>{"id", "deleted"}</tt>
67
+ def delete(id)
68
+ delete_data(path("clients", id))
69
+ end
70
+
71
+ # POST /clients/:client_id/claim_invites: emails your customer an
72
+ # invite to claim a managed client.
73
+ #
74
+ # +return_to+ (optional, sent only when given) is where EndPointBlank
75
+ # sends the customer's browser once they have claimed it. It must
76
+ # equal, byte for byte, a claim return URL your organization
77
+ # registered in EndPointBlank, or the call is refused with
78
+ # +return_to_not_registered+ (422).
79
+ # @return [Hash] <tt>{"client_id", "email", "sent_at", "expires_at"}</tt>
80
+ def claim_invite(client_id, email:, return_to: nil, idempotency_key: nil)
81
+ body = { email: email }
82
+ body[:return_to] = return_to unless return_to.nil?
83
+ post_data(path("clients", client_id, "claim_invites"), body, idempotency_key)
84
+ end
85
+ end
86
+
87
+ # +/clients/:client_id/packages+: the API packages a client holds from
88
+ # you, or (on a pending invite) is set up to get when it accepts.
89
+ #
90
+ # An assignment: <tt>{"id", "api_package_id", "api_package_name",
91
+ # "environment_id", "status" ("active"/"pending"/"refused"), "refusal",
92
+ # "inserted_at", "updated_at"}</tt>.
93
+ class PackageAssignments < Base
94
+ # @return [Page]
95
+ def list(client_id, limit: nil, after: nil)
96
+ list_page(path("clients", client_id, "packages"), limit, after)
97
+ end
98
+
99
+ # @return [Enumerator, nil]
100
+ def each(client_id, limit: nil, &block)
101
+ each_item(path("clients", client_id, "packages"), limit, &block)
102
+ end
103
+
104
+ # POST /clients/:client_id/packages. Refused with e.g.
105
+ # +nothing_published_in_environment+ or +already_assigned+.
106
+ # @return [Hash] the assignment
107
+ def create(client_id, api_package_id:, environment_id:, idempotency_key: nil)
108
+ post_data(path("clients", client_id, "packages"),
109
+ { api_package_id: api_package_id, environment_id: environment_id }, idempotency_key)
110
+ end
111
+ alias assign create
112
+
113
+ # PATCH /clients/:client_id/packages/:id: move the assignment to
114
+ # another environment. @return [Hash]
115
+ def update(client_id, id, environment_id:)
116
+ patch_data(path("clients", client_id, "packages", id), { environment_id: environment_id })
117
+ end
118
+
119
+ # DELETE /clients/:client_id/packages/:id. @return [Hash] <tt>{"id", "deleted"}</tt>
120
+ def delete(client_id, id)
121
+ delete_data(path("clients", client_id, "packages", id))
122
+ end
123
+ alias unassign delete
124
+ end
125
+
126
+ # +/clients/:client_id/grants+: the grants a client holds directly from
127
+ # you (package-derived grants are managed through {PackageAssignments}).
128
+ #
129
+ # A grant: <tt>{"id", "client_organization_id", "target_application_id",
130
+ # "target_endpoint_id", "all_endpoints", "environment_id",
131
+ # "also_granted_by_api_package_ids", "synced_at", "status", "refusal",
132
+ # "inserted_at", "updated_at"}</tt>.
133
+ class Grants < Base
134
+ # @return [Page]
135
+ def list(client_id, limit: nil, after: nil)
136
+ list_page(path("clients", client_id, "grants"), limit, after)
137
+ end
138
+
139
+ # @return [Enumerator, nil]
140
+ def each(client_id, limit: nil, &block)
141
+ each_item(path("clients", client_id, "grants"), limit, &block)
142
+ end
143
+
144
+ # POST /clients/:client_id/grants. Leave +target_endpoint_id+ nil to
145
+ # grant every endpoint of the application. @return [Hash] the grant
146
+ def create(client_id, target_application_id:, environment_id:, target_endpoint_id: nil, idempotency_key: nil)
147
+ body = compact(target_application_id: target_application_id, target_endpoint_id: target_endpoint_id,
148
+ environment_id: environment_id)
149
+ post_data(path("clients", client_id, "grants"), body, idempotency_key)
150
+ end
151
+
152
+ # DELETE /clients/:client_id/grants/:id. @return [Hash] <tt>{"id", "deleted"}</tt>
153
+ def delete(client_id, id)
154
+ delete_data(path("clients", client_id, "grants", id))
155
+ end
156
+ alias revoke delete
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module EndPointBlank
6
+ module Management
7
+ # Decides whether a failed management API request is sent again, and
8
+ # after how long. See {Transport} for the rules.
9
+ class RetryPolicy
10
+ RETRYABLE_STATUSES = [500, 502, 503, 504].freeze
11
+
12
+ # GET and DELETE are idempotent; POST always carries an Idempotency-Key.
13
+ # PATCH is neither, so a 5xx or a lost answer to one is never re-sent.
14
+ RETRYABLE_METHODS = %w[GET DELETE POST].freeze
15
+
16
+ # Wait before retrying a 409 in progress that has no Retry-After header.
17
+ IN_PROGRESS_WAIT = 1
18
+ # Wait before retrying a 429 that has no Retry-After header (app_portal
19
+ # always sends one; a proxy's 429 may not).
20
+ RATE_LIMITED_WAIT = 1
21
+ # First backoff for a 5xx or a request with no answer; doubles per attempt.
22
+ BACKOFF_BASE = 0.5
23
+
24
+ attr_reader :max_retries, :max_retry_wait
25
+
26
+ def initialize(max_retries:, max_retry_wait:)
27
+ @max_retries = max_retries
28
+ @max_retry_wait = max_retry_wait
29
+ end
30
+
31
+ # Seconds to wait before attempt number +attempt+ + 1 (0 is the first
32
+ # retry), or nil when +error+ must be raised now.
33
+ def delay(method, error, attempt)
34
+ return nil if attempt >= max_retries
35
+
36
+ seconds = wanted_wait(method, error, attempt)
37
+ # A wait longer than max_retry_wait is not waited out.
38
+ seconds && seconds <= max_retry_wait ? seconds : nil
39
+ end
40
+
41
+ private
42
+
43
+ def wanted_wait(method, error, attempt)
44
+ return error.retry_after || RATE_LIMITED_WAIT if error.status == 429
45
+ return in_progress_wait(method, error) if error.code?(ErrorCodes::IDEMPOTENCY_REQUEST_IN_PROGRESS)
46
+ return nil unless server_side_failure?(error) && RETRYABLE_METHODS.include?(method)
47
+
48
+ error.retry_after || backoff(attempt)
49
+ end
50
+
51
+ # Only a POST carries the key the first request is still running under.
52
+ def in_progress_wait(method, error)
53
+ method == "POST" ? error.retry_after || IN_PROGRESS_WAIT : nil
54
+ end
55
+
56
+ def server_side_failure?(error)
57
+ error.code?(ErrorCodes::CONNECTION_ERROR) || RETRYABLE_STATUSES.include?(error.status)
58
+ end
59
+
60
+ def backoff(attempt)
61
+ [BACKOFF_BASE * (2**attempt), max_retry_wait].min
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "excon"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "../version"
7
+ require_relative "error"
8
+ require_relative "retry_policy"
9
+ require_relative "idempotency_key"
10
+ require_relative "url_path"
11
+
12
+ module EndPointBlank
13
+ module Management
14
+ # The HTTP layer under {Client}: one request to the management API, with
15
+ # its headers, its Idempotency-Key and its retries. Built on Excon, the
16
+ # HTTP library the rest of this gem already uses.
17
+ #
18
+ # Retries (at most +max_retries+ after the first attempt, each wait passed
19
+ # to +sleeper+):
20
+ #
21
+ # - 429 +rate_limited+, any method: after Retry-After seconds. Nothing was
22
+ # done, so even a PATCH is safe to send again. A Retry-After longer than
23
+ # +max_retry_wait+ is not waited out; the error is raised instead.
24
+ # - 409 +idempotency_request_in_progress+ (POST only): shortly, with the
25
+ # same key, so the first request's answer is replayed once it finishes.
26
+ # - 5xx (+internal_server_error+, +audit_unavailable+,
27
+ # +intake_unavailable+, or a proxy's 502/503/504) and requests that never
28
+ # got an answer: for GET and DELETE, which are idempotent, and for POST,
29
+ # which always carries an Idempotency-Key. Never for PATCH.
30
+ #
31
+ # Every POST carries an Idempotency-Key (a random UUID v4 unless the
32
+ # caller passed one), and every retry of it sends the same key.
33
+ #
34
+ # 409 +idempotency_replay_unavailable+ is never retried: the first POST
35
+ # succeeded and its answer held a secret shown only once.
36
+ class Transport
37
+ API_PREFIX = "/api/v1"
38
+
39
+ attr_reader :base_url
40
+
41
+ # rubocop:disable Metrics/ParameterLists
42
+ def initialize(api_key:, base_url:, max_retries:, max_retry_wait:, connect_timeout:, read_timeout:,
43
+ sleeper:, excon_options: {})
44
+ @api_key = api_key
45
+ @base_url = base_url
46
+ @base_path = UrlPath.strip_trailing_slashes(URI.parse(base_url).path)
47
+ @retry_policy = RetryPolicy.new(max_retries: max_retries, max_retry_wait: max_retry_wait)
48
+ @connect_timeout = connect_timeout
49
+ @read_timeout = read_timeout
50
+ @sleeper = sleeper
51
+ @excon_options = excon_options
52
+ end
53
+ # rubocop:enable Metrics/ParameterLists
54
+
55
+ # Sends one API request and answers its decoded JSON body (nil for an
56
+ # empty body). +path+ is under /api/v1, e.g. "/organization".
57
+ #
58
+ # @raise [Error] for any answer outside 2xx, once retries are spent
59
+ def request(method, path, query: nil, body: nil, idempotency_key: nil)
60
+ method = method.to_s.upcase
61
+ idempotency_key = IdempotencyKey.resolve(method, idempotency_key)
62
+ attempt = 0
63
+
64
+ loop do
65
+ result = attempt_request(method, path, query, body, idempotency_key)
66
+ return result unless result.is_a?(Error)
67
+
68
+ wait_before_retry(method, path, result, attempt)
69
+ attempt += 1
70
+ end
71
+ end
72
+
73
+ def self.user_agent
74
+ "end_point_blank-ruby/#{EndPointBlank::VERSION} (management)"
75
+ end
76
+
77
+ def inspect
78
+ "#<#{self.class.name} base_url=#{base_url.inspect}>"
79
+ end
80
+
81
+ private
82
+
83
+ # The decoded body on success, or the Error to raise or retry on.
84
+ def attempt_request(method, path, query, body, idempotency_key)
85
+ response = perform(method, path, query, body, idempotency_key)
86
+ return Error.from_response(**error_fields(response, method, path)) unless success?(response)
87
+
88
+ decode(response, method, path)
89
+ rescue Excon::Error::StubNotFound
90
+ raise
91
+ rescue Excon::Error => e
92
+ connection_error(e, method, path)
93
+ end
94
+
95
+ def perform(method, path, query, body, idempotency_key)
96
+ connection = Excon.new(base_url, connect_timeout: @connect_timeout, read_timeout: @read_timeout,
97
+ write_timeout: @read_timeout, persistent: false, **@excon_options)
98
+ params = { method: method, path: "#{@base_path}#{API_PREFIX}#{path}",
99
+ headers: headers(body, idempotency_key) }
100
+ params[:query] = query if query && !query.empty?
101
+ params[:body] = JSON.generate(body) unless body.nil?
102
+ connection.request(params)
103
+ end
104
+
105
+ def headers(body, idempotency_key)
106
+ headers = {
107
+ "Authorization" => "Bearer #{@api_key}",
108
+ "Accept" => "application/json",
109
+ "User-Agent" => self.class.user_agent
110
+ }
111
+ headers["Content-Type"] = "application/json" unless body.nil?
112
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
113
+ headers
114
+ end
115
+
116
+ def success?(response)
117
+ (200..299).cover?(response.status)
118
+ end
119
+
120
+ def decode(response, method, path)
121
+ text = response.body.to_s
122
+ return nil if text.strip.empty?
123
+
124
+ JSON.parse(text)
125
+ rescue JSON::ParserError
126
+ Error.new("The management API answered HTTP #{response.status} with a body that is not JSON.",
127
+ code: ErrorCodes::INVALID_RESPONSE, status: response.status, http_method: method, path: path)
128
+ end
129
+
130
+ def error_fields(response, method, path)
131
+ { status: response.status, headers: response.headers, body: response.body,
132
+ http_method: method, path: path }
133
+ end
134
+
135
+ def connection_error(error, method, path)
136
+ detail = redact("#{error.class.name}: #{error.message}")
137
+ Error.new("Could not reach the management API at #{base_url} (#{detail}).",
138
+ code: ErrorCodes::CONNECTION_ERROR, http_method: method, path: path)
139
+ end
140
+
141
+ # Sleeps before the next attempt, or raises +error+ when it is not to
142
+ # be retried.
143
+ def wait_before_retry(method, path, error, attempt)
144
+ delay = @retry_policy.delay(method, error, attempt)
145
+ raise error if delay.nil?
146
+
147
+ log_retry(method, path, error, delay)
148
+ @sleeper.call(delay)
149
+ end
150
+
151
+ # Method, path, status and code only: never headers, query or bodies,
152
+ # which carry the key, ids and credential secrets.
153
+ def log_retry(method, path, error, delay)
154
+ EndPointBlank.logger.debug(
155
+ "[EndPointBlank] management API #{method} #{path} answered " \
156
+ "#{error.status || "no response"} (#{error.code}); retrying in #{delay}s"
157
+ )
158
+ end
159
+
160
+ def redact(text)
161
+ return text if @api_key.nil? || @api_key.empty?
162
+
163
+ text.gsub(@api_key, "[REDACTED]")
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ module Management
5
+ # URL string helpers for the management client.
6
+ module UrlPath
7
+ SLASH = "/".ord
8
+
9
+ # +text+ without trailing slashes. One backward scan over the bytes, not
10
+ # a regex, so its cost is linear however the input is shaped.
11
+ def self.strip_trailing_slashes(text)
12
+ stop = text.bytesize
13
+ stop -= 1 while stop.positive? && text.getbyte(stop - 1) == SLASH
14
+ text.byteslice(0, stop)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration_error"
4
+ require_relative "management/configuration"
5
+ require_relative "management/error_codes"
6
+ require_relative "management/error"
7
+ require_relative "management/page"
8
+ require_relative "management/retry_policy"
9
+ require_relative "management/idempotency_key"
10
+ require_relative "management/url_path"
11
+ require_relative "management/transport"
12
+ require_relative "management/client"
13
+
14
+ module EndPointBlank
15
+ # The organization management API client. See {Management::Client}.
16
+ #
17
+ # Configure defaults once, e.g. in config/initializers/end_point_blank.rb,
18
+ # apart from the runtime's EndPointBlank.configure:
19
+ #
20
+ # EndPointBlank::Management.configure do |m|
21
+ # m.api_key = Rails.application.credentials.dig(:end_point_blank, :management_key)
22
+ # end
23
+ #
24
+ # EndPointBlank::Management.client.organization
25
+ module Management
26
+ @configuration_mutex = Mutex.new
27
+
28
+ class << self
29
+ # The defaults {Client.new} reads. @return [Configuration]
30
+ def configuration
31
+ @configuration_mutex.synchronize { @configuration ||= Configuration.new }
32
+ end
33
+
34
+ # Yields {configuration} to change the defaults.
35
+ def configure
36
+ yield configuration
37
+ configuration
38
+ end
39
+
40
+ # A new {Client} from {configuration}; +options+ override it.
41
+ # @return [Client]
42
+ def client(**options)
43
+ Client.new(**options)
44
+ end
45
+
46
+ # Back to the built-in defaults (for tests).
47
+ def reset_configuration!
48
+ @configuration_mutex.synchronize { @configuration = Configuration.new }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,6 +1,18 @@
1
1
  module EndPointBlank
2
2
  module Rack
3
3
  module Headers
4
+ # Headers the SDK never sends to intake, lower-cased and matched in any
5
+ # letter case (sc-1470).
6
+ #
7
+ # A request record used to carry every inbound header, so a caller's
8
+ # `Authorization: Basic client_id:secret` or bearer token, a proxy
9
+ # credential and its session cookie landed in the provider's request log
10
+ # unless the provider had written a masking rule for them. The writers
11
+ # drop these before masking runs, rather than mask them, so no rule and
12
+ # no mask_hook can bring them back. `Set-Cookie` never arrives on a
13
+ # request; it is listed so a response record can never carry it either.
14
+ SENSITIVE_HEADERS = %w[authorization proxy-authorization cookie set-cookie].freeze
15
+
4
16
  def self.extract
5
17
  env = ::EndPointBlank::Rack::EnvStore.get
6
18
  return {} if env.nil?
@@ -8,6 +20,20 @@ module EndPointBlank
8
20
  env.select { |k,v| k.start_with? 'HTTP_'}.
9
21
  transform_keys { |k| k.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') }
10
22
  end
23
+
24
+ # The request's headers as a request or response record may send them:
25
+ # {extract} without any of {SENSITIVE_HEADERS}.
26
+ def self.reportable
27
+ without_sensitive(extract)
28
+ end
29
+
30
+ # A new hash of `headers` without any of {SENSITIVE_HEADERS}, whatever
31
+ # their letter case. Never changes its argument; nil is {}.
32
+ def self.without_sensitive(headers)
33
+ return {} if headers.nil?
34
+
35
+ headers.reject { |name, _value| SENSITIVE_HEADERS.include?(name.to_s.downcase) }
36
+ end
11
37
  end
12
38
  end
13
- end
39
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EndPointBlank
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.1"
5
5
  end
@@ -25,7 +25,9 @@ module EndPointBlank
25
25
  env = ::EndPointBlank::Rack::EnvStore.get
26
26
  request = ::Rack::Request.new(env)
27
27
  version = Commands::VersionFinder.new.find(request)
28
- headers = ::EndPointBlank::Rack::Headers.extract
28
+ # Authorization, Proxy-Authorization and Cookie are never sent,
29
+ # masking rule or not (Headers::SENSITIVE_HEADERS, sc-1470).
30
+ headers = ::EndPointBlank::Rack::Headers.reportable
29
31
 
30
32
  {
31
33
  app_name: EndPointBlank::Configuration.instance.app_name,
@@ -24,7 +24,10 @@ module EndPointBlank
24
24
  def payload(status:, headers:, body:, data: {})
25
25
  request = ::EndPointBlank::Rack::EnvStore.request
26
26
  env = ::EndPointBlank::Rack::EnvStore.get
27
- headers = ::EndPointBlank::Rack::Headers.extract
27
+ # The record carries the request's headers, not the `headers:`
28
+ # argument, so without the sc-1470 filter a caller's Authorization
29
+ # and Cookie reached the response record too.
30
+ headers = ::EndPointBlank::Rack::Headers.reportable
28
31
  version = request ? Commands::VersionFinder.new.find(request) : nil
29
32
  route = request ? Commands::RoutePatternFinder.find(request) : nil
30
33
 
@@ -36,6 +36,7 @@ require_relative "end_point_blank/rack/headers"
36
36
  require_relative "end_point_blank/unauthorized_error"
37
37
  require_relative "end_point_blank/token_unavailable_error"
38
38
  require_relative "end_point_blank/configuration_error"
39
+ require_relative "end_point_blank/management"
39
40
  if defined?(::Rails)
40
41
  require_relative "end_point_blank/rails/authenticated"
41
42
  require_relative "end_point_blank/rails/authorized"