k9o 1.2.0-aarch64-linux → 1.5.0-aarch64-linux
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 +115 -1
- data/README.md +1575 -180
- data/k9o.gemspec +2 -2
- data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
- data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
- data/lib/k9o/ai.rb +15 -0
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +253 -0
- data/lib/k9o/audit.rb +162 -14
- data/lib/k9o/client.rb +28 -9
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +125 -5
- 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 +127 -9
- data/lib/k9o/gate.rb +52 -22
- data/lib/k9o/http_transport.rb +78 -16
- 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.rb +3 -3
- 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 +27 -87
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +81 -68
- data/lib/k9o/orgs.rb +1 -0
- data/lib/k9o/policies.rb +25 -6
- data/lib/k9o/projects.rb +43 -8
- data/lib/k9o/query.rb +174 -0
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -32
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/schema.rb +35 -0
- data/lib/k9o/streams.rb +94 -0
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +79 -84
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +17 -0
- data/sig/k9o/ai.rbs +4 -0
- data/sig/k9o/approvals.rbs +71 -0
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +9 -6
- 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/query.rbs +26 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/region.rbs +14 -3
- data/sig/k9o/workload.rbs +64 -0
- metadata +33 -4
data/lib/k9o/data.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module K9o
|
|
4
6
|
# Data-plane DSL.
|
|
5
7
|
#
|
|
@@ -17,12 +19,22 @@ module K9o
|
|
|
17
19
|
SelectBuilder.new(@config, @collection)
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
# Insert one record. With +require_approval: true+ the write is sent as +?requireApproval=true+ and waits for
|
|
23
|
+
# approval; the answer is then a queued change (HTTP 202) or, when an +auto_approve+ policy covers the write,
|
|
24
|
+
# the inserted row. Tell them apart with K9o.queued_write?.
|
|
25
|
+
def insert(record = nil, require_approval: false, **fields)
|
|
26
|
+
record = fields if record.nil? # a braceless hash literal arrives as keywords on Ruby 3
|
|
21
27
|
HttpTransport.request(
|
|
22
|
-
config: @config, method: 'POST', path: FromBuilder.collection_path(@config, @collection), body: record
|
|
28
|
+
config: @config, method: 'POST', path: FromBuilder.collection_path(@config, @collection), body: record,
|
|
29
|
+
query: FromBuilder.approval_query(require_approval)
|
|
23
30
|
)
|
|
24
31
|
end
|
|
25
32
|
|
|
33
|
+
# +{ 'requireApproval' => 'true' }+ for a write that asks to be queued, +nil+ (no query) otherwise.
|
|
34
|
+
def self.approval_query(require_approval)
|
|
35
|
+
require_approval ? { 'requireApproval' => 'true' } : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
def update(patch)
|
|
27
39
|
UpdateBuilder.new(@config, @collection, patch)
|
|
28
40
|
end
|
|
@@ -57,6 +69,7 @@ module K9o
|
|
|
57
69
|
@collection = collection
|
|
58
70
|
@filters = []
|
|
59
71
|
@limit_val = nil
|
|
72
|
+
@require_approval = false
|
|
60
73
|
@cursor_val = nil
|
|
61
74
|
@count_mode = nil
|
|
62
75
|
end
|
|
@@ -113,13 +126,104 @@ module K9o
|
|
|
113
126
|
end
|
|
114
127
|
|
|
115
128
|
class SelectBuilder < BaseBuilder
|
|
129
|
+
SEARCH_COLUMN = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
|
|
130
|
+
SEARCH_LANGUAGE = /\A[a-zA-Z_]+\z/.freeze
|
|
131
|
+
TEXT_TYPES = %w[plain phrase websearch].freeze
|
|
132
|
+
VECTOR_METRICS = %w[cosine euclidean dot].freeze
|
|
133
|
+
|
|
134
|
+
def initialize(config, collection)
|
|
135
|
+
super
|
|
136
|
+
@search = {}
|
|
137
|
+
@vector = nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Full-text search over one text column. +type+ is "plain", "phrase" or "websearch".
|
|
141
|
+
def text_search(column, query, language: 'english', type: 'plain')
|
|
142
|
+
check_search_column(column)
|
|
143
|
+
raise search_error('text search query must be a non-empty string') unless query.is_a?(String) && !query.empty?
|
|
144
|
+
unless language.is_a?(String) && language.match?(SEARCH_LANGUAGE)
|
|
145
|
+
raise search_error('text search language must be letters and underscores only')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
type = type.to_s
|
|
149
|
+
raise search_error("text search type must be one of #{TEXT_TYPES.join(', ')}") unless TEXT_TYPES.include?(type)
|
|
150
|
+
|
|
151
|
+
@search.delete('textSearchLanguage')
|
|
152
|
+
@search.delete('textSearchType')
|
|
153
|
+
@search['textSearch'] = "#{column}:#{query}"
|
|
154
|
+
@search['textSearchLanguage'] = language unless language == 'english'
|
|
155
|
+
@search['textSearchType'] = type unless type == 'plain'
|
|
156
|
+
self
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Nearest-neighbour search over one vector column; results come back closest first. The embedding travels in
|
|
160
|
+
# the request body (POST data/<collection>/search), so a real embedding of any length works.
|
|
161
|
+
# +metric+ is "cosine", "euclidean" or "dot"; +limit+ is 1 to 1000 (server default 10). The rows returned are
|
|
162
|
+
# the smaller of this +limit+ and the query's +limit+ (default 50): to get N rows set both.
|
|
163
|
+
# It cannot be combined with +text_search+.
|
|
164
|
+
def vector_search(column, embedding, metric: 'cosine', limit: nil)
|
|
165
|
+
check_search_column(column)
|
|
166
|
+
unless embedding.is_a?(Array) && !embedding.empty? &&
|
|
167
|
+
embedding.all? { |v| v.is_a?(Integer) || (v.is_a?(Float) && v.finite?) }
|
|
168
|
+
raise search_error('embedding must be a non-empty array of finite numbers')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
metric = metric.to_s
|
|
172
|
+
raise search_error("vector metric must be one of #{VECTOR_METRICS.join(', ')}") unless VECTOR_METRICS.include?(metric)
|
|
173
|
+
unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 1000)
|
|
174
|
+
raise search_error('vector limit must be an integer from 1 to 1000')
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@vector = { 'column' => column, 'vector' => embedding }
|
|
178
|
+
@vector['metric'] = metric unless metric == 'cosine'
|
|
179
|
+
@vector['limit'] = limit unless limit.nil?
|
|
180
|
+
self
|
|
181
|
+
end
|
|
182
|
+
|
|
116
183
|
def execute
|
|
184
|
+
return execute_vector_search if @vector
|
|
185
|
+
|
|
117
186
|
HttpTransport.request(
|
|
118
187
|
config: @config, method: 'GET',
|
|
119
188
|
path: FromBuilder.collection_path(@config, @collection),
|
|
120
|
-
query: to_query
|
|
189
|
+
query: to_query.merge(@search)
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
BODY_KEYS = %w[select order limit cursor count or].freeze
|
|
196
|
+
|
|
197
|
+
# POST data/<collection>/search: the vector search entry becomes an object, filters go under +filter+, and the
|
|
198
|
+
# list keys (select, order, limit, cursor, count, or) stay at the top level.
|
|
199
|
+
def execute_vector_search
|
|
200
|
+
raise search_error('text_search cannot be combined with vector_search') if @search.key?('textSearch')
|
|
201
|
+
|
|
202
|
+
body = { 'vectorSearch' => @vector }
|
|
203
|
+
filter = {}
|
|
204
|
+
to_query.each do |key, value|
|
|
205
|
+
if BODY_KEYS.include?(key)
|
|
206
|
+
body[key] = key == 'limit' ? @limit_val : value
|
|
207
|
+
else
|
|
208
|
+
filter[key] = value
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
body['filter'] = filter unless filter.empty?
|
|
212
|
+
HttpTransport.request(
|
|
213
|
+
config: @config, method: 'POST',
|
|
214
|
+
path: "#{FromBuilder.collection_path(@config, @collection)}/search", body: body
|
|
121
215
|
)
|
|
122
216
|
end
|
|
217
|
+
|
|
218
|
+
def search_error(message)
|
|
219
|
+
KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: { collection: @collection })
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def check_search_column(column)
|
|
223
|
+
return if column.is_a?(String) && column.match?(SEARCH_COLUMN)
|
|
224
|
+
|
|
225
|
+
raise search_error('search column must be a column name: letters, digits and underscores, starting with a letter or underscore')
|
|
226
|
+
end
|
|
123
227
|
end
|
|
124
228
|
|
|
125
229
|
class UpdateBuilder < BaseBuilder
|
|
@@ -128,6 +232,13 @@ module K9o
|
|
|
128
232
|
@patch = patch
|
|
129
233
|
end
|
|
130
234
|
|
|
235
|
+
# Ask for this update to wait for approval (+?requireApproval=true+). The answer is then a queued change or, when an
|
|
236
|
+
# +auto_approve+ policy covers the write, the updated row: check it with K9o.queued_write?.
|
|
237
|
+
def require_approval
|
|
238
|
+
@require_approval = true
|
|
239
|
+
self
|
|
240
|
+
end
|
|
241
|
+
|
|
131
242
|
def execute
|
|
132
243
|
id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
|
|
133
244
|
raise KError.new(
|
|
@@ -137,11 +248,19 @@ module K9o
|
|
|
137
248
|
) unless id_filter
|
|
138
249
|
|
|
139
250
|
path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
|
|
140
|
-
HttpTransport.request(config: @config, method: 'PATCH', path: path, body: @patch
|
|
251
|
+
HttpTransport.request(config: @config, method: 'PATCH', path: path, body: @patch,
|
|
252
|
+
query: FromBuilder.approval_query(@require_approval))
|
|
141
253
|
end
|
|
142
254
|
end
|
|
143
255
|
|
|
144
256
|
class DeleteBuilder < BaseBuilder
|
|
257
|
+
# Ask for this delete to wait for approval (+?requireApproval=true+). The answer is then a queued change or, when an
|
|
258
|
+
# +auto_approve+ policy covers the write, the deleted record: check it with K9o.queued_write?.
|
|
259
|
+
def require_approval
|
|
260
|
+
@require_approval = true
|
|
261
|
+
self
|
|
262
|
+
end
|
|
263
|
+
|
|
145
264
|
def execute
|
|
146
265
|
id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
|
|
147
266
|
raise KError.new(
|
|
@@ -151,7 +270,8 @@ module K9o
|
|
|
151
270
|
) unless id_filter
|
|
152
271
|
|
|
153
272
|
path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
|
|
154
|
-
HttpTransport.request(config: @config, method: 'DELETE', path: path
|
|
273
|
+
HttpTransport.request(config: @config, method: 'DELETE', path: path,
|
|
274
|
+
query: FromBuilder.approval_query(@require_approval))
|
|
155
275
|
end
|
|
156
276
|
end
|
|
157
277
|
end
|
data/lib/k9o/ed25519.rb
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module K9o
|
|
6
|
+
# Ed25519 signature verification (RFC 8032). Uses the openssl library when the Ruby in use can verify Ed25519, and
|
|
7
|
+
# an in-process implementation (plain integer arithmetic) otherwise, so the answer never depends on the build of
|
|
8
|
+
# OpenSSL underneath Ruby.
|
|
9
|
+
module Ed25519
|
|
10
|
+
P = (2**255) - 19
|
|
11
|
+
L = (2**252) + 27_742_317_777_372_353_535_851_937_790_883_648_493
|
|
12
|
+
D = (-121_665 * 121_666.pow(P - 2, P)) % P
|
|
13
|
+
SQRT_M1 = 2.pow((P - 1) / 4, P)
|
|
14
|
+
BASE_Y = (4 * 5.pow(P - 2, P)) % P
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# True when +signature+ (64 bytes) is a valid Ed25519 signature of +message+ under +public_key+ (32 bytes).
|
|
19
|
+
# Malformed input answers false.
|
|
20
|
+
def verify(public_key, message, signature)
|
|
21
|
+
public_key = public_key.b
|
|
22
|
+
message = message.b
|
|
23
|
+
signature = signature.b
|
|
24
|
+
return false unless public_key.bytesize == 32 && signature.bytesize == 64
|
|
25
|
+
|
|
26
|
+
if native?
|
|
27
|
+
verify_native(public_key, message, signature)
|
|
28
|
+
else
|
|
29
|
+
Pure.verify(public_key, message, signature)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# True when this Ruby's openssl can verify Ed25519 signatures.
|
|
34
|
+
def native?
|
|
35
|
+
return @native unless @native.nil?
|
|
36
|
+
|
|
37
|
+
@native = begin
|
|
38
|
+
OpenSSL::PKey.respond_to?(:new_raw_public_key) &&
|
|
39
|
+
OpenSSL::PKey.new_raw_public_key('ED25519', ("\x00".b * 31) + "\x01".b).respond_to?(:verify)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def verify_native(public_key, message, signature)
|
|
46
|
+
OpenSSL::PKey.new_raw_public_key('ED25519', public_key).verify(nil, signature, message)
|
|
47
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# RFC 8032 section 5.1 in plain integers (extended coordinates).
|
|
52
|
+
module Pure
|
|
53
|
+
P = Ed25519::P
|
|
54
|
+
L = Ed25519::L
|
|
55
|
+
D = Ed25519::D
|
|
56
|
+
IDENTITY = [0, 1, 1, 0].freeze
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
def verify(public_key, message, signature)
|
|
61
|
+
a_point = decode_point(public_key)
|
|
62
|
+
return false if a_point.nil?
|
|
63
|
+
|
|
64
|
+
r_bytes = signature.byteslice(0, 32)
|
|
65
|
+
s = int_le(signature.byteslice(32, 32))
|
|
66
|
+
return false unless s < L
|
|
67
|
+
return false if decode_point(r_bytes).nil?
|
|
68
|
+
|
|
69
|
+
k = int_le(sha512(r_bytes + public_key + message)) % L
|
|
70
|
+
# [s]B - [k]A must equal R.
|
|
71
|
+
check = add(scalar_mult(base, s), scalar_mult(negate(a_point), k))
|
|
72
|
+
encode_point(check) == r_bytes
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The 32-byte public key for a 32-byte seed. Exposed so tests can sign with a fresh key.
|
|
76
|
+
def public_key(seed)
|
|
77
|
+
encode_point(scalar_mult(base, expand(seed).first))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A 64-byte signature of +message+ by +seed+. Exposed so tests can sign; callers verify with Ed25519.verify.
|
|
81
|
+
def sign(seed, message)
|
|
82
|
+
a, prefix = expand(seed)
|
|
83
|
+
pub = encode_point(scalar_mult(base, a))
|
|
84
|
+
r = int_le(sha512(prefix + message)) % L
|
|
85
|
+
r_bytes = encode_point(scalar_mult(base, r))
|
|
86
|
+
k = int_le(sha512(r_bytes + pub + message)) % L
|
|
87
|
+
r_bytes + le32((r + (k * a)) % L)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def expand(seed)
|
|
91
|
+
h = sha512(seed)
|
|
92
|
+
a = int_le(h.byteslice(0, 32))
|
|
93
|
+
a &= (1 << 254) - 8
|
|
94
|
+
a |= 1 << 254
|
|
95
|
+
[a, h.byteslice(32, 32)]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def sha512(data)
|
|
99
|
+
OpenSSL::Digest::SHA512.digest(data)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def int_le(bytes)
|
|
103
|
+
bytes.unpack('C*').each_with_index.sum { |byte, i| byte << (8 * i) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def le32(int)
|
|
107
|
+
Array.new(32) { |i| (int >> (8 * i)) & 0xff }.pack('C*')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def base
|
|
111
|
+
x = recover_x(Ed25519::BASE_Y, 0)
|
|
112
|
+
[x, Ed25519::BASE_Y, 1, (x * Ed25519::BASE_Y) % P]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def recover_x(y, sign)
|
|
116
|
+
return nil if y >= P
|
|
117
|
+
|
|
118
|
+
x2 = ((y * y) - 1) * ((D * y * y) + 1).pow(P - 2, P) % P
|
|
119
|
+
if x2.zero?
|
|
120
|
+
return sign.zero? ? 0 : nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
x = x2.pow((P + 3) / 8, P)
|
|
124
|
+
x = (x * Ed25519::SQRT_M1) % P unless ((x * x) - x2) % P == 0
|
|
125
|
+
return nil unless ((x * x) - x2) % P == 0
|
|
126
|
+
|
|
127
|
+
x = P - x if (x & 1) != sign
|
|
128
|
+
x
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def decode_point(bytes)
|
|
132
|
+
return nil unless bytes.bytesize == 32
|
|
133
|
+
|
|
134
|
+
y = int_le(bytes)
|
|
135
|
+
sign = y >> 255
|
|
136
|
+
y &= (1 << 255) - 1
|
|
137
|
+
x = recover_x(y, sign)
|
|
138
|
+
return nil if x.nil?
|
|
139
|
+
|
|
140
|
+
[x, y, 1, (x * y) % P]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def encode_point(point)
|
|
144
|
+
x, y, z, = point
|
|
145
|
+
zinv = z.pow(P - 2, P)
|
|
146
|
+
xa = (x * zinv) % P
|
|
147
|
+
ya = (y * zinv) % P
|
|
148
|
+
le32(ya | ((xa & 1) << 255))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def add(p1, p2)
|
|
152
|
+
x1, y1, z1, t1 = p1
|
|
153
|
+
x2, y2, z2, t2 = p2
|
|
154
|
+
a = ((y1 - x1) * (y2 - x2)) % P
|
|
155
|
+
b = ((y1 + x1) * (y2 + x2)) % P
|
|
156
|
+
c = (2 * t1 * t2 * D) % P
|
|
157
|
+
d = (2 * z1 * z2) % P
|
|
158
|
+
e = b - a
|
|
159
|
+
f = d - c
|
|
160
|
+
g = d + c
|
|
161
|
+
h = b + a
|
|
162
|
+
[(e * f) % P, (g * h) % P, (f * g) % P, (e * h) % P]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def negate(point)
|
|
166
|
+
x, y, z, t = point
|
|
167
|
+
[(-x) % P, y, z, (-t) % P]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def scalar_mult(point, scalar)
|
|
171
|
+
result = IDENTITY
|
|
172
|
+
addend = point
|
|
173
|
+
while scalar.positive?
|
|
174
|
+
result = add(result, addend) if scalar.odd?
|
|
175
|
+
addend = add(addend, addend)
|
|
176
|
+
scalar >>= 1
|
|
177
|
+
end
|
|
178
|
+
result
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# End users: k9o.end_users.*, the people who sign in to the project's own apps.
|
|
5
|
+
#
|
|
6
|
+
# list(status:, q:, limit:, cursor:) GET /@<slug>/end-users
|
|
7
|
+
# get(id) GET /@<slug>/end-users/<id>
|
|
8
|
+
# role_grants.list(end_user_id:) GET /@<slug>/end-user-role-grants
|
|
9
|
+
# role_grants.grant(end_user_id:, role:) POST /@<slug>/end-user-role-grants
|
|
10
|
+
# role_grants.revoke(grant_id) DELETE /@<slug>/end-user-role-grants/<grant_id>
|
|
11
|
+
#
|
|
12
|
+
# Listing and reading end users needs the project admin role. Role grants are created and revoked by project
|
|
13
|
+
# admins; reading them is open to a wider set of project roles.
|
|
14
|
+
class EndUsers
|
|
15
|
+
STATUSES = %w[active locked deleted all].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :role_grants
|
|
18
|
+
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
@role_grants = EndUserRoleGrants.new(config)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# End users, newest first, one page at a time. +status+ is active, locked, deleted or all (default: active and
|
|
25
|
+
# locked); +q+ is an email prefix; +limit+ is 1 to 200 (default 50).
|
|
26
|
+
def list(status: nil, q: nil, limit: nil, cursor: nil)
|
|
27
|
+
ApprovalChecks.check_limit(limit)
|
|
28
|
+
query = {}
|
|
29
|
+
query['status'] = ApprovalChecks.enum!(status, STATUSES, 'status') unless status.nil?
|
|
30
|
+
query['q'] = q unless q.nil?
|
|
31
|
+
query['limit'] = limit unless limit.nil?
|
|
32
|
+
query['cursor'] = cursor unless cursor.nil?
|
|
33
|
+
HttpTransport.request(config: @config, method: 'GET', path: path('end-users'), query: query)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# One end user. An unknown id is a 404 K9O_END_USER_NOT_FOUND.
|
|
37
|
+
def get(id)
|
|
38
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
39
|
+
path: path("end-users/#{ApprovalChecks.id_segment(id, 'end user')}"))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def path(tail)
|
|
45
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'end_users'), tail)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A role is a free string (1 to 128 characters), not a fixed list.
|
|
50
|
+
class EndUserRoleGrants
|
|
51
|
+
def initialize(config)
|
|
52
|
+
@config = config
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The active grants, oldest first. Not paginated. +data+ is the array of grants (the gateway wraps it in
|
|
56
|
+
# { 'grants' => [...] }). An unknown +end_user_id+ answers an empty list.
|
|
57
|
+
def list(end_user_id: nil)
|
|
58
|
+
query = {}
|
|
59
|
+
query['endUserId'] = end_user_id unless end_user_id.nil?
|
|
60
|
+
unwrap(HttpTransport.request(config: @config, method: 'GET', path: path, query: query), 'grants')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Give an end user a role; +data+ is the grant. Granting a role the user already holds is a 400
|
|
64
|
+
# K9O_INVALID_FIELD_VALUE; an unknown end user is a 404 K9O_NOT_FOUND. A revoked role can be granted again.
|
|
65
|
+
def grant(end_user_id:, role:)
|
|
66
|
+
body = { 'endUserId' => text!(end_user_id, 'end_user_id', 64), 'role' => text!(role, 'role', 128) }
|
|
67
|
+
unwrap(HttpTransport.request(config: @config, method: 'POST', path: path, body: body), 'grant')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Revoke a grant (it is kept, marked revoked). +data+ is { 'revoked' => true|false, 'revokedAt' => ... }; an
|
|
71
|
+
# unknown or already revoked id is not an error, it answers revoked false and revokedAt nil.
|
|
72
|
+
def revoke(grant_id)
|
|
73
|
+
HttpTransport.request(config: @config, method: 'DELETE',
|
|
74
|
+
path: path("/#{ApprovalChecks.id_segment(grant_id, 'grant')}"))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def unwrap(envelope, key)
|
|
80
|
+
Envelope.new(data: envelope.data.is_a?(Hash) ? envelope.data[key] : envelope.data, meta: envelope.meta)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def text!(value, what, max)
|
|
84
|
+
return value if value.is_a?(String) && !value.empty? && value.length <= max
|
|
85
|
+
|
|
86
|
+
raise ApprovalChecks.invalid("#{what} must be a string of 1 to #{max} characters")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def path(tail = '')
|
|
90
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'end_users'), "end-user-role-grants#{tail}")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/k9o/envelope.rb
CHANGED
|
@@ -20,9 +20,10 @@ module K9o
|
|
|
20
20
|
|
|
21
21
|
# Meta accessor sugar.
|
|
22
22
|
class Meta
|
|
23
|
-
attr_reader :cursor, :subscription, :idempotency, :warnings, :cost, :timing, :context
|
|
23
|
+
attr_reader :cursor, :subscription, :idempotency, :warnings, :cost, :timing, :context, :approval
|
|
24
24
|
|
|
25
|
-
def initialize(cursor: nil, subscription: nil, idempotency: nil, warnings: [], cost: {}, timing: nil, context: nil
|
|
25
|
+
def initialize(cursor: nil, subscription: nil, idempotency: nil, warnings: [], cost: {}, timing: nil, context: nil,
|
|
26
|
+
approval: nil)
|
|
26
27
|
@cursor = cursor
|
|
27
28
|
@subscription = subscription
|
|
28
29
|
@idempotency = idempotency
|
|
@@ -30,6 +31,7 @@ module K9o
|
|
|
30
31
|
@cost = cost
|
|
31
32
|
@timing = timing
|
|
32
33
|
@context = context
|
|
34
|
+
@approval = approval
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def self.from_wire(wire)
|
|
@@ -38,11 +40,12 @@ module K9o
|
|
|
38
40
|
idem = wire['idempotency'] ? MetaIdempotency.new(replayed: !!wire['idempotency']['replayed']) : nil
|
|
39
41
|
warnings = (wire['warnings'] || []).map { |w| MetaWarning.new(code: w['code'] || '', message: w['message'] || '') }
|
|
40
42
|
timing = wire['timing'] ? MetaTiming.new(total_ms: wire['timing']['totalMs'].to_i) : nil
|
|
41
|
-
context = wire['context'] && wire['context']['requestId'] ? MetaContext.new(request_id: wire['context']['requestId']) : nil
|
|
43
|
+
context = wire['context'] && wire['context']['requestId'] ? MetaContext.new(request_id: wire['context']['requestId'], project_id: wire['context']['projectId']) : nil
|
|
42
44
|
Meta.new(
|
|
43
45
|
cursor: cursor, subscription: sub, idempotency: idem,
|
|
44
46
|
warnings: warnings, cost: wire['cost'] || {},
|
|
45
|
-
timing: timing, context: context
|
|
47
|
+
timing: timing, context: context,
|
|
48
|
+
approval: wire['approval'].is_a?(Hash) ? wire['approval'] : nil
|
|
46
49
|
)
|
|
47
50
|
end
|
|
48
51
|
end
|
|
@@ -60,5 +63,17 @@ module K9o
|
|
|
60
63
|
MetaIdempotency = Struct.new(:replayed, keyword_init: true)
|
|
61
64
|
MetaWarning = Struct.new(:code, :message, keyword_init: true)
|
|
62
65
|
MetaTiming = Struct.new(:total_ms, keyword_init: true)
|
|
63
|
-
MetaContext = Struct.new(:request_id, keyword_init: true)
|
|
66
|
+
MetaContext = Struct.new(:request_id, :project_id, keyword_init: true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
module K9o
|
|
70
|
+
# A binary download answer: the bytes exactly as sent and the content type the gateway named (or nil).
|
|
71
|
+
class Download
|
|
72
|
+
attr_reader :body, :content_type
|
|
73
|
+
|
|
74
|
+
def initialize(body:, content_type: nil)
|
|
75
|
+
@body = body
|
|
76
|
+
@content_type = content_type
|
|
77
|
+
end
|
|
78
|
+
end
|
|
64
79
|
end
|