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 +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +13 -2
- data/end_point_blank.gemspec +1 -1
- data/lib/end_point_blank/commands/endpoint_authorize.rb +39 -4
- data/lib/end_point_blank/management/client.rb +8 -0
- data/lib/end_point_blank/management/error.rb +2 -1
- data/lib/end_point_blank/management/error_codes.rb +17 -2
- data/lib/end_point_blank/management/resources/clients.rb +44 -4
- data/lib/end_point_blank/middleware/rack/report_interaction.rb +6 -2
- data/lib/end_point_blank/rack/env_store.rb +13 -0
- data/lib/end_point_blank/rack/headers.rb +33 -1
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank/writers/log_writer.rb +1 -1
- data/lib/end_point_blank/writers/response_writer.rb +4 -4
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3074c1bcab8168bdd552228eceb3f5736cacde44e9b00b84445cdb86021d92b
|
|
4
|
+
data.tar.gz: e94489b6b2b35aa0677273a2d897a7df0f4981be731c13585833fad8b8f0e591
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
data/end_point_blank.gemspec
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
47
|
-
|
|
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,
|
|
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
|
|
48
|
-
::EndPointBlank::Writers::ResponseWriter.write(status:, headers
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
28
|
-
#
|
|
29
|
-
# and
|
|
30
|
-
headers = ::EndPointBlank::Rack::Headers.
|
|
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.
|
|
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-
|
|
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.
|
|
178
|
+
version: '3.3'
|
|
179
179
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
180
180
|
requirements:
|
|
181
181
|
- - ">="
|