k9o 1.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.
Potentially problematic release.
This version of k9o might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +201 -0
- data/NOTICE +4 -0
- data/README.md +67 -0
- data/k9o.gemspec +41 -0
- data/lib/k9o/ai.rb +28 -0
- data/lib/k9o/audit.rb +29 -0
- data/lib/k9o/barbican.rb +239 -0
- data/lib/k9o/client.rb +59 -0
- data/lib/k9o/compute.rb +67 -0
- data/lib/k9o/data.rb +157 -0
- data/lib/k9o/envelope.rb +64 -0
- data/lib/k9o/functions.rb +51 -0
- data/lib/k9o/gate.rb +64 -0
- data/lib/k9o/http_transport.rb +318 -0
- data/lib/k9o/kerror.rb +52 -0
- data/lib/k9o/kibo.rb +114 -0
- data/lib/k9o/me.rb +160 -0
- data/lib/k9o/migrations.rb +244 -0
- data/lib/k9o/multipart.rb +42 -0
- data/lib/k9o/org.rb +213 -0
- data/lib/k9o/orgs.rb +33 -0
- data/lib/k9o/policies.rb +65 -0
- data/lib/k9o/projects.rb +33 -0
- data/lib/k9o/realtime.rb +80 -0
- data/lib/k9o/region.rb +95 -0
- data/lib/k9o/storage.rb +176 -0
- data/lib/k9o/studio.rb +175 -0
- data/lib/k9o/url_grammar.rb +113 -0
- data/lib/k9o/vault.rb +235 -0
- data/lib/k9o/version.rb +5 -0
- data/lib/k9o.rb +54 -0
- data/sig/k9o/ai.rbs +16 -0
- data/sig/k9o/gate.rbs +11 -0
- data/sig/k9o/migrations.rbs +138 -0
- data/sig/k9o/region.rbs +19 -0
- data/sig/k9o/storage.rbs +41 -0
- metadata +98 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative 'multipart'
|
|
7
|
+
|
|
8
|
+
module K9o
|
|
9
|
+
# Retry + backoff policy (ch13 §7.1). Applied to idempotent methods
|
|
10
|
+
# (GET/HEAD/OPTIONS/DELETE) on transient failures only (transport error /
|
|
11
|
+
# 502 / 503 / 504 / 429); semantic 4xx and non-idempotent methods are
|
|
12
|
+
# never retried.
|
|
13
|
+
class RetryConfig
|
|
14
|
+
attr_reader :max_retries, :backoff, :initial_delay_ms, :max_delay_ms
|
|
15
|
+
|
|
16
|
+
# backoff: 'exponential' | 'linear' | 'none'
|
|
17
|
+
def initialize(max_retries: 3, backoff: 'exponential', initial_delay_ms: 100, max_delay_ms: 5000)
|
|
18
|
+
@max_retries = max_retries
|
|
19
|
+
@backoff = backoff
|
|
20
|
+
@initial_delay_ms = initial_delay_ms
|
|
21
|
+
@max_delay_ms = max_delay_ms
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.default
|
|
25
|
+
new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Per-instance HTTP config bound on K9oClient.
|
|
30
|
+
class HttpClientConfig
|
|
31
|
+
attr_reader :base_url, :api_key, :org_slug, :project_slug, :default_headers, :http_client, :retry_config,
|
|
32
|
+
:region_mutex
|
|
33
|
+
# Cached regional origin (see K9o::Region); nil until a definite answer.
|
|
34
|
+
attr_accessor :regional_origin
|
|
35
|
+
|
|
36
|
+
def initialize(base_url:, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil,
|
|
37
|
+
retry_config: nil)
|
|
38
|
+
@base_url = base_url
|
|
39
|
+
@api_key = api_key
|
|
40
|
+
@org_slug = org_slug
|
|
41
|
+
@project_slug = project_slug
|
|
42
|
+
@default_headers = default_headers.freeze
|
|
43
|
+
@http_client = http_client
|
|
44
|
+
@retry_config = retry_config
|
|
45
|
+
@region_mutex = Mutex.new
|
|
46
|
+
@regional_origin = nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# HTTP transport using Net::HTTP stdlib.
|
|
51
|
+
module HttpTransport
|
|
52
|
+
RETRYABLE_METHODS = %w[GET HEAD OPTIONS DELETE].freeze
|
|
53
|
+
RETRYABLE_STATUS = [502, 503, 504, 429].freeze
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
# Issue a request with retry on transient failures. Idempotent methods
|
|
58
|
+
# are retried with backoff per config.retry_config (ch13 §7.1);
|
|
59
|
+
# semantic / non-idempotent failures raise immediately.
|
|
60
|
+
#
|
|
61
|
+
# `headers:` carries per-call header overrides on top of
|
|
62
|
+
# `config.default_headers` — e.g. `client.kibo.vault.secrets.reveal`'s
|
|
63
|
+
# single-use `X-K9O-Reveal-Challenge` header, which is per-request rather
|
|
64
|
+
# than a client-wide default.
|
|
65
|
+
#
|
|
66
|
+
# `multipart:` is an array of parts (see K9o::Multipart) sent as a
|
|
67
|
+
# multipart/form-data body instead of JSON.
|
|
68
|
+
#
|
|
69
|
+
# `authenticated: false` sends NO credential: no api_key bearer, and any
|
|
70
|
+
# authorization / cookie header from `default_headers` or `headers:` is dropped.
|
|
71
|
+
def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil)
|
|
72
|
+
retry_with_backoff(method, config.retry_config || RetryConfig.default) do
|
|
73
|
+
request_once(config: config, method: method, path: path, body: body, query: query, headers: headers,
|
|
74
|
+
authenticated: authenticated, multipart: multipart)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Run a block with retry-with-backoff. Extracted so the retry policy is
|
|
79
|
+
# unit-testable without a live HTTP round-trip.
|
|
80
|
+
def retry_with_backoff(method, retry_config)
|
|
81
|
+
last_error = nil
|
|
82
|
+
attempt = 0
|
|
83
|
+
while attempt <= retry_config.max_retries
|
|
84
|
+
begin
|
|
85
|
+
return yield
|
|
86
|
+
rescue KError => e
|
|
87
|
+
last_error = e
|
|
88
|
+
raise e if attempt == retry_config.max_retries || !retryable?(e, method)
|
|
89
|
+
|
|
90
|
+
delay_ms = compute_delay_ms(retry_config, attempt)
|
|
91
|
+
sleep(delay_ms / 1000.0) if delay_ms > 0
|
|
92
|
+
attempt += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
raise last_error
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def retryable?(error, method)
|
|
99
|
+
return false unless RETRYABLE_METHODS.include?(method.to_s.upcase)
|
|
100
|
+
return true if error.code == 'K9O_UPSTREAM_UNAVAILABLE'
|
|
101
|
+
|
|
102
|
+
RETRYABLE_STATUS.include?(error.status)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def compute_delay_ms(config, attempt)
|
|
106
|
+
return 0 if config.backoff == 'none'
|
|
107
|
+
|
|
108
|
+
base = if config.backoff == 'linear'
|
|
109
|
+
config.initial_delay_ms * (attempt + 1)
|
|
110
|
+
else
|
|
111
|
+
config.initial_delay_ms * (2**attempt)
|
|
112
|
+
end
|
|
113
|
+
capped = [base, config.max_delay_ms].min
|
|
114
|
+
jitter = capped * 0.25 * (rand * 2 - 1)
|
|
115
|
+
[(capped + jitter).to_i, 0].max
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Single-attempt request. `request` wraps this in retry-with-backoff.
|
|
119
|
+
def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil)
|
|
120
|
+
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|
|
121
|
+
if query && !query.empty?
|
|
122
|
+
url += '?' + URI.encode_www_form(query)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
uri = URI.parse(url)
|
|
126
|
+
http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
|
|
127
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
128
|
+
http.read_timeout = 30
|
|
129
|
+
|
|
130
|
+
req_class = case method.to_s.upcase
|
|
131
|
+
when 'GET' then ::Net::HTTP::Get
|
|
132
|
+
when 'POST' then ::Net::HTTP::Post
|
|
133
|
+
when 'PATCH' then ::Net::HTTP::Patch
|
|
134
|
+
when 'PUT' then ::Net::HTTP::Put
|
|
135
|
+
when 'DELETE' then ::Net::HTTP::Delete
|
|
136
|
+
else raise ArgumentError, "Unsupported method: #{method}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
req = req_class.new(uri.request_uri)
|
|
140
|
+
req['accept'] = 'application/json'
|
|
141
|
+
config.default_headers.each { |k, v| req[k] = v }
|
|
142
|
+
req['x-k9o-project-slug'] = config.project_slug if config.project_slug
|
|
143
|
+
headers&.each { |k, v| req[k] = v }
|
|
144
|
+
req['x-k9o-org-slug'] = config.org_slug if config.org_slug
|
|
145
|
+
if authenticated
|
|
146
|
+
req['authorization'] = "Bearer #{config.api_key}" if config.api_key
|
|
147
|
+
else
|
|
148
|
+
req.delete('authorization')
|
|
149
|
+
req.delete('cookie')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if multipart
|
|
153
|
+
# multipart/form-data: the encoder sets the boundary; never force JSON.
|
|
154
|
+
data, content_type = Multipart.encode(multipart)
|
|
155
|
+
req['content-type'] = content_type
|
|
156
|
+
req.body = data
|
|
157
|
+
elsif body
|
|
158
|
+
req['content-type'] = 'application/json'
|
|
159
|
+
req.body = JSON.dump(body)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
begin
|
|
163
|
+
response = http.request(req)
|
|
164
|
+
rescue => e
|
|
165
|
+
raise KError.new(
|
|
166
|
+
code: 'K9O_UPSTREAM_UNAVAILABLE',
|
|
167
|
+
message: "Network error reaching #{url}: #{e.message}",
|
|
168
|
+
context: { url: url },
|
|
169
|
+
recommendation: 'Check connectivity to the gateway; retry with backoff if transient.'
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
status = response.code.to_i
|
|
174
|
+
|
|
175
|
+
return Envelope.new(data: nil, meta: Meta.new) if status == 204
|
|
176
|
+
|
|
177
|
+
begin
|
|
178
|
+
parsed = JSON.parse(response.body || '')
|
|
179
|
+
rescue JSON::ParserError => e
|
|
180
|
+
raise KError.new(
|
|
181
|
+
code: 'K9O_INTERNAL_ERROR',
|
|
182
|
+
message: "Failed to parse JSON response from #{url}: #{e.message}",
|
|
183
|
+
context: { url: url, status: status },
|
|
184
|
+
status: status
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if status >= 200 && status < 300
|
|
189
|
+
unless parsed.is_a?(Hash) && parsed.key?('data') && parsed.key?('meta')
|
|
190
|
+
raise KError.new(
|
|
191
|
+
code: 'K9O_INTERNAL_ERROR',
|
|
192
|
+
message: "Malformed envelope from #{url}: expected {data, meta}",
|
|
193
|
+
context: { url: url, status: status },
|
|
194
|
+
status: status
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
return Envelope.from_wire(parsed)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
|
|
201
|
+
raise KError.from_wire(parsed, status)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
raise KError.new(
|
|
205
|
+
code: 'K9O_INTERNAL_ERROR',
|
|
206
|
+
message: "Upstream returned #{status} with non-KError body",
|
|
207
|
+
context: { url: url, status: status },
|
|
208
|
+
status: status
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Open an SSE stream and return an Enumerator yielding decoded `data:`
|
|
213
|
+
# payloads (parsed JSON). Used by Compute LLMs #invoke_stream (ch07
|
|
214
|
+
# §10.2) + Realtime #subscribe SSE transport (ch07 §11.3). The [DONE]
|
|
215
|
+
# sentinel terminates the stream; non-2xx raises KError before any
|
|
216
|
+
# event is yielded.
|
|
217
|
+
#
|
|
218
|
+
# Per ch13 §8 the Ruby async-iteration idiom is Enumerator — caller
|
|
219
|
+
# writes `client.compute.llms.invoke_stream(...).each { |chunk| ... }`.
|
|
220
|
+
def stream_sse(config:, method:, path:, body: nil, query: nil)
|
|
221
|
+
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|
|
222
|
+
if query && !query.empty?
|
|
223
|
+
url += '?' + URI.encode_www_form(query)
|
|
224
|
+
end
|
|
225
|
+
uri = URI.parse(url)
|
|
226
|
+
|
|
227
|
+
Enumerator.new do |yielder|
|
|
228
|
+
http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
|
|
229
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
230
|
+
http.read_timeout = 300
|
|
231
|
+
|
|
232
|
+
req_class = case method.to_s.upcase
|
|
233
|
+
when 'GET' then ::Net::HTTP::Get
|
|
234
|
+
when 'POST' then ::Net::HTTP::Post
|
|
235
|
+
else raise ArgumentError, "Unsupported stream method: #{method}"
|
|
236
|
+
end
|
|
237
|
+
req = req_class.new(uri.request_uri)
|
|
238
|
+
req['accept'] = 'text/event-stream'
|
|
239
|
+
config.default_headers.each { |k, v| req[k] = v }
|
|
240
|
+
req['x-k9o-project-slug'] = config.project_slug if config.project_slug
|
|
241
|
+
req['x-k9o-org-slug'] = config.org_slug if config.org_slug
|
|
242
|
+
req['authorization'] = "Bearer #{config.api_key}" if config.api_key
|
|
243
|
+
if body
|
|
244
|
+
req['content-type'] = 'application/json'
|
|
245
|
+
req.body = JSON.dump(body)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
begin
|
|
249
|
+
http.request(req) do |response|
|
|
250
|
+
status = response.code.to_i
|
|
251
|
+
if status < 200 || status >= 300
|
|
252
|
+
raw = +''
|
|
253
|
+
response.read_body { |c| raw << c }
|
|
254
|
+
parsed = begin
|
|
255
|
+
JSON.parse(raw)
|
|
256
|
+
rescue JSON::ParserError
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
|
|
260
|
+
raise KError.from_wire(parsed, status)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
raise KError.new(
|
|
264
|
+
code: 'K9O_INTERNAL_ERROR',
|
|
265
|
+
message: "Upstream returned #{status} opening stream",
|
|
266
|
+
context: { url: url, status: status },
|
|
267
|
+
status: status
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
buffer = +''
|
|
272
|
+
data_lines = []
|
|
273
|
+
flush = lambda do
|
|
274
|
+
return true if data_lines.empty?
|
|
275
|
+
|
|
276
|
+
payload = data_lines.join("\n")
|
|
277
|
+
data_lines = []
|
|
278
|
+
return false if payload == '[DONE]'
|
|
279
|
+
|
|
280
|
+
begin
|
|
281
|
+
yielder << JSON.parse(payload)
|
|
282
|
+
rescue JSON::ParserError => e
|
|
283
|
+
raise KError.new(code: 'K9O_INTERNAL_ERROR', message: "failed to decode stream chunk: #{e.message}")
|
|
284
|
+
end
|
|
285
|
+
true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
response.read_body do |chunk|
|
|
289
|
+
buffer << chunk
|
|
290
|
+
while (idx = buffer.index("\n"))
|
|
291
|
+
line = buffer.slice!(0..idx).chomp
|
|
292
|
+
if line.empty?
|
|
293
|
+
break unless flush.call
|
|
294
|
+
elsif line.start_with?(':')
|
|
295
|
+
next
|
|
296
|
+
elsif line.start_with?('data:')
|
|
297
|
+
rest = line[5..]
|
|
298
|
+
rest = rest[1..] if rest.start_with?(' ')
|
|
299
|
+
data_lines << rest
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
flush.call
|
|
304
|
+
end
|
|
305
|
+
rescue KError
|
|
306
|
+
raise
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
raise KError.new(
|
|
309
|
+
code: 'K9O_UPSTREAM_UNAVAILABLE',
|
|
310
|
+
message: "Network error reaching #{url}: #{e.message}",
|
|
311
|
+
context: { url: url },
|
|
312
|
+
recommendation: 'Check connectivity to the gateway; retry with backoff if transient.'
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
data/lib/k9o/kerror.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# KError — the closed-taxonomy error raised by every k9o surface
|
|
5
|
+
# on non-2xx gateway responses or SDK-side validation failures.
|
|
6
|
+
#
|
|
7
|
+
# Ruby idiomatic shape: raise K9o::KError.
|
|
8
|
+
# Code-narrowing: callers rescue K9o::KError and switch on e.code.
|
|
9
|
+
class KError < StandardError
|
|
10
|
+
attr_reader :code, :context, :recommendation, :status, :request_id
|
|
11
|
+
|
|
12
|
+
def initialize(code:, message:, context: {}, recommendation: nil, status: nil, request_id: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@code = code
|
|
15
|
+
@message_text = message # avoid infinite recursion in to_s (Exception#message calls to_s)
|
|
16
|
+
@context = context.freeze
|
|
17
|
+
@recommendation = recommendation
|
|
18
|
+
@status = status
|
|
19
|
+
@request_id = request_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_recommendation(rec)
|
|
23
|
+
KError.new(
|
|
24
|
+
code: @code, message: @message_text,
|
|
25
|
+
context: @context, recommendation: rec,
|
|
26
|
+
status: @status, request_id: @request_id
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Override message to return the raw text (Exception's default calls to_s
|
|
31
|
+
# which causes infinite recursion since we override to_s).
|
|
32
|
+
def message
|
|
33
|
+
@message_text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_s
|
|
37
|
+
@status ? "[#{@code}] #{@message_text} (status=#{@status})" : "[#{@code}] #{@message_text}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Rehydrate a wire KError payload into a typed K9o::KError.
|
|
41
|
+
def self.from_wire(wire, status)
|
|
42
|
+
KError.new(
|
|
43
|
+
code: wire['code'] || 'K9O_INTERNAL_ERROR',
|
|
44
|
+
message: wire['message'] || '',
|
|
45
|
+
context: wire['context'] || {},
|
|
46
|
+
recommendation: wire['recommendation'],
|
|
47
|
+
status: status,
|
|
48
|
+
request_id: wire['requestId'] || wire['request_id']
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/k9o/kibo.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'kerror'
|
|
4
|
+
|
|
5
|
+
module K9o
|
|
6
|
+
# Zero-Knowledge BYOK/HYOK vault crypto — +K9o::Kibo.encrypt+ / +.decrypt+.
|
|
7
|
+
#
|
|
8
|
+
# Thin wrapper over the bound core (+kibo-kms-rest-ffi+, piece C) via uniffi.
|
|
9
|
+
# ZERO crypto here: every wrap/unwrap, envelope framing, posture-floor
|
|
10
|
+
# enforcement, and KMS round-trip lives in the audited Rust core.
|
|
11
|
+
#
|
|
12
|
+
# Ruby has no async runtime, so this calls the SYNC +zk_encrypt_blocking+ /
|
|
13
|
+
# +zk_decrypt_blocking+ exports — which drive the SAME canonical async op to
|
|
14
|
+
# completion on a process-wide tokio runtime inside the core (crypto-identical
|
|
15
|
+
# to the async exports the async-native languages use; W7C-confirmed neutral).
|
|
16
|
+
#
|
|
17
|
+
# The generated uniffi binding + native lib (+lib/k9o/_ffi/+) are build
|
|
18
|
+
# artifacts (CI-injected per platform; gitignored). On a platform/Ruby without
|
|
19
|
+
# the lib, +require+ still succeeds and +encrypt+/+decrypt+ raise a clear
|
|
20
|
+
# +K9o::KError+ — so loading +k9o+ never breaks for the rest of the SDK.
|
|
21
|
+
module Kibo
|
|
22
|
+
# Attempt to load the native binding; never raises at require time.
|
|
23
|
+
NATIVE_AVAILABLE =
|
|
24
|
+
begin
|
|
25
|
+
require_relative '_ffi/kibo_kms_rest_ffi'
|
|
26
|
+
true
|
|
27
|
+
rescue LoadError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Re-export the binding's input/output + enum types for ergonomic use
|
|
32
|
+
# (+K9o::Kibo::EncryptInput+, +K9o::Kibo::FfiPosture::BYOK+, …).
|
|
33
|
+
if NATIVE_AVAILABLE
|
|
34
|
+
EncryptInput = KiboKmsRestFfi::EncryptInput
|
|
35
|
+
DecryptInput = KiboKmsRestFfi::DecryptInput
|
|
36
|
+
EncryptOutput = KiboKmsRestFfi::EncryptOutput
|
|
37
|
+
DecryptOutput = KiboKmsRestFfi::DecryptOutput
|
|
38
|
+
FfiCredential = KiboKmsRestFfi::FfiCredential
|
|
39
|
+
FfiKeyRef = KiboKmsRestFfi::FfiKeyRef
|
|
40
|
+
FfiProviderConfig = KiboKmsRestFfi::FfiProviderConfig
|
|
41
|
+
FfiKvPair = KiboKmsRestFfi::FfiKvPair
|
|
42
|
+
FfiPosturePolicy = KiboKmsRestFfi::FfiPosturePolicy
|
|
43
|
+
FfiAllowedOracleModes = KiboKmsRestFfi::FfiAllowedOracleModes
|
|
44
|
+
FfiPosture = KiboKmsRestFfi::FfiPosture
|
|
45
|
+
FfiStrategy = KiboKmsRestFfi::FfiStrategy
|
|
46
|
+
FfiOracleMode = KiboKmsRestFfi::FfiOracleMode
|
|
47
|
+
FfiAttestationTier = KiboKmsRestFfi::FfiAttestationTier
|
|
48
|
+
FfiMinPosture = KiboKmsRestFfi::FfiMinPosture
|
|
49
|
+
KmsProvider = KiboKmsRestFfi::KmsProvider
|
|
50
|
+
|
|
51
|
+
# uniffi-ruby's error variants are +< StandardError+ namespaced under the
|
|
52
|
+
# +ZkFfiUniffiError+ module but do NOT +include+ it, so +rescue+-ing the
|
|
53
|
+
# module misses them — rescue the concrete variant classes instead.
|
|
54
|
+
FFI_ERRORS = [
|
|
55
|
+
KiboKmsRestFfi::ZkFfiUniffiError::Platform,
|
|
56
|
+
KiboKmsRestFfi::ZkFfiUniffiError::UpstreamKms,
|
|
57
|
+
KiboKmsRestFfi::ZkFfiUniffiError::PostureViolation
|
|
58
|
+
].freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Encrypt +input.plaintext+ via the customer KMS, client-side. Returns the
|
|
62
|
+
# opaque ZKFE envelope (+EncryptOutput+). Raises +K9o::KError+ on failure.
|
|
63
|
+
def self.encrypt(input)
|
|
64
|
+
ensure_native!
|
|
65
|
+
begin
|
|
66
|
+
KiboKmsRestFfi.zk_encrypt_blocking(input)
|
|
67
|
+
rescue *FFI_ERRORS => e
|
|
68
|
+
raise to_kerror(e)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Decrypt a ZKFE +input.envelope+. +strategy+, +attestation_tier+, and
|
|
73
|
+
# +encryption_context+ MUST match the encrypt-time values (AAD-bound).
|
|
74
|
+
def self.decrypt(input)
|
|
75
|
+
ensure_native!
|
|
76
|
+
begin
|
|
77
|
+
KiboKmsRestFfi.zk_decrypt_blocking(input)
|
|
78
|
+
rescue *FFI_ERRORS => e
|
|
79
|
+
raise to_kerror(e)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Guard: raise a clear KError if the native lib is absent (evaluated BEFORE
|
|
84
|
+
# any reference to the +KiboKmsRestFfi+ namespace, which may be undefined).
|
|
85
|
+
def self.ensure_native!
|
|
86
|
+
return if NATIVE_AVAILABLE
|
|
87
|
+
|
|
88
|
+
raise K9o::KError.new(
|
|
89
|
+
code: 'KIBO_NATIVE_UNAVAILABLE',
|
|
90
|
+
message: 'kibo ZK native library not available for this platform/Ruby; install the platform-specific k9o gem.'
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
private_class_method :ensure_native!
|
|
94
|
+
|
|
95
|
+
# Map the bound core's uniffi error -> +K9o::KError+, codes passed through.
|
|
96
|
+
def self.to_kerror(e)
|
|
97
|
+
case e
|
|
98
|
+
when KiboKmsRestFfi::ZkFfiUniffiError::PostureViolation
|
|
99
|
+
K9o::KError.new(
|
|
100
|
+
code: e.code, message: e.message,
|
|
101
|
+
context: { dimension: e.dimension, requested: e.requested, declared: e.declared }
|
|
102
|
+
)
|
|
103
|
+
when KiboKmsRestFfi::ZkFfiUniffiError::Platform
|
|
104
|
+
K9o::KError.new(code: e.code, message: e.message)
|
|
105
|
+
when KiboKmsRestFfi::ZkFfiUniffiError::UpstreamKms
|
|
106
|
+
# Customer's own KMS failed — no K9O_* code (outside the platform error namespace).
|
|
107
|
+
K9o::KError.new(code: 'UPSTREAM_KMS_ERROR', message: e.message)
|
|
108
|
+
else
|
|
109
|
+
K9o::KError.new(code: 'KIBO_FFI_ERROR', message: e.message)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_class_method :to_kerror
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/k9o/me.rb
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Operator-self namespace — k9o.me.*. Cross-org;
|
|
5
|
+
# org-plane routes under /me. Sub-namespaces: profile / sessions /
|
|
6
|
+
# api_tokens / notifications / audit_log / data_export.
|
|
7
|
+
class Me
|
|
8
|
+
attr_reader :profile, :sessions, :api_tokens, :notifications, :audit_log, :data_export
|
|
9
|
+
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@profile = Profile.new(config)
|
|
12
|
+
@sessions = Sessions.new(config)
|
|
13
|
+
@api_tokens = ApiTokens.new(config)
|
|
14
|
+
@notifications = Notifications.new(config)
|
|
15
|
+
@audit_log = AuditLog.new(config)
|
|
16
|
+
@data_export = DataExport.new(config)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# k9o.me.profile.*
|
|
20
|
+
class Profile
|
|
21
|
+
def initialize(config)
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get
|
|
26
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me'))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update(display_name: nil)
|
|
30
|
+
HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('me'),
|
|
31
|
+
body: { 'displayName' => display_name })
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# k9o.me.sessions.*
|
|
36
|
+
class Sessions
|
|
37
|
+
def initialize(config)
|
|
38
|
+
@config = config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def list
|
|
42
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/sessions'))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def revoke(session_id)
|
|
46
|
+
HttpTransport.request(config: @config, method: 'DELETE',
|
|
47
|
+
path: UrlGrammar.org_path("me/sessions/#{enc(session_id)}"))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def revoke_all
|
|
51
|
+
HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path('me/sessions'))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def enc(s)
|
|
57
|
+
::URI.encode_www_form_component(s)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# k9o.me.api_tokens.*
|
|
62
|
+
class ApiTokens
|
|
63
|
+
def initialize(config)
|
|
64
|
+
@config = config
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def list
|
|
68
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/api-tokens'))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def create(name:, expires_in_days: nil, scope: nil)
|
|
72
|
+
body = { 'name' => name }
|
|
73
|
+
body['expiresInDays'] = expires_in_days if expires_in_days
|
|
74
|
+
body['scope'] = scope if scope
|
|
75
|
+
HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('me/api-tokens'), body: body)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delete(token_id)
|
|
79
|
+
HttpTransport.request(config: @config, method: 'DELETE',
|
|
80
|
+
path: UrlGrammar.org_path("me/api-tokens/#{enc(token_id)}"))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def enc(s)
|
|
86
|
+
::URI.encode_www_form_component(s)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# k9o.me.notifications.*
|
|
91
|
+
class Notifications
|
|
92
|
+
def initialize(config)
|
|
93
|
+
@config = config
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def list(unread_only: nil, limit: nil, cursor: nil)
|
|
97
|
+
q = {}
|
|
98
|
+
q['unreadOnly'] = unread_only unless unread_only.nil?
|
|
99
|
+
q['limit'] = limit if limit
|
|
100
|
+
q['cursor'] = cursor unless cursor.nil?
|
|
101
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/notifications'), query: q)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def mark_read(notification_id)
|
|
105
|
+
HttpTransport.request(config: @config, method: 'PATCH',
|
|
106
|
+
path: UrlGrammar.org_path("me/notifications/#{enc(notification_id)}"), body: { 'read' => true })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def mark_all_read
|
|
110
|
+
HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('me/notifications'),
|
|
111
|
+
body: { 'read' => true })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def enc(s)
|
|
117
|
+
::URI.encode_www_form_component(s)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# k9o.me.audit_log.*
|
|
122
|
+
class AuditLog
|
|
123
|
+
def initialize(config)
|
|
124
|
+
@config = config
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def query(since: nil, until_: nil, type: nil, limit: nil, cursor: nil)
|
|
128
|
+
q = {}
|
|
129
|
+
q['since'] = since if since
|
|
130
|
+
q['until'] = until_ if until_
|
|
131
|
+
q['type'] = type if type
|
|
132
|
+
q['limit'] = limit if limit
|
|
133
|
+
q['cursor'] = cursor if cursor
|
|
134
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/audit-log'), query: q)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# k9o.me.data_export.*
|
|
139
|
+
class DataExport
|
|
140
|
+
def initialize(config)
|
|
141
|
+
@config = config
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def request(format: 'json')
|
|
145
|
+
HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('me/data-export'),
|
|
146
|
+
body: { 'format' => format })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def status(job_id)
|
|
150
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("me/data-export/#{enc(job_id)}"))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def enc(s)
|
|
156
|
+
::URI.encode_www_form_component(s)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|