end_point_blank 0.2.1 → 0.6.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 +4 -4
- data/CHANGELOG.md +36 -0
- data/HARDENING_REPORT.md +210 -0
- data/README.md +59 -8
- data/deploy +5 -0
- data/end_point_blank.gemspec +7 -2
- data/lib/end_point_blank/access_tokens.rb +191 -43
- data/lib/end_point_blank/authorization.rb +10 -2
- data/lib/end_point_blank/base_url.rb +218 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +34 -12
- data/lib/end_point_blank/commands/endpoint_update.rb +1 -1
- data/lib/end_point_blank/commands/generate_access_token.rb +3 -3
- data/lib/end_point_blank/configuration.rb +2 -1
- data/lib/end_point_blank/deprecation_headers.rb +90 -0
- data/lib/end_point_blank/middleware/rack/report_interaction.rb +8 -0
- data/lib/end_point_blank/rack/env_store.rb +48 -4
- data/lib/end_point_blank/rack/headers.rb +2 -0
- data/lib/end_point_blank/rails/authorized.rb +3 -0
- data/lib/end_point_blank/rails/versioned.rb +14 -16
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank/writers/request_writer.rb +7 -3
- data/lib/end_point_blank/writers/response_writer.rb +1 -0
- data/lib/end_point_blank.rb +2 -0
- metadata +11 -6
|
@@ -9,9 +9,17 @@ module EndPointBlank
|
|
|
9
9
|
EndPointBlank::Configuration.instance
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# Builds an outbound Authorization header value.
|
|
13
|
+
# @param base_url [String, nil] the URL you are about to call, with any
|
|
14
|
+
# query string and fragment removed. If given, a token covering it is
|
|
15
|
+
# used (minting one if necessary) and returned as a Bearer header.
|
|
16
|
+
# Called with no argument this is the Basic form -- which is what the
|
|
17
|
+
# calls to intake itself use, since intake already holds this
|
|
18
|
+
# service's own credential.
|
|
19
|
+
# @return [String] "Bearer <token>" or "Basic <credentials>"
|
|
20
|
+
def header(base_url = nil)
|
|
13
21
|
token = nil
|
|
14
|
-
token = EndPointBlank::AccessTokens.token(
|
|
22
|
+
token = EndPointBlank::AccessTokens.token(base_url) if base_url
|
|
15
23
|
|
|
16
24
|
if token
|
|
17
25
|
"Bearer " + token
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EndPointBlank
|
|
4
|
+
# Resolves the base URL the caller used -- scheme, host and port -- from a
|
|
5
|
+
# Rack env.
|
|
6
|
+
#
|
|
7
|
+
# This deliberately does not go through Rack::Request#host / #scheme / #port.
|
|
8
|
+
# Rack, Express, the servlet spec and Plug each resolve "host" differently
|
|
9
|
+
# (Rack takes the last X-Forwarded-Host hop, Express the first, WSGI and Plug
|
|
10
|
+
# neither), which is precisely why the same request produced five different
|
|
11
|
+
# answers across the five clients. The env is read directly so that this
|
|
12
|
+
# algorithm is the same one implemented in the JS, Python, Java and Elixir
|
|
13
|
+
# libraries.
|
|
14
|
+
#
|
|
15
|
+
# Forwarded headers are honored when `trust_proxy_headers` is on, which it is
|
|
16
|
+
# by default: `host` was already caller-controlled in every client (all five
|
|
17
|
+
# read the Host header), so this opens no new hole for the field that matters
|
|
18
|
+
# most, and taking the LAST hop means that behind a proxy that appends, the
|
|
19
|
+
# value is the proxy's own observation rather than anything the caller
|
|
20
|
+
# planted. A directly-exposed deployment -- where nothing sits in front to
|
|
21
|
+
# overwrite the headers -- can set the flag to false, and then only the
|
|
22
|
+
# connection and the Host header are consulted.
|
|
23
|
+
#
|
|
24
|
+
# The flag arrives as an argument rather than being read from Configuration
|
|
25
|
+
# here, so that this module stays framework- and configuration-free and both
|
|
26
|
+
# states are directly testable.
|
|
27
|
+
module BaseUrl
|
|
28
|
+
HOSTNAME = /\A[a-z0-9._-]+\z/
|
|
29
|
+
IPV6 = /\A\[[0-9a-f:.]+\]\z/
|
|
30
|
+
SCHEME = /\A[a-z][a-z0-9+.-]{0,31}\z/
|
|
31
|
+
DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Returns a Hash carrying only the fields that resolved to a usable value.
|
|
36
|
+
# A field that could not be resolved is absent, never nil: the receiver has
|
|
37
|
+
# to be able to tell "this SDK did not report a port" from "the port is
|
|
38
|
+
# null".
|
|
39
|
+
#
|
|
40
|
+
# With trust_proxy_headers: false the three X-Forwarded-* headers are not
|
|
41
|
+
# read at all, so the request is never treated as proxied and the
|
|
42
|
+
# connection's scheme and port stay evidence.
|
|
43
|
+
def from_rack_env(env, trust_proxy_headers: true)
|
|
44
|
+
return {} unless env.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
forwarded_scheme = trust_proxy_headers ? clean_scheme(last_hop(env["HTTP_X_FORWARDED_PROTO"])) : nil
|
|
47
|
+
forwarded_host_part, forwarded_host_authority_port =
|
|
48
|
+
trust_proxy_headers ? split_authority(last_hop(env["HTTP_X_FORWARDED_HOST"])) : [nil, nil]
|
|
49
|
+
forwarded_host = clean_host(forwarded_host_part)
|
|
50
|
+
forwarded_port = trust_proxy_headers ? parse_port(last_hop(env["HTTP_X_FORWARDED_PORT"])) : nil
|
|
51
|
+
|
|
52
|
+
# Evidence is judged AFTER validation, not on raw header presence: a
|
|
53
|
+
# malformed header (e.g. "X-Forwarded-Port: not-a-port") parses to
|
|
54
|
+
# nothing and so must never count as proxy evidence, or an
|
|
55
|
+
# unauthenticated caller could blank an otherwise-valid field just by
|
|
56
|
+
# sending garbage.
|
|
57
|
+
#
|
|
58
|
+
# Only a forwarded scheme or port counts as evidence strong enough to
|
|
59
|
+
# distrust the raw connection. A forwarded Host alone does not: some
|
|
60
|
+
# proxies rewrite only the Host header and pass scheme/port through
|
|
61
|
+
# unchanged, so a valid X-Forwarded-Host by itself says nothing about
|
|
62
|
+
# whether the connection's own scheme/port belong to the proxy or the
|
|
63
|
+
# caller.
|
|
64
|
+
proxied = !forwarded_scheme.nil? || !forwarded_port.nil?
|
|
65
|
+
|
|
66
|
+
# A malformed X-Forwarded-Host (wrong shape, or too long -- see
|
|
67
|
+
# clean_host) gets the same treatment as a malformed proto or port: it
|
|
68
|
+
# is ignored entirely and falls back to the direct Host header, exactly
|
|
69
|
+
# as if the header were absent, rather than leaving host unresolved.
|
|
70
|
+
host_part, authority_port =
|
|
71
|
+
if forwarded_host
|
|
72
|
+
[forwarded_host_part, forwarded_host_authority_port]
|
|
73
|
+
else
|
|
74
|
+
split_authority(host_authority(env))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
scheme = forwarded_scheme || (proxied ? nil : clean_scheme(env["rack.url_scheme"]))
|
|
78
|
+
host = forwarded_host || clean_host(host_part)
|
|
79
|
+
port_candidate = forwarded_port ||
|
|
80
|
+
parse_port(authority_port) ||
|
|
81
|
+
(proxied ? nil : parse_port(env["SERVER_PORT"]))
|
|
82
|
+
port = usable_port(port_candidate, scheme)
|
|
83
|
+
|
|
84
|
+
resolved = {}
|
|
85
|
+
resolved[:scheme] = scheme if scheme
|
|
86
|
+
resolved[:host] = host if host
|
|
87
|
+
resolved[:port] = port if port
|
|
88
|
+
resolved
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The hostname alone, for the authorize path.
|
|
92
|
+
#
|
|
93
|
+
# Deliberately NOT from_rack_env(env)[:host]: reads the Host header only,
|
|
94
|
+
# never the forwarded chain, however trust_proxy_headers is set. The
|
|
95
|
+
# value feeds `target_hostname` and the access-token cache key, and the
|
|
96
|
+
# portal resolves an application environment from it -- a value matching
|
|
97
|
+
# no registered row is a hard 422 with no fallback, not a cache miss.
|
|
98
|
+
#
|
|
99
|
+
# Composed from the same split_authority/clean_host pair from_rack_env
|
|
100
|
+
# uses, so IPv6 bracketing, lowercasing, and shape and length validation
|
|
101
|
+
# are identical between the two; only the authority's source differs.
|
|
102
|
+
def hostname_from_rack_env(env)
|
|
103
|
+
return nil unless env.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
host_part, _authority_port = split_authority(host_authority(env))
|
|
106
|
+
clean_host(host_part)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A proxy that appends writes its own observation last. A proxy that
|
|
110
|
+
# overwrites (nginx, Caddy, ALB) emits one value, where first and last are
|
|
111
|
+
# the same thing.
|
|
112
|
+
def last_hop(value)
|
|
113
|
+
return nil unless value.is_a?(String)
|
|
114
|
+
|
|
115
|
+
hops = value.split(",").map(&:strip).reject(&:empty?)
|
|
116
|
+
hops.last
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The authority the caller named, for both resolution paths.
|
|
120
|
+
#
|
|
121
|
+
# An empty Host header is treated as ABSENT, not as a present-but-unusable
|
|
122
|
+
# value. A caller that sends `Host:` with nothing after it has said nothing
|
|
123
|
+
# about which host it meant, so there is nothing there to prefer over
|
|
124
|
+
# SERVER_NAME. Falling through concedes no control the caller did not
|
|
125
|
+
# already have either: SERVER_NAME is a server-side value, not one a
|
|
126
|
+
# request can steer.
|
|
127
|
+
#
|
|
128
|
+
# On the authorize path the alternative is worse than cosmetic. Resolving
|
|
129
|
+
# the host to nil there drops the request to Basic auth and skips the token
|
|
130
|
+
# mint entirely, where falling through yields a usable
|
|
131
|
+
# application-environment lookup key.
|
|
132
|
+
#
|
|
133
|
+
# This CHANGES Ruby's behavior: an empty Host header used to resolve the
|
|
134
|
+
# host to nil. `env["HTTP_HOST"] || env["SERVER_NAME"]` stops at "" because
|
|
135
|
+
# "" is truthy in Ruby. Python, Java and JS already fell through, because
|
|
136
|
+
# "" is falsy there; Ruby and Elixir stopped, because "" is truthy in both.
|
|
137
|
+
# One expression written five times, diverging only on the empty case. It
|
|
138
|
+
# now lives at one site per SDK, and this comment is why.
|
|
139
|
+
#
|
|
140
|
+
# `.to_s.empty?` so a nil Host header takes the same branch as an empty one.
|
|
141
|
+
def host_authority(env)
|
|
142
|
+
host = env["HTTP_HOST"]
|
|
143
|
+
host.to_s.empty? ? env["SERVER_NAME"] : host
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# "api.example.com:8443" -> ["api.example.com", "8443"]
|
|
147
|
+
# "[2001:db8::1]:8443" -> ["[2001:db8::1]", "8443"]
|
|
148
|
+
def split_authority(value)
|
|
149
|
+
return [nil, nil] unless value.is_a?(String)
|
|
150
|
+
|
|
151
|
+
authority = value.strip
|
|
152
|
+
if authority.start_with?("[")
|
|
153
|
+
head, bracket, tail = authority.partition("]")
|
|
154
|
+
return [nil, nil] if bracket.empty?
|
|
155
|
+
|
|
156
|
+
["#{head}]", tail.start_with?(":") ? tail[1..] : nil]
|
|
157
|
+
elsif authority.count(":") == 1
|
|
158
|
+
host, _, port = authority.partition(":")
|
|
159
|
+
[host, port]
|
|
160
|
+
else
|
|
161
|
+
[authority, nil]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Normalize, then validate. "HTTPS" and "https:" both have to reach intake
|
|
166
|
+
# as "https": JS's location.protocol and Node's URL#protocol keep the
|
|
167
|
+
# colon, nothing pins the case, and intake never rewrites a stored row --
|
|
168
|
+
# two spellings of the same scheme would split the dominant-triple
|
|
169
|
+
# grouping forever. delete_suffix removes one colon, not all of them, so
|
|
170
|
+
# "https::" still fails the shape check rather than sneaking through.
|
|
171
|
+
def clean_scheme(value)
|
|
172
|
+
return nil unless value.is_a?(String)
|
|
173
|
+
|
|
174
|
+
scheme = value.strip.downcase.delete_suffix(":")
|
|
175
|
+
scheme.match?(SCHEME) ? scheme : nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# DNS caps a hostname at 253 characters, and the receiving column is
|
|
179
|
+
# varchar(255). No web-server adapter validates the length of
|
|
180
|
+
# X-Forwarded-Host, so without this a caller could make the SDK report an
|
|
181
|
+
# arbitrarily long value; dropped, not truncated, because a truncated
|
|
182
|
+
# hostname is a plausible-looking WRONG one and the portal reads `host`
|
|
183
|
+
# verbatim to assemble a base URL.
|
|
184
|
+
def clean_host(value)
|
|
185
|
+
return nil unless value.is_a?(String)
|
|
186
|
+
|
|
187
|
+
host = value.strip.downcase
|
|
188
|
+
return nil if host.empty? || host.bytesize > 253
|
|
189
|
+
|
|
190
|
+
host.match?(HOSTNAME) || host.match?(IPV6) ? host : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Numeric validation only -- 1..65535, nothing scheme-aware. Used both to
|
|
194
|
+
# decide whether a forwarded/authority port counts as a usable value and
|
|
195
|
+
# as a port candidate; default-port omission happens exactly once, in
|
|
196
|
+
# usable_port, against the FINAL resolved scheme, so it can never be
|
|
197
|
+
# skipped just because the scheme happened to resolve from a different
|
|
198
|
+
# source than the port did.
|
|
199
|
+
def parse_port(value)
|
|
200
|
+
port = Integer(value.to_s.strip, 10, exception: false)
|
|
201
|
+
return nil if port.nil? || port < 1 || port > 65_535
|
|
202
|
+
|
|
203
|
+
port
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A port is reported only when it can be classified against a resolved
|
|
207
|
+
# scheme. With no scheme, "default" is meaningless, so an unclassifiable
|
|
208
|
+
# port is withheld entirely rather than guessed at -- the same origin must
|
|
209
|
+
# never be reportable two ways depending on which headers happened to
|
|
210
|
+
# arrive.
|
|
211
|
+
def usable_port(candidate, scheme)
|
|
212
|
+
return nil if candidate.nil? || scheme.nil?
|
|
213
|
+
return nil if DEFAULT_PORTS[scheme] == candidate
|
|
214
|
+
|
|
215
|
+
candidate
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/bin/ruby
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require_relative 'http'
|
|
4
5
|
require_relative 'authentication_cache'
|
|
5
6
|
|
|
@@ -19,35 +20,56 @@ module EndPointBlank
|
|
|
19
20
|
method = request.request_method
|
|
20
21
|
path = request.route_uri_pattern.to_s.gsub(/\([^)]*\)/, '')
|
|
21
22
|
app_name = Configuration.instance.app_name
|
|
22
|
-
|
|
23
|
+
# The version is part of the key because authorization is decided per
|
|
24
|
+
# endpoint version, and so is the deprecation carried back with it.
|
|
25
|
+
# Without it, two callers on different versions of the same route share
|
|
26
|
+
# one entry: whichever authorizes first decides both, so a client on a
|
|
27
|
+
# deprecated version can get no warning, or one on a current version
|
|
28
|
+
# can be told it is retiring.
|
|
29
|
+
version = VersionFinder.new.find(request)
|
|
30
|
+
cache_key = "epb_auth:#{client_auth}:#{path}:#{method}:#{app_name}:#{version}"
|
|
23
31
|
|
|
24
32
|
cache = AuthenticationCache.instance
|
|
25
|
-
|
|
33
|
+
# The cached value is the authorize response body, not a truthy
|
|
34
|
+
# marker.
|
|
35
|
+
#
|
|
36
|
+
# It has to be, for two reasons. Callers parse the body — a cache hit
|
|
37
|
+
# returning '' made JSON.parse raise, so a cached authorization became
|
|
38
|
+
# a 500 rather than a fast success. And the body is where the
|
|
39
|
+
# deprecation block lives; without it the Deprecation and Sunset
|
|
40
|
+
# headers would appear only on cache misses, which reads as a flaky
|
|
41
|
+
# feature rather than a missing one.
|
|
42
|
+
if (cached = cache.retrieve(cache_key))
|
|
43
|
+
return CachedResponse.new(201, cached)
|
|
44
|
+
end
|
|
26
45
|
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
# Host header only, never the forwarded chain -- see
|
|
47
|
+
# BaseUrl.hostname_from_rack_env. request.host would read
|
|
48
|
+
# X-Forwarded-Host unconditionally.
|
|
49
|
+
hostname = EndPointBlank::BaseUrl.hostname_from_rack_env(request.env)
|
|
29
50
|
body = {
|
|
30
51
|
path: path,
|
|
31
52
|
http_method: method,
|
|
32
53
|
client_auth: client_auth,
|
|
33
54
|
target_hostname: hostname,
|
|
34
55
|
application: app_name,
|
|
35
|
-
endpoint_version:
|
|
56
|
+
endpoint_version: version,
|
|
36
57
|
source_ip: request.remote_ip,
|
|
37
58
|
uuid: request.uuid
|
|
38
59
|
}
|
|
39
|
-
response = Http.post(configuration.authorize_url, auth, body)
|
|
40
60
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
# Basic, not Bearer. This call is to intake, which already holds
|
|
62
|
+
# this service's credential -- minting a token to present it back
|
|
63
|
+
# was a hop that bought nothing. With no Bearer there is no stale
|
|
64
|
+
# token, so the 401 retry that used to live here is gone: a 401 now
|
|
65
|
+
# means the credential is wrong, which is worth surfacing rather
|
|
66
|
+
# than retrying.
|
|
67
|
+
response = Http.post(configuration.authorize_url, Authorization.header, body)
|
|
46
68
|
|
|
47
69
|
return nil if response.nil?
|
|
48
70
|
EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
|
|
49
71
|
if response.status == 201
|
|
50
|
-
cache.store(cache_key,
|
|
72
|
+
cache.store(cache_key, response.body)
|
|
51
73
|
elsif response.status > 299
|
|
52
74
|
EndPointBlank.logger.error "Failed to authorize endpoint: #{response.status} - #{response.body}"
|
|
53
75
|
end
|
|
@@ -110,7 +110,7 @@ module EndPointBlank
|
|
|
110
110
|
nil
|
|
111
111
|
else
|
|
112
112
|
controller_class = "#{route.defaults[:controller].camelize}Controller".constantize
|
|
113
|
-
versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) :
|
|
113
|
+
versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) : []
|
|
114
114
|
{
|
|
115
115
|
path: route.path.spec.to_s.gsub(/\([^)]*\)/, ''),
|
|
116
116
|
http_method: route.verb,
|
|
@@ -12,8 +12,8 @@ module EndPointBlank
|
|
|
12
12
|
EndPointBlank::Configuration.instance
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def token(
|
|
16
|
-
body = {
|
|
15
|
+
def token(base_url)
|
|
16
|
+
body = {base_url: base_url}
|
|
17
17
|
if configuration.token_ttl
|
|
18
18
|
body[:token_ttl] = configuration.token_ttl
|
|
19
19
|
end
|
|
@@ -23,7 +23,7 @@ module EndPointBlank
|
|
|
23
23
|
body: body.to_json,
|
|
24
24
|
**EndPointBlank::Commands::Http::TIMEOUT_OPTIONS
|
|
25
25
|
)
|
|
26
|
-
EndPointBlank.logger.info "Authentication response: #{response.status}
|
|
26
|
+
EndPointBlank.logger.info "Authentication response: #{response.status}"
|
|
27
27
|
parsed_body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
|
|
28
28
|
parsed_body.transform_keys(&:to_sym)
|
|
29
29
|
rescue => e
|
|
@@ -15,7 +15,7 @@ module EndPointBlank
|
|
|
15
15
|
|
|
16
16
|
attr_accessor :worker_count, :log_mode,
|
|
17
17
|
:version_finder, :application_version, :token_ttl, :cache_ttl,
|
|
18
|
-
:masking_rules, :mask_hook, :logger
|
|
18
|
+
:masking_rules, :mask_hook, :logger, :trust_proxy_headers
|
|
19
19
|
|
|
20
20
|
def initialize
|
|
21
21
|
@worker_count = 4
|
|
@@ -23,6 +23,7 @@ module EndPointBlank
|
|
|
23
23
|
@cache_ttl = 300
|
|
24
24
|
@masking_rules = []
|
|
25
25
|
@mask_hook = nil
|
|
26
|
+
@trust_proxy_headers = true
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Returns the configured client id, falling back to the
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
# Formats the deprecation facts returned by an authorize call into the
|
|
7
|
+
# standard response headers.
|
|
8
|
+
#
|
|
9
|
+
# Deprecation RFC 9745 — an Item Structured Header Date: "@1688169599"
|
|
10
|
+
# Sunset RFC 8594 — an HTTP-date: "Sat, 31 Dec 2018 23:59:59 GMT"
|
|
11
|
+
#
|
|
12
|
+
# RFC 9745 permits a past value ("was deprecated at that date"), which is what
|
|
13
|
+
# EndPointBlank emits: deprecation takes effect when it is declared.
|
|
14
|
+
#
|
|
15
|
+
# Pure and stateless on purpose. The SDK does not know what a lifecycle is —
|
|
16
|
+
# it relays two timestamps the portal already decided about, and this turns
|
|
17
|
+
# them into two strings. That keeps the vectors in
|
|
18
|
+
# docs/superpowers/specs/2026-08-01-header-vectors.md assertable without
|
|
19
|
+
# constructing a request.
|
|
20
|
+
module DeprecationHeaders
|
|
21
|
+
DEPRECATION = "Deprecation"
|
|
22
|
+
SUNSET = "Sunset"
|
|
23
|
+
|
|
24
|
+
# Fixed English abbreviations. Ruby's %a/%b are locale-independent, but
|
|
25
|
+
# spelling them out removes the question entirely — a server running under a
|
|
26
|
+
# different locale must still emit an HTTP-date.
|
|
27
|
+
DAYS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
|
|
28
|
+
MONTHS = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# @param deprecation [Hash, nil] the authorize response's "deprecation"
|
|
32
|
+
# block: {"deprecated_at" => iso8601, "sunset_at" => iso8601 | nil}
|
|
33
|
+
# @return [Hash] header name => value; empty when there is nothing to say
|
|
34
|
+
def build(deprecation)
|
|
35
|
+
return {} unless deprecation.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
headers = {}
|
|
38
|
+
|
|
39
|
+
if (at = parse(deprecation["deprecated_at"] || deprecation[:deprecated_at]))
|
|
40
|
+
headers[DEPRECATION] = deprecation_value(at)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if (at = parse(deprecation["sunset_at"] || deprecation[:sunset_at]))
|
|
44
|
+
headers[SUNSET] = sunset_value(at)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
headers
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# "@1688169599" — no quotes, no sub-second precision.
|
|
51
|
+
def deprecation_value(time)
|
|
52
|
+
"@#{time.to_i}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "Sat, 31 Dec 2018 23:59:59 GMT" — day-of-month zero padded, always GMT.
|
|
56
|
+
def sunset_value(time)
|
|
57
|
+
t = time.utc
|
|
58
|
+
format(
|
|
59
|
+
"%s, %02d %s %04d %02d:%02d:%02d GMT",
|
|
60
|
+
DAYS[t.wday], t.day, MONTHS[t.month - 1], t.year, t.hour, t.min, t.sec
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Never raise into the provider's response path. A malformed timestamp
|
|
67
|
+
# from the portal is a bug worth no header; it is not worth a 500 on a
|
|
68
|
+
# request that already succeeded.
|
|
69
|
+
#
|
|
70
|
+
# Types are matched rather than coerced. `Time.parse(12345)` does not
|
|
71
|
+
# raise — it happily returns a date in 2012 — so a `to_s` here would turn
|
|
72
|
+
# a nonsense value into a plausible-looking header, which is the one
|
|
73
|
+
# outcome worse than no header at all.
|
|
74
|
+
def parse(value)
|
|
75
|
+
case value
|
|
76
|
+
when Time then value.utc
|
|
77
|
+
when String then parse_string(value)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_string(value)
|
|
82
|
+
return nil if value.empty?
|
|
83
|
+
|
|
84
|
+
Time.parse(value).utc
|
|
85
|
+
rescue ArgumentError, TypeError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -19,6 +19,14 @@ module EndPointBlank
|
|
|
19
19
|
|
|
20
20
|
status, headers, body = @app.call(env)
|
|
21
21
|
|
|
22
|
+
# RFC 9745 / RFC 8594. The provider writes no code for this: the
|
|
23
|
+
# portal decided the version is deprecated, authorize relayed the
|
|
24
|
+
# dates, and this attaches them to the response their consumer
|
|
25
|
+
# already receives.
|
|
26
|
+
::EndPointBlank::DeprecationHeaders
|
|
27
|
+
.build(::EndPointBlank::Rack::EnvStore.deprecation)
|
|
28
|
+
.each { |name, value| headers[name] = value }
|
|
29
|
+
|
|
22
30
|
[status, headers, body]
|
|
23
31
|
rescue ::EndPointBlank::UnauthorizedError => e
|
|
24
32
|
# We don't want to log unauthorized errors as they are expected to happen
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
module EndPointBlank
|
|
2
2
|
module Rack
|
|
3
|
+
# Per-request state, keyed off the Rack env.
|
|
4
|
+
#
|
|
5
|
+
# Only the env itself is held in a thread-local; everything derived from a
|
|
6
|
+
# request lives *inside* that env. That distinction is the whole point.
|
|
7
|
+
#
|
|
8
|
+
# Threads are reused between requests, so a thread-local is correct only for
|
|
9
|
+
# as long as something reliably clears it — here, the middleware's `ensure`.
|
|
10
|
+
# Anything that set a value without that middleware in the stack would
|
|
11
|
+
# strand it on the thread, and the next request served by that thread could
|
|
12
|
+
# read the previous caller's data: their app-environment id on our audit
|
|
13
|
+
# row, their sunset date on our response.
|
|
14
|
+
#
|
|
15
|
+
# The env is per-request by construction. The server builds a fresh hash per
|
|
16
|
+
# request and `set/1` replaces it wholesale at the top of the middleware, so
|
|
17
|
+
# a stranded value is unreachable even if `clear/0` never runs. Correctness
|
|
18
|
+
# stops depending on cleanup happening.
|
|
19
|
+
#
|
|
20
|
+
# Outside a Rack request there is nowhere to put anything and no response to
|
|
21
|
+
# decorate, so the writers are no-ops and the readers return nil.
|
|
3
22
|
class EnvStore
|
|
4
23
|
KEY = 'end_point_blank.rack_env'.freeze
|
|
5
24
|
SOURCE_ENV_ID_KEY = 'end_point_blank.source_application_environment_id'.freeze
|
|
25
|
+
DEPRECATION_KEY = 'end_point_blank.deprecation'.freeze
|
|
6
26
|
|
|
7
27
|
def self.set(env)
|
|
8
28
|
Thread.current[KEY] = env
|
|
@@ -17,18 +37,42 @@ module EndPointBlank
|
|
|
17
37
|
env && ::Rack::Request.new(env)
|
|
18
38
|
end
|
|
19
39
|
|
|
40
|
+
# The calling service's application environment, resolved by `authorize!`
|
|
41
|
+
# and stamped onto every audit row for this request.
|
|
20
42
|
def self.set_source_application_environment_id(id)
|
|
21
|
-
|
|
43
|
+
put(SOURCE_ENV_ID_KEY, id)
|
|
22
44
|
end
|
|
23
45
|
|
|
24
46
|
def self.source_application_environment_id
|
|
25
|
-
|
|
47
|
+
fetch(SOURCE_ENV_ID_KEY)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The authorize response's deprecation block, stashed on the way in so the
|
|
51
|
+
# response middleware can turn it into `Deprecation` and `Sunset` headers
|
|
52
|
+
# on the way out.
|
|
53
|
+
def self.set_deprecation(deprecation)
|
|
54
|
+
put(DEPRECATION_KEY, deprecation)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.deprecation
|
|
58
|
+
fetch(DEPRECATION_KEY)
|
|
26
59
|
end
|
|
27
60
|
|
|
28
61
|
def self.clear
|
|
29
62
|
Thread.current[KEY] = nil
|
|
30
|
-
Thread.current[SOURCE_ENV_ID_KEY] = nil
|
|
31
63
|
end
|
|
64
|
+
|
|
65
|
+
def self.put(key, value)
|
|
66
|
+
env = get
|
|
67
|
+
env[key] = value if env
|
|
68
|
+
end
|
|
69
|
+
private_class_method :put
|
|
70
|
+
|
|
71
|
+
def self.fetch(key)
|
|
72
|
+
env = get
|
|
73
|
+
env && env[key]
|
|
74
|
+
end
|
|
75
|
+
private_class_method :fetch
|
|
32
76
|
end
|
|
33
77
|
end
|
|
34
|
-
end
|
|
78
|
+
end
|
|
@@ -17,6 +17,9 @@ module EndPointBlank
|
|
|
17
17
|
result_json = JSON.parse(result.body)
|
|
18
18
|
app_env_id = result_json['data'][0]['source_application_environment_id']
|
|
19
19
|
::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(app_env_id)
|
|
20
|
+
# Present only when the called version is deprecated; the middleware
|
|
21
|
+
# turns it into Deprecation / Sunset headers on the way out.
|
|
22
|
+
::EndPointBlank::Rack::EnvStore.set_deprecation(result_json['deprecation'])
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
private
|
|
@@ -4,15 +4,20 @@ module EndPointBlank
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
class_methods do
|
|
7
|
-
#
|
|
7
|
+
# Record which versions specific actions serve.
|
|
8
|
+
#
|
|
9
|
+
# Lifecycle state (Current, Deprecated, ...) is NOT declared here. It is
|
|
10
|
+
# managed in the EndPointBlank portal, where changing it does not require
|
|
11
|
+
# shipping code. This reports which versions exist, not what they mean.
|
|
12
|
+
#
|
|
8
13
|
# @param versions [Array<String>] List of version strings (e.g., ["v1", "v2"])
|
|
9
14
|
# @param options [Hash] Options hash with :only or :except keys
|
|
10
15
|
# @option options [Array<Symbol>] :only Actions to include in versioning
|
|
11
16
|
# @option options [Array<Symbol>] :except Actions to exclude from versioning
|
|
12
17
|
#
|
|
13
18
|
# Example:
|
|
14
|
-
# version ["v1", "v2"], only: [:index]
|
|
15
|
-
# version ["v3"], except: [:destroy]
|
|
19
|
+
# version ["v1", "v2"], only: [:index]
|
|
20
|
+
# version ["v3"], except: [:destroy]
|
|
16
21
|
def version(values, options = {})
|
|
17
22
|
versions = Array(values)
|
|
18
23
|
@versioning_config ||= {}
|
|
@@ -20,23 +25,16 @@ module EndPointBlank
|
|
|
20
25
|
actions = determine_actions(options)
|
|
21
26
|
|
|
22
27
|
actions.each do |action|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@versioning_config[action][
|
|
26
|
-
@versioning_config[action][state] = (@versioning_config[action][state] + versions).uniq
|
|
28
|
+
# uniq preserves declaration order, so the manifest stays stable
|
|
29
|
+
# between deploys instead of churning.
|
|
30
|
+
@versioning_config[action] = ((@versioning_config[action] || []) + versions).uniq
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
#
|
|
31
|
-
# @return [
|
|
32
|
-
#
|
|
33
|
-
# Example return value:
|
|
34
|
-
# {
|
|
35
|
-
# index: ["v1", "v2"],
|
|
36
|
-
# show: ["v1"]
|
|
37
|
-
# }
|
|
34
|
+
# Versions the given action serves.
|
|
35
|
+
# @return [Array<String>] e.g. ["v1", "v2"]; empty when none declared.
|
|
38
36
|
def versions(action)
|
|
39
|
-
@versioning_config&.fetch(action,
|
|
37
|
+
@versioning_config&.fetch(action, []) || []
|
|
40
38
|
end
|
|
41
39
|
|
|
42
40
|
private
|
|
@@ -26,11 +26,10 @@ module EndPointBlank
|
|
|
26
26
|
version = Commands::VersionFinder.new.find(request)
|
|
27
27
|
headers = ::EndPointBlank::Rack::Headers.extract
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
{
|
|
30
30
|
app_name: EndPointBlank::Configuration.instance.app_name,
|
|
31
31
|
env: SessionConfiguration.env_name,
|
|
32
32
|
uuid: request_uuid(env),
|
|
33
|
-
host: request.host,
|
|
34
33
|
status: request_status(request),
|
|
35
34
|
headers: headers,
|
|
36
35
|
path: request.path,
|
|
@@ -38,7 +37,12 @@ module EndPointBlank
|
|
|
38
37
|
endpoint_version: version,
|
|
39
38
|
request: request_body(request),
|
|
40
39
|
sent_at: Time.now.utc.iso8601(3)
|
|
41
|
-
}
|
|
40
|
+
}.merge(
|
|
41
|
+
::EndPointBlank::BaseUrl.from_rack_env(
|
|
42
|
+
env,
|
|
43
|
+
trust_proxy_headers: EndPointBlank::Configuration.instance.trust_proxy_headers
|
|
44
|
+
)
|
|
45
|
+
)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def write()
|