k9o 1.4.0 → 1.5.2
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 +69 -1
- data/README.md +1273 -142
- 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 +4 -5
- 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/audit.rb
CHANGED
|
@@ -4,6 +4,11 @@ module K9o
|
|
|
4
4
|
# Project-plane audit log namespace — k9o.audit.*.
|
|
5
5
|
# Distinct from k9o.org.audit_log.
|
|
6
6
|
class Audit
|
|
7
|
+
TOPIC = 'audit.project'
|
|
8
|
+
TYPE_PATTERN = /\A[A-Za-z0-9_][A-Za-z0-9_.\-]*\z/.freeze
|
|
9
|
+
DEFAULT_REPLAY_LIMIT = 1000
|
|
10
|
+
EVENT_ID_PATTERN = /\Aevt_[0-9A-Za-z]{1,40}\z/.freeze
|
|
11
|
+
|
|
7
12
|
def initialize(config)
|
|
8
13
|
@config = config
|
|
9
14
|
end
|
|
@@ -23,5 +28,150 @@ module K9o
|
|
|
23
28
|
q['cursor'] = cursor unless cursor.nil?
|
|
24
29
|
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'audit-log'), query: q)
|
|
25
30
|
end
|
|
31
|
+
|
|
32
|
+
# Live tail of the project's audit events: an Enumerator of { "auditEventId", "type", "actorType", "occurredAt",
|
|
33
|
+
# "outcome", "eventId" }. The first five are the audit event (read the full row with #query by event type and
|
|
34
|
+
# time); +eventId+ is the publisher's own id for the frame, which is not the +auditEventId+.
|
|
35
|
+
#
|
|
36
|
+
# +types+ is a list of audit event types: one type subscribes to +audit.project.<type>+, none to
|
|
37
|
+
# +audit.project.*+, several to +audit.project.*+ with the SDK keeping only those types. Needs ORG_OWNER,
|
|
38
|
+
# ORG_ADMIN, PROJECT_ADMIN or PROJECT_AUDITOR (re-checked every 60 seconds; others get 403 K9O_RBAC_DENY);
|
|
39
|
+
# extensions cannot subscribe. +warden.checkpoint+ events are not in the live tail: the audit log and #replay
|
|
40
|
+
# carry them.
|
|
41
|
+
#
|
|
42
|
+
# It is the same reconnecting reader as +realtime.subscribe+: it resumes with +Last-Event-ID+ (the frame's SSE
|
|
43
|
+
# +id+), drops events it already delivered (by the frame's +eventId+ and by +auditEventId+), ignores heartbeats
|
|
44
|
+
# and abandons a connection that has been silent for +Realtime.idle_timeout+ seconds. When the gateway says the
|
|
45
|
+
# position is lost (+event: resync+, action +replay-ledger+; audit streams cannot be resumed by sequence) the tail
|
|
46
|
+
# first reconnects fresh and buffers that connection's frames, then calls #replay after the last +auditEventId+ it
|
|
47
|
+
# delivered (page by page while there are more), yields the events it missed (they also carry +sequence+, and may
|
|
48
|
+
# include +warden.checkpoint+), calls +on_resync+ with the frame's hash, then yields the buffered and live frames,
|
|
49
|
+
# each event once. Nothing is lost: the connection is open before the replay runs. If it had delivered nothing yet
|
|
50
|
+
# it only reconnects (a new connection starts at the live head; a resync that opens a fresh connection is informational and ignored). Stop with +break+ or +first(n)+.
|
|
51
|
+
def tail(types: nil, on_resync: nil)
|
|
52
|
+
topic, keep = topic_for(types)
|
|
53
|
+
slug = Studio.require_project_slug(@config, 'audit')
|
|
54
|
+
catch_up = lambda do |_info, last_event|
|
|
55
|
+
last_event ? collect_replay(topic, last_event['auditEventId']) : []
|
|
56
|
+
end
|
|
57
|
+
decode = lambda do |data, frame|
|
|
58
|
+
payload = data.is_a?(Hash) ? data['payload'] : nil
|
|
59
|
+
next nil unless payload.is_a?(Hash) && payload['auditEventId'].is_a?(String)
|
|
60
|
+
|
|
61
|
+
event = {
|
|
62
|
+
'auditEventId' => payload['auditEventId'],
|
|
63
|
+
'type' => payload['type'].is_a?(String) ? payload['type'] : (data['eventType'] || frame[:event]),
|
|
64
|
+
'actorType' => payload['actorType'],
|
|
65
|
+
'occurredAt' => payload['occurredAt'],
|
|
66
|
+
'outcome' => payload['outcome']
|
|
67
|
+
}
|
|
68
|
+
event['eventId'] = data['eventId'] if data['eventId'].is_a?(String)
|
|
69
|
+
event
|
|
70
|
+
end
|
|
71
|
+
keys = lambda do |event, _frame|
|
|
72
|
+
[event['eventId'], event['auditEventId']].compact
|
|
73
|
+
end
|
|
74
|
+
cursor = lambda do |event, frame|
|
|
75
|
+
(frame && frame[:id]) || event['eventId']
|
|
76
|
+
end
|
|
77
|
+
Realtime.new(@config).feed(UrlGrammar.project_path(slug, 'realtime/subscribe'), { 'topic' => topic }, nil,
|
|
78
|
+
on_resync, decode: decode, keys: keys, cursor: cursor, accept: keep,
|
|
79
|
+
catch_up: catch_up, fresh_head_resync: true)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# POST /@<slug>/realtime/replay for the audit tail: a batch of past audit events in order, including
|
|
83
|
+
# +warden.checkpoint+. +topic+ is +audit.project.*+ (default) or +audit.project.<type>+. Give
|
|
84
|
+
# +after_audit_event_id+ (the last +auditEventId+ you received; unknown ids answer 400 K9O_INVALID_CURSOR) or
|
|
85
|
+
# +from_sequence+ (+after_audit_event_id+ wins when both are given); +until_sequence+ and +limit+ bound the batch (+limit+ is
|
|
86
|
+
# 1000 when omitted, up to 10,000). Needs the audit log's access.
|
|
87
|
+
#
|
|
88
|
+
# +data+ is the typed page { "events" => [event], "nextCursor", "hasMore" }; each event is { "auditEventId", "type",
|
|
89
|
+
# "actorType", "occurredAt", "outcome", "sequence" } (+sequence+ is a String, as the gateway sends it).
|
|
90
|
+
def replay(topic: "#{TOPIC}.*", after_audit_event_id: nil, from_sequence: nil, until_sequence: nil, limit: nil)
|
|
91
|
+
unless topic.is_a?(String) && (topic == TOPIC || topic.start_with?("#{TOPIC}."))
|
|
92
|
+
raise ApprovalChecks.invalid('topic must be audit.project.* or audit.project.<type>', topic: topic)
|
|
93
|
+
end
|
|
94
|
+
unless after_audit_event_id.nil? || (after_audit_event_id.is_a?(String) && EVENT_ID_PATTERN.match?(after_audit_event_id))
|
|
95
|
+
raise ApprovalChecks.invalid('after_audit_event_id must look like evt_<id>', after_audit_event_id: after_audit_event_id)
|
|
96
|
+
end
|
|
97
|
+
{from_sequence: from_sequence, until_sequence: until_sequence}.each do |name, value|
|
|
98
|
+
next if value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
99
|
+
|
|
100
|
+
raise ApprovalChecks.invalid("#{name} must be a whole number, 0 or more", name => value)
|
|
101
|
+
end
|
|
102
|
+
unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 10_000)
|
|
103
|
+
raise ApprovalChecks.invalid('limit must be a whole number from 1 to 10000', limit: limit)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
body = { 'topic' => topic }
|
|
107
|
+
body['afterAuditEventId'] = after_audit_event_id unless after_audit_event_id.nil?
|
|
108
|
+
body['fromSequence'] = from_sequence unless from_sequence.nil?
|
|
109
|
+
body['untilSequence'] = until_sequence unless until_sequence.nil?
|
|
110
|
+
body['limit'] = limit.nil? ? DEFAULT_REPLAY_LIMIT : limit
|
|
111
|
+
slug = Studio.require_project_slug(@config, 'audit')
|
|
112
|
+
envelope = HttpTransport.request(config: @config, method: 'POST',
|
|
113
|
+
path: UrlGrammar.project_path(slug, 'realtime/replay'), body: body)
|
|
114
|
+
Envelope.new(data: replay_page(envelope.data), meta: envelope.meta)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# [topic, filter or nil]
|
|
120
|
+
def topic_for(types)
|
|
121
|
+
list = types.nil? ? [] : Array(types).map { |t| t.is_a?(Symbol) ? t.to_s : t }
|
|
122
|
+
list.each do |t|
|
|
123
|
+
unless t.is_a?(String) && TYPE_PATTERN.match?(t)
|
|
124
|
+
raise ApprovalChecks.invalid('types must be audit event type names such as "data.write.access"', type: t)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
list = list.uniq
|
|
128
|
+
return ["#{TOPIC}.*", nil] if list.empty?
|
|
129
|
+
return ["#{TOPIC}.#{list.first}", nil] if list.size == 1
|
|
130
|
+
|
|
131
|
+
["#{TOPIC}.*", ->(event) { list.include?(event['type']) }]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The replay answer with its events flattened to the tail's shape (+sequence+ kept).
|
|
135
|
+
def replay_page(data)
|
|
136
|
+
data = {} unless data.is_a?(Hash)
|
|
137
|
+
events = Array(data['events']).map { |e| flatten_replayed(e) }.compact
|
|
138
|
+
{ 'events' => events, 'nextCursor' => data['nextCursor'], 'hasMore' => data['hasMore'] == true }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def flatten_replayed(raw)
|
|
142
|
+
return nil unless raw.is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
payload = raw['payload'].is_a?(Hash) ? raw['payload'] : {}
|
|
145
|
+
return nil unless payload['auditEventId'].is_a?(String)
|
|
146
|
+
|
|
147
|
+
event = {
|
|
148
|
+
'auditEventId' => payload['auditEventId'],
|
|
149
|
+
'type' => payload['type'].is_a?(String) ? payload['type'] : raw['eventType'],
|
|
150
|
+
'actorType' => payload['actorType'],
|
|
151
|
+
'occurredAt' => payload['occurredAt'],
|
|
152
|
+
'outcome' => payload['outcome']
|
|
153
|
+
}
|
|
154
|
+
event['sequence'] = raw['sequence'] if raw.key?('sequence')
|
|
155
|
+
event
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Events after +last_id+ from the replay route, page after page while it says there are more. The route has no
|
|
159
|
+
# page cursor: each next page starts after the last event of the one before.
|
|
160
|
+
def collect_replay(topic, last_id)
|
|
161
|
+
out = []
|
|
162
|
+
cursor = last_id
|
|
163
|
+
loop do
|
|
164
|
+
data = replay(topic: topic, after_audit_event_id: cursor).data
|
|
165
|
+
events = data['events']
|
|
166
|
+
break if events.empty?
|
|
167
|
+
|
|
168
|
+
out.concat(events)
|
|
169
|
+
next_cursor = events.last['auditEventId']
|
|
170
|
+
break unless data['hasMore'] && next_cursor != cursor
|
|
171
|
+
|
|
172
|
+
cursor = next_cursor
|
|
173
|
+
end
|
|
174
|
+
out
|
|
175
|
+
end
|
|
26
176
|
end
|
|
27
177
|
end
|
data/lib/k9o/client.rb
CHANGED
|
@@ -6,7 +6,7 @@ module K9o
|
|
|
6
6
|
include QueryMethods
|
|
7
7
|
|
|
8
8
|
attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :streams, :org,
|
|
9
|
-
:approvals, :approval_policies, :schema, :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage
|
|
9
|
+
:approvals, :approval_policies, :schedules, :vectors, :metadata, :rampart, :custody, :end_users, :schema, :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage, :analytics, :rollups, :keys, :crypto
|
|
10
10
|
|
|
11
11
|
def initialize(config)
|
|
12
12
|
@config = config
|
|
@@ -18,6 +18,12 @@ module K9o
|
|
|
18
18
|
@streams = Streams.new(config)
|
|
19
19
|
@approvals = Approvals.new(config)
|
|
20
20
|
@approval_policies = ApprovalPolicies.new(config)
|
|
21
|
+
@schedules = Schedules.new(config)
|
|
22
|
+
@vectors = Vectors.new(config)
|
|
23
|
+
@metadata = Metadata.new(config)
|
|
24
|
+
@rampart = Rampart.new(config)
|
|
25
|
+
@custody = Custody.new(config)
|
|
26
|
+
@end_users = EndUsers.new(config)
|
|
21
27
|
@schema = Schema.new(config)
|
|
22
28
|
@org = Org.new(config)
|
|
23
29
|
@orgs = Orgs.new(config)
|
|
@@ -29,6 +35,10 @@ module K9o
|
|
|
29
35
|
@kibo = KiboNamespace.new(config)
|
|
30
36
|
@ai = Ai.new(config)
|
|
31
37
|
@storage = Storage.new(config)
|
|
38
|
+
@analytics = Analytics.new(config)
|
|
39
|
+
@rollups = Rollups.new(config)
|
|
40
|
+
@keys = Keys.new(config)
|
|
41
|
+
@crypto = Crypto.new(config)
|
|
32
42
|
end
|
|
33
43
|
|
|
34
44
|
def from(collection)
|
|
@@ -51,12 +61,17 @@ module K9o
|
|
|
51
61
|
# base_url is OPTIONAL. When omitted it defaults to the public-cloud platform host (a convenience only, never used
|
|
52
62
|
# to parse a host). When the host is a platform host (+api.<apex>+ or +api-staging.<apex>+, any apex) and org_slug
|
|
53
63
|
# is set, the org host +<org_slug>.<host>+ is composed. Any other host passes through unchanged.
|
|
54
|
-
|
|
64
|
+
#
|
|
65
|
+
# expected_key_refs is optional: an Array of the key identifiers (from your own cloud configuration) your BYOK or HYOK
|
|
66
|
+
# key may be. It is checked here (see Crypto.check_expected_key_refs) and handed to the client core on every crypto call.
|
|
67
|
+
def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil,
|
|
68
|
+
expected_key_refs: nil)
|
|
69
|
+
refs = Crypto.check_expected_key_refs(expected_key_refs)
|
|
55
70
|
normalised = UrlGrammar.resolve_base_url(base_url, org_slug)
|
|
56
71
|
config = HttpClientConfig.new(
|
|
57
72
|
base_url: normalised,
|
|
58
73
|
api_key: api_key, org_slug: org_slug, project_slug: project_slug,
|
|
59
|
-
default_headers: default_headers, http_client: http_client
|
|
74
|
+
default_headers: default_headers, http_client: http_client, expected_key_refs: refs
|
|
60
75
|
)
|
|
61
76
|
K9oClient.new(config)
|
|
62
77
|
end
|