end_point_blank 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97bfbad03254a86968687a6f11f10c566b6357bf1294c82521fe8216261586eb
4
- data.tar.gz: 28512438acaea4072288edd364cf246f307abf64cbb2e64eb6decd66f8295b0b
3
+ metadata.gz: 14c18a9f81130c9ec607b05a25aa5a4a67d9726c6692eb313a395734ce79bcf3
4
+ data.tar.gz: 4081ad91a0c10503521993a7d2d7887cd0810faab8b8f22229a57f9ed7685749
5
5
  SHA512:
6
- metadata.gz: cdb4c6de628daa762e7c17925d96b51dd4cc26379531de385b61cfcb44fbf8c87c6a87f14b3c104fe4c1f5bd5ffdc953ee16f8ff36a61c0d4a90dfe9c2701ba7
7
- data.tar.gz: 9fd76a38f763fd134758d9b4c89792bd983f707821008dfc9cf4f341fd8a35f53d9455c93c965d20a9649667b076f77f0359d52bc96425966cc4c4770708250d
6
+ metadata.gz: 181770dadb24b746868274196d0600ff58d92ad7e430f2165b8ff7f59e376a0790ed027b9a8fb000b607b20698190755f5de5525ca78d540c198addab72c5fdd
7
+ data.tar.gz: 88593bf5bb77615e05d9277de7a4b167d5885a155cb9b30cfac83dbe0335003e84801abe7b826fe55ab81793b8990e0d39578bb05b2033bef2512a4cc5c11d6f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.1
4
+
5
+ ### Security
6
+
7
+ - **Request and response records no longer carry credentials or cookies
8
+ (sc-1470).** Both records carried every `HTTP_*` header of the request (the
9
+ response record sends the request's headers, not the response's), so unless
10
+ you had written a masking rule for it, a caller's `Authorization` header
11
+ (Basic `client_id:secret` or a bearer token) landed in your request and
12
+ response logs in EndPointBlank. `RequestWriter` and `ResponseWriter` now
13
+ leave out `Authorization`, `Proxy-Authorization`, `Cookie` and `Set-Cookie`,
14
+ in any letter case, before masking runs, so they are not in the payload the
15
+ rules and hook receive, and they are not sent at all. The list is
16
+ `EndPointBlank::Rack::Headers::SENSITIVE_HEADERS`; the SDK's own reads of
17
+ the request (`Headers.extract`, used to find the endpoint version) are
18
+ unchanged. A masking rule that targeted one of these headers now has nothing
19
+ to match and can be removed. Records sent by earlier versions may hold these
20
+ values; rotate any client secret a caller sent while it was in use.
21
+
22
+ ### Upgrading
23
+
24
+ - A `mask_hook` that reads `Authorization`, `Proxy-Authorization`, `Cookie` or
25
+ `Set-Cookie` from `headers` now finds it absent; guard the lookup.
26
+
27
+ ### Added
28
+
29
+ - **`claim_invite` takes an optional `return_to` (sc-1515).**
30
+ `clients.claim_invite(client_id, email:, return_to:)` and a managed
31
+ client's `claim_invite(email:, return_to:)` send `return_to`, where
32
+ EndPointBlank sends the customer's browser once they have claimed the
33
+ managed client. It is sent only when given, and must equal, byte for byte,
34
+ a claim return URL your organization registered in EndPointBlank; anything
35
+ else is refused with `return_to_not_registered` (422), now in
36
+ `ErrorCodes` as `RETURN_TO_NOT_REGISTERED`.
37
+
38
+ ## 0.13.0
39
+
40
+ ### Added
41
+
42
+ - **A client for the organization management API (sc-1505).**
43
+ `EndPointBlank::Management::Client` calls app_portal's `/api/v1` so a
44
+ provider can manage its EndPointBlank setup from code instead of
45
+ hand-rolling HTTP calls: the organization (`organization`), API packages
46
+ and what they publish (`api_packages`, `endpoints`), clients and their
47
+ invites (`clients`, including `invite` with pre-assigned `packages` and
48
+ `grants`, and `create_managed`), package assignments
49
+ (`package_assignments`), direct grants (`grants`), `applications` and their
50
+ environments, `environments`, and runtime `credentials` (create and
51
+ `rotate` return the one-time `client_secret`). `for_managed_client(id)`
52
+ gives the same applications, environments and credentials calls for a
53
+ managed client, under `/clients/:client_id/`, plus `claim_invite`.
54
+
55
+ It is plain Ruby (no Rails needed) and separate from the runtime
56
+ configuration: it authenticates only with a management API key, sent as
57
+ `Authorization: Bearer epb_mk_...`, refuses at construction a key without
58
+ that prefix, never sends the runtime `client_id`/`client_secret`, never
59
+ calls intake, and never shows the key in `inspect`, `to_s` or an error
60
+ message. Defaults come from `EndPointBlank::Management.configure` (e.g. a
61
+ Rails initializer) or `ENDPOINTBLANK_MANAGEMENT_KEY` /
62
+ `ENDPOINTBLANK_MANAGEMENT_BASE_URL`, and the base URL defaults to
63
+ `https://app.endpointblank.com`.
64
+
65
+ - Lists answer an `EndPointBlank::Management::Page` (`data`,
66
+ `next_cursor`); each list's `each` walks every page lazily, as an
67
+ `Enumerator` without a block.
68
+ - Every POST sends an `Idempotency-Key` (a random UUID v4 unless you pass
69
+ `idempotency_key:`), and a retry sends the same one.
70
+ - A 429 is retried after its `Retry-After` seconds (1 second without
71
+ one); a 5xx or a request that got no answer is retried with backoff for
72
+ GET, DELETE and POST, never for PATCH; `idempotency_request_in_progress` is retried with the
73
+ same key. At most 2 retries by default (`max_retries:`, `0` turns them
74
+ off), and no single wait longer than `max_retry_wait:` (60 seconds).
75
+ - Every refusal raises `EndPointBlank::Management::Error` (a subclass of
76
+ `EndPointBlank::Error`) with `code`, `message`, `details`, `status`,
77
+ `retry_after`, `location` and `request_id`. `ErrorCodes` lists every
78
+ code the API documents; a code it does not know still raises with that
79
+ code. `idempotency_replay_unavailable` is never retried, and its message
80
+ says to read or list the resource instead.
81
+ - Uses Excon, already a dependency; no new runtime dependency.
82
+
3
83
  ## 0.12.0
4
84
 
5
85
  ### Breaking changes
data/README.md CHANGED
@@ -16,6 +16,9 @@ auto-loads (railtie + middleware) when Rails is present.
16
16
  - **Client-side data masking** (`EndPointBlank::Masking` / `masking_rules`) — strip or redact
17
17
  sensitive fields from payloads *before* they leave your process, as defense in depth on top of
18
18
  server-side masking.
19
+ - **Management API client** (`EndPointBlank::Management::Client`) — manage API packages, clients,
20
+ grants, applications, environments, credentials and managed clients from code. See
21
+ [Management API](#management-api).
19
22
  - **Framework-agnostic core** — `EndPointBlank::Middleware::Rack::ReportInteraction` and the
20
23
  writers work directly against Rack env/`::Rack::Request`, so the gem behaves correctly under
21
24
  plain Ruby, Sinatra, or any Rack app. When `::Rails` is defined, a `Railtie` auto-inserts the
@@ -482,6 +485,11 @@ regex is applied only within the path-selected node(s). When a `regex` is presen
482
485
  match; `$$` for a literal `$`). Stacktraces and log messages/data are never masked (there is no
483
486
  `log` entry in the masking field map).
484
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` and `Set-Cookie`, whatever
490
+ their letter case. They are left out of the record, not masked: they are not in the payload the
491
+ rules and hook receive. The list is `EndPointBlank::Rack::Headers::SENSITIVE_HEADERS`.
492
+
485
493
  ## Framework integration
486
494
 
487
495
  ### Rails
@@ -548,6 +556,196 @@ and clears the env store, reporting any raised exception via `ExceptionWriter` a
548
556
  reads/writes plain Rack request objects (`::Rack::Request`), so it works identically under any
549
557
  Rack-compatible server or framework, not only Sinatra.
550
558
 
559
+ ## Management API
560
+
561
+ `EndPointBlank::Management::Client` manages your organization's EndPointBlank setup from code:
562
+ API packages, clients and their invites, package assignments, direct grants, applications,
563
+ environments, runtime credentials, and the managed clients you run for your customers. It calls
564
+ app_portal's management API (`https://app.endpointblank.com/api/v1`). See the
565
+ [guide](https://app.endpointblank.com/docs/management-api) and the
566
+ [reference](https://app.endpointblank.com/docs/management-api-reference).
567
+
568
+ It is plain Ruby, usable from a script, a job or a console as well as a Rails app, and it is
569
+ **separate from the runtime configuration above**. It authenticates only with a management API
570
+ key (create one in the portal under Settings > API Keys), sent as
571
+ `Authorization: Bearer epb_mk_...`. It never sends your runtime `client_id`/`client_secret`, never
572
+ calls intake, and never shows the key in `inspect`, `to_s` or an error message. A key without the
573
+ `epb_mk_` prefix is refused when the client is built, with `EndPointBlank::ConfigurationError`.
574
+
575
+ ### Quick start
576
+
577
+ ```ruby
578
+ require "end_point_blank"
579
+
580
+ mgmt = EndPointBlank::Management::Client.new(api_key: ENV.fetch("EPB_MGMT_KEY"))
581
+
582
+ mgmt.organization # => {"id" => "...", "name" => "Acme", "slug" => "acme", "key" => {"name" => "ci", "scope" => "write"}, ...}
583
+
584
+ # One page at a time (limit 1..100, default 50) ...
585
+ page = mgmt.applications.list(limit: 20)
586
+ page.data # => [{"id" => "...", "name" => "Orders", ...}, ...]
587
+ page.next_cursor # => pass as `after:` for the next page; nil on the last one
588
+
589
+ # ... or every item, fetching pages as it goes (an Enumerator without a block).
590
+ mgmt.applications.each { |application| puts application["name"] }
591
+ names = mgmt.api_packages.each(limit: 100).map { |package| package["name"] }
592
+ ```
593
+
594
+ Every call answers what the API sent, decoded from JSON into Hashes with String keys: the
595
+ resource itself (the response's `data`), a `Page` for a list, and `{"id" => ..., "deleted" => true}`
596
+ for a delete. `api_packages.add_endpoint` and `remove_endpoint` answer the whole body,
597
+ `{"data" => ..., "warnings" => [...]}`, so the `assignment_derives_nothing` warnings are not lost.
598
+ Optional keyword arguments left `nil` are not sent.
599
+
600
+ ### Invite a client and assign an API package
601
+
602
+ ```ruby
603
+ # Environment names are unique per organization, and "production" is reserved for the one every
604
+ # organization already has; look an existing one up with mgmt.environments.each instead.
605
+ staging = mgmt.environments.create(name: "staging", domain: "staging.example.com")
606
+ orders = mgmt.applications.create(name: "Orders",
607
+ environment_base_urls: { staging["id"] => "https://orders.staging.example.com" })
608
+
609
+ package = mgmt.api_packages.create(name: "Orders read")
610
+
611
+ # An application's endpoints are listed once its runtime SDK has reported them, so a just-created
612
+ # application has none yet. Publish one endpoint when it is there, else the whole application
613
+ # (endpoint_id nil covers every endpoint, including ones reported later).
614
+ endpoint = mgmt.endpoints.each(application_id: orders["id"]).find { |e| e["path"] == "/orders" && e["action"] == "GET" }
615
+ mgmt.api_packages.add_endpoint(package["id"], application_id: orders["id"], endpoint_id: endpoint&.fetch("id"),
616
+ environment_id: staging["id"])
617
+ ```
618
+
619
+ Then give a client the package in one of two ways; doing both for the same package and environment
620
+ is refused with `already_assigned`.
621
+
622
+ ```ruby
623
+ # Either: set it up on the invite, and it is assigned the moment the client accepts.
624
+ globex = mgmt.clients.invite(
625
+ name: "Globex",
626
+ contacts: [{ email: "dev@globex.example", first_name: "Hank", last_name: "Scorpio" }],
627
+ packages: [{ api_package_id: package["id"], environment_id: staging["id"] }]
628
+ )
629
+ globex["invite_code"] # send this to the client; it accepts from its own EndPointBlank organization
630
+
631
+ # Or: invite first, then assign (pending until the client accepts, active after) and grant directly.
632
+ initrode = mgmt.clients.invite(name: "Initrode")
633
+ mgmt.package_assignments.assign(initrode["id"], api_package_id: package["id"], environment_id: staging["id"])
634
+ mgmt.grants.create(initrode["id"], target_application_id: orders["id"], environment_id: staging["id"])
635
+ ```
636
+
637
+ ### Runtime credentials
638
+
639
+ ```ruby
640
+ app_env = mgmt.applications.list_environments(orders["id"]).first
641
+ credential = mgmt.credentials.create(application_environment_id: app_env["id"])
642
+ credential["client_id"]
643
+ credential["client_secret"] # shown once, here and nowhere else: store it now
644
+
645
+ rotated = mgmt.credentials.rotate(credential["id"])
646
+ rotated["client_secret"] # the new secret; the old one keeps working for the grace window
647
+
648
+ mgmt.credentials.revoke(credential["id"])
649
+ ```
650
+
651
+ `list` and `get` answer metadata only (`secret_last_4`, never the secret). This SDK never logs a
652
+ secret, the key, or any request or response body.
653
+
654
+ ### Managed clients
655
+
656
+ A managed client is an organization you create and run for a customer until they claim it.
657
+ `for_managed_client(id)` gives the same applications, environments and credentials calls, sent
658
+ under `/api/v1/clients/:client_id/`:
659
+
660
+ ```ruby
661
+ customer = mgmt.clients.create_managed(name: "Initech")
662
+ initech = mgmt.for_managed_client(customer["id"])
663
+
664
+ # The managed client's organization already has a "production" environment (the name is
665
+ # reserved); create others alongside it.
666
+ initech_staging = initech.environments.create(name: "staging", domain: "staging.initech.example")
667
+ billing_url = "https://billing.staging.initech.example"
668
+ app = initech.applications.create(name: "Initech billing",
669
+ environment_base_urls: { initech_staging["id"] => billing_url })
670
+ app_env = initech.applications.list_environments(app["id"]).first
671
+ secret = initech.credentials.create(application_environment_id: app_env["id"])["client_secret"]
672
+
673
+ # Grant it your APIs like any accepted client (package and staging from the example above) ...
674
+ mgmt.package_assignments.assign(customer["id"], api_package_id: package["id"], environment_id: staging["id"])
675
+
676
+ # ... and hand it over: the customer gets an email, and claiming rotates every credential you issued.
677
+ initech.claim_invite(email: "it@initech.example")
678
+
679
+ # Optionally send the customer's browser somewhere once they have claimed it. `return_to` must
680
+ # equal, byte for byte, a claim return URL your organization registered in EndPointBlank;
681
+ # anything else is refused with `return_to_not_registered` (422).
682
+ initech.claim_invite(email: "it@initech.example", return_to: "https://app.example/welcome")
683
+ ```
684
+
685
+ Once claimed, the managed client's calls answer `not_found`. Remove an unclaimed one with
686
+ `mgmt.clients.delete(customer["id"])` after revoking its credentials.
687
+
688
+ ### Errors, retries and idempotency
689
+
690
+ Every refusal raises `EndPointBlank::Management::Error` (a subclass of `EndPointBlank::Error`)
691
+ with `code`, `message`, `details`, `status`, `retry_after`, `location` and `request_id`. Match on
692
+ `code`, which is stable; `message` is for people. `EndPointBlank::Management::ErrorCodes` has a
693
+ constant for every code the API documents, and an unknown code still raises with that code.
694
+
695
+ ```ruby
696
+ codes = EndPointBlank::Management::ErrorCodes
697
+
698
+ begin
699
+ mgmt.clients.invite(name: "Umbrella")
700
+ rescue EndPointBlank::Management::Error => e
701
+ case e.code
702
+ when codes::PLAN_LIMIT then warn "upgrade your plan to add clients" # 402
703
+ when codes::VALIDATION_FAILED then warn "invalid fields: #{e.details.inspect}" # 422
704
+ when codes::NOT_FOUND then warn "no such resource" # 404
705
+ when codes::INSUFFICIENT_SCOPE then warn "this is a read-only key" # 403
706
+ else raise
707
+ end
708
+ end
709
+ ```
710
+
711
+ The SDK raises three codes of its own: `connection_error` (no answer at all; `status` is nil),
712
+ `http_error` (an error answer that is not the API's JSON, e.g. from a proxy) and
713
+ `invalid_response` (a success answer that is not JSON).
714
+
715
+ - **Idempotency.** Every POST sends an `Idempotency-Key`: a random UUID unless you pass
716
+ `idempotency_key:` (1 to 255 characters), and a retry sends the same key, so a POST is never run
717
+ twice. A credential `create` or `rotate` retried after the first one succeeded raises
718
+ `idempotency_replay_unavailable` instead of replaying the secret: read or list the credential
719
+ (rotate it if you never got the secret).
720
+ - **Retries.** A 429 `rate_limited` is retried after its `Retry-After` seconds (1 second
721
+ when it has none). A 5xx
722
+ (`internal_server_error`, `audit_unavailable`, `intake_unavailable`) or a request that got no
723
+ answer is retried with backoff for GET, DELETE and POST, never for PATCH.
724
+ `idempotency_request_in_progress` is retried shortly with the same key. 4xx refusals are never
725
+ retried.
726
+
727
+ | `Client.new` option | Env var fallback | Default | Notes |
728
+ |---|---|---|---|
729
+ | `api_key` | `ENDPOINTBLANK_MANAGEMENT_KEY` | none (required) | A management API key, `epb_mk_...`. |
730
+ | `base_url` | `ENDPOINTBLANK_MANAGEMENT_BASE_URL` | `https://app.endpointblank.com` | app_portal, not intake. |
731
+ | `max_retries` | — | `2` | Retries after the first attempt; `0` turns them off. |
732
+ | `max_retry_wait` | — | `60` | The longest single wait, in seconds; a longer `Retry-After` raises instead. |
733
+ | `connect_timeout` / `read_timeout` | — | `5` / `30` | Seconds. |
734
+ | `sleeper` | — | `Kernel#sleep` | Called with the seconds before each retry (replace it in tests). |
735
+ | `excon_options` | — | `{}` | Extra `Excon.new` options, e.g. a proxy. |
736
+
737
+ In a Rails app, set the defaults once in an initializer, apart from `EndPointBlank.configure`:
738
+
739
+ ```ruby
740
+ # config/initializers/end_point_blank_management.rb
741
+ EndPointBlank::Management.configure do |m|
742
+ m.api_key = Rails.application.credentials.dig(:end_point_blank, :management_key)
743
+ m.max_retries = 3
744
+ end
745
+
746
+ EndPointBlank::Management.client.organization # a Client built from that configuration
747
+ ```
748
+
551
749
  ## Development
552
750
 
553
751
  ```sh
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "configuration"
5
+ require_relative "error"
6
+ require_relative "transport"
7
+ require_relative "resources/base"
8
+ require_relative "resources/api_packages"
9
+ require_relative "resources/clients"
10
+ require_relative "resources/applications"
11
+
12
+ module EndPointBlank
13
+ module Management
14
+ # A client for the EndPointBlank organization management API (app_portal's
15
+ # +/api/v1+): your organization's API packages, clients, package
16
+ # assignments, grants, applications, environments and runtime credentials,
17
+ # and the managed clients you run for your customers.
18
+ #
19
+ # Plain Ruby: usable from a script, a job or a console as well as a Rails
20
+ # app. It authenticates only with a management API key
21
+ # (<tt>Authorization: Bearer epb_mk_...</tt>, created in the portal under
22
+ # Settings > API Keys) and shares nothing with the runtime configuration:
23
+ # it never sends a runtime client_id/client_secret and never calls intake.
24
+ #
25
+ # mgmt = EndPointBlank::Management::Client.new(api_key: ENV.fetch("EPB_MGMT_KEY"))
26
+ # mgmt.organization # => {"id" => ..., "name" => ..., ...}
27
+ # mgmt.api_packages.each { |package| puts package["name"] }
28
+ #
29
+ # Arguments left nil fall back to {Management.configure} (and its
30
+ # ENDPOINTBLANK_MANAGEMENT_* environment variables), then the defaults.
31
+ #
32
+ # Thread-safe: each request opens its own connection.
33
+ class Client
34
+ KEY_PREFIX = "epb_mk_"
35
+ # The prefix and the rest of the key in the alphabet app_portal mints it
36
+ # in (URL-safe base64): nothing else can be a valid key, so nothing else
37
+ # is ever put in a header.
38
+ KEY_FORMAT = /\Aepb_mk_[A-Za-z0-9_-]+\z/.freeze
39
+
40
+ # The options {#initialize} takes besides api_key and base_url.
41
+ OPTIONS = %i[max_retries max_retry_wait connect_timeout read_timeout sleeper excon_options].freeze
42
+
43
+ DEFAULT_SLEEPER = ->(seconds) { Kernel.sleep(seconds) }
44
+
45
+ # @return [Resources::ApiPackages]
46
+ attr_reader :api_packages
47
+ # @return [Resources::Endpoints]
48
+ attr_reader :endpoints
49
+ # @return [Resources::Clients]
50
+ attr_reader :clients
51
+ # @return [Resources::PackageAssignments]
52
+ attr_reader :package_assignments
53
+ # @return [Resources::Grants]
54
+ attr_reader :grants
55
+ # @return [Resources::Applications]
56
+ attr_reader :applications
57
+ # @return [Resources::Environments]
58
+ attr_reader :environments
59
+ # @return [Resources::Credentials]
60
+ attr_reader :credentials
61
+
62
+ # @param api_key [String] a management API key, epb_mk_...
63
+ # @param base_url [String] app_portal's URL (default https://app.endpointblank.com)
64
+ # @param max_retries [Integer] retries after the first attempt; 0 turns them off
65
+ # @param max_retry_wait [Numeric] the longest single wait, in seconds
66
+ # @param connect_timeout [Numeric] seconds
67
+ # @param read_timeout [Numeric] seconds
68
+ # @param sleeper [#call] called with the seconds to wait before a retry
69
+ # (default Kernel#sleep); replace it in tests
70
+ # @param excon_options [Hash] extra options for Excon.new (e.g. a proxy,
71
+ # or <tt>mock: true</tt> with Excon.stub in tests)
72
+ # @raise [EndPointBlank::ConfigurationError] for a missing or malformed key or base URL
73
+ def initialize(api_key: nil, base_url: nil, **options)
74
+ unknown = options.keys - OPTIONS
75
+ raise ArgumentError, "unknown option(s): #{unknown.join(", ")}" unless unknown.empty?
76
+
77
+ config = Management.configuration
78
+ @transport = Transport.new(
79
+ api_key: self.class.validate_key(api_key || config.api_key),
80
+ base_url: self.class.validate_base_url(base_url || config.base_url),
81
+ **transport_options(config, options)
82
+ )
83
+ build_resources
84
+ end
85
+
86
+ # app_portal's URL this client calls.
87
+ def base_url
88
+ @transport.base_url
89
+ end
90
+
91
+ # GET /organization: the organization the key belongs to, and the key's
92
+ # name and scope.
93
+ # @return [Hash] <tt>{"id", "name", "domain", "slug", "key" => {"name", "scope"}}</tt>
94
+ def organization
95
+ body = @transport.request("GET", "/organization")
96
+ body.is_a?(Hash) ? body["data"] : body
97
+ end
98
+
99
+ # A view of the API as your managed client +client_id+ (a client
100
+ # created with <tt>clients.create_managed</tt>): its applications,
101
+ # environments and credentials, under /clients/:client_id/. Works only
102
+ # while the client is unclaimed; afterwards every call answers 404.
103
+ #
104
+ # @return [ManagedClient]
105
+ def for_managed_client(client_id)
106
+ ManagedClient.new(@transport, client_id, @clients)
107
+ end
108
+
109
+ # Never shows the key.
110
+ def inspect
111
+ "#<#{self.class.name} base_url=#{base_url.inspect} api_key=[REDACTED]>"
112
+ end
113
+ alias to_s inspect
114
+
115
+ # The key, if it is a management API key. The error never repeats it.
116
+ # @api private
117
+ def self.validate_key(api_key)
118
+ key = normalized_key(api_key)
119
+ return key if key&.match?(KEY_FORMAT)
120
+
121
+ given = key.nil? || key.empty? ? "No key was given" : "The key given does not have that form"
122
+ raise EndPointBlank::ConfigurationError,
123
+ "EndPointBlank::Management::Client needs a management API key: #{KEY_PREFIX} followed by " \
124
+ "the rest of the key, as the portal shows it under Settings > API Keys. #{given}. " \
125
+ "Runtime client credentials are not accepted by the management API."
126
+ end
127
+
128
+ # +api_key+ without surrounding whitespace (a key read from a file or an
129
+ # env var often ends in a newline), or nil when it is not a readable
130
+ # String at all.
131
+ def self.normalized_key(api_key)
132
+ return nil unless api_key.is_a?(String) && api_key.valid_encoding? && api_key.encoding.ascii_compatible?
133
+
134
+ api_key.strip
135
+ end
136
+ private_class_method :normalized_key
137
+
138
+ # +base_url+ without a trailing slash, if it is an http(s) URL with a
139
+ # host and no userinfo, query or fragment. The error never repeats it,
140
+ # since a URL can carry credentials.
141
+ # @api private
142
+ def self.validate_base_url(base_url)
143
+ return UrlPath.strip_trailing_slashes(base_url.to_s) if plain_http_url?(base_url)
144
+
145
+ raise EndPointBlank::ConfigurationError,
146
+ "The management API base_url must be an http(s) URL with a host and no userinfo, " \
147
+ "query or fragment, such as #{Configuration::DEFAULT_BASE_URL}."
148
+ end
149
+
150
+ def self.plain_http_url?(base_url)
151
+ uri = URI.parse(base_url.to_s)
152
+ %w[http https].include?(uri.scheme) && !uri.host.to_s.empty? &&
153
+ [uri.userinfo, uri.query, uri.fragment].all?(&:nil?)
154
+ rescue URI::InvalidURIError
155
+ false
156
+ end
157
+ private_class_method :plain_http_url?
158
+
159
+ private
160
+
161
+ def transport_options(config, options)
162
+ {
163
+ max_retries: non_negative(:max_retries, options.fetch(:max_retries, config.max_retries)),
164
+ max_retry_wait: non_negative(:max_retry_wait, options.fetch(:max_retry_wait, config.max_retry_wait)),
165
+ connect_timeout: options.fetch(:connect_timeout, config.connect_timeout),
166
+ read_timeout: options.fetch(:read_timeout, config.read_timeout),
167
+ sleeper: options.fetch(:sleeper, DEFAULT_SLEEPER),
168
+ excon_options: options.fetch(:excon_options, {})
169
+ }
170
+ end
171
+
172
+ def non_negative(name, value)
173
+ return value if value.is_a?(Numeric) && !value.negative?
174
+
175
+ raise ArgumentError, "#{name} must be a non-negative number, got #{value.inspect}"
176
+ end
177
+
178
+ def build_resources
179
+ @api_packages = Resources::ApiPackages.new(@transport)
180
+ @endpoints = Resources::Endpoints.new(@transport)
181
+ @clients = Resources::Clients.new(@transport)
182
+ @package_assignments = Resources::PackageAssignments.new(@transport)
183
+ @grants = Resources::Grants.new(@transport)
184
+ @applications = Resources::Applications.new(@transport)
185
+ @environments = Resources::Environments.new(@transport)
186
+ @credentials = Resources::Credentials.new(@transport)
187
+ end
188
+ end
189
+
190
+ # Your managed client's applications, environments and credentials, from
191
+ # {Client#for_managed_client}. The same calls as the {Client}'s own,
192
+ # sent under /api/v1/clients/:client_id/.
193
+ class ManagedClient
194
+ # @return [String] the client's id (as in /clients/:client_id)
195
+ attr_reader :client_id
196
+ # @return [Resources::Applications]
197
+ attr_reader :applications
198
+ # @return [Resources::Environments]
199
+ attr_reader :environments
200
+ # @return [Resources::Credentials]
201
+ attr_reader :credentials
202
+
203
+ def initialize(transport, client_id, clients)
204
+ prefix = "/clients/#{Resources::Base.escape(client_id)}"
205
+ @client_id = client_id
206
+ @clients = clients
207
+ @applications = Resources::Applications.new(transport, prefix)
208
+ @environments = Resources::Environments.new(transport, prefix)
209
+ @credentials = Resources::Credentials.new(transport, prefix)
210
+ end
211
+
212
+ # The client itself (GET /clients/:client_id). @return [Hash]
213
+ def get
214
+ @clients.get(client_id)
215
+ end
216
+
217
+ # POST /clients/:client_id/claim_invites: emails your customer an
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
+ def inspect
226
+ "#<#{self.class.name} client_id=#{client_id.inspect}>"
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ module Management
5
+ # Defaults for {Client.new}, set with {Management.configure}, e.g. in a
6
+ # Rails initializer.
7
+ #
8
+ # Separate from {EndPointBlank::Configuration} on purpose: the runtime
9
+ # client_id/client_secret are never sent to the management API, and the
10
+ # management key is never sent to intake. Nothing here reads the runtime
11
+ # configuration, and nothing in the runtime configuration reads this.
12
+ class Configuration
13
+ DEFAULT_BASE_URL = "https://app.endpointblank.com"
14
+ DEFAULT_MAX_RETRIES = 2
15
+ DEFAULT_MAX_RETRY_WAIT = 60
16
+ DEFAULT_CONNECT_TIMEOUT = 5
17
+ DEFAULT_READ_TIMEOUT = 30
18
+
19
+ attr_writer :api_key, :base_url
20
+
21
+ # Retries after the first attempt (see {Transport}). 0 turns them off.
22
+ attr_accessor :max_retries
23
+ # The longest wait, in seconds, a retry will sleep (a longer
24
+ # Retry-After raises the error instead).
25
+ attr_accessor :max_retry_wait
26
+ attr_accessor :connect_timeout, :read_timeout
27
+
28
+ def initialize
29
+ @max_retries = DEFAULT_MAX_RETRIES
30
+ @max_retry_wait = DEFAULT_MAX_RETRY_WAIT
31
+ @connect_timeout = DEFAULT_CONNECT_TIMEOUT
32
+ @read_timeout = DEFAULT_READ_TIMEOUT
33
+ end
34
+
35
+ # The management API key (epb_mk_...), falling back to the
36
+ # ENDPOINTBLANK_MANAGEMENT_KEY environment variable.
37
+ def api_key
38
+ @api_key || ENV.fetch("ENDPOINTBLANK_MANAGEMENT_KEY", nil)
39
+ end
40
+
41
+ # app_portal's URL, falling back to the
42
+ # ENDPOINTBLANK_MANAGEMENT_BASE_URL environment variable, then
43
+ # {DEFAULT_BASE_URL}. Not the runtime's intake base_url.
44
+ def base_url
45
+ @base_url || ENV.fetch("ENDPOINTBLANK_MANAGEMENT_BASE_URL", nil) || DEFAULT_BASE_URL
46
+ end
47
+
48
+ # Never shows the key.
49
+ def inspect
50
+ "#<#{self.class.name} base_url=#{base_url.inspect} api_key=#{api_key ? "[REDACTED]" : "nil"} " \
51
+ "max_retries=#{max_retries.inspect}>"
52
+ end
53
+ alias to_s inspect
54
+ end
55
+ end
56
+ end