k9o 1.3.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +1516 -159
  4. data/k9o.gemspec +2 -2
  5. data/lib/k9o/ai.rb +15 -0
  6. data/lib/k9o/analytics.rb +403 -0
  7. data/lib/k9o/approvals.rb +253 -0
  8. data/lib/k9o/audit.rb +150 -0
  9. data/lib/k9o/client.rb +25 -4
  10. data/lib/k9o/crypto.rb +471 -0
  11. data/lib/k9o/custody.rb +28 -0
  12. data/lib/k9o/data.rb +125 -5
  13. data/lib/k9o/ed25519.rb +182 -0
  14. data/lib/k9o/end_users.rb +93 -0
  15. data/lib/k9o/envelope.rb +20 -5
  16. data/lib/k9o/functions.rb +82 -3
  17. data/lib/k9o/gate.rb +46 -7
  18. data/lib/k9o/http_transport.rb +71 -14
  19. data/lib/k9o/jcs.rb +117 -0
  20. data/lib/k9o/kerror.rb +57 -5
  21. data/lib/k9o/keys.rb +131 -0
  22. data/lib/k9o/kibo_client_core.rb +385 -0
  23. data/lib/k9o/kibo_ffi_core.rb +178 -0
  24. data/lib/k9o/locate.rb +118 -0
  25. data/lib/k9o/me.rb +1 -1
  26. data/lib/k9o/metadata.rb +342 -0
  27. data/lib/k9o/moat.rb +443 -0
  28. data/lib/k9o/org.rb +1 -1
  29. data/lib/k9o/query.rb +174 -0
  30. data/lib/k9o/rampart.rb +273 -0
  31. data/lib/k9o/realtime.rb +261 -10
  32. data/lib/k9o/schedules.rb +109 -0
  33. data/lib/k9o/schema.rb +35 -0
  34. data/lib/k9o/streams.rb +94 -0
  35. data/lib/k9o/vault.rb +3 -4
  36. data/lib/k9o/vectors.rb +124 -0
  37. data/lib/k9o/version.rb +1 -1
  38. data/lib/k9o.rb +17 -0
  39. data/sig/k9o/ai.rbs +4 -0
  40. data/sig/k9o/approvals.rbs +71 -0
  41. data/sig/k9o/datasets.rbs +125 -0
  42. data/sig/k9o/gate.rbs +2 -0
  43. data/sig/k9o/kerror.rbs +24 -0
  44. data/sig/k9o/kibo_client_core.rbs +25 -0
  45. data/sig/k9o/metadata.rbs +54 -0
  46. data/sig/k9o/moat.rbs +29 -0
  47. data/sig/k9o/query.rbs +26 -0
  48. data/sig/k9o/rampart.rbs +59 -0
  49. data/sig/k9o/realtime.rbs +34 -0
  50. data/sig/k9o/workload.rbs +64 -0
  51. metadata +33 -4
data/lib/k9o/gate.rb CHANGED
@@ -15,17 +15,26 @@ module K9o
15
15
  end
16
16
 
17
17
  # POST /@<slug>/gate/signin { email, password }
18
+ #
19
+ # Asks the project which region holds the email first (POST /@<slug>/gate/locate, the email only, no credential),
20
+ # then sends the password to that region's host if it is a host of your own org and apex, else to the base URL.
21
+ # A 429 or 503 from that step raises as any gateway error does, nothing else is sent, and it is safe to retry.
18
22
  def sign_in(email:, password:)
19
- HttpTransport.request(
23
+ origin = Locate.origin_for(@config, email, gate_path('locate'))
24
+ out = HttpTransport.request(
20
25
  config: @config, method: 'POST', path: gate_path('signin'),
21
- body: { 'email' => email, 'password' => password }
26
+ body: { 'email' => email, 'password' => password }, origin: origin
22
27
  )
28
+ Locate.remember(session_refresh_token(out), origin)
29
+ out
23
30
  end
24
31
 
25
32
  # POST /@<slug>/gate/signout { refreshToken } — the refresh token is required (the gateway answers 400 without it).
26
33
  def sign_out(refresh_token:)
27
- HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
28
- body: { 'refreshToken' => refresh_token })
34
+ out = HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
35
+ body: { 'refreshToken' => refresh_token }, origin: Locate.hint(refresh_token))
36
+ Locate.forget(refresh_token)
37
+ out
29
38
  end
30
39
 
31
40
  # GET /@<slug>/gate/whoami
@@ -33,23 +42,53 @@ module K9o
33
42
  HttpTransport.request(config: @config, method: 'GET', path: gate_path('whoami'))
34
43
  end
35
44
 
45
+ # POST /@<slug>/auth/sessions/revoke-all
46
+ #
47
+ # Ends every live session of the signed-in end user, on every device, this one included. Call it with the end
48
+ # user's access token as the client's `api_key` (the token `whoami` uses), not a refresh token. The answer's `data`
49
+ # is `{ revokedCount }`. Each ended session's next refresh raises a KError with code `K9O_SESSION_EXPIRED` and a
50
+ # `reason` of `"revoked"` in its `context`; access tokens already issued keep working until they expire. An
51
+ # operator's token raises `K9O_TRUST_ZONE_VIOLATION` (403), and a token issued for another project
52
+ # `K9O_PROJECT_SCOPE_MISMATCH` (403).
53
+ def revoke_all_sessions
54
+ HttpTransport.request(config: @config, method: 'POST', path: project_path('auth/sessions/revoke-all'))
55
+ end
56
+
36
57
  # POST /@<slug>/gate/refresh { refreshToken }
58
+ #
59
+ # The answer's `data` is `{ accessToken, refreshToken, expiresAt }`. The refresh token you sent stops working: store
60
+ # the returned `refreshToken` before the next refresh, and refresh one at a time per session. A second call with a
61
+ # secret that was already used is a replay: the session is revoked and the call raises a KError with code
62
+ # `K9O_SESSION_EXPIRED` and a `reason` of `"replay"` in its `context`. The newer secret then gets `"revoked"`. The
63
+ # other reasons are `"inactive"` and `"max_length"`; sign in again.
37
64
  def refresh_token(refresh_token:)
38
- HttpTransport.request(
65
+ out = HttpTransport.request(
39
66
  config: @config, method: 'POST', path: gate_path('refresh'),
40
- body: { 'refreshToken' => refresh_token }
67
+ body: { 'refreshToken' => refresh_token }, origin: Locate.hint(refresh_token)
41
68
  )
69
+ Locate.rotate(refresh_token, out.respond_to?(:data) ? out.data : nil)
70
+ out
42
71
  end
43
72
 
44
73
  private
45
74
 
75
+ def session_refresh_token(envelope)
76
+ data = envelope.respond_to?(:data) ? envelope.data : nil
77
+ session = data.is_a?(Hash) ? data['session'] : nil
78
+ session.is_a?(Hash) ? session['refreshToken'] : nil
79
+ end
80
+
46
81
  def gate_path(action)
82
+ project_path("gate/#{action}")
83
+ end
84
+
85
+ def project_path(suffix)
47
86
  raise KError.new(
48
87
  code: 'K9O_MISSING_PROJECT_CONTEXT',
49
88
  message: 'k9o.gate operations require project_slug bound on the client',
50
89
  context: {}
51
90
  ) if @config.project_slug.nil? || @config.project_slug.empty?
52
- UrlGrammar.project_path(@config.project_slug, "gate/#{action}")
91
+ UrlGrammar.project_path(@config.project_slug, suffix)
53
92
  end
54
93
  end
55
94
  end
@@ -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 = 30
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
- http.read_timeout = 300
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
- def self.from_wire(wire, status)
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