end_point_blank 0.13.0 → 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 +100 -0
- data/README.md +22 -1
- 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 +13 -3
- data/lib/end_point_blank/management/error.rb +2 -1
- data/lib/end_point_blank/management/error_codes.rb +20 -2
- data/lib/end_point_blank/management/resources/clients.rb +54 -6
- 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 +59 -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/request_writer.rb +3 -1
- data/lib/end_point_blank/writers/response_writer.rb +4 -1
- 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,105 @@
|
|
|
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
|
+
|
|
68
|
+
## 0.13.1
|
|
69
|
+
|
|
70
|
+
### Security
|
|
71
|
+
|
|
72
|
+
- **Request and response records no longer carry credentials or cookies
|
|
73
|
+
(sc-1470).** Both records carried every `HTTP_*` header of the request (the
|
|
74
|
+
response record sends the request's headers, not the response's), so unless
|
|
75
|
+
you had written a masking rule for it, a caller's `Authorization` header
|
|
76
|
+
(Basic `client_id:secret` or a bearer token) landed in your request and
|
|
77
|
+
response logs in EndPointBlank. `RequestWriter` and `ResponseWriter` now
|
|
78
|
+
leave out `Authorization`, `Proxy-Authorization`, `Cookie` and `Set-Cookie`,
|
|
79
|
+
in any letter case, before masking runs, so they are not in the payload the
|
|
80
|
+
rules and hook receive, and they are not sent at all. The list is
|
|
81
|
+
`EndPointBlank::Rack::Headers::SENSITIVE_HEADERS`; the SDK's own reads of
|
|
82
|
+
the request (`Headers.extract`, used to find the endpoint version) are
|
|
83
|
+
unchanged. A masking rule that targeted one of these headers now has nothing
|
|
84
|
+
to match and can be removed. Records sent by earlier versions may hold these
|
|
85
|
+
values; rotate any client secret a caller sent while it was in use.
|
|
86
|
+
|
|
87
|
+
### Upgrading
|
|
88
|
+
|
|
89
|
+
- A `mask_hook` that reads `Authorization`, `Proxy-Authorization`, `Cookie` or
|
|
90
|
+
`Set-Cookie` from `headers` now finds it absent; guard the lookup.
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- **`claim_invite` takes an optional `return_to` (sc-1515).**
|
|
95
|
+
`clients.claim_invite(client_id, email:, return_to:)` and a managed
|
|
96
|
+
client's `claim_invite(email:, return_to:)` send `return_to`, where
|
|
97
|
+
EndPointBlank sends the customer's browser once they have claimed the
|
|
98
|
+
managed client. It is sent only when given, and must equal, byte for byte,
|
|
99
|
+
a claim return URL your organization registered in EndPointBlank; anything
|
|
100
|
+
else is refused with `return_to_not_registered` (422), now in
|
|
101
|
+
`ErrorCodes` as `RETURN_TO_NOT_REGISTERED`.
|
|
102
|
+
|
|
3
103
|
## 0.13.0
|
|
4
104
|
|
|
5
105
|
### Added
|
data/README.md
CHANGED
|
@@ -485,6 +485,13 @@ regex is applied only within the path-selected node(s). When a `regex` is presen
|
|
|
485
485
|
match; `$$` for a literal `$`). Stacktraces and log messages/data are never masked (there is no
|
|
486
486
|
`log` entry in the masking field map).
|
|
487
487
|
|
|
488
|
+
**Credential and cookie headers are never sent.** Before any rule runs, the request and response
|
|
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
|
|
492
|
+
their letter case. They are left out of the record, not masked: they are not in the payload the
|
|
493
|
+
rules and hook receive. The list is `EndPointBlank::Rack::Headers::SENSITIVE_HEADERS`.
|
|
494
|
+
|
|
488
495
|
## Framework integration
|
|
489
496
|
|
|
490
497
|
### Rails
|
|
@@ -653,7 +660,9 @@ A managed client is an organization you create and run for a customer until they
|
|
|
653
660
|
under `/api/v1/clients/:client_id/`:
|
|
654
661
|
|
|
655
662
|
```ruby
|
|
656
|
-
|
|
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")
|
|
657
666
|
initech = mgmt.for_managed_client(customer["id"])
|
|
658
667
|
|
|
659
668
|
# The managed client's organization already has a "production" environment (the name is
|
|
@@ -670,6 +679,18 @@ mgmt.package_assignments.assign(customer["id"], api_package_id: package["id"], e
|
|
|
670
679
|
|
|
671
680
|
# ... and hand it over: the customer gets an email, and claiming rotates every credential you issued.
|
|
672
681
|
initech.claim_invite(email: "it@initech.example")
|
|
682
|
+
|
|
683
|
+
# Optionally send the customer's browser somewhere once they have claimed it. `return_to` must
|
|
684
|
+
# equal, byte for byte, a claim return URL your organization registered in EndPointBlank;
|
|
685
|
+
# anything else is refused with `return_to_not_registered` (422).
|
|
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
|
|
673
694
|
```
|
|
674
695
|
|
|
675
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
|
|
@@ -215,9 +215,19 @@ module EndPointBlank
|
|
|
215
215
|
end
|
|
216
216
|
|
|
217
217
|
# POST /clients/:client_id/claim_invites: emails your customer an
|
|
218
|
-
# invite to claim the client
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
# invite to claim the client, and with +return_to+ (a claim return URL
|
|
219
|
+
# your organization registered) where to send them once claimed; see
|
|
220
|
+
# {Resources::Clients#claim_invite}. @return [Hash]
|
|
221
|
+
def claim_invite(email:, return_to: nil, idempotency_key: nil)
|
|
222
|
+
@clients.claim_invite(client_id, email: email, return_to: return_to, idempotency_key: idempotency_key)
|
|
223
|
+
end
|
|
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)
|
|
221
231
|
end
|
|
222
232
|
|
|
223
233
|
def inspect
|
|
@@ -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"
|
|
@@ -66,6 +73,14 @@ module EndPointBlank
|
|
|
66
73
|
ENVIRONMENT_NOT_IN_APPLICATION = "environment_not_in_application"
|
|
67
74
|
ALREADY_GRANTED = "already_granted"
|
|
68
75
|
GRANT_REVOKED_CONCURRENTLY = "grant_revoked_concurrently"
|
|
76
|
+
# A claim invite's +return_to+ is not one of the claim return URLs your
|
|
77
|
+
# organization registered.
|
|
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"
|
|
69
84
|
|
|
70
85
|
# The warning code API package endpoint writes answer in +warnings+ (not
|
|
71
86
|
# an error): a client assignment of the package now derives no grant.
|
|
@@ -94,10 +109,13 @@ module EndPointBlank
|
|
|
94
109
|
DELETE_REFUSED => 422, INTAKE_CREDENTIAL => 409, INTAKE_REJECTED => 422, INTAKE_UNAVAILABLE => 503,
|
|
95
110
|
INVALID_CONTACTS => 422, INVALID_PACKAGES => 422, INVALID_GRANTS => 422, INVALID_MANAGED => 422,
|
|
96
111
|
CLIENT_NOT_ACCEPTED => 422, CLIENT_ACCEPTED => 422, CLIENT_NOT_MANAGED => 422,
|
|
97
|
-
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,
|
|
98
115
|
ENVIRONMENT_NOT_FOUND => 422, ALREADY_ASSIGNED => 422, NOTHING_PUBLISHED_IN_ENVIRONMENT => 422,
|
|
99
116
|
APPLICATION_NOT_FOUND => 422, ENDPOINT_NOT_FOUND => 422, ENVIRONMENT_NOT_IN_APPLICATION => 422,
|
|
100
|
-
ALREADY_GRANTED => 422, GRANT_REVOKED_CONCURRENTLY => 409
|
|
117
|
+
ALREADY_GRANTED => 422, GRANT_REVOKED_CONCURRENTLY => 409, RETURN_TO_NOT_REGISTERED => 422,
|
|
118
|
+
RETURN_URL_NOT_REGISTERED => 422, OWNER_EMAIL_MISSING => 422
|
|
101
119
|
}.freeze
|
|
102
120
|
|
|
103
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+
|
|
@@ -70,9 +82,45 @@ module EndPointBlank
|
|
|
70
82
|
|
|
71
83
|
# POST /clients/:client_id/claim_invites: emails your customer an
|
|
72
84
|
# invite to claim a managed client.
|
|
85
|
+
#
|
|
86
|
+
# +return_to+ (optional, sent only when given) is where EndPointBlank
|
|
87
|
+
# sends the customer's browser once they have claimed it. It must
|
|
88
|
+
# equal, byte for byte, a claim return URL your organization
|
|
89
|
+
# registered in EndPointBlank, or the call is refused with
|
|
90
|
+
# +return_to_not_registered+ (422).
|
|
73
91
|
# @return [Hash] <tt>{"client_id", "email", "sent_at", "expires_at"}</tt>
|
|
74
|
-
def claim_invite(client_id, email:, idempotency_key: nil)
|
|
75
|
-
|
|
92
|
+
def claim_invite(client_id, email:, return_to: nil, idempotency_key: nil)
|
|
93
|
+
body = { email: email }
|
|
94
|
+
body[:return_to] = return_to unless return_to.nil?
|
|
95
|
+
post_data(path("clients", client_id, "claim_invites"), body, idempotency_key)
|
|
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)
|
|
76
124
|
end
|
|
77
125
|
end
|
|
78
126
|
|
|
@@ -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.
|
|
@@ -1,6 +1,37 @@
|
|
|
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
|
+
#
|
|
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
|
|
34
|
+
|
|
4
35
|
def self.extract
|
|
5
36
|
env = ::EndPointBlank::Rack::EnvStore.get
|
|
6
37
|
return {} if env.nil?
|
|
@@ -8,6 +39,33 @@ module EndPointBlank
|
|
|
8
39
|
env.select { |k,v| k.start_with? 'HTTP_'}.
|
|
9
40
|
transform_keys { |k| k.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') }
|
|
10
41
|
end
|
|
42
|
+
|
|
43
|
+
# The request's headers as a request or response record may send them:
|
|
44
|
+
# {extract} without any of {SENSITIVE_HEADERS}.
|
|
45
|
+
def self.reportable
|
|
46
|
+
without_sensitive(extract)
|
|
47
|
+
end
|
|
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
|
+
|
|
62
|
+
# A new hash of `headers` without any of {SENSITIVE_HEADERS}, whatever
|
|
63
|
+
# their letter case. Never changes its argument; nil is {}.
|
|
64
|
+
def self.without_sensitive(headers)
|
|
65
|
+
return {} if headers.nil?
|
|
66
|
+
|
|
67
|
+
headers.reject { |name, _value| SENSITIVE_HEADERS.include?(name.to_s.downcase) }
|
|
68
|
+
end
|
|
11
69
|
end
|
|
12
70
|
end
|
|
13
|
-
end
|
|
71
|
+
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
|
-
|
|
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
|
|
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)
|
|
28
31
|
version = request ? Commands::VersionFinder.new.find(request) : nil
|
|
29
32
|
route = request ? Commands::RoutePatternFinder.find(request) : nil
|
|
30
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
|
- - ">="
|