k9o 1.2.0 → 1.3.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 +35 -0
- data/README.md +90 -52
- data/lib/k9o/audit.rb +12 -14
- data/lib/k9o/client.rb +3 -5
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/functions.rb +48 -9
- data/lib/k9o/gate.rb +9 -18
- data/lib/k9o/http_transport.rb +10 -5
- data/lib/k9o/kibo.rb +3 -3
- data/lib/k9o/me.rb +27 -87
- 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/realtime.rb +13 -35
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +76 -80
- data/lib/k9o/version.rb +1 -1
- data/sig/k9o/gate.rbs +7 -6
- data/sig/k9o/region.rbs +14 -3
- metadata +2 -2
data/lib/k9o/realtime.rb
CHANGED
|
@@ -1,54 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
|
-
# Realtime Subscriptions namespace
|
|
4
|
+
# Realtime Subscriptions namespace.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# Net::HTTP-based Ruby SDK uses SSE primary (no stdlib WebSocket);
|
|
10
|
-
# #subscribe_poll exposes the poll fallback. #replay is one-shot catch-up.
|
|
6
|
+
# Transport: SSE over GET /@<slug>/realtime/subscribe. The gateway serves no WebSocket upgrade and no durable
|
|
7
|
+
# replay, so neither is offered; after an `event: resync` frame the caller re-reads current state from the data
|
|
8
|
+
# plane and resubscribes. #subscribe returns an Enumerator iterated via `.each`.
|
|
11
9
|
class Realtime
|
|
12
10
|
def initialize(config)
|
|
13
11
|
@config = config
|
|
14
12
|
end
|
|
15
13
|
|
|
16
|
-
# Subscribe via SSE
|
|
17
|
-
#
|
|
14
|
+
# Subscribe via SSE; returns an Enumerator yielding event hashes
|
|
15
|
+
# { 'topic', 'eventType', 'payload', 'occurredAt' } (the gateway's spelling). Control frames such as `resync`
|
|
16
|
+
# carry no +eventType+ and are skipped.
|
|
18
17
|
def subscribe(topic, filter: nil)
|
|
19
18
|
slug = Studio.require_project_slug(@config, 'realtime')
|
|
20
19
|
q = { 'topic' => topic }
|
|
21
20
|
q['filter'] = JSON.dump(filter) if filter
|
|
22
|
-
HttpTransport.stream_sse(config: @config, method: 'GET',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
slug = Studio.require_project_slug(@config, 'realtime')
|
|
29
|
-
unless from_sequence.is_a?(Integer) && from_sequence >= 0
|
|
30
|
-
raise KError.new(
|
|
31
|
-
code: 'K9O_INVALID_FILTER',
|
|
32
|
-
message: 'replay from_sequence must be a non-negative integer',
|
|
33
|
-
context: { fromSequence: from_sequence },
|
|
34
|
-
recommendation: 'Pass the highest sequence number you have already observed (or 0 to replay from the start).'
|
|
35
|
-
)
|
|
21
|
+
frames = HttpTransport.stream_sse(config: @config, method: 'GET',
|
|
22
|
+
path: UrlGrammar.project_path(slug, 'realtime/subscribe'), query: q)
|
|
23
|
+
Enumerator.new do |yielder|
|
|
24
|
+
frames.each do |event|
|
|
25
|
+
yielder << event if event.is_a?(Hash) && event['eventType'].is_a?(String)
|
|
26
|
+
end
|
|
36
27
|
end
|
|
37
|
-
body = { 'topic' => topic, 'fromSequence' => from_sequence }
|
|
38
|
-
body['filter'] = filter if filter
|
|
39
|
-
body['untilSequence'] = until_sequence if until_sequence
|
|
40
|
-
body['limit'] = limit if limit
|
|
41
|
-
HttpTransport.request(config: @config, method: 'POST',
|
|
42
|
-
path: UrlGrammar.project_path(slug, 'realtime/replay'), body: body)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Poll-transport fallback (SDK-internal SPLIT). Single batch
|
|
46
|
-
# via replay endpoint; caller loops tracking highest observed sequence.
|
|
47
|
-
def subscribe_poll(topic, from_sequence:, filter: nil)
|
|
48
|
-
replay(topic, from_sequence: from_sequence, filter: filter)
|
|
49
28
|
end
|
|
50
29
|
|
|
51
|
-
# Typed builders for the 6 closed topic families.
|
|
52
30
|
module Topic
|
|
53
31
|
module_function
|
|
54
32
|
|
data/lib/k9o/region.rb
CHANGED
|
@@ -6,30 +6,32 @@ require 'uri'
|
|
|
6
6
|
module K9o
|
|
7
7
|
# Regional host resolution.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
# only and REFUSES a request carrying a body /
|
|
11
|
-
# credentials must go to `<org>.api
|
|
12
|
-
# the region with ONE unauthenticated
|
|
13
|
-
# (Net::HTTP never follows redirects, so the 308 is read, not followed),
|
|
9
|
+
# A region-free org host `<org>.api.<rest>` (or `<org>.api-staging.<rest>`)
|
|
10
|
+
# redirects GET/HEAD only and REFUSES a request carrying a body /
|
|
11
|
+
# Authorization / cookie, so credentials must go to `<org>.api-<rgn>.<rest>`.
|
|
12
|
+
# The SDK learns the region with ONE unauthenticated HEAD to the configured
|
|
13
|
+
# host (Net::HTTP never follows redirects, so the 308 is read, not followed),
|
|
14
14
|
# takes the 308 `Location`, and caches the regional origin per client config.
|
|
15
15
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
16
|
+
# Nothing here knows an apex: the host comes from the configured base URL and
|
|
17
|
+
# `<rest>` is whatever follows the gateway label, so public-cloud,
|
|
18
|
+
# internal-apex, air-gapped and localhost deployments all work.
|
|
19
|
+
#
|
|
20
|
+
# - Only a region-free org host (see UrlGrammar.parse_api_host) is ever
|
|
21
|
+
# probed; any other base URL is used exactly as given.
|
|
18
22
|
# - The probe carries no Authorization, cookie or default header.
|
|
19
23
|
# - The Location is trusted only if https, no userinfo, no port, and host is
|
|
20
|
-
# exactly
|
|
24
|
+
# exactly the same org and the same remainder with a region code
|
|
25
|
+
# ([a-z]{2}\d{2}) appended to the gateway label.
|
|
21
26
|
# - Any other status or failure keeps the configured base URL. A definite
|
|
22
27
|
# answer is cached; a network failure of the probe is not.
|
|
23
28
|
module Region
|
|
24
|
-
REGION_FREE_ORG_HOST = /\A([a-z][a-z0-9-]{1,62})\.(api|api-staging)\.k9o\.io\z/.freeze
|
|
25
|
-
REGION_CODE = /\A[a-z]{2}\d{2}\z/.freeze
|
|
26
|
-
|
|
27
29
|
module_function
|
|
28
30
|
|
|
29
31
|
# The regional origin `location` may name for `host`, or nil. Pure.
|
|
30
32
|
def origin_from_location(host, location, base)
|
|
31
|
-
|
|
32
|
-
return nil if
|
|
33
|
+
from = UrlGrammar.parse_api_host(host)
|
|
34
|
+
return nil if from.nil? || from.org_slug.nil? || !from.region.nil?
|
|
33
35
|
|
|
34
36
|
begin
|
|
35
37
|
u = URI.join(base.sub(%r{/+\z}, '') + '/', location)
|
|
@@ -40,11 +42,10 @@ module K9o
|
|
|
40
42
|
return nil unless u.userinfo.nil? && u.user.nil? && u.password.nil?
|
|
41
43
|
return nil unless u.port == u.default_port
|
|
42
44
|
|
|
43
|
-
org = Regexp.escape(m[1])
|
|
44
|
-
apex = Regexp.escape(m[2])
|
|
45
45
|
h = u.host.to_s.downcase
|
|
46
|
-
|
|
47
|
-
return nil if
|
|
46
|
+
to = UrlGrammar.parse_api_host(h)
|
|
47
|
+
return nil if to.nil? || to.region.nil?
|
|
48
|
+
return nil unless to.org_slug == from.org_slug && to.family == from.family && to.apex == from.apex
|
|
48
49
|
|
|
49
50
|
"https://#{h}"
|
|
50
51
|
end
|
|
@@ -70,7 +71,8 @@ module K9o
|
|
|
70
71
|
rescue URI::Error
|
|
71
72
|
return [configured, true]
|
|
72
73
|
end
|
|
73
|
-
|
|
74
|
+
parsed_host = UrlGrammar.parse_api_host(host)
|
|
75
|
+
return [configured, true] if parsed_host.nil? || parsed_host.org_slug.nil? || !parsed_host.region.nil?
|
|
74
76
|
|
|
75
77
|
begin
|
|
76
78
|
probe_uri = URI.parse("#{configured.sub(%r{/+\z}, '')}/")
|
data/lib/k9o/studio.rb
CHANGED
|
@@ -25,7 +25,7 @@ module K9o
|
|
|
25
25
|
slug
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# k9o.studio.workflows
|
|
28
|
+
# k9o.studio.workflows.*
|
|
29
29
|
class Workflows
|
|
30
30
|
attr_reader :runs
|
|
31
31
|
|
|
@@ -34,17 +34,19 @@ module K9o
|
|
|
34
34
|
@runs = Runs.new(config)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
# GET /@<slug>/workflows — the route filters by +status+ and pages with +limit+ / +cursor+.
|
|
38
|
+
def list(status: nil, limit: nil, cursor: nil)
|
|
38
39
|
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
39
40
|
q = {}
|
|
40
41
|
q['status'] = status if status
|
|
41
|
-
q['triggerType'] = trigger_type if trigger_type
|
|
42
42
|
q['limit'] = limit if limit
|
|
43
43
|
q['cursor'] = cursor if cursor
|
|
44
44
|
HttpTransport.request(config: @config, method: 'GET',
|
|
45
45
|
path: UrlGrammar.project_path(slug, 'workflows'), query: q)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# POST /@<slug>/workflows. +workflow+ is the gateway's body: 'name', 'triggerType', 'triggerConfig',
|
|
49
|
+
# 'actions' (each { 'index', 'action_type', 'config', ... }), optional 'description', 'status', 'rbacBinding'.
|
|
48
50
|
def create(workflow)
|
|
49
51
|
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
50
52
|
HttpTransport.request(config: @config, method: 'POST',
|
|
@@ -69,12 +71,12 @@ module K9o
|
|
|
69
71
|
path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}"))
|
|
70
72
|
end
|
|
71
73
|
|
|
72
|
-
# Manual-trigger a run (
|
|
74
|
+
# Manual-trigger a run (manual workflows only): POST /@<slug>/workflows/<id>/runs { triggerContext }.
|
|
73
75
|
def trigger(workflow_id, trigger_context = {})
|
|
74
76
|
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
75
77
|
HttpTransport.request(config: @config, method: 'POST',
|
|
76
78
|
path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}/runs"),
|
|
77
|
-
body: trigger_context)
|
|
79
|
+
body: { 'triggerContext' => trigger_context })
|
|
78
80
|
end
|
|
79
81
|
|
|
80
82
|
private
|
|
@@ -83,15 +85,18 @@ module K9o
|
|
|
83
85
|
::URI.encode_www_form_component(s)
|
|
84
86
|
end
|
|
85
87
|
|
|
86
|
-
# k9o.studio.workflows.runs
|
|
88
|
+
# k9o.studio.workflows.runs.*
|
|
87
89
|
class Runs
|
|
88
90
|
def initialize(config)
|
|
89
91
|
@config = config
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
|
|
94
|
+
# GET /@<slug>/workflows/<id>/runs — pages with +limit+ / +cursor+ (the route has no status filter).
|
|
95
|
+
def list(workflow_id, limit: nil, cursor: nil)
|
|
93
96
|
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
94
|
-
q =
|
|
97
|
+
q = {}
|
|
98
|
+
q['limit'] = limit if limit
|
|
99
|
+
q['cursor'] = cursor if cursor
|
|
95
100
|
HttpTransport.request(config: @config, method: 'GET',
|
|
96
101
|
path: UrlGrammar.project_path(slug, "workflows/#{enc(workflow_id)}/runs"), query: q)
|
|
97
102
|
end
|
|
@@ -116,15 +121,20 @@ module K9o
|
|
|
116
121
|
end
|
|
117
122
|
end
|
|
118
123
|
|
|
119
|
-
# k9o.studio.dashboards
|
|
124
|
+
# k9o.studio.dashboards.* — widget fields on the wire are snake_case ('widget_type', 'data_source', 'config').
|
|
120
125
|
class Dashboards
|
|
121
126
|
def initialize(config)
|
|
122
127
|
@config = config
|
|
123
128
|
end
|
|
124
129
|
|
|
125
|
-
|
|
130
|
+
# GET /@<slug>/dashboards — filters: +status+, +pinned_only+; pages with +limit+ / +cursor+.
|
|
131
|
+
def list(status: nil, pinned_only: nil, limit: nil, cursor: nil)
|
|
126
132
|
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
127
|
-
q =
|
|
133
|
+
q = {}
|
|
134
|
+
q['status'] = status if status
|
|
135
|
+
q['pinnedOnly'] = pinned_only.to_s unless pinned_only.nil?
|
|
136
|
+
q['limit'] = limit if limit
|
|
137
|
+
q['cursor'] = cursor if cursor
|
|
128
138
|
HttpTransport.request(config: @config, method: 'GET',
|
|
129
139
|
path: UrlGrammar.project_path(slug, 'dashboards'), query: q)
|
|
130
140
|
end
|
data/lib/k9o/url_grammar.rb
CHANGED
|
@@ -7,11 +7,18 @@ module K9o
|
|
|
7
7
|
module UrlGrammar
|
|
8
8
|
SLUG_RE = /\A[a-z][a-z0-9-]{1,62}\z/
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
10
|
+
# The convenience default when the caller passes no base_url. A default value only: never used to parse a host.
|
|
11
|
+
DEFAULT_BASE_URL = 'https://api.k9o.io'
|
|
12
|
+
|
|
13
|
+
# `api` or `api-staging`, optionally followed by `-<rgn>`: the label that marks a gateway host.
|
|
14
|
+
API_LABEL = /\A(api|api-staging)(?:-([a-z]{2}\d{2}))?\z/.freeze
|
|
15
|
+
|
|
16
|
+
# The structure of a gateway API host, read from the host itself.
|
|
17
|
+
# org_slug: first label of an org host (+acme+ in +acme.api.corp.internal+); nil for a platform host.
|
|
18
|
+
# family: +api+ or +api-staging+.
|
|
19
|
+
# region: 4-character region code when the host is regional (+api-de01+), else nil.
|
|
20
|
+
# apex: everything after the gateway label; never matched against a list.
|
|
21
|
+
ApiHost = Struct.new(:org_slug, :family, :region, :apex)
|
|
15
22
|
|
|
16
23
|
module_function
|
|
17
24
|
|
|
@@ -65,24 +72,43 @@ module K9o
|
|
|
65
72
|
"#{uri.scheme}://#{host_port}"
|
|
66
73
|
end
|
|
67
74
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
# (
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
# Read the org slug and region out of a gateway host without knowing its apex. The gateway label is +api+ or
|
|
76
|
+
# +api-staging+, optionally followed by +-<rgn>+; the label before it is the org slug and whatever follows it is
|
|
77
|
+
# the apex. Returns nil for any other shape (localhost, an IP, a single-label host, a host with no gateway
|
|
78
|
+
# label), which callers use exactly as given.
|
|
79
|
+
def parse_api_host(host)
|
|
80
|
+
labels = host.to_s.downcase.split('.', -1)
|
|
81
|
+
return nil if labels.empty? || labels.any?(&:empty?)
|
|
82
|
+
|
|
83
|
+
build = lambda do |gateway_label, org_slug, rest|
|
|
84
|
+
m = API_LABEL.match(gateway_label)
|
|
85
|
+
next nil if m.nil? || rest.empty?
|
|
86
|
+
|
|
87
|
+
ApiHost.new(org_slug, m[1], m[2], rest.join('.'))
|
|
88
|
+
end
|
|
89
|
+
# Org host first: <org>.<api[-rgn]>.<apex>.
|
|
90
|
+
if labels.length >= 3 && labels[0].match?(SLUG_RE) && !labels[0].end_with?('-') && API_LABEL.match?(labels[1])
|
|
91
|
+
return build.call(labels[1], labels[0], labels[2..-1])
|
|
92
|
+
end
|
|
93
|
+
# Platform host: api.<apex>.
|
|
94
|
+
return build.call(labels[0], nil, labels[1..-1]) if labels.length >= 2 && API_LABEL.match?(labels[0])
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Resolve the effective base_url. base_url is OPTIONAL: omitted, it defaults to DEFAULT_BASE_URL (the public
|
|
100
|
+
# cloud, a convenience only). When the (validated) host is a platform host (+api.<apex>+ or +api-staging.<apex>+,
|
|
101
|
+
# any apex) and org_slug is supplied, the canonical org host +<org_slug>.<host>+ is composed. Any other host (an
|
|
102
|
+
# org host already, an internal or air-gapped host, localhost) passes through unchanged; org_slug is then only
|
|
103
|
+
# sent as a header.
|
|
80
104
|
def resolve_base_url(base_url, org_slug = nil)
|
|
81
|
-
validated = validate_base_url(base_url.nil? ?
|
|
105
|
+
validated = validate_base_url(base_url.nil? ? DEFAULT_BASE_URL : base_url)
|
|
82
106
|
uri = URI.parse(validated)
|
|
83
|
-
|
|
107
|
+
host = parse_api_host(uri.host)
|
|
108
|
+
if !host.nil? && host.org_slug.nil? && host.region.nil? && org_slug
|
|
84
109
|
validate_slug(org_slug, 'org')
|
|
85
|
-
|
|
110
|
+
host_port = uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
|
|
111
|
+
return "#{uri.scheme}://#{org_slug}.#{host_port}"
|
|
86
112
|
end
|
|
87
113
|
validated
|
|
88
114
|
end
|
data/lib/k9o/vault.rb
CHANGED
|
@@ -4,45 +4,33 @@ require 'base64'
|
|
|
4
4
|
require 'uri'
|
|
5
5
|
|
|
6
6
|
module K9o
|
|
7
|
-
# `k9o.kibo.vault.secrets.*`
|
|
7
|
+
# `k9o.kibo.vault.secrets.*` - the typed secrets surface
|
|
8
8
|
# (`services/gateway/src/routes/vault-secrets.ts`). Full family: list / get /
|
|
9
9
|
# create / reveal_challenge / reveal / update / rotate / move / delete.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# `
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# - The wire `value` field is base64 in BOTH directions, contradicting the
|
|
22
|
-
# OpenAPI's plain-language "the plaintext to seal" description (reads
|
|
23
|
-
# like it should be raw). A raw non-base64 `value` on create/update is
|
|
24
|
-
# rejected as unparseable. `create`/`update` take plaintext as a normal
|
|
25
|
-
# Ruby String and base64-encode it here; `reveal` base64-decodes the
|
|
26
|
-
# wire `value` back to plaintext before returning it — callers never
|
|
27
|
-
# see base64 on either side.
|
|
28
|
-
# - `update`/`rotate`/`move` responses are LIVE-VERIFIED NARROWER than (or
|
|
29
|
-
# entirely undocumented vs.) the OpenAPI's claimed `SecretSummary` — see
|
|
30
|
-
# the comment on each method below.
|
|
11
|
+
# - `region` is OPTIONAL on every named-secret operation: the gateway
|
|
12
|
+
# ignores any caller-supplied region and uses its own. It is sent on the
|
|
13
|
+
# query string only when the caller passes one (never by `get`, which
|
|
14
|
+
# reads the list route).
|
|
15
|
+
# - The wire `value` field is base64 in BOTH directions. `create`/`update`
|
|
16
|
+
# take plaintext as a normal Ruby String and base64-encode it here;
|
|
17
|
+
# `reveal` base64-decodes the wire `value` back to plaintext before
|
|
18
|
+
# returning it - callers never see base64 on either side.
|
|
19
|
+
# - `update`/`rotate`/`move` responses are narrower than a full summary -
|
|
20
|
+
# see the comment on each method below.
|
|
31
21
|
#
|
|
32
22
|
# `data` is returned as the raw wire Hash (camelCase string keys), matching
|
|
33
|
-
# this package's existing convention
|
|
34
|
-
#
|
|
35
|
-
# camelCase<->snake_case response transform anywhere in this gem); `reveal`
|
|
36
|
-
# is the one exception, where the `value` key is base64-decoded in place
|
|
37
|
-
# (mirroring the TS reference's own `value`-only transform).
|
|
23
|
+
# this package's existing convention; `reveal` is the one exception, where
|
|
24
|
+
# the `value` key is base64-decoded in place.
|
|
38
25
|
class VaultSecrets
|
|
26
|
+
MAX_GET_PAGES = 20
|
|
27
|
+
|
|
39
28
|
def initialize(config)
|
|
40
29
|
@config = config
|
|
41
30
|
end
|
|
42
31
|
|
|
43
32
|
# List secrets (metadata only — never lists ciphertext).
|
|
44
33
|
# `GET /@<project_slug>/vault/secrets`.
|
|
45
|
-
# The one secrets operation that does NOT need `region` (live-verified).
|
|
46
34
|
def list(prefix: nil, cursor: nil, limit: nil)
|
|
47
35
|
slug = require_project_slug
|
|
48
36
|
q = {}
|
|
@@ -56,40 +44,42 @@ module K9o
|
|
|
56
44
|
)
|
|
57
45
|
end
|
|
58
46
|
|
|
59
|
-
# Read a secret's metadata (never its value
|
|
47
|
+
# Read a secret's metadata (never its value - use `reveal_challenge` +
|
|
60
48
|
# `reveal` for that).
|
|
61
|
-
# `GET /@<project_slug>/vault/secrets/<name>?region=<region>`.
|
|
62
49
|
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
50
|
+
# `GET /@<project>/vault/secrets/<name>` is a reveal and always demands an
|
|
51
|
+
# `X-K9O-Reveal-Challenge` header, so metadata is read from the list route:
|
|
52
|
+
# list with the exact name as `prefix` (limit 100, following `nextCursor`
|
|
53
|
+
# for up to MAX_GET_PAGES pages) and return the secret whose `name` equals
|
|
54
|
+
# the argument. Raises `KError` `K9O_NOT_FOUND` (status 404) when none
|
|
55
|
+
# matches. `region` is accepted for symmetry and not sent.
|
|
56
|
+
def get(name, region: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
57
|
+
cursor = nil
|
|
58
|
+
MAX_GET_PAGES.times do
|
|
59
|
+
env = list(prefix: name, cursor: cursor, limit: 100)
|
|
60
|
+
data = env.data || {}
|
|
61
|
+
secret = (data['secrets'] || []).find { |s| s['name'] == name }
|
|
62
|
+
return Envelope.new(data: secret, meta: env.meta) if secret
|
|
63
|
+
|
|
64
|
+
cursor = data['nextCursor']
|
|
65
|
+
break if cursor.nil? || cursor.to_s.empty?
|
|
66
|
+
end
|
|
67
|
+
raise KError.new(
|
|
68
|
+
code: 'K9O_NOT_FOUND',
|
|
69
|
+
message: "No secret named '#{name}' in this project.",
|
|
70
|
+
context: { 'name' => name },
|
|
71
|
+
status: 404
|
|
77
72
|
)
|
|
78
73
|
end
|
|
79
74
|
|
|
80
75
|
# Create a secret under the caller's own auth.
|
|
81
|
-
# `POST /@<project_slug>/vault/secrets/<name
|
|
82
|
-
# `sec_<base62>` summary.
|
|
83
|
-
#
|
|
84
|
-
# `region` scopes the vault WRITE itself (residency guard) and is
|
|
85
|
-
# REQUIRED — live-verified against staging, not documented in the route's
|
|
86
|
-
# OpenAPI schema.
|
|
76
|
+
# `POST /@<project_slug>/vault/secrets/<name>` (+ `region` when given) -> the
|
|
77
|
+
# full `sec_<base62>` summary.
|
|
87
78
|
#
|
|
88
79
|
# `visibility` (`{'kind' => 'private'}` or
|
|
89
80
|
# `{'kind' => 'shared', 'principals' => [...], 'roles' => [...]}`) and
|
|
90
|
-
# `metadata` (`Hash[String, String]`) are passed through verbatim
|
|
91
|
-
|
|
92
|
-
def create(name:, value:, region:, visibility: nil, metadata: nil)
|
|
81
|
+
# `metadata` (`Hash[String, String]`) are passed through verbatim.
|
|
82
|
+
def create(name:, value:, region: nil, visibility: nil, metadata: nil)
|
|
93
83
|
slug = require_project_slug
|
|
94
84
|
body = { 'value' => Base64.strict_encode64(value) }
|
|
95
85
|
body['visibility'] = visibility if visibility
|
|
@@ -97,34 +87,34 @@ module K9o
|
|
|
97
87
|
HttpTransport.request(
|
|
98
88
|
config: @config, method: 'POST',
|
|
99
89
|
path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
|
|
100
|
-
query:
|
|
90
|
+
query: region_query(region),
|
|
101
91
|
body: body
|
|
102
92
|
)
|
|
103
93
|
end
|
|
104
94
|
|
|
105
95
|
# Mint a single-use reveal challenge — present it as
|
|
106
96
|
# `X-K9O-Reveal-Challenge` on the immediately-following `reveal` call.
|
|
107
|
-
# `POST .../<name>?op=reveal-challenge
|
|
108
|
-
def reveal_challenge(name, region:)
|
|
97
|
+
# `POST .../<name>?op=reveal-challenge`.
|
|
98
|
+
def reveal_challenge(name, region: nil)
|
|
109
99
|
slug = require_project_slug
|
|
110
100
|
HttpTransport.request(
|
|
111
101
|
config: @config, method: 'POST',
|
|
112
102
|
path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
|
|
113
|
-
query:
|
|
103
|
+
query: region_query(region, 'op' => 'reveal-challenge')
|
|
114
104
|
)
|
|
115
105
|
end
|
|
116
106
|
|
|
117
107
|
# Reveal a secret's plaintext value using a single-use challenge from
|
|
118
|
-
# `reveal_challenge`. `GET .../<name
|
|
108
|
+
# `reveal_challenge`. `GET .../<name>` with
|
|
119
109
|
# `X-K9O-Reveal-Challenge`. Base64-decodes the wire's `value` back to
|
|
120
|
-
# plaintext (
|
|
121
|
-
#
|
|
122
|
-
def reveal(name,
|
|
110
|
+
# plaintext (the reveal response's `value` is base64, symmetric with
|
|
111
|
+
# `create`'s encode) before returning it to the caller.
|
|
112
|
+
def reveal(name, challenge:, region: nil)
|
|
123
113
|
slug = require_project_slug
|
|
124
114
|
envelope = HttpTransport.request(
|
|
125
115
|
config: @config, method: 'GET',
|
|
126
116
|
path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
|
|
127
|
-
query:
|
|
117
|
+
query: region_query(region),
|
|
128
118
|
headers: { 'X-K9O-Reveal-Challenge' => challenge }
|
|
129
119
|
)
|
|
130
120
|
data = envelope.data
|
|
@@ -135,67 +125,73 @@ module K9o
|
|
|
135
125
|
end
|
|
136
126
|
|
|
137
127
|
# Update a secret's value (bumps version) — owner-only.
|
|
138
|
-
# `PUT .../<name
|
|
128
|
+
# `PUT .../<name>`.
|
|
139
129
|
#
|
|
140
|
-
#
|
|
130
|
+
# Narrower than a full summary: the gateway returns only
|
|
141
131
|
# `{id, name, version}` on a real PUT, not the full summary the OpenAPI
|
|
142
132
|
# schema claims — call `get` afterward if the full summary is needed.
|
|
143
|
-
def update(name,
|
|
133
|
+
def update(name, value:, region: nil, metadata: nil)
|
|
144
134
|
slug = require_project_slug
|
|
145
135
|
body = { 'value' => Base64.strict_encode64(value) }
|
|
146
136
|
body['metadata'] = metadata if metadata
|
|
147
137
|
HttpTransport.request(
|
|
148
138
|
config: @config, method: 'PUT',
|
|
149
139
|
path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
|
|
150
|
-
query:
|
|
140
|
+
query: region_query(region),
|
|
151
141
|
body: body
|
|
152
142
|
)
|
|
153
143
|
end
|
|
154
144
|
|
|
155
145
|
# Rotate a secret's wrapping key (re-wraps under a fresh `kid`; content
|
|
156
|
-
# unchanged) — owner-only. `POST .../<name>?op=rotate
|
|
146
|
+
# unchanged) — owner-only. `POST .../<name>?op=rotate`.
|
|
157
147
|
#
|
|
158
|
-
#
|
|
148
|
+
# Response shape: `{id, name, version, rotatedAt,
|
|
159
149
|
# ledgerSequenceNumber}` — the route's OpenAPI schema doesn't document
|
|
160
150
|
# this response at all.
|
|
161
|
-
def rotate(name, region:)
|
|
151
|
+
def rotate(name, region: nil)
|
|
162
152
|
slug = require_project_slug
|
|
163
153
|
HttpTransport.request(
|
|
164
154
|
config: @config, method: 'POST',
|
|
165
155
|
path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
|
|
166
|
-
query:
|
|
156
|
+
query: region_query(region, 'op' => 'rotate')
|
|
167
157
|
)
|
|
168
158
|
end
|
|
169
159
|
|
|
170
160
|
# Move (rename) a secret's hierarchical path — owner-only.
|
|
171
|
-
# `POST .../<name>?op=move
|
|
161
|
+
# `POST .../<name>?op=move` with `{newPath}`.
|
|
172
162
|
#
|
|
173
|
-
#
|
|
163
|
+
# Response shape: `{id, name, event}` (observed
|
|
174
164
|
# `event: "SECRET_PATH_CHANGED"`; treat as a plain String, not a closed
|
|
175
165
|
# enum — only one sample was observed).
|
|
176
|
-
def move(name,
|
|
166
|
+
def move(name, new_path:, region: nil)
|
|
177
167
|
slug = require_project_slug
|
|
178
168
|
HttpTransport.request(
|
|
179
169
|
config: @config, method: 'POST',
|
|
180
170
|
path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
|
|
181
|
-
query:
|
|
171
|
+
query: region_query(region, 'op' => 'move'),
|
|
182
172
|
body: { 'newPath' => new_path }
|
|
183
173
|
)
|
|
184
174
|
end
|
|
185
175
|
|
|
186
|
-
# Retire a secret
|
|
187
|
-
#
|
|
188
|
-
def delete(name, region:)
|
|
176
|
+
# Retire a secret - owner-only. `DELETE .../<name>`; any 2xx is success
|
|
177
|
+
# (204 No Content maps to `data: nil`).
|
|
178
|
+
def delete(name, region: nil)
|
|
189
179
|
slug = require_project_slug
|
|
190
180
|
HttpTransport.request(
|
|
191
181
|
config: @config, method: 'DELETE',
|
|
192
182
|
path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
|
|
193
|
-
query:
|
|
183
|
+
query: region_query(region)
|
|
194
184
|
)
|
|
195
185
|
end
|
|
196
186
|
|
|
197
187
|
private
|
|
198
188
|
|
|
189
|
+
def region_query(region, extra = {})
|
|
190
|
+
q = extra.dup
|
|
191
|
+
q['region'] = region if region
|
|
192
|
+
q
|
|
193
|
+
end
|
|
194
|
+
|
|
199
195
|
def require_project_slug
|
|
200
196
|
Studio.require_project_slug(@config, 'kibo.vault.secrets')
|
|
201
197
|
end
|
|
@@ -206,8 +202,8 @@ module K9o
|
|
|
206
202
|
end
|
|
207
203
|
|
|
208
204
|
# `k9o.kibo.vault.*` — the Kibo vault surface (member-plane, project-scoped).
|
|
209
|
-
# Currently just `secrets
|
|
210
|
-
# (
|
|
205
|
+
# Currently just `secrets` ; a `zk_deks` accessor
|
|
206
|
+
# (`POST vault/zk-deks` / `GET vault/zk-deks/<id>`) would
|
|
211
207
|
# nest here alongside it if/when that surface is built for Ruby — out of
|
|
212
208
|
# scope for this wrapper, which only covers the vault-secrets audit gap.
|
|
213
209
|
class KiboVault
|
|
@@ -220,7 +216,7 @@ module K9o
|
|
|
220
216
|
|
|
221
217
|
# `k9o.kibo.*` — per-client namespace root for the Kibo vault REST surface.
|
|
222
218
|
# Distinct from the module-level `K9o::Kibo` (native ZK `encrypt`/`decrypt`
|
|
223
|
-
# FFI bindings,
|
|
219
|
+
# FFI bindings, called directly as
|
|
224
220
|
# `K9o::Kibo.encrypt`/`.decrypt` since that surface needs no per-client HTTP
|
|
225
221
|
# config): this class exists so `client.kibo.vault.secrets.*` is reachable
|
|
226
222
|
# per client instance, mirroring the TS reference's
|
data/lib/k9o/version.rb
CHANGED
data/sig/k9o/gate.rbs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
# RBS signatures for the `client.gate` namespace
|
|
1
|
+
# RBS signatures for the `client.gate` namespace.
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
4
|
class Gate
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
def
|
|
5
|
+
def sign_up: (email: String, password: String, ?display_name: String?) -> Envelope
|
|
6
|
+
def sign_in: (email: String, password: String) -> Envelope
|
|
7
|
+
# The refresh token is required: the gateway answers 400 without it.
|
|
8
|
+
def sign_out: (refresh_token: String) -> Envelope
|
|
9
|
+
def whoami: () -> Envelope
|
|
10
|
+
def refresh_token: (refresh_token: String) -> Envelope
|
|
10
11
|
end
|
|
11
12
|
end
|