k9o 1.4.0 → 1.5.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 +61 -1
- data/README.md +1245 -141
- data/k9o.gemspec +2 -2
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +102 -8
- data/lib/k9o/audit.rb +150 -0
- data/lib/k9o/client.rb +18 -3
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +65 -11
- data/lib/k9o/ed25519.rb +182 -0
- data/lib/k9o/end_users.rb +93 -0
- data/lib/k9o/envelope.rb +20 -5
- data/lib/k9o/functions.rb +82 -3
- data/lib/k9o/gate.rb +46 -7
- data/lib/k9o/http_transport.rb +71 -14
- data/lib/k9o/jcs.rb +117 -0
- data/lib/k9o/kerror.rb +57 -5
- data/lib/k9o/keys.rb +131 -0
- data/lib/k9o/kibo_client_core.rb +385 -0
- data/lib/k9o/kibo_ffi_core.rb +178 -0
- data/lib/k9o/locate.rb +118 -0
- data/lib/k9o/me.rb +1 -1
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +1 -1
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -10
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/vault.rb +3 -4
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +13 -0
- data/sig/k9o/approvals.rbs +25 -3
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +2 -0
- data/sig/k9o/kerror.rbs +24 -0
- data/sig/k9o/kibo_client_core.rbs +25 -0
- data/sig/k9o/metadata.rbs +54 -0
- data/sig/k9o/moat.rbs +29 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/workload.rbs +64 -0
- metadata +27 -4
data/lib/k9o/http_transport.rb
CHANGED
|
@@ -29,12 +29,13 @@ module K9o
|
|
|
29
29
|
# Per-instance HTTP config bound on K9oClient.
|
|
30
30
|
class HttpClientConfig
|
|
31
31
|
attr_reader :base_url, :api_key, :org_slug, :project_slug, :default_headers, :http_client, :retry_config,
|
|
32
|
-
:region_mutex
|
|
32
|
+
:region_mutex, :expected_key_refs
|
|
33
33
|
# Cached regional origin (see K9o::Region); nil until a definite answer.
|
|
34
34
|
attr_accessor :regional_origin
|
|
35
35
|
|
|
36
36
|
def initialize(base_url:, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil,
|
|
37
|
-
retry_config: nil)
|
|
37
|
+
retry_config: nil, expected_key_refs: nil)
|
|
38
|
+
@expected_key_refs = expected_key_refs
|
|
38
39
|
@base_url = base_url
|
|
39
40
|
@api_key = api_key
|
|
40
41
|
@org_slug = org_slug
|
|
@@ -49,6 +50,8 @@ module K9o
|
|
|
49
50
|
|
|
50
51
|
# HTTP transport using Net::HTTP stdlib.
|
|
51
52
|
module HttpTransport
|
|
53
|
+
# Seconds to wait for a response, as in the Python SDK (120 s read). A long call, such as Rampart `classify` on a long input, can run about 20 s.
|
|
54
|
+
DEFAULT_READ_TIMEOUT = 120
|
|
52
55
|
RETRYABLE_METHODS = %w[GET HEAD OPTIONS DELETE].freeze
|
|
53
56
|
RETRYABLE_STATUS = [502, 503, 504, 429].freeze
|
|
54
57
|
|
|
@@ -68,14 +71,32 @@ module K9o
|
|
|
68
71
|
#
|
|
69
72
|
# `authenticated: false` sends NO credential: no api_key bearer, and any
|
|
70
73
|
# authorization / cookie header from `default_headers` or `headers:` is dropped.
|
|
74
|
+
#
|
|
75
|
+
# `origin:` sends the request to that origin instead of the client's regional base. Only the end-user credential
|
|
76
|
+
# flows set it, after the rule in K9o::Locate.
|
|
77
|
+
#
|
|
78
|
+
# `bare: true` is for the few public feeds that answer `{ ...fields, meta }` without a `data` wrapper (the Rampart
|
|
79
|
+
# key feed): the fields come back as `data`.
|
|
80
|
+
#
|
|
81
|
+
# `binary: true` is for download routes: a 2xx answer comes back as a K9o::Download (bytes and content type),
|
|
82
|
+
# not parsed; an error answer is still the JSON error envelope.
|
|
71
83
|
def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
|
|
72
|
-
raw_body: nil)
|
|
84
|
+
raw_body: nil, bare: false, binary: false, origin: nil)
|
|
73
85
|
retry_with_backoff(method, config.retry_config || RetryConfig.default) do
|
|
74
86
|
request_once(config: config, method: method, path: path, body: body, query: query, headers: headers,
|
|
75
|
-
authenticated: authenticated, multipart: multipart, raw_body: raw_body
|
|
87
|
+
authenticated: authenticated, multipart: multipart, raw_body: raw_body, bare: bare,
|
|
88
|
+
binary: binary, origin: origin)
|
|
76
89
|
end
|
|
77
90
|
end
|
|
78
91
|
|
|
92
|
+
# A response header, or nil (also for response doubles that have no header table).
|
|
93
|
+
def header_of(response, name)
|
|
94
|
+
value = response[name]
|
|
95
|
+
value.is_a?(String) ? value : nil
|
|
96
|
+
rescue NameError, IndexError, TypeError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
79
100
|
# Run a block with retry-with-backoff. Extracted so the retry policy is
|
|
80
101
|
# unit-testable without a live HTTP round-trip.
|
|
81
102
|
def retry_with_backoff(method, retry_config)
|
|
@@ -118,8 +139,8 @@ module K9o
|
|
|
118
139
|
|
|
119
140
|
# Single-attempt request. `request` wraps this in retry-with-backoff.
|
|
120
141
|
def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
|
|
121
|
-
raw_body: nil)
|
|
122
|
-
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|
|
142
|
+
raw_body: nil, bare: false, binary: false, origin: nil)
|
|
143
|
+
url = "#{(origin || Region.resolve_origin(config)).sub(%r{/\z}, '')}#{path}"
|
|
123
144
|
if query && !query.empty?
|
|
124
145
|
url += '?' + URI.encode_www_form(query)
|
|
125
146
|
end
|
|
@@ -127,7 +148,7 @@ module K9o
|
|
|
127
148
|
uri = URI.parse(url)
|
|
128
149
|
http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
|
|
129
150
|
http.use_ssl = (uri.scheme == 'https')
|
|
130
|
-
http.read_timeout =
|
|
151
|
+
http.read_timeout = DEFAULT_READ_TIMEOUT
|
|
131
152
|
|
|
132
153
|
req_class = case method.to_s.upcase
|
|
133
154
|
when 'GET' then ::Net::HTTP::Get
|
|
@@ -179,6 +200,10 @@ module K9o
|
|
|
179
200
|
|
|
180
201
|
return Envelope.new(data: nil, meta: Meta.new) if status == 204
|
|
181
202
|
|
|
203
|
+
if binary && status >= 200 && status < 300
|
|
204
|
+
return Download.new(body: (response.body || '').b, content_type: header_of(response, 'content-type'))
|
|
205
|
+
end
|
|
206
|
+
|
|
182
207
|
begin
|
|
183
208
|
parsed = JSON.parse(response.body || '')
|
|
184
209
|
rescue JSON::ParserError => e
|
|
@@ -186,11 +211,17 @@ module K9o
|
|
|
186
211
|
code: 'K9O_INTERNAL_ERROR',
|
|
187
212
|
message: "Failed to parse JSON response from #{url}: #{e.message}",
|
|
188
213
|
context: { url: url, status: status },
|
|
189
|
-
status: status
|
|
214
|
+
status: status,
|
|
215
|
+
request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
|
|
190
216
|
)
|
|
191
217
|
end
|
|
192
218
|
|
|
193
219
|
if status >= 200 && status < 300
|
|
220
|
+
if bare && parsed.is_a?(Hash)
|
|
221
|
+
return Envelope.new(data: parsed.reject { |key, _| key == 'meta' },
|
|
222
|
+
meta: Meta.from_wire(parsed['meta'].is_a?(Hash) ? parsed['meta'] : {}))
|
|
223
|
+
end
|
|
224
|
+
|
|
194
225
|
unless parsed.is_a?(Hash) && parsed.key?('data') && parsed.key?('meta')
|
|
195
226
|
raise KError.new(
|
|
196
227
|
code: 'K9O_INTERNAL_ERROR',
|
|
@@ -203,14 +234,16 @@ module K9o
|
|
|
203
234
|
end
|
|
204
235
|
|
|
205
236
|
if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
|
|
206
|
-
raise KError.from_wire(parsed, status)
|
|
237
|
+
raise KError.from_wire(parsed, status, retry_after_header: header_of(response, 'retry-after'),
|
|
238
|
+
request_id_header: header_of(response, 'x-k9o-request-id'))
|
|
207
239
|
end
|
|
208
240
|
|
|
209
241
|
raise KError.new(
|
|
210
242
|
code: 'K9O_INTERNAL_ERROR',
|
|
211
243
|
message: "Upstream returned #{status} with non-KError body",
|
|
212
244
|
context: { url: url, status: status },
|
|
213
|
-
status: status
|
|
245
|
+
status: status,
|
|
246
|
+
request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
|
|
214
247
|
)
|
|
215
248
|
end
|
|
216
249
|
|
|
@@ -222,7 +255,7 @@ module K9o
|
|
|
222
255
|
#
|
|
223
256
|
# The Ruby async-iteration idiom is Enumerator — caller
|
|
224
257
|
# writes `client.compute.llms.invoke_stream(...).each { |chunk| ... }`.
|
|
225
|
-
def stream_sse(config:, method:, path:, body: nil, query: nil)
|
|
258
|
+
def stream_sse(config:, method:, path:, body: nil, query: nil, headers: nil, idle_timeout: nil, raw: false)
|
|
226
259
|
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|
|
227
260
|
if query && !query.empty?
|
|
228
261
|
url += '?' + URI.encode_www_form(query)
|
|
@@ -232,7 +265,8 @@ module K9o
|
|
|
232
265
|
Enumerator.new do |yielder|
|
|
233
266
|
http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
|
|
234
267
|
http.use_ssl = (uri.scheme == 'https')
|
|
235
|
-
|
|
268
|
+
# The read timeout is a per-read idle limit, not a total limit on the stream.
|
|
269
|
+
http.read_timeout = idle_timeout || 300
|
|
236
270
|
|
|
237
271
|
req_class = case method.to_s.upcase
|
|
238
272
|
when 'GET' then ::Net::HTTP::Get
|
|
@@ -241,6 +275,7 @@ module K9o
|
|
|
241
275
|
end
|
|
242
276
|
req = req_class.new(uri.request_uri)
|
|
243
277
|
req['accept'] = 'text/event-stream'
|
|
278
|
+
headers&.each { |k, v| req[k] = v }
|
|
244
279
|
config.default_headers.each { |k, v| req[k] = v }
|
|
245
280
|
req['x-k9o-project-slug'] = config.project_slug if config.project_slug
|
|
246
281
|
req['x-k9o-org-slug'] = config.org_slug if config.org_slug
|
|
@@ -262,20 +297,34 @@ module K9o
|
|
|
262
297
|
nil
|
|
263
298
|
end
|
|
264
299
|
if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
|
|
265
|
-
raise KError.from_wire(parsed, status)
|
|
300
|
+
raise KError.from_wire(parsed, status, retry_after_header: header_of(response, 'retry-after'),
|
|
301
|
+
request_id_header: header_of(response, 'x-k9o-request-id'))
|
|
266
302
|
end
|
|
267
303
|
|
|
268
304
|
raise KError.new(
|
|
269
305
|
code: 'K9O_INTERNAL_ERROR',
|
|
270
306
|
message: "Upstream returned #{status} opening stream",
|
|
271
307
|
context: { url: url, status: status },
|
|
272
|
-
status: status
|
|
308
|
+
status: status,
|
|
309
|
+
request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
|
|
273
310
|
)
|
|
274
311
|
end
|
|
275
312
|
|
|
276
313
|
buffer = +''
|
|
277
314
|
data_lines = []
|
|
315
|
+
event_name = nil
|
|
316
|
+
event_id = nil
|
|
317
|
+
retry_ms = nil
|
|
278
318
|
flush = lambda do
|
|
319
|
+
if raw
|
|
320
|
+
# Raw mode yields every frame as { event:, id:, data:, retry: } (comment lines are dropped).
|
|
321
|
+
had = !data_lines.empty? || event_name || event_id || retry_ms
|
|
322
|
+
frame = { event: event_name, id: event_id, data: data_lines.empty? ? nil : data_lines.join("\n"), retry: retry_ms }
|
|
323
|
+
data_lines = []
|
|
324
|
+
event_name = event_id = retry_ms = nil
|
|
325
|
+
yielder << frame if had
|
|
326
|
+
next true
|
|
327
|
+
end
|
|
279
328
|
return true if data_lines.empty?
|
|
280
329
|
|
|
281
330
|
payload = data_lines.join("\n")
|
|
@@ -302,6 +351,14 @@ module K9o
|
|
|
302
351
|
rest = line[5..]
|
|
303
352
|
rest = rest[1..] if rest.start_with?(' ')
|
|
304
353
|
data_lines << rest
|
|
354
|
+
elsif raw && line.start_with?('event:', 'id:', 'retry:')
|
|
355
|
+
field, rest = line.split(':', 2)
|
|
356
|
+
rest = rest[1..] if rest.start_with?(' ')
|
|
357
|
+
case field
|
|
358
|
+
when 'event' then event_name = rest
|
|
359
|
+
when 'id' then event_id = rest
|
|
360
|
+
else retry_ms = Integer(rest, exception: false)
|
|
361
|
+
end
|
|
305
362
|
end
|
|
306
363
|
end
|
|
307
364
|
end
|
data/lib/k9o/jcs.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# The platform's canonical JSON form (the Kilimanjaro JCS code-point profile), implemented here with no dependency.
|
|
5
|
+
#
|
|
6
|
+
# K9o::Jcs.canonicalize({ "b" => 2, "a" => [1, 3] }) # => '{"a":[1,3],"b":2}'
|
|
7
|
+
#
|
|
8
|
+
# Object members are sorted by UTF-8 byte order (the same as Unicode code-point order), there is no whitespace,
|
|
9
|
+
# strings are escaped minimally (+"+ and +\+, the short escapes for backspace, tab, newline, form feed and carriage
|
|
10
|
+
# return, every other control character as lowercase +\u00xx+; +/+ and every other character are written as they
|
|
11
|
+
# are), and numbers are integers only: base 10, no exponent, +-0+ written +0+. A fractional number, NaN, an infinity
|
|
12
|
+
# or an integer whose absolute value is above 2^53 - 1 raises, so a value that cannot be hashed identically
|
|
13
|
+
# everywhere never gets a hash.
|
|
14
|
+
module Jcs
|
|
15
|
+
# Raised for a value that has no canonical form. +kind+ is :non_integer_number, :numeric_ceiling_exceeded or
|
|
16
|
+
# :unsupported_value.
|
|
17
|
+
class Error < ArgumentError
|
|
18
|
+
attr_reader :kind
|
|
19
|
+
|
|
20
|
+
def initialize(message, kind = :unsupported_value)
|
|
21
|
+
super(message)
|
|
22
|
+
@kind = kind
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ESCAPES = {
|
|
27
|
+
'"' => '\\"', '\\' => '\\\\', "\b" => '\\b', "\t" => '\\t', "\n" => '\\n', "\f" => '\\f', "\r" => '\\r'
|
|
28
|
+
}.freeze
|
|
29
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# The canonical form of +value+ as a UTF-8 String.
|
|
34
|
+
def canonicalize(value)
|
|
35
|
+
out = +''
|
|
36
|
+
write(value, out)
|
|
37
|
+
out.force_encoding(Encoding::UTF_8)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The canonical form as bytes (UTF-8), the preimage to hash.
|
|
41
|
+
def canonical_bytes(value)
|
|
42
|
+
canonicalize(value).b
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write(value, out)
|
|
46
|
+
case value
|
|
47
|
+
when nil then out << 'null'
|
|
48
|
+
when true then out << 'true'
|
|
49
|
+
when false then out << 'false'
|
|
50
|
+
when Integer then out << number(value)
|
|
51
|
+
when Float then out << number(value)
|
|
52
|
+
when String then write_string(value, out)
|
|
53
|
+
when Symbol then write_string(value.to_s, out)
|
|
54
|
+
when Array
|
|
55
|
+
out << '['
|
|
56
|
+
value.each_with_index do |item, index|
|
|
57
|
+
out << ',' if index.positive?
|
|
58
|
+
write(item, out)
|
|
59
|
+
end
|
|
60
|
+
out << ']'
|
|
61
|
+
when Hash then write_object(value, out)
|
|
62
|
+
else
|
|
63
|
+
raise Error, "#{value.class} has no JSON form"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def write_object(hash, out)
|
|
68
|
+
members = hash.map { |key, val| [key.is_a?(Symbol) ? key.to_s : key, val] }
|
|
69
|
+
members.each do |key, _|
|
|
70
|
+
raise Error, 'object keys must be strings' unless key.is_a?(String)
|
|
71
|
+
end
|
|
72
|
+
keys = members.map(&:first)
|
|
73
|
+
raise Error, 'object has duplicate keys' unless keys.uniq.size == keys.size
|
|
74
|
+
|
|
75
|
+
sorted = members.sort_by { |key, _| key.b }
|
|
76
|
+
out << '{'
|
|
77
|
+
sorted.each_with_index do |(key, val), index|
|
|
78
|
+
out << ',' if index.positive?
|
|
79
|
+
write_string(key, out)
|
|
80
|
+
out << ':'
|
|
81
|
+
write(val, out)
|
|
82
|
+
end
|
|
83
|
+
out << '}'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_string(string, out)
|
|
87
|
+
text = string.encoding == Encoding::UTF_8 ? string : string.encode(Encoding::UTF_8)
|
|
88
|
+
raise Error, 'string is not valid UTF-8' unless text.valid_encoding?
|
|
89
|
+
|
|
90
|
+
out << '"'
|
|
91
|
+
text.each_char do |ch|
|
|
92
|
+
if (esc = ESCAPES[ch])
|
|
93
|
+
out << esc
|
|
94
|
+
elsif ch.ord < 0x20
|
|
95
|
+
out << format('\\u%04x', ch.ord)
|
|
96
|
+
else
|
|
97
|
+
out << ch
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
out << '"'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Integers only, within +/-(2^53 - 1). A float that is a whole number is written as that integer (a JSON reader
|
|
104
|
+
# that parses +3.0+ sees the integer 3).
|
|
105
|
+
def number(num)
|
|
106
|
+
if num.is_a?(Float)
|
|
107
|
+
raise Error.new('NaN and Infinity have no canonical form', :non_integer_number) unless num.finite?
|
|
108
|
+
raise Error.new('a fractional number has no canonical form', :non_integer_number) unless num == num.floor
|
|
109
|
+
|
|
110
|
+
num = num.to_i
|
|
111
|
+
end
|
|
112
|
+
raise Error.new("integer #{num} is above 2^53 - 1", :numeric_ceiling_exceeded) if num.abs > MAX_SAFE_INTEGER
|
|
113
|
+
|
|
114
|
+
num.to_s
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/k9o/kerror.rb
CHANGED
|
@@ -8,8 +8,12 @@ module K9o
|
|
|
8
8
|
# Code-narrowing: callers rescue K9o::KError and switch on e.code.
|
|
9
9
|
class KError < StandardError
|
|
10
10
|
attr_reader :code, :context, :recommendation, :status, :request_id
|
|
11
|
+
# Seconds the gateway asks the caller to wait before retrying, or nil.
|
|
12
|
+
# Informational: the SDK's own retry behaviour does not read it.
|
|
13
|
+
attr_reader :retry_after_sec
|
|
11
14
|
|
|
12
|
-
def initialize(code:, message:, context: {}, recommendation: nil, status: nil, request_id: nil
|
|
15
|
+
def initialize(code:, message:, context: {}, recommendation: nil, status: nil, request_id: nil,
|
|
16
|
+
retry_after_sec: nil)
|
|
13
17
|
super(message)
|
|
14
18
|
@code = code
|
|
15
19
|
@message_text = message # avoid infinite recursion in to_s (Exception#message calls to_s)
|
|
@@ -17,13 +21,21 @@ module K9o
|
|
|
17
21
|
@recommendation = recommendation
|
|
18
22
|
@status = status
|
|
19
23
|
@request_id = request_id
|
|
24
|
+
@retry_after_sec = retry_after_sec
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The step-up challenge a 401 K9O_STEP_UP_REQUIRED carries (context.stepUp) as a K9o::Keys::StepUpChallenge
|
|
28
|
+
# (+scope_set_root+, +nonce+, +min_tier+, +methods+, +max_age_sec+, +single_use+, +rp_id+), or nil.
|
|
29
|
+
def step_up
|
|
30
|
+
Keys::StepUp.challenge_from(@context)
|
|
20
31
|
end
|
|
21
32
|
|
|
22
33
|
def with_recommendation(rec)
|
|
23
34
|
KError.new(
|
|
24
35
|
code: @code, message: @message_text,
|
|
25
36
|
context: @context, recommendation: rec,
|
|
26
|
-
status: @status, request_id: @request_id
|
|
37
|
+
status: @status, request_id: @request_id,
|
|
38
|
+
retry_after_sec: @retry_after_sec
|
|
27
39
|
)
|
|
28
40
|
end
|
|
29
41
|
|
|
@@ -38,15 +50,55 @@ module K9o
|
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
# Rehydrate a wire KError payload into a typed K9o::KError.
|
|
41
|
-
|
|
53
|
+
# +retry_after_header+ is the response's Retry-After header (whole seconds); it is used when the body's
|
|
54
|
+
# recommendation carries no +retryAfterSec+. +request_id_header+ is the response's x-k9o-request-id header;
|
|
55
|
+
# it is used when the body carries no request id.
|
|
56
|
+
def self.from_wire(wire, status, retry_after_header: nil, request_id_header: nil)
|
|
42
57
|
KError.new(
|
|
43
58
|
code: wire['code'] || 'K9O_INTERNAL_ERROR',
|
|
44
59
|
message: wire['message'] || '',
|
|
45
60
|
context: wire['context'] || {},
|
|
46
|
-
recommendation: wire['recommendation'],
|
|
61
|
+
recommendation: recommendation_text(wire['recommendation']),
|
|
47
62
|
status: status,
|
|
48
|
-
request_id: wire['requestId'] || wire['request_id']
|
|
63
|
+
request_id: wire['requestId'] || wire['request_id'] || request_id_from_header(request_id_header),
|
|
64
|
+
retry_after_sec: retry_after(wire['recommendation']) || retry_after_from_header(retry_after_header)
|
|
49
65
|
)
|
|
50
66
|
end
|
|
67
|
+
|
|
68
|
+
# The gateway's request id from the x-k9o-request-id header value, or nil when absent or blank.
|
|
69
|
+
def self.request_id_from_header(value)
|
|
70
|
+
return nil unless value.is_a?(String) && !value.strip.empty?
|
|
71
|
+
|
|
72
|
+
value.strip
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whole seconds from a Retry-After header value; an HTTP date or anything else gives nil.
|
|
76
|
+
def self.retry_after_from_header(value)
|
|
77
|
+
return nil unless value.is_a?(String) && value.strip.match?(/\A\d+\z/)
|
|
78
|
+
|
|
79
|
+
value.strip.to_i
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The gateway sends `recommendation` as null, a string, or an object such
|
|
83
|
+
# as {"action": "..."} or {"retryAfterSec": 30, "note": "..."}. A string is
|
|
84
|
+
# kept; an object gives its `action`, else its `note`, when a string;
|
|
85
|
+
# anything else gives nil.
|
|
86
|
+
def self.recommendation_text(raw)
|
|
87
|
+
return raw if raw.is_a?(String)
|
|
88
|
+
return nil unless raw.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
[raw['action'], raw['note']].find { |v| v.is_a?(String) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# `retryAfterSec` from an object recommendation when a finite number >= 0.
|
|
94
|
+
def self.retry_after(raw)
|
|
95
|
+
return nil unless raw.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
n = raw['retryAfterSec']
|
|
98
|
+
return nil unless n.is_a?(Numeric) && !n.is_a?(Complex) && n >= 0
|
|
99
|
+
return nil if n.respond_to?(:finite?) && !n.finite?
|
|
100
|
+
|
|
101
|
+
n
|
|
102
|
+
end
|
|
51
103
|
end
|
|
52
104
|
end
|
data/lib/k9o/keys.rb
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module K9o
|
|
6
|
+
# client.keys: the project's encryption keys. Metadata only: no key material is ever returned.
|
|
7
|
+
#
|
|
8
|
+
# list(status: nil) GET /@<slug>/vault/keys data: { "items" => [key] } (not paged)
|
|
9
|
+
# get(key_id) GET /@<slug>/vault/keys/<id> data: key
|
|
10
|
+
# rotate(key_id, step_up: nil) POST /@<slug>/vault/keys/<id>/rotate
|
|
11
|
+
#
|
|
12
|
+
# A key is { "id", "version", "status", "custodyMode", "createdAt", "rotatedAt", "byokRef", "hyokRef" }.
|
|
13
|
+
# Reading needs PROJECT_ADMIN or PROJECT_AUDITOR; rotating needs PROJECT_ADMIN and a fresh step-up proof.
|
|
14
|
+
# A person's credential only: service, workload, extension and end-user tokens are refused (403).
|
|
15
|
+
class Keys
|
|
16
|
+
STATUSES = %w[active all].freeze
|
|
17
|
+
STEP_UP_HEADER = 'x-k9o-step-up-proof'
|
|
18
|
+
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The project's keys. Only the active key by default; +status:+ "all" adds the rotating and revoked versions.
|
|
24
|
+
# The list is not paged: +data+ is { "items" => [key] } and nothing else.
|
|
25
|
+
def list(status: nil)
|
|
26
|
+
query = {}
|
|
27
|
+
query['status'] = ApprovalChecks.enum!(status, STATUSES, 'status') unless status.nil?
|
|
28
|
+
envelope = call('GET', '', query: query)
|
|
29
|
+
data = envelope.data
|
|
30
|
+
return envelope unless data.is_a?(Hash) && data.key?('nextCursor')
|
|
31
|
+
|
|
32
|
+
Envelope.new(data: data.reject { |k, _| k == 'nextCursor' }, meta: envelope.meta)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get(key_id)
|
|
36
|
+
call('GET', "/#{ApprovalChecks.id_segment(key_id, 'key')}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Rotate the active key: every data key is re-wrapped under a new version and the old version is revoked.
|
|
40
|
+
# +data+ is { "keyId", "previousVersion", "newVersion", "status" => "rotated" }.
|
|
41
|
+
#
|
|
42
|
+
# Rotation needs a fresh step-up proof. The first request answers 401 K9O_STEP_UP_REQUIRED with a challenge
|
|
43
|
+
# (K9o::KError#step_up, a K9o::Keys::StepUpChallenge). Pass +step_up:+ a callable and the SDK hands it the
|
|
44
|
+
# challenge, the HTTP method and the URL being called, expects { method:, proof: } back (+method+ is one of the
|
|
45
|
+
# challenge's +methods+; +proof+ is that method's own proof, for DPoP bound to this method and URL) and sends the
|
|
46
|
+
# same request again with the +x-k9o-step-up-proof+ header: base64url(JSON { scopeSetRoot, nonce, method, proof }).
|
|
47
|
+
# It retries exactly once. Without a hook the K9O_STEP_UP_REQUIRED error is raised and carries the challenge.
|
|
48
|
+
# An error from your hook, a second 401 or any other failure of the retry is raised as it is. The proof is never
|
|
49
|
+
# logged. Nothing in the platform mints the proof for you.
|
|
50
|
+
#
|
|
51
|
+
# Other errors (all K9o::KError): 409 K9O_STATE_CONFLICT with context reason "rotation_in_flight" or
|
|
52
|
+
# "key_not_active"; 429 K9O_RATE_LIMITED with reason "rotation_rate_cap" (at most 3 rotations per rolling 24 hours;
|
|
53
|
+
# +retry_after_sec+ says when the next one is possible) or "key_service_busy". A refused rotation does not use up
|
|
54
|
+
# a proof. +headers:+ adds request headers. The call is never retried otherwise.
|
|
55
|
+
def rotate(key_id, step_up: nil, headers: nil)
|
|
56
|
+
tail = "/#{ApprovalChecks.id_segment(key_id, 'key')}/rotate"
|
|
57
|
+
unless step_up.nil? || step_up.respond_to?(:call)
|
|
58
|
+
raise ApprovalChecks.invalid('step_up must respond to call(challenge, method, url)')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
call('POST', tail, headers: headers)
|
|
63
|
+
rescue KError => e
|
|
64
|
+
raise unless step_up && e.code == 'K9O_STEP_UP_REQUIRED' && e.status == 401
|
|
65
|
+
|
|
66
|
+
challenge = e.step_up
|
|
67
|
+
raise unless challenge
|
|
68
|
+
|
|
69
|
+
method = 'POST'
|
|
70
|
+
url = "#{Region.resolve_origin(@config).sub(%r{/\z}, '')}#{UrlGrammar.project_path(slug_for_keys, "vault/keys#{tail}")}"
|
|
71
|
+
answer = step_up.call(challenge, method, url)
|
|
72
|
+
proof_header = StepUp.header(challenge, answer)
|
|
73
|
+
call('POST', tail, headers: (headers || {}).merge(STEP_UP_HEADER => proof_header))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The step-up challenge from a 401 K9O_STEP_UP_REQUIRED (context.stepUp).
|
|
78
|
+
StepUpChallenge = Struct.new(:scope_set_root, :nonce, :min_tier, :methods, :max_age_sec, :single_use, :rp_id,
|
|
79
|
+
keyword_init: true)
|
|
80
|
+
|
|
81
|
+
module StepUp
|
|
82
|
+
module_function
|
|
83
|
+
|
|
84
|
+
# The challenge in an error's context, or nil.
|
|
85
|
+
def challenge_from(context)
|
|
86
|
+
raw = context.is_a?(Hash) ? (context['stepUp'] || context[:stepUp]) : nil
|
|
87
|
+
return nil unless raw.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
root = raw['scopeSetRoot']
|
|
90
|
+
nonce = raw['nonce']
|
|
91
|
+
return nil unless root.is_a?(String) && nonce.is_a?(String)
|
|
92
|
+
|
|
93
|
+
StepUpChallenge.new(scope_set_root: root, nonce: nonce, min_tier: raw['minTier'],
|
|
94
|
+
methods: Array(raw['methods']), max_age_sec: raw['maxAgeSec'],
|
|
95
|
+
single_use: raw['singleUse'], rp_id: raw['rpId'])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# base64url (no padding) of JSON { scopeSetRoot, nonce, method, proof }, from the hook's answer.
|
|
99
|
+
def header(challenge, answer)
|
|
100
|
+
unless answer.is_a?(Hash)
|
|
101
|
+
raise ApprovalChecks.invalid('the step_up hook must return { method:, proof: }')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
method = answer[:method] || answer['method']
|
|
105
|
+
proof = answer[:proof] || answer['proof']
|
|
106
|
+
unless method.is_a?(String) && !method.empty? && proof.is_a?(String) && !proof.empty?
|
|
107
|
+
raise ApprovalChecks.invalid('the step_up hook must return a method and a proof, both non-empty strings')
|
|
108
|
+
end
|
|
109
|
+
unless challenge.methods.empty? || challenge.methods.include?(method)
|
|
110
|
+
raise ApprovalChecks.invalid("step-up method must be one of: #{challenge.methods.join(', ')}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
json = JSON.generate('scopeSetRoot' => challenge.scope_set_root, 'nonce' => challenge.nonce,
|
|
114
|
+
'method' => method, 'proof' => proof)
|
|
115
|
+
[json].pack('m0').tr('+/', '-_').delete('=')
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def slug_for_keys
|
|
122
|
+
Studio.require_project_slug(@config, 'keys')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def call(method, tail, query: nil, headers: nil)
|
|
126
|
+
slug = slug_for_keys
|
|
127
|
+
HttpTransport.request(config: @config, method: method, path: UrlGrammar.project_path(slug, "vault/keys#{tail}"),
|
|
128
|
+
query: query, headers: headers)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|