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/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
|
data/lib/k9o/functions.rb
CHANGED
|
@@ -9,11 +9,14 @@ module K9o
|
|
|
9
9
|
class Functions
|
|
10
10
|
ARTIFACT_CONTENT_TYPES = { 'wasm' => 'application/wasm', 'tar' => 'application/x-tar' }.freeze
|
|
11
11
|
|
|
12
|
+
attr_reader :triggers
|
|
13
|
+
|
|
12
14
|
def initialize(config)
|
|
13
15
|
@config = config
|
|
16
|
+
@triggers = FunctionTriggers.new(config)
|
|
14
17
|
end
|
|
15
18
|
|
|
16
|
-
# POST /@<slug>/functions/artifacts with the raw bytes: a WebAssembly module for the gateway tier (up to 16 MiB,
|
|
19
|
+
# POST /@<slug>/functions/artifacts with the raw bytes: a WebAssembly module for the gateway tier (for the platform operator; a project's deploy to it is refused) (up to 16 MiB,
|
|
17
20
|
# +kind+ 'wasm') or an OCI image archive for the sandbox tier (up to 256 MiB, +kind+ 'tar'). Uploading the same
|
|
18
21
|
# bytes again returns the same key. The envelope's data is { 'artifactKey' => String }.
|
|
19
22
|
def upload_artifact(bytes, kind)
|
|
@@ -30,8 +33,9 @@ module K9o
|
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# POST /@<slug>/functions — deploy or redeploy from an uploaded artifact.
|
|
33
|
-
# runtime: bun python go java kotlin dotnet
|
|
34
|
-
#
|
|
36
|
+
# Functions run in the sandbox. tier: 'sandbox' (OCI image archive); runtime: bun python go java kotlin dotnet
|
|
37
|
+
# swift php ruby rust zig. 'gateway' (WebAssembly) is for the platform operator and is refused for a customer
|
|
38
|
+
# deploy; tinygo and assemblyscript run only there, so they cannot be deployed to a project.
|
|
35
39
|
# artifact_key: the key +upload_artifact+ returned; version: label for this deploy (1 to 64 characters)
|
|
36
40
|
def deploy(name:, runtime:, tier:, artifact_key:, version:, description: nil)
|
|
37
41
|
slug = require_project_slug
|
|
@@ -87,4 +91,79 @@ module K9o
|
|
|
87
91
|
::URI.encode_www_form_component(s)
|
|
88
92
|
end
|
|
89
93
|
end
|
|
94
|
+
|
|
95
|
+
# Event triggers: k9o.functions.triggers.* run a function when a record is inserted, updated, deleted or upserted
|
|
96
|
+
# in a collection. They need the project admin role. For triggers on a timetable see +k9o.schedules+.
|
|
97
|
+
#
|
|
98
|
+
# list(name) GET /@<slug>/functions/<name>/triggers
|
|
99
|
+
# create(name, collection:, events:, ...) POST /@<slug>/functions/<name>/triggers
|
|
100
|
+
# update(name, trigger_id, **fields) PATCH /@<slug>/functions/<name>/triggers/<id>
|
|
101
|
+
# delete(name, trigger_id) DELETE /@<slug>/functions/<name>/triggers/<id> (204, no body)
|
|
102
|
+
class FunctionTriggers
|
|
103
|
+
EVENTS = %w[insert update delete upsert].freeze
|
|
104
|
+
COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,127}\z/.freeze
|
|
105
|
+
UPDATE_FIELDS = %i[events filter enabled log_ok_invocations].freeze
|
|
106
|
+
|
|
107
|
+
def initialize(config)
|
|
108
|
+
@config = config
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Every trigger of the function, newest first. Not paginated.
|
|
112
|
+
def list(name)
|
|
113
|
+
call('GET', path(name))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Create an event trigger; answers 201 with the trigger. +events+ lists 1 to 4 of insert, update, delete, upsert.
|
|
117
|
+
# +filter+ is a predicate Hash over the record; leave it out to fire on every matching event.
|
|
118
|
+
def create(name, collection:, events:, filter: nil, enabled: nil, log_ok_invocations: nil)
|
|
119
|
+
unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
|
|
120
|
+
raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]*$ (1 to 128 characters)',
|
|
121
|
+
collection: collection)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
body = { 'collection' => collection, 'events' => check_events(events) }
|
|
125
|
+
body['filter'] = filter unless filter.nil?
|
|
126
|
+
body['enabled'] = enabled unless enabled.nil?
|
|
127
|
+
body['logOkInvocations'] = log_ok_invocations unless log_ok_invocations.nil?
|
|
128
|
+
call('POST', path(name), body: body)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Change a trigger; send at least one of +events+, +filter+, +enabled+, +log_ok_invocations+. A Hash +filter+
|
|
132
|
+
# replaces the filter and an explicit +filter: nil+ clears it. The collection cannot be changed.
|
|
133
|
+
def update(name, trigger_id, **fields)
|
|
134
|
+
unknown = fields.keys - UPDATE_FIELDS
|
|
135
|
+
raise ApprovalChecks.invalid("unknown field: #{unknown.join(', ')}") unless unknown.empty?
|
|
136
|
+
raise ApprovalChecks.invalid("patch needs at least one of: #{UPDATE_FIELDS.join(', ')}") if fields.empty?
|
|
137
|
+
|
|
138
|
+
body = {}
|
|
139
|
+
body['events'] = check_events(fields[:events]) if fields.key?(:events)
|
|
140
|
+
body['filter'] = fields[:filter] if fields.key?(:filter)
|
|
141
|
+
body['enabled'] = fields[:enabled] if fields.key?(:enabled)
|
|
142
|
+
body['logOkInvocations'] = fields[:log_ok_invocations] if fields.key?(:log_ok_invocations)
|
|
143
|
+
call('PATCH', path(name, "/#{ApprovalChecks.id_segment(trigger_id, 'trigger')}"), body: body)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Delete a trigger. Answers 204, so +data+ is nil; deleting again is a 404.
|
|
147
|
+
def delete(name, trigger_id)
|
|
148
|
+
call('DELETE', path(name, "/#{ApprovalChecks.id_segment(trigger_id, 'trigger')}"))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def check_events(events)
|
|
154
|
+
list = events.is_a?(Array) ? events.map { |e| e.is_a?(Symbol) ? e.to_s : e } : nil
|
|
155
|
+
return list if list && list.size.between?(1, 4) && list.all? { |e| EVENTS.include?(e) }
|
|
156
|
+
|
|
157
|
+
raise ApprovalChecks.invalid('events must list 1 to 4 of insert, update, delete, upsert', events: events)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def call(method, route, body: nil)
|
|
161
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def path(name, tail = '')
|
|
165
|
+
slug = Studio.require_project_slug(@config, 'functions')
|
|
166
|
+
UrlGrammar.project_path(slug, "functions/#{ApprovalChecks.id_segment(name, 'function')}/triggers#{tail}")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
90
169
|
end
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
91
|
+
UrlGrammar.project_path(@config.project_slug, suffix)
|
|
53
92
|
end
|
|
54
93
|
end
|
|
55
94
|
end
|