end_point_blank 0.13.1 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14c18a9f81130c9ec607b05a25aa5a4a67d9726c6692eb313a395734ce79bcf3
4
- data.tar.gz: 4081ad91a0c10503521993a7d2d7887cd0810faab8b8f22229a57f9ed7685749
3
+ metadata.gz: d3074c1bcab8168bdd552228eceb3f5736cacde44e9b00b84445cdb86021d92b
4
+ data.tar.gz: e94489b6b2b35aa0677273a2d897a7df0f4981be731c13585833fad8b8f0e591
5
5
  SHA512:
6
- metadata.gz: 181770dadb24b746868274196d0600ff58d92ad7e430f2165b8ff7f59e376a0790ed027b9a8fb000b607b20698190755f5de5525ca78d540c198addab72c5fdd
7
- data.tar.gz: 88593bf5bb77615e05d9277de7a4b167d5885a155cb9b30cfac83dbe0335003e84801abe7b826fe55ab81793b8990e0d39578bb05b2033bef2512a4cc5c11d6f
6
+ metadata.gz: fe2541cb6c5e8e387daddec748689dd14277e90c71658b92b48c310b71987d4a2e77179dab3966fc0e7c773be3b94d3622cfb7f4d9edd4af1e2e56c507bcc102
7
+ data.tar.gz: d68a18303b8d04efecfbb4358c8c809a077cbfafc76274e0da4c34782349e822933865fbe9513ee36f2dbc4e39ed237079c0fc433edb5a842c691fce405d4445
data/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Added
6
+
7
+ - **Managed clients carry an `owner_email` (sc-1567).** `clients.create`
8
+ (with `managed: true`) and `clients.create_managed` take `owner_email:`,
9
+ the person at your customer who will own the managed client, and the new
10
+ `clients.update(id, owner_email:)` (`PATCH /api/v1/clients/:id`) changes
11
+ it. Like every PATCH, the update sends no Idempotency-Key and is never
12
+ retried after a 5xx or a lost connection.
13
+ - **The calling organization's id is kept from `/authorize` (sc-1571).**
14
+ `EndPointBlank::Rack::EnvStore.source_organization_id` answers
15
+ `data[0].source_organization_id`, the caller's EndPointBlank organization
16
+ id, beside `source_application_environment_id`, on a cache miss and on a
17
+ cache hit alike (the cache holds intake's whole answer). nil, without a log
18
+ line, when intake is older than the field or the organization has no id. An
19
+ answer cached before intake sent the field still authorizes, with no
20
+ organization, until its entry expires (`cache_ttl`).
21
+ - **`create_portal_session` signs a managed client's owner in to its
22
+ EndPointBlank portal (sc-1574).** `clients.create_portal_session(client_id,
23
+ return_url: nil)` and a managed client's `create_portal_session(return_url:
24
+ nil)` call `POST /api/v1/clients/:client_id/portal_sessions` and answer
25
+ `{"client_id", "url", "expires_at", "return_url"}`: a single-use link that
26
+ expires 60 seconds after it is minted, so mint it when the user clicks and
27
+ redirect their browser to it. `return_url` is sent only when given and must
28
+ equal one of your organization's claim return URLs. Refused with 404 for a
29
+ client that is not yours, and 422 (`client_not_managed`,
30
+ `client_being_removed`, `owner_email_missing`, `return_url_not_registered`)
31
+ for one that is not an unclaimed managed client open to claims with an
32
+ owner email. The answer is never replayed: each call sends a new
33
+ Idempotency-Key, and a reused one answers 409
34
+ `idempotency_replay_unavailable`, whose message now also says to create a
35
+ new portal session with a new key.
36
+ - `ErrorCodes` lists the API codes it was missing: `already_invited` (409),
37
+ `invite_accepted`, `invite_not_open`, `invite_rate_limited` (429),
38
+ `not_an_email_invite`, `client_being_removed`, `client_not_removable`,
39
+ `return_url_not_registered` and `owner_email_missing`.
40
+
41
+ ### Fixed
42
+
43
+ - **The gem loads on Ruby 3.3 (sc-1628).** `LogWriter#payload` defaulted each
44
+ keyword to itself (`message: message`), which Ruby 3.3 rejects with
45
+ "circular argument reference" (a SyntaxError), so requiring the gem failed.
46
+ The keywords are now plain required keywords. `required_ruby_version` is
47
+ lowered from `>= 3.4.2` to `>= 3.3`, and CI runs the suite on 3.3 and 3.4.2.
48
+ - **The response record carries the response's headers (sc-1627).**
49
+ `ReportInteraction` sent the request's headers in the response record, so
50
+ intake's response log showed request headers and any credential header not
51
+ dropped by sc-1470 reached intake twice. It now sends the app's response
52
+ headers (none when the app raised), minus the same sensitive headers the
53
+ request record drops. A header Rack gives as an array is joined with ", ".
54
+ - **API key and token headers are never sent (sc-1626).** Request and
55
+ response records already left out `Authorization`, `Proxy-Authorization`,
56
+ `Cookie` and `Set-Cookie` (sc-1470). They now also leave out `X-Api-Key`,
57
+ `Api-Key`, `X-Auth-Token`, `X-Access-Token`, `X-Refresh-Token`,
58
+ `X-Amz-Security-Token`, `X-Csrf-Token`, `X-Xsrf-Token`, `X-Goog-Api-Key` and
59
+ `Ocp-Apim-Subscription-Key`, in any letter case. Every SDK and intake drop
60
+ the same set.
61
+ - **A failed authorization no longer leaves an earlier caller in the request
62
+ store.** `EndpointAuthorize.authorize` clears the source environment and
63
+ organization ids before it asks, so a refused or unanswered authorization on
64
+ a reused env (a direct caller of the command) cannot name the previous
65
+ caller. The Rack middleware already starts every request from an empty
66
+ store, by installing that request's own env.
67
+
3
68
  ## 0.13.1
4
69
 
5
70
  ### Security
data/README.md CHANGED
@@ -486,7 +486,9 @@ match; `$$` for a literal `$`). Stacktraces and log messages/data are never mask
486
486
  `log` entry in the masking field map).
487
487
 
488
488
  **Credential and cookie headers are never sent.** Before any rule runs, the request and response
489
- records leave out `Authorization`, `Proxy-Authorization`, `Cookie` and `Set-Cookie`, whatever
489
+ records leave out `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`,
490
+ `Api-Key`, `X-Auth-Token`, `X-Access-Token`, `X-Refresh-Token`, `X-Amz-Security-Token`,
491
+ `X-Csrf-Token`, `X-Xsrf-Token`, `X-Goog-Api-Key` and `Ocp-Apim-Subscription-Key`, whatever
490
492
  their letter case. They are left out of the record, not masked: they are not in the payload the
491
493
  rules and hook receive. The list is `EndPointBlank::Rack::Headers::SENSITIVE_HEADERS`.
492
494
 
@@ -658,7 +660,9 @@ A managed client is an organization you create and run for a customer until they
658
660
  under `/api/v1/clients/:client_id/`:
659
661
 
660
662
  ```ruby
661
- customer = mgmt.clients.create_managed(name: "Initech")
663
+ # `owner_email` (optional) names the person at your customer who will own it; change it later
664
+ # with `mgmt.clients.update(customer["id"], owner_email: ...)`.
665
+ customer = mgmt.clients.create_managed(name: "Initech", owner_email: "it@initech.example")
662
666
  initech = mgmt.for_managed_client(customer["id"])
663
667
 
664
668
  # The managed client's organization already has a "production" environment (the name is
@@ -680,6 +684,13 @@ initech.claim_invite(email: "it@initech.example")
680
684
  # equal, byte for byte, a claim return URL your organization registered in EndPointBlank;
681
685
  # anything else is refused with `return_to_not_registered` (422).
682
686
  initech.claim_invite(email: "it@initech.example", return_to: "https://app.example/welcome")
687
+
688
+ # Until they claim it, send its owner into its EndPointBlank portal from your app: mint a link
689
+ # when they click and redirect their browser to it. The link works once and expires after 60
690
+ # seconds, so never render it into a page, and mint a new one (with a new Idempotency-Key, the
691
+ # default) on every click. `return_url:` (optional) must be one of your claim return URLs too.
692
+ portal = initech.create_portal_session(return_url: "https://app.example/welcome")
693
+ redirect_to portal["url"], allow_other_host: true
683
694
  ```
684
695
 
685
696
  Once claimed, the managed client's calls answer `not_found`. Remove an unclaimed one with
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Ruby and Rails SDK for EndPointBlank: authorize service-to-service API calls, report endpoint versions, and see which clients still call deprecated API versions."
12
12
  spec.description = "Ruby and Rails SDK for EndPointBlank: authorize service-to-service (machine-to-machine) API calls, report endpoint versions, and see which clients still call deprecated API versions before you sunset them. A framework-agnostic core runs in plain Ruby / Sinatra, with Rails supported as an auto-loaded adapter."
13
13
  spec.homepage = "https://endpointblank.com"
14
- spec.required_ruby_version = ">= 3.4.2"
14
+ spec.required_ruby_version = ">= 3.3"
15
15
  spec.license = "Nonstandard"
16
16
 
17
17
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
@@ -16,6 +16,12 @@ module EndPointBlank
16
16
  end
17
17
 
18
18
  def authorize(request)
19
+ # A thread that serves more than one request, or a direct caller
20
+ # that reuses one env, must not carry the last caller's identity
21
+ # into this one: a refused or failed authorization leaves both ids
22
+ # unset rather than naming whoever was authorized before.
23
+ record_source(nil, nil)
24
+
19
25
  client_auth = request.headers['Authorization'].to_s
20
26
  method = request.request_method
21
27
  path = request.route_uri_pattern.to_s.gsub(/\([^)]*\)/, '')
@@ -33,14 +39,16 @@ module EndPointBlank
33
39
  # The cached value is the authorize response body, not a truthy
34
40
  # marker.
35
41
  #
36
- # It has to be, for two reasons. Callers parse the body — a cache hit
42
+ # It has to be, for three reasons. Callers parse the body — a cache hit
37
43
  # returning '' made JSON.parse raise, so a cached authorization became
38
44
  # a 500 rather than a fast success. And the body is where the
39
45
  # deprecation block lives; without it the Deprecation and Sunset
40
46
  # headers would appear only on cache misses, which reads as a flaky
41
- # feature rather than a missing one.
47
+ # feature rather than a missing one. And the calling organization's
48
+ # id (sc-1571) is in it too, for the same reason. A body cached
49
+ # before intake sent that id simply has none, and answers nil.
42
50
  if (cached = cache.retrieve(cache_key))
43
- record_source_application_environment_id(cached)
51
+ record_source_ids(cached)
44
52
  return CachedResponse.new(201, cached)
45
53
  end
46
54
 
@@ -70,7 +78,7 @@ module EndPointBlank
70
78
  return nil if response.nil?
71
79
  EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
72
80
  if response.status == 201
73
- record_source_application_environment_id(response.body)
81
+ record_source_ids(response.body)
74
82
  cache.store(cache_key, response.body)
75
83
  elsif response.status > 299
76
84
  EndPointBlank.logger.error "Failed to authorize endpoint: #{response.status} - #{response.body}"
@@ -80,6 +88,33 @@ module EndPointBlank
80
88
 
81
89
  private
82
90
 
91
+ def record_source(source_env_id, source_organization_id)
92
+ ::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(source_env_id)
93
+ ::EndPointBlank::Rack::EnvStore.set_source_organization_id(source_organization_id)
94
+ end
95
+
96
+ def record_source_ids(body)
97
+ record_source_application_environment_id(body)
98
+ ::EndPointBlank::Rack::EnvStore.set_source_organization_id(source_organization_id(body))
99
+ end
100
+
101
+ # sc-1571: the calling organization's EndPointBlank id. An intake older
102
+ # than the field does not send it, and an organization with no id
103
+ # there gets null; both are nil here, silently, since neither is a
104
+ # broken contract. A body that is not a JSON object has already been
105
+ # logged by record_source_application_environment_id.
106
+ def source_organization_id(body)
107
+ parsed = JSON.parse(body)
108
+ return nil unless parsed.is_a?(Hash)
109
+
110
+ data = parsed['data']
111
+ first = data.is_a?(Array) ? data.first : nil
112
+ id = first.is_a?(Hash) ? first['source_organization_id'] : nil
113
+ id.is_a?(String) && !id.empty? ? id : nil
114
+ rescue JSON::ParserError
115
+ nil
116
+ end
117
+
83
118
  # Intake renders the grant under data[0]. The Rails controller also
84
119
  # records this value, but the command is also used directly (today,
85
120
  # by end_point_blank_deploy's conformance driver), so the command must
@@ -222,6 +222,14 @@ module EndPointBlank
222
222
  @clients.claim_invite(client_id, email: email, return_to: return_to, idempotency_key: idempotency_key)
223
223
  end
224
224
 
225
+ # POST /clients/:client_id/portal_sessions: a single-use, 60-second
226
+ # link that signs the client's owner in to its EndPointBlank portal
227
+ # until they claim it; see {Resources::Clients#create_portal_session}.
228
+ # @return [Hash]
229
+ def create_portal_session(return_url: nil, idempotency_key: nil)
230
+ @clients.create_portal_session(client_id, return_url: return_url, idempotency_key: idempotency_key)
231
+ end
232
+
225
233
  def inspect
226
234
  "#<#{self.class.name} client_id=#{client_id.inspect}>"
227
235
  end
@@ -125,7 +125,8 @@ module EndPointBlank
125
125
  "that is shown only once, so it can't be replayed and was not retried. Read or list " \
126
126
  "the resource to see its current state"
127
127
  message << " (#{location})" if location
128
- message << ". If you need a secret you never received, rotate the credential."
128
+ message << ". If you need a secret you never received, rotate the credential; for a portal " \
129
+ "session, create a new one with a new key."
129
130
  message
130
131
  end
131
132
  end
@@ -56,6 +56,13 @@ module EndPointBlank
56
56
  CLIENT_ACCEPTED = "client_accepted"
57
57
  CLIENT_NOT_MANAGED = "client_not_managed"
58
58
  ALREADY_A_MEMBER = "already_a_member"
59
+ ALREADY_INVITED = "already_invited"
60
+ INVITE_ACCEPTED = "invite_accepted"
61
+ INVITE_NOT_OPEN = "invite_not_open"
62
+ INVITE_RATE_LIMITED = "invite_rate_limited"
63
+ NOT_AN_EMAIL_INVITE = "not_an_email_invite"
64
+ CLIENT_BEING_REMOVED = "client_being_removed"
65
+ CLIENT_NOT_REMOVABLE = "client_not_removable"
59
66
  MANAGED_CLIENT_HAS_CREDENTIALS = "managed_client_has_credentials"
60
67
  API_PACKAGE_NOT_FOUND = "api_package_not_found"
61
68
  ENVIRONMENT_NOT_FOUND = "environment_not_found"
@@ -69,6 +76,11 @@ module EndPointBlank
69
76
  # A claim invite's +return_to+ is not one of the claim return URLs your
70
77
  # organization registered.
71
78
  RETURN_TO_NOT_REGISTERED = "return_to_not_registered"
79
+ # A portal session's +return_url+ is not one of those claim return URLs.
80
+ RETURN_URL_NOT_REGISTERED = "return_url_not_registered"
81
+ # A portal session for a managed client with no owner email; set one
82
+ # with <tt>clients.update</tt>.
83
+ OWNER_EMAIL_MISSING = "owner_email_missing"
72
84
 
73
85
  # The warning code API package endpoint writes answer in +warnings+ (not
74
86
  # an error): a client assignment of the package now derives no grant.
@@ -97,10 +109,13 @@ module EndPointBlank
97
109
  DELETE_REFUSED => 422, INTAKE_CREDENTIAL => 409, INTAKE_REJECTED => 422, INTAKE_UNAVAILABLE => 503,
98
110
  INVALID_CONTACTS => 422, INVALID_PACKAGES => 422, INVALID_GRANTS => 422, INVALID_MANAGED => 422,
99
111
  CLIENT_NOT_ACCEPTED => 422, CLIENT_ACCEPTED => 422, CLIENT_NOT_MANAGED => 422,
100
- ALREADY_A_MEMBER => 422, MANAGED_CLIENT_HAS_CREDENTIALS => 422, API_PACKAGE_NOT_FOUND => 422,
112
+ ALREADY_A_MEMBER => 422, ALREADY_INVITED => 409, INVITE_ACCEPTED => 422, INVITE_NOT_OPEN => 422,
113
+ INVITE_RATE_LIMITED => 429, NOT_AN_EMAIL_INVITE => 422, CLIENT_BEING_REMOVED => 422,
114
+ CLIENT_NOT_REMOVABLE => 422, MANAGED_CLIENT_HAS_CREDENTIALS => 422, API_PACKAGE_NOT_FOUND => 422,
101
115
  ENVIRONMENT_NOT_FOUND => 422, ALREADY_ASSIGNED => 422, NOTHING_PUBLISHED_IN_ENVIRONMENT => 422,
102
116
  APPLICATION_NOT_FOUND => 422, ENDPOINT_NOT_FOUND => 422, ENVIRONMENT_NOT_IN_APPLICATION => 422,
103
- ALREADY_GRANTED => 422, GRANT_REVOKED_CONCURRENTLY => 409, RETURN_TO_NOT_REGISTERED => 422
117
+ ALREADY_GRANTED => 422, GRANT_REVOKED_CONCURRENTLY => 409, RETURN_TO_NOT_REGISTERED => 422,
118
+ RETURN_URL_NOT_REGISTERED => 422, OWNER_EMAIL_MISSING => 422
104
119
  }.freeze
105
120
 
106
121
  # Every code the API can send, as strings.
@@ -40,11 +40,16 @@ module EndPointBlank
40
40
  # target_endpoint_id: nil}</tt>
41
41
  # @param managed [Boolean, nil] true creates a managed client (no
42
42
  # packages or grants with it; assign those afterwards)
43
+ # @param owner_email [String, nil] with <tt>managed: true</tt>, the
44
+ # email address of the person at your customer who will own the
45
+ # managed client; change it later with {#update}
43
46
  # @return [Hash] the new client; +invite_code+ is what the client
44
47
  # accepts with
45
48
  # 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)
49
+ def create(name:, contacts: nil, packages: nil, grants: nil, managed: nil, owner_email: nil,
50
+ idempotency_key: nil)
51
+ body = compact(name: name, contacts: contacts, packages: packages, grants: grants, managed: managed,
52
+ owner_email: owner_email)
48
53
  post_data(path("clients"), body, idempotency_key)
49
54
  end
50
55
  # rubocop:enable Metrics/ParameterLists
@@ -57,8 +62,15 @@ module EndPointBlank
57
62
 
58
63
  # Creates a managed client: {#create} with <tt>managed: true</tt>. Set
59
64
  # 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)
65
+ def create_managed(name:, contacts: nil, owner_email: nil, idempotency_key: nil)
66
+ create(name: name, contacts: contacts, managed: true, owner_email: owner_email,
67
+ idempotency_key: idempotency_key)
68
+ end
69
+
70
+ # PATCH /clients/:id: +owner_email+ is the email address of the
71
+ # person at your customer who will own a managed client. @return [Hash]
72
+ def update(id, owner_email:)
73
+ patch_data(path("clients", id), { owner_email: owner_email })
62
74
  end
63
75
 
64
76
  # DELETE /clients/:id. Refused with +managed_client_has_credentials+
@@ -82,6 +94,34 @@ module EndPointBlank
82
94
  body[:return_to] = return_to unless return_to.nil?
83
95
  post_data(path("clients", client_id, "claim_invites"), body, idempotency_key)
84
96
  end
97
+
98
+ # POST /clients/:client_id/portal_sessions: mints a single-use link
99
+ # that signs the owner of managed client +client_id+ in to its
100
+ # EndPointBlank portal.
101
+ #
102
+ # The link expires 60 seconds after it is minted and works once, so
103
+ # mint it when the user clicks and redirect their browser to it; never
104
+ # render it into a page, log it or send it in an email. +return_url+
105
+ # (optional, sent only when given) is where the portal links back to,
106
+ # and must equal, byte for byte, a claim return URL your organization
107
+ # registered.
108
+ #
109
+ # Each call sends a new Idempotency-Key unless you pass one, and that
110
+ # is what you want: the answer is never replayed, so a key used before
111
+ # is refused with +idempotency_replay_unavailable+ (409). Never reuse a
112
+ # key across clicks.
113
+ #
114
+ # Refused with +not_found+ (404) for a client that is not yours, and
115
+ # with +client_not_managed+ (not a managed client, or already
116
+ # claimed), +client_being_removed+, +owner_email_missing+ (set one with
117
+ # {#update}) or +return_url_not_registered+ (an empty string included),
118
+ # all 422.
119
+ # @return [Hash] <tt>{"client_id", "url", "expires_at", "return_url"}</tt>,
120
+ # +return_url+ nil when none was given
121
+ def create_portal_session(client_id, return_url: nil, idempotency_key: nil)
122
+ body = return_url.nil? ? nil : { return_url: return_url }
123
+ post_data(path("clients", client_id, "portal_sessions"), body, idempotency_key)
124
+ end
85
125
  end
86
126
 
87
127
  # +/clients/:client_id/packages+: the API packages a client holds from
@@ -14,6 +14,10 @@ module EndPointBlank
14
14
  end
15
15
 
16
16
  def call(env)
17
+ # Start from nothing: installing this request's own env replaces the
18
+ # store wholesale, so an earlier request on this thread whose ensure
19
+ # never ran cannot lend its caller (source environment and
20
+ # organization ids, deprecation) to this one.
17
21
  ::EndPointBlank::Rack::EnvStore.set(env)
18
22
  ::EndPointBlank::Writers::RequestWriter.write
19
23
 
@@ -44,8 +48,8 @@ module EndPointBlank
44
48
 
45
49
  raise e
46
50
  ensure
47
- headers = ::EndPointBlank::Rack::Headers.extract
48
- ::EndPointBlank::Writers::ResponseWriter.write(status:, headers:, body:)
51
+ # The app's response headers; none when the app raised (sc-1627).
52
+ ::EndPointBlank::Writers::ResponseWriter.write(status:, headers: headers || {}, body:)
49
53
  ::EndPointBlank::Rack::EnvStore.clear
50
54
  end
51
55
 
@@ -22,6 +22,7 @@ module EndPointBlank
22
22
  class EnvStore
23
23
  KEY = 'end_point_blank.rack_env'.freeze
24
24
  SOURCE_ENV_ID_KEY = 'end_point_blank.source_application_environment_id'.freeze
25
+ SOURCE_ORGANIZATION_ID_KEY = 'end_point_blank.source_organization_id'.freeze
25
26
  DEPRECATION_KEY = 'end_point_blank.deprecation'.freeze
26
27
 
27
28
  def self.set(env)
@@ -47,6 +48,18 @@ module EndPointBlank
47
48
  fetch(SOURCE_ENV_ID_KEY)
48
49
  end
49
50
 
51
+ # The calling organization's EndPointBlank id, from intake's authorize
52
+ # answer (data[0].source_organization_id, sc-1571), resolved beside the
53
+ # source application environment id. nil when intake is older than that
54
+ # field or the organization has no id there.
55
+ def self.set_source_organization_id(id)
56
+ put(SOURCE_ORGANIZATION_ID_KEY, id)
57
+ end
58
+
59
+ def self.source_organization_id
60
+ fetch(SOURCE_ORGANIZATION_ID_KEY)
61
+ end
62
+
50
63
  # The authorize response's deprecation block, stashed on the way in so the
51
64
  # response middleware can turn it into `Deprecation` and `Sunset` headers
52
65
  # on the way out.
@@ -11,7 +11,26 @@ module EndPointBlank
11
11
  # drop these before masking runs, rather than mask them, so no rule and
12
12
  # no mask_hook can bring them back. `Set-Cookie` never arrives on a
13
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
14
+ #
15
+ # sc-1626 added the other headers that commonly carry an API key or
16
+ # token. Every SDK and intake's ingest backstop drop this same set; keep
17
+ # them in step.
18
+ SENSITIVE_HEADERS = %w[
19
+ authorization
20
+ proxy-authorization
21
+ cookie
22
+ set-cookie
23
+ x-api-key
24
+ api-key
25
+ x-auth-token
26
+ x-access-token
27
+ x-refresh-token
28
+ x-amz-security-token
29
+ x-csrf-token
30
+ x-xsrf-token
31
+ x-goog-api-key
32
+ ocp-apim-subscription-key
33
+ ].freeze
15
34
 
16
35
  def self.extract
17
36
  env = ::EndPointBlank::Rack::EnvStore.get
@@ -27,6 +46,19 @@ module EndPointBlank
27
46
  without_sensitive(extract)
28
47
  end
29
48
 
49
+ # A response's own headers as a response record may send them
50
+ # (sc-1627): a plain hash of name to string, without any of
51
+ # {SENSITIVE_HEADERS}. Accepts a Hash or a Rack::Headers; a header Rack 3
52
+ # gives as an array of values is joined with ", ". nil is {}.
53
+ def self.reportable_response(headers)
54
+ return {} if headers.nil?
55
+
56
+ plain = headers.to_h.to_h do |name, value|
57
+ [name.to_s, value.is_a?(Array) ? value.join(", ") : value.to_s]
58
+ end
59
+ without_sensitive(plain)
60
+ end
61
+
30
62
  # A new hash of `headers` without any of {SENSITIVE_HEADERS}, whatever
31
63
  # their letter case. Never changes its argument; nil is {}.
32
64
  def self.without_sensitive(headers)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EndPointBlank
4
- VERSION = "0.13.1"
4
+ VERSION = "0.14.0"
5
5
  end
@@ -37,7 +37,7 @@ module EndPointBlank
37
37
  instance.write(message, level, data)
38
38
  end
39
39
 
40
- def payload(message: message, level: level, data: data)
40
+ def payload(message:, level:, data:)
41
41
  env = ::EndPointBlank::Rack::EnvStore.get
42
42
  uuid = request_uuid(env)
43
43
 
@@ -24,10 +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
- # 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
27
+ # The response's own headers (sc-1627). This used to send the
28
+ # request's headers instead, so the response log showed the caller's
29
+ # headers and any credential header reached intake a second time.
30
+ headers = ::EndPointBlank::Rack::Headers.reportable_response(headers)
31
31
  version = request ? Commands::VersionFinder.new.find(request) : nil
32
32
  route = request ? Commands::RoutePatternFinder.find(request) : nil
33
33
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: end_point_blank
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert A. Lasch
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-06 00:00:00.000000000 Z
10
+ date: 2026-10-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: excon
@@ -175,7 +175,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
175
175
  requirements:
176
176
  - - ">="
177
177
  - !ruby/object:Gem::Version
178
- version: 3.4.2
178
+ version: '3.3'
179
179
  required_rubygems_version: !ruby/object:Gem::Requirement
180
180
  requirements:
181
181
  - - ">="