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/crypto.rb
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module K9o
|
|
6
|
+
# client.crypto: application encryption with the project's key. The caller never names a key. The calls route by
|
|
7
|
+
# custody (see "Routing" below): a platform-managed key is used at the API; a key in the customer's own KMS or HSM
|
|
8
|
+
# (BYOK or HYOK) is used by a registered client core, so the platform never sees plaintext.
|
|
9
|
+
#
|
|
10
|
+
# encrypt(crypt, items) POST /@<slug>/crypto/encrypt items: [{ plaintext:, context: }]
|
|
11
|
+
# decrypt(crypt, items) POST /@<slug>/crypto/decrypt items: [{ ciphertext:, context: }]
|
|
12
|
+
# rewrap(crypt, items) POST /@<slug>/crypto/rewrap items: [{ ciphertext:, context: }]
|
|
13
|
+
# federated_token(crypt, op) POST /@<slug>/crypto/federated-token op: "encrypt", "decrypt" or "rewrap"
|
|
14
|
+
# crypts_list GET /@<slug>/crypto/crypts
|
|
15
|
+
# crypts_create(name) POST /@<slug>/crypto/crypts body: { name: }
|
|
16
|
+
# crypts_retire(name) POST /@<slug>/crypto/crypts/<name>/retire no body
|
|
17
|
+
#
|
|
18
|
+
# An item is a Hash (symbol or string keys) or, for brevity, a bare String (the plaintext, or the ciphertext).
|
|
19
|
+
# Plaintext goes in as a String of bytes (any encoding; its bytes are sent) and comes back as a binary String;
|
|
20
|
+
# the SDK does the base64 on both sides. A ciphertext is the base64 String the gateway gave you: one opaque container
|
|
21
|
+
# that the SDK never parses. +context+ is an optional String of at most 256 bytes; the same +crypt+ and +context+
|
|
22
|
+
# must be given on decrypt.
|
|
23
|
+
#
|
|
24
|
+
# The answer is an Envelope whose +data+ is { "items" => [...] } in the order sent. Each item is
|
|
25
|
+
# { "ciphertext" } (encrypt, rewrap), { "plaintext" } (decrypt, decoded to bytes) or { "code", "message" } for an item
|
|
26
|
+
# that failed on its own; fields the SDK does not know are kept as they are. A call-level failure raises K9o::KError:
|
|
27
|
+
# 400 K9O_ENVELOPE_INTEGRITY_FAILURE (every cannot-open, with no reason, on purpose), 409
|
|
28
|
+
# K9O_SEALED_UNDER_RETIRED_KEY (+context["resealableUntil"]+ while #rewrap can still fix it), 413
|
|
29
|
+
# K9O_PAYLOAD_TOO_LARGE, 429 K9O_RATE_LIMITED (+retry_after_sec+), 503 K9O_KEY_SERVICE_UNAVAILABLE (fails the whole
|
|
30
|
+
# call).
|
|
31
|
+
#
|
|
32
|
+
# Routing. #encrypt checks the project's custody mode on every call (GET crypto/mode, never cached), then sends
|
|
33
|
+
# plaintext to the API only for a platform-managed, confidential-workload or SKR project (the body carries the
|
|
34
|
+
# +custodyEpoch+ from the check). A BYOK or HYOK project goes to the client core; an unknown mode or a failed check
|
|
35
|
+
# sends nothing. If the API answers 409 K9O_ZK_STORE_FLOW_REQUIRED (custody moved between check and send), the mode
|
|
36
|
+
# is checked again and the call is routed once more; a second 409 is raised. #decrypt and #rewrap route by the
|
|
37
|
+
# ciphertext: they go to the API first, and a 409 K9O_ZK_STORE_FLOW_REQUIRED whose +context["route"]+ is "client"
|
|
38
|
+
# reroutes the same call to the client core. With no client core registered (#use_client_core), a call that needs
|
|
39
|
+
# one raises K9o::KError K9O_ZK_CLIENT_CORE_UNAVAILABLE (501).
|
|
40
|
+
# The SDK checks the crypt, the item count and the sizes before sending, with the same codes. It never retries
|
|
41
|
+
# these calls on its own.
|
|
42
|
+
class Crypto
|
|
43
|
+
CRYPT_PATTERN = /\A(?!actions\z)[a-z0-9-]{1,64}\z/.freeze
|
|
44
|
+
MAX_ITEMS = 100
|
|
45
|
+
MAX_ITEM_BYTES = 64 * 1024
|
|
46
|
+
MAX_CALL_BYTES = 1024 * 1024
|
|
47
|
+
MAX_CONTEXT_BYTES = 256
|
|
48
|
+
|
|
49
|
+
ZK_STORE_FLOW_REQUIRED = 'K9O_ZK_STORE_FLOW_REQUIRED'
|
|
50
|
+
ZK_CLIENT_CORE_UNAVAILABLE = 'K9O_ZK_CLIENT_CORE_UNAVAILABLE'
|
|
51
|
+
CUSTODY_MODE_UNKNOWN = 'K9O_CRYPTO_CUSTODY_UNKNOWN'
|
|
52
|
+
KEY_PIN_UNENFORCED = 'K9O_CRYPTO_KEY_PIN_UNENFORCED'
|
|
53
|
+
# 404 on encrypt: the crypt name is not registered. 409 on encrypt: the crypt name is retired. Both pass through as KError.
|
|
54
|
+
CRYPT_NOT_FOUND = 'K9O_CRYPT_NOT_FOUND'
|
|
55
|
+
CRYPT_RETIRED = 'K9O_CRYPT_RETIRED'
|
|
56
|
+
# Gateway refusals of crypts_create and crypts_retire; they pass through as KError.
|
|
57
|
+
CRYPT_ALREADY_EXISTS = 'K9O_CRYPT_ALREADY_EXISTS'
|
|
58
|
+
CRYPT_LIMIT_EXCEEDED = 'K9O_CRYPT_LIMIT_EXCEEDED'
|
|
59
|
+
CRYPT_RULE = 'crypt must be 1 to 64 characters of a-z, 0-9 and "-", and not "actions"'
|
|
60
|
+
CRYPT_STATUSES = %w[active retired].freeze
|
|
61
|
+
SERVER_MODES = %w[PLATFORM_MANAGED CONFIDENTIAL_WORKLOAD SKR].freeze
|
|
62
|
+
CLIENT_MODES = %w[BYOK HYOK].freeze
|
|
63
|
+
CLIENT_CORE_METHODS = %i[encrypt decrypt rewrap].freeze
|
|
64
|
+
|
|
65
|
+
# The answer of GET crypto/mode: the project's custody mode and the opaque epoch that changes with every custody
|
|
66
|
+
# transition. +route+ is "client" or "server" when the gateway named one, otherwise nil (an older gateway, or a value this
|
|
67
|
+
# SDK does not know): follow it, do not infer the route from +custody_mode+.
|
|
68
|
+
Mode = Struct.new(:custody_mode, :epoch, :route)
|
|
69
|
+
|
|
70
|
+
TOKEN_OPS = %w[encrypt decrypt rewrap].freeze
|
|
71
|
+
TOKEN_FIELDS = %w[token expiresAt issuer subject audience kid].freeze
|
|
72
|
+
|
|
73
|
+
# The answer of POST crypto/federated-token: a short-lived RS256 JWT (+token+, valid for at most 15 minutes) for
|
|
74
|
+
# the crypt and operation asked, with +expires_at+ (date-time String), +issuer+, +subject+, +audience+ and the
|
|
75
|
+
# signing +kid+. When the gateway names them, +provider+ (the cloud, for example "aws") and +project_id+ (the +projectId+
|
|
76
|
+
# field) are decoded too, otherwise they are nil; they are only ever checked against your configuration, never followed.
|
|
77
|
+
# The token is a credential: +inspect+, +to_s+ and +pp+ leave it out.
|
|
78
|
+
FederatedToken = Struct.new(:token, :expires_at, :issuer, :subject, :audience, :kid, :provider, :project_id) do
|
|
79
|
+
def inspect
|
|
80
|
+
extra = +''
|
|
81
|
+
extra << " provider=#{provider.inspect}" unless provider.nil?
|
|
82
|
+
extra << " project_id=#{project_id.inspect}" unless project_id.nil?
|
|
83
|
+
"#<struct K9o::Crypto::FederatedToken token=[redacted] expires_at=#{expires_at.inspect} issuer=#{issuer.inspect} " \
|
|
84
|
+
"subject=#{subject.inspect} audience=#{audience.inspect} kid=#{kid.inspect}#{extra}>"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_s
|
|
88
|
+
inspect
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def pretty_print(pp)
|
|
92
|
+
pp.text(inspect)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(config)
|
|
97
|
+
@config = config
|
|
98
|
+
@client_core = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Register the client core: an object with +encrypt(crypt, items, **options)+, +decrypt(crypt, items, **options)+ and
|
|
102
|
+
# +rewrap(crypt, items, **options)+. This is the hook the Ruby Kibo binding fills. When the client was built with
|
|
103
|
+
# +expected_key_refs:+ the router passes it as the keyword +expected_key_refs:+ (a frozen Array of String) on every
|
|
104
|
+
# call, and the core must refuse a key whose reference (version ignored, see Crypto.key_ref_matches?) is not in it,
|
|
105
|
+
# before it uses any token or makes any network call. The keyword is absent when the option was not set. A core
|
|
106
|
+
# declares that it does this with a method +enforces_expected_key_refs?+ that returns true; with the option set and
|
|
107
|
+
# no such declaration the call raises K9o::KError K9O_CRYPTO_KEY_PIN_UNENFORCED before the core is called. Items are Hashes with symbol keys
|
|
108
|
+
# ({ plaintext:, context: } with binary plaintext bytes, or { ciphertext:, context: }; +context+ only when given).
|
|
109
|
+
# Each method answers the per-item results (an Array of item Hashes in the order given, or a Hash or Envelope whose
|
|
110
|
+
# data holds "items"), with plaintext as bytes and ciphertext as the base64 String. Pass nil to remove it. Returns the core.
|
|
111
|
+
def use_client_core(core)
|
|
112
|
+
unless core.nil? || CLIENT_CORE_METHODS.all? { |m| core.respond_to?(m) }
|
|
113
|
+
raise invalid('client core must respond to encrypt, decrypt and rewrap')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@client_core = core
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The project's custody mode and epoch right now (GET crypto/mode), as a Mode with +custody_mode+, +epoch+ and +route+.
|
|
120
|
+
# With +crypt:+ the gateway authorizes the caller on that crypt (a personal access token or workload policy scoped to
|
|
121
|
+
# named crypts is refused without one); the crypt is checked first (K9O_INVALID_FIELD_VALUE) and nothing is sent if invalid.
|
|
122
|
+
def mode(crypt: nil)
|
|
123
|
+
slug = Studio.require_project_slug(@config, 'crypto')
|
|
124
|
+
unless crypt.nil? || (crypt.is_a?(String) && CRYPT_PATTERN.match?(crypt))
|
|
125
|
+
raise invalid('crypt must be 1 to 64 characters of a-z, 0-9 and -, and not "actions"', crypt: crypt)
|
|
126
|
+
end
|
|
127
|
+
data = mode_answer(slug, crypt)
|
|
128
|
+
route = %w[client server].include?(data['route']) ? data['route'] : nil
|
|
129
|
+
Mode.new(data['custodyMode'], data['epoch'], route)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Encrypt each item's plaintext (up to 64 KiB each and 1 MiB per call). A crypt is a registered name. The gateway can
|
|
133
|
+
# answer 404 K9O_CRYPT_NOT_FOUND (name not registered) or 409 K9O_CRYPT_RETIRED (name retired); both raise K9o::KError
|
|
134
|
+
# unchanged. Decrypt and rewrap are not refused for either.
|
|
135
|
+
def encrypt(crypt, items)
|
|
136
|
+
route_encrypt(*prepare('encrypt', crypt, items, :plaintext))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Open each item's ciphertext. +data["items"][n]["plaintext"]+ is a binary String.
|
|
140
|
+
def decrypt(crypt, items)
|
|
141
|
+
route_by_ciphertext('decrypt', *prepare('decrypt', crypt, items, :ciphertext))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Open and re-seal each item's ciphertext under the project's current key version in one call; the plaintext never
|
|
145
|
+
# leaves the platform. It is the fix for 409 K9O_SEALED_UNDER_RETIRED_KEY while the window is open.
|
|
146
|
+
def rewrap(crypt, items)
|
|
147
|
+
route_by_ciphertext('rewrap', *prepare('rewrap', crypt, items, :ciphertext))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Ask the gateway for a short-lived federated token (POST crypto/federated-token) for +op+ ("encrypt", "decrypt" or
|
|
151
|
+
# "rewrap") on +crypt+, as a FederatedToken. The SDK checks both before sending, with K9O_INVALID_FIELD_VALUE. The
|
|
152
|
+
# call is never retried. The token is a credential: keep it out of logs and error reports. A refusal raises
|
|
153
|
+
# K9o::KError (400 K9O_INVALID_FIELD_VALUE, 401, 403, 409 K9O_CUSTODY_SERVER_ROUTE (context['route'] is 'server' or 'client-idp'), 429 K9O_RATE_LIMITED, 503
|
|
154
|
+
# K9O_KEY_SERVICE_UNAVAILABLE).
|
|
155
|
+
def federated_token(crypt, op)
|
|
156
|
+
slug = Studio.require_project_slug(@config, 'crypto')
|
|
157
|
+
unless crypt.is_a?(String) && CRYPT_PATTERN.match?(crypt)
|
|
158
|
+
raise invalid('crypt must be 1 to 64 characters of a-z, 0-9 and -, and not "actions"', crypt: crypt)
|
|
159
|
+
end
|
|
160
|
+
raise invalid('op must be encrypt, decrypt or rewrap', op: op.is_a?(String) ? op : nil) unless TOKEN_OPS.include?(op)
|
|
161
|
+
|
|
162
|
+
data = HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'crypto/federated-token'),
|
|
163
|
+
body: { crypt: crypt, op: op }).data
|
|
164
|
+
unless data.is_a?(Hash) && TOKEN_FIELDS.all? { |f| data[f].is_a?(String) && !data[f].empty? }
|
|
165
|
+
raise KError.new(code: 'K9O_INTERNAL_ERROR', message: 'Malformed crypto/federated-token response: expected token, expiresAt, issuer, subject, audience and kid')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
optional = %w[provider projectId].map { |f| data[f].is_a?(String) && !data[f].empty? ? data[f] : nil }
|
|
169
|
+
FederatedToken.new(*TOKEN_FIELDS.map { |f| data[f] }, *optional)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The crypts you hold (GET crypto/crypts): +data["crypts"]+ is an Array of { "name", "status" ("active" or "retired"),
|
|
173
|
+
# "createdAt", "retiredAt" (String or nil) }. Every crypt if you have a +*+ grant on crypts, none if you are a member
|
|
174
|
+
# with no grant. A malformed answer raises K9O_INTERNAL_ERROR. Gateway refusals (K9O_ACCESS_DENIED) raise K9o::KError.
|
|
175
|
+
def crypts_list
|
|
176
|
+
slug = Studio.require_project_slug(@config, 'crypto')
|
|
177
|
+
envelope = HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'crypto/crypts'))
|
|
178
|
+
data = envelope.data
|
|
179
|
+
raise malformed_crypt('list', 'without crypts') unless data.is_a?(Hash) && data['crypts'].is_a?(Array)
|
|
180
|
+
|
|
181
|
+
Envelope.new(data: { 'crypts' => data['crypts'].map { |c| crypt_of(c, 'list') } }, meta: envelope.meta)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Register a crypt so #encrypt accepts it (POST crypto/crypts, body { name: } and nothing else). +name+ is 1 to 64
|
|
185
|
+
# characters of a-z, 0-9 and -, and not "actions"; a bad name raises K9O_INVALID_FIELD_VALUE before anything is sent.
|
|
186
|
+
# Needs crypto:manage_crypts (project admins by default). Gateway refusals: 409 K9O_CRYPT_ALREADY_EXISTS, 409
|
|
187
|
+
# K9O_CRYPT_LIMIT_EXCEEDED (256 per project, retired ones counted), 409 K9O_CRYPT_RETIRED; all raise K9o::KError.
|
|
188
|
+
def crypts_create(name)
|
|
189
|
+
slug = Studio.require_project_slug(@config, 'crypto')
|
|
190
|
+
check_crypt_name(name, 'create')
|
|
191
|
+
envelope = HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'crypto/crypts'),
|
|
192
|
+
body: { name: name })
|
|
193
|
+
Envelope.new(data: crypt_of(envelope.data, 'create'), meta: envelope.meta)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Retire a crypt (POST crypto/crypts/<name>/retire, no body): #encrypt then refuses the name. #decrypt and #rewrap
|
|
197
|
+
# never read the registry, so data stays readable. This does not isolate, enforce or destroy anything. Needs
|
|
198
|
+
# crypto:manage_crypts; a personal access token is refused (K9O_RBAC_DENY). Gateway refusals: 404 K9O_CRYPT_NOT_FOUND,
|
|
199
|
+
# 409 K9O_CRYPT_RETIRED.
|
|
200
|
+
def crypts_retire(name)
|
|
201
|
+
slug = Studio.require_project_slug(@config, 'crypto')
|
|
202
|
+
check_crypt_name(name, 'retire')
|
|
203
|
+
envelope = HttpTransport.request(config: @config, method: 'POST',
|
|
204
|
+
path: UrlGrammar.project_path(slug, "crypto/crypts/#{name}/retire"))
|
|
205
|
+
Envelope.new(data: crypt_of(envelope.data, 'retire'), meta: envelope.meta)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Check the +expected_key_refs:+ client option and return a frozen copy (nil when not set). It must be a non-empty
|
|
209
|
+
# Array of non-blank Strings without whitespace, and none may contain "*", since a wildcard pins nothing. Raises
|
|
210
|
+
# K9o::KError K9O_INVALID_FIELD_VALUE (+context["field"]+ is "expectedKeyRefs") otherwise.
|
|
211
|
+
def self.check_expected_key_refs(refs)
|
|
212
|
+
return nil if refs.nil?
|
|
213
|
+
|
|
214
|
+
bad = lambda do |message|
|
|
215
|
+
KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: "expectedKeyRefs: #{message}", context: { 'field' => 'expectedKeyRefs' })
|
|
216
|
+
end
|
|
217
|
+
raise bad.call('pass a non-empty Array of key identifiers, or leave it out') unless refs.is_a?(Array) && !refs.empty?
|
|
218
|
+
|
|
219
|
+
refs.each do |r|
|
|
220
|
+
raise bad.call('each entry must be a key identifier without spaces') unless r.is_a?(String) && !r.strip.empty? && !r.match?(/\s/)
|
|
221
|
+
raise bad.call('an entry cannot contain "*"; name the exact key') if r.include?('*')
|
|
222
|
+
end
|
|
223
|
+
refs.map { |r| r.dup.freeze }.freeze
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Whether +key_ref+ is one of +expected+, ignoring the key version, by the rule the Kibo client core applies: AWS
|
|
227
|
+
# compares the whole reference; GCP drops +/cryptoKeyVersions/<n>+ and what follows; Azure compares the first five
|
|
228
|
+
# +/+-separated parts (+https://<host>/keys/<key>+) in lower case, so a version and the case of the host and key name
|
|
229
|
+
# are ignored; Vault compares +<path>/<key>+ without a +:<version>+ suffix. +provider:+ is required (:aws, :gcp,
|
|
230
|
+
# :azure or :vault) and is never inferred from the reference, because a Vault reference can look like a URL. Any
|
|
231
|
+
# other value raises K9O_INVALID_FIELD_VALUE. For authors of a client core; the SDK does not call it.
|
|
232
|
+
def self.key_ref_matches?(key_ref, expected, provider:)
|
|
233
|
+
k = key_identity(key_ref.to_s, provider)
|
|
234
|
+
Array(expected).any? { |e| key_identity(e.to_s, provider) == k }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def self.key_identity(ref, provider)
|
|
238
|
+
case provider.respond_to?(:to_sym) ? provider.to_sym : provider
|
|
239
|
+
when :aws then ref
|
|
240
|
+
when :gcp then ref.split('/cryptoKeyVersions/', -1).first.to_s
|
|
241
|
+
when :azure then ref.split('/', -1).first(5).join('/').downcase
|
|
242
|
+
when :vault
|
|
243
|
+
cut = ref.rindex('/')
|
|
244
|
+
path = cut.nil? ? '' : ref[0...cut]
|
|
245
|
+
key = cut.nil? ? ref : ref[(cut + 1)..]
|
|
246
|
+
"#{path}/#{key.split(':', 2).first.to_s}"
|
|
247
|
+
else
|
|
248
|
+
raise KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: 'provider must be :aws, :gcp, :azure or :vault',
|
|
249
|
+
context: { 'field' => 'provider' })
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
private_class_method :key_identity
|
|
253
|
+
|
|
254
|
+
# The text of a decrypted item as a UTF-8 String (nil for an item without plaintext).
|
|
255
|
+
def self.text(item)
|
|
256
|
+
plain = item.is_a?(Hash) ? item['plaintext'] : nil
|
|
257
|
+
plain.is_a?(String) ? plain.dup.force_encoding(Encoding::UTF_8) : nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def check_crypt_name(name, op)
|
|
263
|
+
return name if name.is_a?(String) && CRYPT_PATTERN.match?(name)
|
|
264
|
+
|
|
265
|
+
raise invalid("crypto.crypts.#{op}: #{CRYPT_RULE.sub('crypt must', 'name must')}", field: 'name')
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def malformed_crypt(op, why)
|
|
269
|
+
KError.new(code: 'K9O_INTERNAL_ERROR', message: "crypto.crypts.#{op}: the gateway answered #{why}")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def crypt_of(item, op)
|
|
273
|
+
raise malformed_crypt(op, 'without a crypt') unless item.is_a?(Hash)
|
|
274
|
+
raise malformed_crypt(op, 'without a name') unless item['name'].is_a?(String) && !item['name'].empty?
|
|
275
|
+
raise malformed_crypt(op, 'with an unknown status') unless CRYPT_STATUSES.include?(item['status'])
|
|
276
|
+
raise malformed_crypt(op, 'without createdAt') unless item['createdAt'].is_a?(String) && !item['createdAt'].empty?
|
|
277
|
+
unless item['retiredAt'].nil? || item['retiredAt'].is_a?(String)
|
|
278
|
+
raise malformed_crypt(op, 'with a retiredAt that is neither a date nor null')
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
{ 'name' => item['name'], 'status' => item['status'], 'createdAt' => item['createdAt'], 'retiredAt' => item['retiredAt'] }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def invalid(message, context = {})
|
|
285
|
+
ApprovalChecks.invalid(message, context)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def too_large(message, context)
|
|
289
|
+
KError.new(code: 'K9O_PAYLOAD_TOO_LARGE', message: message, context: context)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Validate, then [crypt, wire items, client-core items]. Nothing is sent here.
|
|
293
|
+
def prepare(_op, crypt, items, field)
|
|
294
|
+
unless crypt.is_a?(String) && CRYPT_PATTERN.match?(crypt)
|
|
295
|
+
raise invalid('crypt must be 1 to 64 characters of a-z, 0-9 and -, and not "actions"', crypt: crypt)
|
|
296
|
+
end
|
|
297
|
+
unless items.is_a?(Array) && items.size >= 1 && items.size <= MAX_ITEMS
|
|
298
|
+
raise invalid("items must be an Array of 1 to #{MAX_ITEMS} items", count: items.is_a?(Array) ? items.size : nil)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
total = 0
|
|
302
|
+
wire = []
|
|
303
|
+
plain = []
|
|
304
|
+
items.each_with_index do |item, i|
|
|
305
|
+
entry, size, core_item = wire_item(item, i, field)
|
|
306
|
+
total += size
|
|
307
|
+
wire << entry
|
|
308
|
+
plain << core_item
|
|
309
|
+
end
|
|
310
|
+
if field == :plaintext && total > MAX_CALL_BYTES
|
|
311
|
+
raise too_large("plaintext is at most #{MAX_CALL_BYTES} bytes per call", limit: MAX_CALL_BYTES, bytes: total)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
[crypt, wire, plain]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def route_encrypt(crypt, wire, plain)
|
|
318
|
+
attempts = 0
|
|
319
|
+
loop do
|
|
320
|
+
attempts += 1
|
|
321
|
+
answer = mode_answer(Studio.require_project_slug(@config, 'crypto'), crypt)
|
|
322
|
+
custody = answer['custodyMode']
|
|
323
|
+
epoch = answer['epoch']
|
|
324
|
+
route = answer['route']
|
|
325
|
+
has_epoch = epoch.is_a?(String) && !epoch.empty?
|
|
326
|
+
# An unknown route refuses before anything is sent, in every custody mode.
|
|
327
|
+
# A route that is present but JSON null counts as unknown; only an absent key is "no route".
|
|
328
|
+
if answer.key?('route') && route != 'server' && route != 'client'
|
|
329
|
+
raise custody_unknown(custody, route)
|
|
330
|
+
end
|
|
331
|
+
if CLIENT_MODES.include?(custody)
|
|
332
|
+
return client_call('encrypt', crypt, plain, custody) unless route == 'server'
|
|
333
|
+
raise custody_unknown(custody, route) unless has_epoch
|
|
334
|
+
elsif !(SERVER_MODES.include?(custody) && has_epoch)
|
|
335
|
+
raise custody_unknown(custody, route)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
begin
|
|
339
|
+
return post('encrypt', 'crypt' => crypt, 'items' => wire, 'custodyEpoch' => epoch)
|
|
340
|
+
rescue KError => e
|
|
341
|
+
raise unless attempts < 2 && zk_flow_required?(e)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# The raw GET crypto/mode answer as a Hash. Read fresh on every call; never cached.
|
|
347
|
+
# A non-nil +crypt+ goes out as ?crypt= so the gateway authorizes the caller on that crypt (encrypt's custody check).
|
|
348
|
+
def mode_answer(slug, crypt = nil)
|
|
349
|
+
data = HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'crypto/mode'),
|
|
350
|
+
query: crypt.nil? ? nil : { 'crypt' => crypt }).data
|
|
351
|
+
data.is_a?(Hash) ? data : {}
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def custody_unknown(custody, route)
|
|
355
|
+
context = { 'custodyMode' => custody }
|
|
356
|
+
context['route'] = route if route.is_a?(String)
|
|
357
|
+
KError.new(code: CUSTODY_MODE_UNKNOWN, status: nil,
|
|
358
|
+
message: 'the project custody mode or route is unknown, so nothing was sent; plaintext is never sent when the route is unclear',
|
|
359
|
+
context: context)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def route_by_ciphertext(op, crypt, wire, plain)
|
|
363
|
+
post(op, 'crypt' => crypt, 'items' => wire)
|
|
364
|
+
rescue KError => e
|
|
365
|
+
raise unless zk_flow_required?(e) && e.context.is_a?(Hash) && e.context['route'] == 'client'
|
|
366
|
+
|
|
367
|
+
client_call(op, crypt, plain, e.context['custodyMode'])
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def zk_flow_required?(error)
|
|
371
|
+
error.status == 409 && error.code == ZK_STORE_FLOW_REQUIRED
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def post(op, body)
|
|
375
|
+
slug = Studio.require_project_slug(@config, 'crypto')
|
|
376
|
+
envelope = HttpTransport.request(config: @config, method: 'POST',
|
|
377
|
+
path: UrlGrammar.project_path(slug, "crypto/#{op}"), body: body)
|
|
378
|
+
Envelope.new(data: decode(envelope.data), meta: envelope.meta)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def client_call(op, crypt, items, custody)
|
|
382
|
+
if @client_core.nil?
|
|
383
|
+
raise KError.new(code: ZK_CLIENT_CORE_UNAVAILABLE, status: 501,
|
|
384
|
+
message: "this project's key is held in your own KMS; install and register the Kibo client core " \
|
|
385
|
+
'for this language (crypto.use_client_core), or call kibo.encrypt/decrypt directly',
|
|
386
|
+
context: { 'custodyMode' => custody, 'requirement' => 'kibo-client-core' })
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
refs = @config.expected_key_refs
|
|
390
|
+
unless refs.nil? || (@client_core.respond_to?(:enforces_expected_key_refs?) && @client_core.enforces_expected_key_refs? == true)
|
|
391
|
+
raise KError.new(code: KEY_PIN_UNENFORCED, status: nil,
|
|
392
|
+
message: 'expectedKeyRefs is set but the client core does not declare that it enforces it; nothing was sent',
|
|
393
|
+
context: { 'custodyMode' => custody, 'requirement' => 'client-core-enforces-expected-key-refs' },
|
|
394
|
+
recommendation: 'Use a client core that enforces the pin (enforces_expected_key_refs? returns true), ' \
|
|
395
|
+
'or leave expected_key_refs out.')
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
opts = {}
|
|
399
|
+
opts[:expected_key_refs] = refs unless refs.nil?
|
|
400
|
+
result = @client_core.public_send(op, crypt, items, **opts)
|
|
401
|
+
result = result.data if result.is_a?(Envelope)
|
|
402
|
+
result = { 'items' => result } if result.is_a?(Array)
|
|
403
|
+
Envelope.new(data: result, meta: Meta.from_wire({}))
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# [wire item, plaintext bytes, client-core item]
|
|
407
|
+
def wire_item(item, index, field)
|
|
408
|
+
item = { field => item } if item.is_a?(String)
|
|
409
|
+
unless item.is_a?(Hash)
|
|
410
|
+
raise invalid("items[#{index}] must be a Hash or a String", index: index)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
value = item[field] || item[field.to_s]
|
|
414
|
+
unless value.is_a?(String) && (field == :plaintext || !value.empty?)
|
|
415
|
+
raise invalid("items[#{index}].#{field} is required and must be a String", index: index)
|
|
416
|
+
end
|
|
417
|
+
other = field == :plaintext ? :ciphertext : :plaintext
|
|
418
|
+
if item.key?(other) || item.key?(other.to_s)
|
|
419
|
+
raise invalid("items[#{index}] takes #{field}, not #{other}", index: index)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
size = 0
|
|
423
|
+
entry = {}
|
|
424
|
+
core_item = {}
|
|
425
|
+
if field == :plaintext
|
|
426
|
+
bytes = value.b
|
|
427
|
+
size = bytes.bytesize
|
|
428
|
+
if size > MAX_ITEM_BYTES
|
|
429
|
+
raise too_large("plaintext is at most #{MAX_ITEM_BYTES} bytes per item", limit: MAX_ITEM_BYTES, index: index,
|
|
430
|
+
bytes: size)
|
|
431
|
+
end
|
|
432
|
+
entry['plaintext'] = [bytes].pack('m0')
|
|
433
|
+
core_item[:plaintext] = bytes
|
|
434
|
+
else
|
|
435
|
+
entry['ciphertext'] = value
|
|
436
|
+
core_item[:ciphertext] = value
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
context = item.key?(:context) ? item[:context] : item['context']
|
|
440
|
+
unless context.nil?
|
|
441
|
+
unless context.is_a?(String) && context.bytesize <= MAX_CONTEXT_BYTES
|
|
442
|
+
raise invalid("items[#{index}].context must be a String of at most #{MAX_CONTEXT_BYTES} bytes", index: index)
|
|
443
|
+
end
|
|
444
|
+
unless context.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
445
|
+
raise invalid("items[#{index}].context must be valid UTF-8 text", index: index)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
entry['context'] = context
|
|
449
|
+
core_item[:context] = context
|
|
450
|
+
end
|
|
451
|
+
[entry, size, core_item]
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Decode each plaintext back to bytes; every other field stays as the gateway sent it.
|
|
455
|
+
def decode(data)
|
|
456
|
+
return data unless data.is_a?(Hash) && data['items'].is_a?(Array)
|
|
457
|
+
|
|
458
|
+
items = data['items'].map do |item|
|
|
459
|
+
next item unless item.is_a?(Hash) && item['plaintext'].is_a?(String)
|
|
460
|
+
|
|
461
|
+
bytes = begin
|
|
462
|
+
item['plaintext'].unpack1('m0')
|
|
463
|
+
rescue ArgumentError
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
466
|
+
bytes.nil? ? item : item.merge('plaintext' => bytes)
|
|
467
|
+
end
|
|
468
|
+
data.merge('items' => items)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
data/lib/k9o/custody.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Key custody: k9o.custody.* reads how the project's keys are held. Changing custody is a Console action.
|
|
5
|
+
#
|
|
6
|
+
# status() GET /@<slug>/custody/status (project admin or auditor)
|
|
7
|
+
#
|
|
8
|
+
# +data+ is { 'mode', 'attached', 'provider', 'lastJob', 'hsm' }: +mode+ is a string such as "PLATFORM_MANAGED",
|
|
9
|
+
# +attached+ a boolean, +provider+ "platform", "external_kms", "hsm" or nil, +lastJob+ nil or
|
|
10
|
+
# { 'kind', 'status', 'completedAt' }. +hsm+ is nil unless the provider is "hsm"; then it is
|
|
11
|
+
# { 'vendor', 'clusterId', 'region', 'attestation' => { 'status', 'verifiedAt' }, 'keys' }, where
|
|
12
|
+
# +attestation+ values and +keys+ are nil for now. An answer without an +hsm+ key reads as +hsm+ nil.
|
|
13
|
+
# Errors: 403 for another role, 404 when the caller is not a project member, 502 K9O_UPSTREAM_UNAVAILABLE when the
|
|
14
|
+
# key manager cannot answer for an attached project.
|
|
15
|
+
class Custody
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def status
|
|
21
|
+
path = UrlGrammar.project_path(Studio.require_project_slug(@config, 'custody'), 'custody/status')
|
|
22
|
+
envelope = HttpTransport.request(config: @config, method: 'GET', path: path)
|
|
23
|
+
data = envelope.data
|
|
24
|
+
data['hsm'] = nil if data.is_a?(Hash) && !data.key?('hsm')
|
|
25
|
+
envelope
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/k9o/data.rb
CHANGED
|
@@ -19,12 +19,22 @@ module K9o
|
|
|
19
19
|
SelectBuilder.new(@config, @collection)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
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
|
|
23
27
|
HttpTransport.request(
|
|
24
|
-
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)
|
|
25
30
|
)
|
|
26
31
|
end
|
|
27
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
|
+
|
|
28
38
|
def update(patch)
|
|
29
39
|
UpdateBuilder.new(@config, @collection, patch)
|
|
30
40
|
end
|
|
@@ -59,6 +69,7 @@ module K9o
|
|
|
59
69
|
@collection = collection
|
|
60
70
|
@filters = []
|
|
61
71
|
@limit_val = nil
|
|
72
|
+
@require_approval = false
|
|
62
73
|
@cursor_val = nil
|
|
63
74
|
@count_mode = nil
|
|
64
75
|
end
|
|
@@ -123,6 +134,7 @@ module K9o
|
|
|
123
134
|
def initialize(config, collection)
|
|
124
135
|
super
|
|
125
136
|
@search = {}
|
|
137
|
+
@vector = nil
|
|
126
138
|
end
|
|
127
139
|
|
|
128
140
|
# Full-text search over one text column. +type+ is "plain", "phrase" or "websearch".
|
|
@@ -144,8 +156,11 @@ module K9o
|
|
|
144
156
|
self
|
|
145
157
|
end
|
|
146
158
|
|
|
147
|
-
# Nearest-neighbour search over one vector column; results come back closest first.
|
|
148
|
-
#
|
|
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+.
|
|
149
164
|
def vector_search(column, embedding, metric: 'cosine', limit: nil)
|
|
150
165
|
check_search_column(column)
|
|
151
166
|
unless embedding.is_a?(Array) && !embedding.empty? &&
|
|
@@ -159,15 +174,15 @@ module K9o
|
|
|
159
174
|
raise search_error('vector limit must be an integer from 1 to 1000')
|
|
160
175
|
end
|
|
161
176
|
|
|
162
|
-
@
|
|
163
|
-
@
|
|
164
|
-
@
|
|
165
|
-
@search['vectorMetric'] = metric unless metric == 'cosine'
|
|
166
|
-
@search['vectorLimit'] = limit.to_s unless limit.nil?
|
|
177
|
+
@vector = { 'column' => column, 'vector' => embedding }
|
|
178
|
+
@vector['metric'] = metric unless metric == 'cosine'
|
|
179
|
+
@vector['limit'] = limit unless limit.nil?
|
|
167
180
|
self
|
|
168
181
|
end
|
|
169
182
|
|
|
170
183
|
def execute
|
|
184
|
+
return execute_vector_search if @vector
|
|
185
|
+
|
|
171
186
|
HttpTransport.request(
|
|
172
187
|
config: @config, method: 'GET',
|
|
173
188
|
path: FromBuilder.collection_path(@config, @collection),
|
|
@@ -177,6 +192,29 @@ module K9o
|
|
|
177
192
|
|
|
178
193
|
private
|
|
179
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
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
180
218
|
def search_error(message)
|
|
181
219
|
KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: { collection: @collection })
|
|
182
220
|
end
|
|
@@ -194,6 +232,13 @@ module K9o
|
|
|
194
232
|
@patch = patch
|
|
195
233
|
end
|
|
196
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
|
+
|
|
197
242
|
def execute
|
|
198
243
|
id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
|
|
199
244
|
raise KError.new(
|
|
@@ -203,11 +248,19 @@ module K9o
|
|
|
203
248
|
) unless id_filter
|
|
204
249
|
|
|
205
250
|
path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
|
|
206
|
-
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))
|
|
207
253
|
end
|
|
208
254
|
end
|
|
209
255
|
|
|
210
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
|
+
|
|
211
264
|
def execute
|
|
212
265
|
id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
|
|
213
266
|
raise KError.new(
|
|
@@ -217,7 +270,8 @@ module K9o
|
|
|
217
270
|
) unless id_filter
|
|
218
271
|
|
|
219
272
|
path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
|
|
220
|
-
HttpTransport.request(config: @config, method: 'DELETE', path: path
|
|
273
|
+
HttpTransport.request(config: @config, method: 'DELETE', path: path,
|
|
274
|
+
query: FromBuilder.approval_query(@require_approval))
|
|
221
275
|
end
|
|
222
276
|
end
|
|
223
277
|
end
|