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.
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
- def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil)
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