infisical-sdk 2.3.9 → 3.0.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/.rspec +3 -0
- data/.rubocop.yml +37 -0
- data/.ruby-version +1 -0
- data/.yardopts +5 -0
- data/LICENSE +201 -0
- data/README.md +134 -0
- data/Rakefile +5 -5
- data/infisical-sdk.gemspec +21 -38
- data/lib/infisical/auth.rb +50 -0
- data/lib/infisical/client.rb +67 -0
- data/lib/infisical/errors.rb +82 -0
- data/lib/infisical/http_client.rb +212 -0
- data/lib/infisical/models/machine_identity_credential.rb +34 -0
- data/lib/infisical/models/secret.rb +93 -0
- data/lib/infisical/secrets.rb +213 -0
- data/lib/infisical/version.rb +6 -0
- data/lib/infisical-sdk.rb +3 -43
- data/lib/infisical.rb +9 -0
- metadata +35 -124
- data/Steepfile +0 -46
- data/lib/clients/auth.rb +0 -100
- data/lib/clients/cryptography.rb +0 -90
- data/lib/clients/secrets.rb +0 -171
- data/lib/command_runner.rb +0 -16
- data/lib/extended_schemas/schemas.rb +0 -38
- data/lib/infisical_error.rb +0 -10
- data/lib/infisical_lib.rb +0 -44
- data/lib/linux-arm64/libinfisical_c.so +0 -0
- data/lib/linux-x64/libinfisical_c.so +0 -0
- data/lib/macos-arm64/libinfisical_c.dylib +0 -0
- data/lib/macos-x64/libinfisical_c.dylib +0 -0
- data/lib/schemas.rb +0 -1674
- data/lib/version.rb +0 -5
- data/lib/windows-x64/infisical_c.dll +0 -0
- data/sig/infisical_sdk/auth_client.rbs +0 -20
- data/sig/infisical_sdk/command_runner.rbs +0 -10
- data/sig/infisical_sdk/cryptography_client.rbs +0 -15
- data/sig/infisical_sdk/infisical_client.rbs +0 -30
- data/sig/infisical_sdk/infisical_lib.rbs +0 -7
- data/sig/infisical_sdk/sdk.rbs +0 -3
- data/sig/infisical_sdk/secrets_client.rbs +0 -56
- data/sig/infisical_sdk/structs.rbs +0 -37
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Infisical
|
|
4
|
+
# Base class for all errors raised by this SDK. Rescue this to catch
|
|
5
|
+
# anything Infisical-related.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when the Infisical API responds with a non-2xx status. Well-known
|
|
9
|
+
# statuses raise a subclass ({AuthenticationError}, {PermissionError},
|
|
10
|
+
# {NotFoundError}, {RateLimitError}, {ServerError}); rescuing this class
|
|
11
|
+
# catches them all.
|
|
12
|
+
class APIError < Error
|
|
13
|
+
# @return [Integer] HTTP status code of the failed response
|
|
14
|
+
attr_reader :status
|
|
15
|
+
|
|
16
|
+
# @return [String] full URL the failed request was sent to
|
|
17
|
+
attr_reader :url
|
|
18
|
+
|
|
19
|
+
# @return [String] uppercase HTTP method of the failed request, e.g. "GET"
|
|
20
|
+
attr_reader :http_method
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] server-assigned request id, when the response carried one
|
|
23
|
+
attr_reader :request_id
|
|
24
|
+
|
|
25
|
+
# Returns the error class that best matches an HTTP status, falling back
|
|
26
|
+
# to {APIError} itself. Constants are resolved at call time, so the
|
|
27
|
+
# subclasses defined below are visible here.
|
|
28
|
+
#
|
|
29
|
+
# @param status [Integer] HTTP status code
|
|
30
|
+
# @return [Class<APIError>]
|
|
31
|
+
def self.for_status(status)
|
|
32
|
+
case status
|
|
33
|
+
when 401 then AuthenticationError
|
|
34
|
+
when 403 then PermissionError
|
|
35
|
+
when 404 then NotFoundError
|
|
36
|
+
when 429 then RateLimitError
|
|
37
|
+
when 500..599 then ServerError
|
|
38
|
+
else self
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param message [String] human-readable error detail from the API
|
|
43
|
+
# @param status [Integer] HTTP status code
|
|
44
|
+
# @param url [String] full URL the request was sent to
|
|
45
|
+
# @param http_method [String] HTTP method of the request
|
|
46
|
+
# @param request_id [String, nil] server-assigned request id, if any
|
|
47
|
+
def initialize(message, status:, url:, http_method:, request_id: nil)
|
|
48
|
+
@status = status
|
|
49
|
+
@url = url
|
|
50
|
+
@http_method = http_method
|
|
51
|
+
@request_id = request_id
|
|
52
|
+
|
|
53
|
+
context = "[Method=#{http_method}] [URL=#{url}] [StatusCode=#{status}]"
|
|
54
|
+
context += " [RequestId=#{request_id}]" if request_id
|
|
55
|
+
|
|
56
|
+
super("#{context} #{message}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Raised on HTTP 401: missing, expired, or invalid credentials.
|
|
61
|
+
class AuthenticationError < APIError; end
|
|
62
|
+
|
|
63
|
+
# Raised on HTTP 403: the authenticated identity lacks permission for the
|
|
64
|
+
# requested resource or action.
|
|
65
|
+
class PermissionError < APIError; end
|
|
66
|
+
|
|
67
|
+
# Raised on HTTP 404: the secret, project, environment, or path does not
|
|
68
|
+
# exist (or is not visible to the authenticated identity).
|
|
69
|
+
class NotFoundError < APIError; end
|
|
70
|
+
|
|
71
|
+
# Raised on HTTP 429, after the client has exhausted its automatic retries.
|
|
72
|
+
class RateLimitError < APIError; end
|
|
73
|
+
|
|
74
|
+
# Raised on HTTP 5xx: the Infisical server failed to process the request.
|
|
75
|
+
class ServerError < APIError; end
|
|
76
|
+
|
|
77
|
+
# Raised when the request itself fails (network/timeout) before a
|
|
78
|
+
# response is received. Always raised from inside a `rescue` for the
|
|
79
|
+
# underlying network exception, so Ruby's built-in `Exception#cause`
|
|
80
|
+
# already carries it through; no need to track it ourselves.
|
|
81
|
+
class RequestError < Error; end
|
|
82
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "version"
|
|
10
|
+
|
|
11
|
+
module Infisical
|
|
12
|
+
# Thin wrapper around Net::HTTP that knows how to talk to the Infisical
|
|
13
|
+
# API: bearer token auth, JSON (de)serialization, and retrying transient
|
|
14
|
+
# failures with exponential backoff + jitter.
|
|
15
|
+
#
|
|
16
|
+
# @api private Internal plumbing; use {Client} instead.
|
|
17
|
+
class HTTPClient
|
|
18
|
+
USER_AGENT = "infisical-ruby-sdk/v#{VERSION}".freeze
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT = 10 # seconds
|
|
21
|
+
DEFAULT_MAX_RETRIES = 4
|
|
22
|
+
DEFAULT_INITIAL_DELAY = 1.0 # seconds
|
|
23
|
+
DEFAULT_BACKOFF_FACTOR = 2
|
|
24
|
+
JITTER_RATIO = 0.2 # +/- 20%
|
|
25
|
+
|
|
26
|
+
RETRYABLE_EXCEPTIONS = [
|
|
27
|
+
Errno::ECONNRESET,
|
|
28
|
+
Errno::ECONNREFUSED,
|
|
29
|
+
Errno::ETIMEDOUT,
|
|
30
|
+
Net::OpenTimeout,
|
|
31
|
+
Net::ReadTimeout,
|
|
32
|
+
SocketError,
|
|
33
|
+
IOError
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
def initialize(base_url:, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
|
|
37
|
+
initial_delay: DEFAULT_INITIAL_DELAY, backoff_factor: DEFAULT_BACKOFF_FACTOR,
|
|
38
|
+
sleeper: ->(seconds) { sleep(seconds) })
|
|
39
|
+
@base_url = base_url.end_with?("/") ? base_url : "#{base_url}/"
|
|
40
|
+
@timeout = timeout
|
|
41
|
+
@max_retries = max_retries
|
|
42
|
+
@initial_delay = initial_delay
|
|
43
|
+
@backoff_factor = backoff_factor
|
|
44
|
+
@sleeper = sleeper
|
|
45
|
+
@access_token = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_writer :access_token
|
|
49
|
+
|
|
50
|
+
def get(path, params: {})
|
|
51
|
+
request(:get, path, params: params)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# auth: false sends the request without the stored access token, for
|
|
55
|
+
# endpoints like login where a stale bearer token must not be attached.
|
|
56
|
+
def post(path, body: nil, params: {}, auth: true)
|
|
57
|
+
request(:post, path, body: body, params: params, auth: auth)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def patch(path, body: nil, params: {})
|
|
61
|
+
request(:patch, path, body: body, params: params)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def delete(path, body: nil, params: {})
|
|
65
|
+
request(:delete, path, body: body, params: params)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Computes the (pre-jitter) backoff delay for a given retry attempt
|
|
69
|
+
# (0-indexed). Exposed for testing.
|
|
70
|
+
def self.backoff_delay(attempt, initial_delay: DEFAULT_INITIAL_DELAY, backoff_factor: DEFAULT_BACKOFF_FACTOR)
|
|
71
|
+
initial_delay * (backoff_factor**attempt)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def request(method, path, body: nil, params: {}, auth: true)
|
|
77
|
+
uri = build_uri(path, params)
|
|
78
|
+
|
|
79
|
+
attempt = 0
|
|
80
|
+
begin
|
|
81
|
+
response = perform(method, uri, body, auth)
|
|
82
|
+
handle_response(response, method: method, uri: uri)
|
|
83
|
+
rescue *RETRYABLE_EXCEPTIONS => e
|
|
84
|
+
raise RequestError, "request to #{uri} failed: #{e.message}" if attempt >= @max_retries
|
|
85
|
+
|
|
86
|
+
attempt += 1
|
|
87
|
+
wait_before_retry(attempt)
|
|
88
|
+
retry
|
|
89
|
+
rescue RetryableAPIError => e
|
|
90
|
+
raise e.api_error if attempt >= @max_retries
|
|
91
|
+
|
|
92
|
+
attempt += 1
|
|
93
|
+
wait_before_retry(attempt, override: e.retry_after)
|
|
94
|
+
retry
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_uri(path, params)
|
|
99
|
+
uri = URI.join(@base_url, path)
|
|
100
|
+
filtered = params.compact
|
|
101
|
+
uri.query = URI.encode_www_form(filtered) unless filtered.empty?
|
|
102
|
+
uri
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def perform(method, uri, body, auth)
|
|
106
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
107
|
+
http.use_ssl = uri.scheme == "https"
|
|
108
|
+
http.open_timeout = @timeout
|
|
109
|
+
http.read_timeout = @timeout
|
|
110
|
+
|
|
111
|
+
request = build_request(method, uri, body, auth)
|
|
112
|
+
http.request(request)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_request(method, uri, body, auth)
|
|
116
|
+
request_class = {
|
|
117
|
+
get: Net::HTTP::Get,
|
|
118
|
+
post: Net::HTTP::Post,
|
|
119
|
+
patch: Net::HTTP::Patch,
|
|
120
|
+
delete: Net::HTTP::Delete
|
|
121
|
+
}.fetch(method)
|
|
122
|
+
|
|
123
|
+
request = request_class.new(uri)
|
|
124
|
+
request["Accept"] = "application/json"
|
|
125
|
+
request["User-Agent"] = USER_AGENT
|
|
126
|
+
request["Authorization"] = "Bearer #{@access_token}" if auth && @access_token
|
|
127
|
+
|
|
128
|
+
unless body.nil?
|
|
129
|
+
request["Content-Type"] = "application/json"
|
|
130
|
+
request.body = JSON.generate(body)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
request
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_response(response, method:, uri:)
|
|
137
|
+
status = response.code.to_i
|
|
138
|
+
if status == 429
|
|
139
|
+
raise RetryableAPIError.new(build_api_error(response, status, method, uri),
|
|
140
|
+
retry_after: parse_retry_after(response["Retry-After"]))
|
|
141
|
+
end
|
|
142
|
+
raise build_api_error(response, status, method, uri) unless (200..299).cover?(status)
|
|
143
|
+
|
|
144
|
+
parse_body(response.body)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Retry-After is usually an integer/float number of seconds, but per
|
|
148
|
+
# RFC 9110 it may also be an HTTP-date.
|
|
149
|
+
def parse_retry_after(value)
|
|
150
|
+
return nil if value.nil? || value.empty?
|
|
151
|
+
|
|
152
|
+
begin
|
|
153
|
+
Float(value)
|
|
154
|
+
rescue ArgumentError, TypeError
|
|
155
|
+
begin
|
|
156
|
+
seconds = Time.httpdate(value) - Time.now
|
|
157
|
+
seconds.positive? ? seconds : nil
|
|
158
|
+
rescue ArgumentError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_api_error(response, status, method, uri)
|
|
165
|
+
data = parse_body(response.body)
|
|
166
|
+
data = {} unless data.is_a?(Hash)
|
|
167
|
+
message = data["message"] || data["error"] || response.body
|
|
168
|
+
|
|
169
|
+
APIError.for_status(status).new(
|
|
170
|
+
message.to_s,
|
|
171
|
+
status: status,
|
|
172
|
+
url: uri.to_s,
|
|
173
|
+
http_method: method.to_s.upcase,
|
|
174
|
+
request_id: data["reqId"]
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_body(raw)
|
|
179
|
+
return nil if raw.nil? || raw.empty?
|
|
180
|
+
|
|
181
|
+
JSON.parse(raw)
|
|
182
|
+
rescue JSON::ParserError
|
|
183
|
+
raw
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def wait_before_retry(attempt, override: nil)
|
|
187
|
+
if override
|
|
188
|
+
@sleeper.call(override)
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
base = self.class.backoff_delay(attempt - 1, initial_delay: @initial_delay, backoff_factor: @backoff_factor)
|
|
193
|
+
jitter = base * JITTER_RATIO * ((rand * 2) - 1)
|
|
194
|
+
@sleeper.call(base + jitter)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Internal-only signal so 429s share the same retry path as network
|
|
198
|
+
# errors without retrying every other 4xx/5xx status. Carries the
|
|
199
|
+
# server's Retry-After hint (if any) so the wait honors it instead of
|
|
200
|
+
# our own computed backoff.
|
|
201
|
+
class RetryableAPIError < StandardError
|
|
202
|
+
attr_reader :api_error, :retry_after
|
|
203
|
+
|
|
204
|
+
def initialize(api_error, retry_after: nil)
|
|
205
|
+
@api_error = api_error
|
|
206
|
+
@retry_after = retry_after
|
|
207
|
+
super(api_error.message)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
private_constant :RetryableAPIError
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Infisical
|
|
4
|
+
module Models
|
|
5
|
+
# Credential material issued by a machine identity login. Exposes the
|
|
6
|
+
# token's lifetime so callers can build their own refresh logic.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute access_token
|
|
9
|
+
# @return [String] bearer token used to authenticate API requests
|
|
10
|
+
# @!attribute expires_in
|
|
11
|
+
# @return [Integer] seconds until the token expires, from issue time
|
|
12
|
+
# @!attribute access_token_max_ttl
|
|
13
|
+
# @return [Integer] hard ceiling in seconds on the token's total
|
|
14
|
+
# lifetime across renewals
|
|
15
|
+
# @!attribute token_type
|
|
16
|
+
# @return [String] token scheme, e.g. "Bearer"
|
|
17
|
+
MachineIdentityCredential = Struct.new(
|
|
18
|
+
:access_token, :expires_in, :access_token_max_ttl, :token_type,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
) do
|
|
21
|
+
# Builds a credential from an API response hash (camelCase keys).
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
def self.from_api(data)
|
|
25
|
+
new(
|
|
26
|
+
access_token: data["accessToken"],
|
|
27
|
+
expires_in: data["expiresIn"],
|
|
28
|
+
access_token_max_ttl: data["accessTokenMaxTTL"],
|
|
29
|
+
token_type: data["tokenType"]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Infisical
|
|
4
|
+
# Value objects returned by the resource clients.
|
|
5
|
+
module Models
|
|
6
|
+
# A key/value metadata entry attached to a secret.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute key
|
|
9
|
+
# @return [String] metadata key
|
|
10
|
+
# @!attribute value
|
|
11
|
+
# @return [String] metadata value
|
|
12
|
+
SecretMetadata = Struct.new(:key, :value, keyword_init: true) do
|
|
13
|
+
# @api private
|
|
14
|
+
def self.from_api(data)
|
|
15
|
+
new(key: data["key"], value: data["value"])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A tag attached to a secret.
|
|
20
|
+
#
|
|
21
|
+
# @!attribute id
|
|
22
|
+
# @return [String] unique id of the tag
|
|
23
|
+
# @!attribute slug
|
|
24
|
+
# @return [String] URL-safe identifier of the tag
|
|
25
|
+
# @!attribute name
|
|
26
|
+
# @return [String] display name of the tag
|
|
27
|
+
# @!attribute color
|
|
28
|
+
# @return [String, nil] display color of the tag
|
|
29
|
+
SecretTag = Struct.new(:id, :slug, :name, :color, keyword_init: true) do
|
|
30
|
+
# @api private
|
|
31
|
+
def self.from_api(data)
|
|
32
|
+
new(id: data["id"], slug: data["slug"], name: data["name"], color: data["color"])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Value object representing a single Infisical secret.
|
|
37
|
+
#
|
|
38
|
+
# @!attribute id
|
|
39
|
+
# @return [String] unique id of the secret
|
|
40
|
+
# @!attribute workspace
|
|
41
|
+
# @return [String] id of the project the secret belongs to
|
|
42
|
+
# @!attribute environment
|
|
43
|
+
# @return [String] environment slug the secret belongs to
|
|
44
|
+
# @!attribute version
|
|
45
|
+
# @return [Integer] version number, incremented on every update
|
|
46
|
+
# @!attribute type
|
|
47
|
+
# @return [String] "shared" or "personal"
|
|
48
|
+
# @!attribute secret_key
|
|
49
|
+
# @return [String] the secret's name
|
|
50
|
+
# @!attribute secret_value
|
|
51
|
+
# @return [String] the secret's value
|
|
52
|
+
# @!attribute secret_comment
|
|
53
|
+
# @return [String, nil] comment stored with the secret
|
|
54
|
+
# @!attribute secret_path
|
|
55
|
+
# @return [String, nil] folder path the secret lives at
|
|
56
|
+
# @!attribute metadata
|
|
57
|
+
# @return [Array<SecretMetadata>] key/value metadata entries
|
|
58
|
+
# @!attribute tags
|
|
59
|
+
# @return [Array<SecretTag>] tags attached to the secret
|
|
60
|
+
# @!attribute created_at
|
|
61
|
+
# @return [String, nil] ISO 8601 creation timestamp
|
|
62
|
+
# @!attribute updated_at
|
|
63
|
+
# @return [String, nil] ISO 8601 last-update timestamp
|
|
64
|
+
Secret = Struct.new(
|
|
65
|
+
:id, :workspace, :environment, :version, :type,
|
|
66
|
+
:secret_key, :secret_value, :secret_comment, :secret_path,
|
|
67
|
+
:metadata, :tags,
|
|
68
|
+
:created_at, :updated_at,
|
|
69
|
+
keyword_init: true
|
|
70
|
+
) do
|
|
71
|
+
# Builds a Secret from an API response hash (camelCase keys).
|
|
72
|
+
#
|
|
73
|
+
# @api private
|
|
74
|
+
def self.from_api(data)
|
|
75
|
+
new(
|
|
76
|
+
id: data["id"],
|
|
77
|
+
workspace: data["workspace"],
|
|
78
|
+
environment: data["environment"],
|
|
79
|
+
version: data["version"],
|
|
80
|
+
type: data["type"],
|
|
81
|
+
secret_key: data["secretKey"],
|
|
82
|
+
secret_value: data["secretValue"],
|
|
83
|
+
secret_comment: data["secretComment"],
|
|
84
|
+
secret_path: data["secretPath"],
|
|
85
|
+
metadata: Array(data["secretMetadata"]).map { |entry| SecretMetadata.from_api(entry) },
|
|
86
|
+
tags: Array(data["tags"]).map { |tag| SecretTag.from_api(tag) },
|
|
87
|
+
created_at: data["createdAt"],
|
|
88
|
+
updated_at: data["updatedAt"]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
require_relative "models/secret"
|
|
6
|
+
|
|
7
|
+
module Infisical
|
|
8
|
+
# CRUD operations against Infisical's v4 secrets API.
|
|
9
|
+
class Secrets
|
|
10
|
+
# @api private
|
|
11
|
+
BASE_PATH = "api/v4/secrets"
|
|
12
|
+
|
|
13
|
+
# @api private Obtain instances via {Client#secrets} instead.
|
|
14
|
+
def initialize(http_client)
|
|
15
|
+
@http_client = http_client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Lists the secrets in a project environment, sorted by key.
|
|
19
|
+
#
|
|
20
|
+
# @param project_id [String] id of the project to read from
|
|
21
|
+
# @param environment [String] environment slug, e.g. "dev"
|
|
22
|
+
# @param secret_path [String] folder path to list from
|
|
23
|
+
# @param include_imports [Boolean] fold in secrets from imported folders;
|
|
24
|
+
# direct secrets win over imports on key conflicts, and earlier import
|
|
25
|
+
# blocks win over later ones
|
|
26
|
+
# @param recursive [Boolean] also list secrets from sub-folders
|
|
27
|
+
# @param skip_unique_validation [Boolean] in recursive mode, duplicate keys
|
|
28
|
+
# across folders are collapsed to one secret per key (last occurrence
|
|
29
|
+
# wins) unless this is true, in which case all of them are kept
|
|
30
|
+
# @param attach_to_process_env [Boolean] export each fetched secret into
|
|
31
|
+
# the process environment (ENV), without overriding variables that are
|
|
32
|
+
# already set
|
|
33
|
+
# @return [Array<Models::Secret>]
|
|
34
|
+
# @raise [APIError] if the API rejects the request
|
|
35
|
+
def list(project_id:, environment:, secret_path: "/", include_imports: true, recursive: false,
|
|
36
|
+
skip_unique_validation: false, attach_to_process_env: false)
|
|
37
|
+
response = @http_client.get(
|
|
38
|
+
BASE_PATH,
|
|
39
|
+
params: {
|
|
40
|
+
projectId: project_id,
|
|
41
|
+
environment: environment,
|
|
42
|
+
secretPath: secret_path,
|
|
43
|
+
includeImports: include_imports,
|
|
44
|
+
recursive: recursive
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
secrets = Array(response["secrets"]).map { |secret| Models::Secret.from_api(secret) }
|
|
49
|
+
secrets = ensure_unique_secrets_by_key(secrets, skip_unique_validation) if recursive
|
|
50
|
+
secrets = merge_imported_secrets(secrets, response["imports"]) if include_imports
|
|
51
|
+
secrets = secrets.sort_by(&:secret_key)
|
|
52
|
+
attach_to_env(secrets) if attach_to_process_env
|
|
53
|
+
secrets
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fetches a single secret by name.
|
|
57
|
+
#
|
|
58
|
+
# @param secret_name [String] key of the secret to fetch
|
|
59
|
+
# @param project_id [String] id of the project to read from
|
|
60
|
+
# @param environment [String] environment slug, e.g. "dev"
|
|
61
|
+
# @param secret_path [String] folder path the secret lives at
|
|
62
|
+
# @param include_imports [Boolean] also look through imported folders when
|
|
63
|
+
# the secret is not found at the path itself
|
|
64
|
+
# @return [Models::Secret]
|
|
65
|
+
# @raise [NotFoundError] if no such secret exists
|
|
66
|
+
def get(secret_name, project_id:, environment:, secret_path: "/", include_imports: true)
|
|
67
|
+
response = @http_client.get(
|
|
68
|
+
secret_path_for(secret_name),
|
|
69
|
+
params: {
|
|
70
|
+
projectId: project_id,
|
|
71
|
+
environment: environment,
|
|
72
|
+
secretPath: secret_path,
|
|
73
|
+
includeImports: include_imports
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
Models::Secret.from_api(response["secret"])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Creates a new secret.
|
|
81
|
+
#
|
|
82
|
+
# @param secret_name [String] key of the secret to create
|
|
83
|
+
# @param secret_value [String] value of the secret
|
|
84
|
+
# @param project_id [String] id of the project to write to
|
|
85
|
+
# @param environment [String] environment slug, e.g. "dev"
|
|
86
|
+
# @param secret_path [String] folder path to create the secret at
|
|
87
|
+
# @param secret_comment [String, nil] optional comment stored with the secret
|
|
88
|
+
# @param skip_multiline_encoding [Boolean, nil] disable the API's encoding
|
|
89
|
+
# of multi-line values; omitted from the request when nil
|
|
90
|
+
# @return [Models::Secret] the created secret
|
|
91
|
+
# @raise [APIError] if the API rejects the request, e.g. the name is taken
|
|
92
|
+
def create(secret_name, secret_value, project_id:, environment:, secret_path: "/", secret_comment: nil,
|
|
93
|
+
skip_multiline_encoding: nil)
|
|
94
|
+
response = @http_client.post(
|
|
95
|
+
secret_path_for(secret_name),
|
|
96
|
+
body: {
|
|
97
|
+
projectId: project_id,
|
|
98
|
+
environment: environment,
|
|
99
|
+
secretPath: secret_path,
|
|
100
|
+
secretValue: secret_value,
|
|
101
|
+
secretComment: secret_comment,
|
|
102
|
+
skipMultilineEncoding: skip_multiline_encoding
|
|
103
|
+
}.compact
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
Models::Secret.from_api(response["secret"])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Updates a secret's value, name, or both.
|
|
110
|
+
#
|
|
111
|
+
# @param secret_name [String] key of the secret to update
|
|
112
|
+
# @param project_id [String] id of the project to write to
|
|
113
|
+
# @param environment [String] environment slug, e.g. "dev"
|
|
114
|
+
# @param secret_value [String, nil] new value, if changing it
|
|
115
|
+
# @param new_secret_name [String, nil] new key, if renaming
|
|
116
|
+
# @param secret_path [String] folder path the secret lives at
|
|
117
|
+
# @param skip_multiline_encoding [Boolean, nil] disable the API's encoding
|
|
118
|
+
# of multi-line values; omitted from the request when nil
|
|
119
|
+
# @return [Models::Secret] the updated secret
|
|
120
|
+
# @raise [ArgumentError] if neither secret_value nor new_secret_name is given
|
|
121
|
+
# @raise [NotFoundError] if no such secret exists
|
|
122
|
+
def update(secret_name, project_id:, environment:, secret_value: nil, new_secret_name: nil, secret_path: "/",
|
|
123
|
+
skip_multiline_encoding: nil)
|
|
124
|
+
if secret_value.nil? && new_secret_name.nil?
|
|
125
|
+
raise ArgumentError, "update requires at least one of secret_value: or new_secret_name:"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
response = @http_client.patch(
|
|
129
|
+
secret_path_for(secret_name),
|
|
130
|
+
body: {
|
|
131
|
+
projectId: project_id,
|
|
132
|
+
environment: environment,
|
|
133
|
+
secretPath: secret_path,
|
|
134
|
+
secretValue: secret_value,
|
|
135
|
+
newSecretName: new_secret_name,
|
|
136
|
+
skipMultilineEncoding: skip_multiline_encoding
|
|
137
|
+
}.compact
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
Models::Secret.from_api(response["secret"])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Deletes a secret.
|
|
144
|
+
#
|
|
145
|
+
# @param secret_name [String] key of the secret to delete
|
|
146
|
+
# @param project_id [String] id of the project to write to
|
|
147
|
+
# @param environment [String] environment slug, e.g. "dev"
|
|
148
|
+
# @param secret_path [String] folder path the secret lives at
|
|
149
|
+
# @return [Models::Secret] the deleted secret
|
|
150
|
+
# @raise [NotFoundError] if no such secret exists
|
|
151
|
+
def delete(secret_name, project_id:, environment:, secret_path: "/")
|
|
152
|
+
response = @http_client.delete(
|
|
153
|
+
secret_path_for(secret_name),
|
|
154
|
+
body: {
|
|
155
|
+
projectId: project_id,
|
|
156
|
+
environment: environment,
|
|
157
|
+
secretPath: secret_path
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
Models::Secret.from_api(response["secret"])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# In recursive mode the same key can exist at several paths; collapse to
|
|
167
|
+
# one secret per key (the last occurrence wins). With
|
|
168
|
+
# skip_unique_validation, secrets are instead kept unique per
|
|
169
|
+
# path+key, so same-named secrets at different paths all survive.
|
|
170
|
+
def ensure_unique_secrets_by_key(secrets, skip_unique_validation)
|
|
171
|
+
secrets.each_with_object({}) do |secret, by_key|
|
|
172
|
+
key = skip_unique_validation ? "#{secret.secret_path}:#{secret.secret_key}" : secret.secret_key
|
|
173
|
+
by_key[key] = secret
|
|
174
|
+
end.values
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Folds secrets from import blocks into the main list. Secrets already
|
|
178
|
+
# present win over imports, and earlier import blocks win over later
|
|
179
|
+
# ones.
|
|
180
|
+
def merge_imported_secrets(secrets, import_blocks)
|
|
181
|
+
merged = secrets.dup
|
|
182
|
+
seen = merged.to_h { |secret| [secret.secret_key, true] }
|
|
183
|
+
|
|
184
|
+
Array(import_blocks).each do |block|
|
|
185
|
+
Array(block["secrets"]).each do |data|
|
|
186
|
+
secret = Models::Secret.from_api(data)
|
|
187
|
+
next if seen[secret.secret_key]
|
|
188
|
+
|
|
189
|
+
seen[secret.secret_key] = true
|
|
190
|
+
merged << secret
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
merged
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Exports secrets into the process environment. A variable that already
|
|
198
|
+
# has a non-empty value is left untouched; an empty value counts as
|
|
199
|
+
# unset, matching the Go SDK.
|
|
200
|
+
def attach_to_env(secrets)
|
|
201
|
+
secrets.each do |secret|
|
|
202
|
+
ENV[secret.secret_key] = secret.secret_value if ENV[secret.secret_key].to_s.empty?
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Escapes a secret name for safe use as a single URI path segment, so
|
|
207
|
+
# names containing "/", "?", "#", or "%" can't be misread as path
|
|
208
|
+
# separators or query-string tokens.
|
|
209
|
+
def secret_path_for(secret_name)
|
|
210
|
+
"#{BASE_PATH}/#{URI::DEFAULT_PARSER.escape(secret_name.to_s, /[^A-Za-z0-9\-._~]/)}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
data/lib/infisical-sdk.rb
CHANGED
|
@@ -1,45 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
|
|
6
|
-
require_relative 'schemas'
|
|
7
|
-
require_relative 'extended_schemas/schemas'
|
|
8
|
-
require_relative 'infisical_lib'
|
|
9
|
-
require_relative 'infisical_error'
|
|
10
|
-
require_relative 'command_runner'
|
|
11
|
-
require_relative 'clients/secrets'
|
|
12
|
-
require_relative 'clients/auth'
|
|
13
|
-
require_relative 'clients/cryptography'
|
|
14
|
-
|
|
15
|
-
module InfisicalSDK
|
|
16
|
-
class InfisicalClient
|
|
17
|
-
attr_reader :infisical, :command_runner, :secrets, :auth, :cryptography
|
|
18
|
-
|
|
19
|
-
def initialize(site_url = nil, cache_ttl = 300)
|
|
20
|
-
settings = ClientSettings.new(
|
|
21
|
-
# We preset these values or we'll get type validation errors (thanks Quicktype!)
|
|
22
|
-
access_token: nil,
|
|
23
|
-
client_secret: nil,
|
|
24
|
-
client_id: nil,
|
|
25
|
-
auth: nil,
|
|
26
|
-
ssl_certificate_path: nil,
|
|
27
|
-
user_agent: 'infisical-ruby-sdk',
|
|
28
|
-
cache_ttl: cache_ttl,
|
|
29
|
-
site_url: site_url
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@infisical = InfisicalLib
|
|
34
|
-
@handle = @infisical.init(settings.to_dynamic.compact.to_json)
|
|
35
|
-
@command_runner = CommandRunner.new(@infisical, @handle)
|
|
36
|
-
@secrets = SecretsClient.new(@command_runner)
|
|
37
|
-
@auth = AuthClient.new(@command_runner)
|
|
38
|
-
@cryptography = CryptographyClient.new(@command_runner)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def free_mem
|
|
42
|
-
@infisical.free_mem(@handle)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
3
|
+
# Entry point matching the gem name, so `Bundler.require` (e.g. in Rails)
|
|
4
|
+
# can `require "infisical-sdk"` without an explicit `require:` override.
|
|
5
|
+
require_relative "infisical"
|