forest_admin_agent 1.38.2 → 1.39.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/AUDIT_TRAIL.md +347 -0
  3. data/lib/forest_admin_agent/audit_trail/action_capture.rb +99 -0
  4. data/lib/forest_admin_agent/audit_trail/audit_record.rb +16 -0
  5. data/lib/forest_admin_agent/audit_trail/capture.rb +229 -0
  6. data/lib/forest_admin_agent/audit_trail/diff.rb +156 -0
  7. data/lib/forest_admin_agent/audit_trail/record_state.rb +43 -0
  8. data/lib/forest_admin_agent/audit_trail/recording.rb +57 -0
  9. data/lib/forest_admin_agent/audit_trail/snapshots.rb +85 -0
  10. data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +53 -0
  11. data/lib/forest_admin_agent/audit_trail/sql/audit_log.rb +16 -0
  12. data/lib/forest_admin_agent/audit_trail/sql/field_filter.rb +58 -0
  13. data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +53 -0
  14. data/lib/forest_admin_agent/audit_trail/sql/migrator.rb +117 -0
  15. data/lib/forest_admin_agent/audit_trail/sql/text_search.rb +78 -0
  16. data/lib/forest_admin_agent/audit_trail/store.rb +253 -0
  17. data/lib/forest_admin_agent/audit_trail.rb +68 -0
  18. data/lib/forest_admin_agent/builder/agent_factory.rb +20 -0
  19. data/lib/forest_admin_agent/http/correlation_id.rb +37 -0
  20. data/lib/forest_admin_agent/http/correlation_id_middleware.rb +29 -0
  21. data/lib/forest_admin_agent/http/router.rb +6 -0
  22. data/lib/forest_admin_agent/routes/action/actions.rb +76 -1
  23. data/lib/forest_admin_agent/routes/capabilities/collections.rb +11 -1
  24. data/lib/forest_admin_agent/routes/resources/audit_trail.rb +237 -0
  25. data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +101 -0
  26. data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +115 -0
  27. data/lib/forest_admin_agent/routes/resources/list.rb +1 -1
  28. data/lib/forest_admin_agent/routes/resources/related/list_related.rb +1 -1
  29. data/lib/forest_admin_agent/routes/resources/show.rb +1 -1
  30. data/lib/forest_admin_agent/serializer/forest_serializer_override.rb +14 -13
  31. data/lib/forest_admin_agent/utils/caller_parser.rb +4 -0
  32. data/lib/forest_admin_agent/utils/query_string_parser.rb +59 -10
  33. data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
  34. data/lib/forest_admin_agent/version.rb +1 -1
  35. data/lib/forest_admin_agent.rb +4 -0
  36. metadata +23 -2
@@ -0,0 +1,37 @@
1
+ require 'securerandom'
2
+
3
+ module ForestAdminAgent
4
+ module Http
5
+ # Per-request correlation id, generated by the agent. Shared between the caller (so the audit
6
+ # trail can group every change made within one request) and the response header echoed back to
7
+ # the client. Mirrors the Node agent's `context.state.requestId` + `x-forest-correlation-id`
8
+ # header: the agent generates the id, never reads it from the incoming request.
9
+ #
10
+ # Stored thread-locally and generated lazily on first read (e.g. when the caller is parsed). The
11
+ # host resets it at the start of each request so a pooled thread never reuses a previous id.
12
+ module CorrelationId
13
+ HEADER = 'x-forest-correlation-id'.freeze
14
+ KEY = :forest_admin_correlation_id
15
+
16
+ module_function
17
+
18
+ # Lazily generate and memoize the id for the current request/thread.
19
+ def current
20
+ Thread.current[KEY] ||= SecureRandom.uuid
21
+ end
22
+
23
+ # The id if one was generated during this request, otherwise nil (does not generate one).
24
+ def current?
25
+ Thread.current[KEY]
26
+ end
27
+
28
+ def current=(value)
29
+ Thread.current[KEY] = value
30
+ end
31
+
32
+ def reset!
33
+ Thread.current[KEY] = nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ module ForestAdminAgent
2
+ module Http
3
+ # Rack middleware echoing the agent-generated correlation id back to the client, mirroring the
4
+ # Node agent's `correlationIdMiddleware` (`router.use(...)`). Hosts mount it in their middleware
5
+ # stack; CORS exposure of the header is handled by the host's CORS config (see the Rails engine).
6
+ #
7
+ # The id itself is generated lazily by the agent during the request (see CorrelationId, called
8
+ # from CallerParser). This middleware only resets the thread-local around the request — so a
9
+ # pooled thread never reuses a previous id — and sets the response header when one was generated.
10
+ class CorrelationIdMiddleware
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ CorrelationId.reset!
17
+
18
+ status, headers, body = @app.call(env)
19
+
20
+ id = CorrelationId.current?
21
+ headers[CorrelationId::HEADER] = id if id
22
+
23
+ [status, headers, body]
24
+ ensure
25
+ CorrelationId.reset!
26
+ end
27
+ end
28
+ end
29
+ end
@@ -50,6 +50,12 @@ module ForestAdminAgent
50
50
  { name: 'charts', handler: -> { Charts::Charts.new.routes } },
51
51
  { name: 'collections', handler: -> { Capabilities::Collections.new.routes } },
52
52
  { name: 'native_query', handler: -> { Resources::NativeQuery.new.routes } },
53
+ # Both must come before the routes matching on `:collection_name`: Rails matches in
54
+ # definition order, so `/_audit-trail/correlations` would otherwise be read as
55
+ # `/:collection_name/:id` and 404 on a collection named `_audit-trail`. Correlation first, so
56
+ # `/_audit-trail/correlations` wins over the per-record `/_audit-trail/:collection_name/:id`.
57
+ { name: 'audit_trail_correlation', handler: -> { Resources::AuditTrailCorrelation.new.routes } },
58
+ { name: 'audit_trail', handler: -> { Resources::AuditTrail.new.routes } },
53
59
  { name: 'count', handler: -> { Resources::Count.new.routes } },
54
60
  { name: 'delete', handler: -> { Resources::Delete.new.routes } },
55
61
  { name: 'csv', handler: -> { Resources::Csv.new.routes } },
@@ -75,7 +75,7 @@ module ForestAdminAgent
75
75
  fields.reject { |field| field.type == 'Layout' }
76
76
  )
77
77
 
78
- result = context.collection.execute(context.caller, @action_name, data, filter_for_caller)
78
+ result = execute_and_audit(context, args, data, filter_for_caller)
79
79
 
80
80
  { content: ForestAdminAgent::Utils::ActionResult.parse(result) }
81
81
  end
@@ -117,6 +117,81 @@ module ForestAdminAgent
117
117
 
118
118
  private
119
119
 
120
+ # Recorded as pending before the action runs and confirmed after, so an action that takes the process
121
+ # down with it still leaves evidence that it started. A failed run is worth recording too — "who tried
122
+ # to run this" is usually the interesting part — and an action answering with an Error result failed
123
+ # just as much as one that raised, it simply said so through `result_builder.error`.
124
+ def execute_and_audit(context, _args, data, filter)
125
+ pending = audit_pending(context, data, filter)
126
+
127
+ begin
128
+ result = context.collection.execute(context.caller, @action_name, data, filter)
129
+ rescue StandardError
130
+ audit_confirm(pending, failed: true)
131
+ raise
132
+ end
133
+
134
+ audit_confirm(pending, result: result, failed: error_result?(result))
135
+
136
+ result
137
+ end
138
+
139
+ def error_result?(result)
140
+ result.is_a?(Hash) && result[:type] == 'Error'
141
+ end
142
+
143
+ # Everything audit-related sits inside the gate, the record selection included: without an audit
144
+ # database none of it runs at all, and a failure refuses the action only under `critical: true` — which
145
+ # is safe here, since the action has not run yet.
146
+ def audit_pending(context, data, filter)
147
+ store = ForestAdminAgent::AuditTrail.store
148
+ return [] unless store
149
+
150
+ ForestAdminAgent::AuditTrail.gate do
151
+ action_capture(store).pending(
152
+ caller: context.caller,
153
+ collection: context.collection.name,
154
+ action_name: @action_name,
155
+ form_values: data,
156
+ record_ids: audited_record_ids(context, filter)
157
+ )
158
+ end || []
159
+ end
160
+
161
+ def audit_confirm(pending, result: nil, failed: false)
162
+ store = ForestAdminAgent::AuditTrail.store
163
+
164
+ action_capture(store).confirm(pending, result: result, failed: failed) if store && pending.any?
165
+ end
166
+
167
+ def action_capture(store)
168
+ ForestAdminAgent::AuditTrail::ActionCapture.new(store, ForestAdminAgent::AuditTrail.options[:redact])
169
+ end
170
+
171
+ # Packed ids, the form the audit store keys on — read back through the caller's own filter rather than
172
+ # taken from the request. The ids a client sends are a claim: in a compliance record, asserting that an
173
+ # operator acted on a record their scope excludes is worse than a missing row. A global action targets
174
+ # no record, and a selection wider than the cap is recorded as one row attached to none.
175
+ def audited_record_ids(context, filter)
176
+ return [] if context.collection.schema[:actions][@action_name].scope == Types::ActionScope::GLOBAL
177
+
178
+ cap = ForestAdminAgent::AuditTrail::MAX_RECORDS_PER_OPERATION
179
+ primary_keys = ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(context.collection)
180
+ records = context.collection.list(
181
+ context.caller, filter.override(page: Page.new(offset: 0, limit: cap + 1)), Projection.new(primary_keys)
182
+ )
183
+
184
+ return records.map { |record| Utils::Id.pack_id(context.collection, record) } if records.size <= cap
185
+
186
+ # Same rule as a bulk write: recording one unattached row for a run that touched more records than
187
+ # the cap is a partial audit, which `critical` exists to refuse. Nothing has run yet — the gate is
188
+ # ahead of `execute` — so refusing costs nothing to repair.
189
+ ForestAdminAgent::AuditTrail.refuse_over_cap! if ForestAdminAgent::AuditTrail.critical?
190
+
191
+ ForestAdminAgent::AuditTrail.log_truncation(0, nil)
192
+ []
193
+ end
194
+
120
195
  def middleware_custom_action_approval_request_data(args)
121
196
  raise Http::Exceptions::UnprocessableError if args.dig(:params, :data, :attributes, :requester_id)
122
197
 
@@ -67,12 +67,22 @@ module ForestAdminAgent
67
67
  canUseProjectionOnGetOne: true,
68
68
  canUseProjectionViaHeader: true,
69
69
  canUseProjectionViaHeaderOnList: true,
70
- canUseMultipleFieldsProjectionOnRelation: true
70
+ canUseMultipleFieldsProjectionOnRelation: true,
71
+ canUseAuditTrail: audit_trail_enabled?
71
72
  }
72
73
  },
73
74
  status: 200
74
75
  }
75
76
  end
77
+
78
+ private
79
+
80
+ # True only where the store the record-history route reads from exists — the same lookup that route
81
+ # mounts itself on, so the capability cannot drift from what the routes actually serve. The front gates
82
+ # its History tab on this.
83
+ def audit_trail_enabled?
84
+ !::ForestAdminAgent::AuditTrail.store.nil?
85
+ end
76
86
  end
77
87
  end
78
88
  end
@@ -0,0 +1,237 @@
1
+ require 'active_support/time'
2
+
3
+ module ForestAdminAgent
4
+ module Routes
5
+ module Resources
6
+ # Record-history route, mirroring the Node agent's `/_audit-trail/{collection}/:id`.
7
+ #
8
+ # Registered only when `config.audit_trail[:database]` is set, in which case the agent factory
9
+ # built the store the capture layer writes to.
10
+ class AuditTrail < AbstractAuthenticatedRoute
11
+ include ForestAdminAgent::Utils
12
+ include AuditTrailRoute
13
+
14
+ DEFAULT_PAGE_SIZE = 20
15
+ MAX_PAGE_SIZE = 100
16
+ DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
17
+ # Wall-clock datetime, `T` or space separator, seconds optional: `YYYY-MM-DD[T ]HH:mm[:ss]`.
18
+ DATE_TIME = /\A(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?\z/
19
+
20
+ def setup_routes
21
+ return self unless store
22
+
23
+ add_route(
24
+ 'forest_audit_trail',
25
+ 'get',
26
+ '/_audit-trail/:collection_name/:id',
27
+ ->(args) { handle_request(args) }
28
+ )
29
+ add_route(
30
+ 'forest_audit_trail_state',
31
+ 'get',
32
+ '/_audit-trail/:collection_name/:id/state',
33
+ ->(args) { handle_state(args) }
34
+ )
35
+
36
+ self
37
+ end
38
+
39
+ def handle_request(args = {})
40
+ context = build(args)
41
+ context.permissions.can?(:read, context.collection)
42
+ assert_record_in_scope(context, context.collection, args[:params]['id'])
43
+
44
+ skip, limit = parse_pagination(args)
45
+ filters = {
46
+ collection: context.collection.name,
47
+ # args[:params]['id'] is already Forest's packed id, the form the audit store keys on — plus any id
48
+ # this record was filed under before a rename, each bounded by when it stopped being that id.
49
+ record_id: record_segments(context.collection, args[:params]['id']),
50
+ **parse_filters(args)
51
+ }
52
+
53
+ history = store.list_by_record(**filters, skip: skip, limit: limit, order: parse_sort(args))
54
+ # `count` reflects the active filters (not the absolute total) and is independent of the page.
55
+ count = store.count_by_record(**filters)
56
+
57
+ {
58
+ name: args[:params]['collection_name'],
59
+ content: { data: history.map { |record| serialize_record(record) }, meta: meta(args, filters, count) }
60
+ }
61
+ end
62
+
63
+ # Record as it stood at `timestamp`: the current record with every later entry undone. `data` is
64
+ # null when the record did not exist yet (or not any more) at that instant. Shape matches the Node
65
+ # agent's handleStateAt — `data` and nothing else.
66
+ def handle_state(args = {})
67
+ context = build(args)
68
+ context.permissions.can?(:read, context.collection)
69
+ # Authorizes and reads in one query: the record it hands back is the one the scope covered.
70
+ current = scoped_record(
71
+ context, context.collection, args[:params]['id'], audited_projection(context.collection)
72
+ )
73
+
74
+ timestamp = parse_state_timestamp(args)
75
+ entries = store.list_since(
76
+ collection: context.collection.name,
77
+ record_id: record_segments(context.collection, args[:params]['id']),
78
+ timestamp: timestamp
79
+ )
80
+ # Fully qualified: inside this class, `AuditTrail` is the route itself.
81
+ state = ::ForestAdminAgent::AuditTrail::RecordState.at(current, entries)
82
+
83
+ { name: args[:params]['collection_name'], content: { data: state } }
84
+ end
85
+
86
+ private
87
+
88
+ # `availableUsers` rides along on the first fetch only — the front keeps the list it saw — and lists the
89
+ # distinct authors of the entries the current filters match, whatever page was asked for. The identity
90
+ # comes from the rows, so someone since renamed or removed still reads as they were when they acted.
91
+ def meta(args, filters, count)
92
+ return { count: count } unless first_fetch?(args)
93
+
94
+ { count: count, availableUsers: available_users(filters) }
95
+ end
96
+
97
+ def first_fetch?(args)
98
+ page = args.dig(:params, 'page')
99
+
100
+ (page.is_a?(Hash) ? page['number'].to_i : 0) <= 1
101
+ end
102
+
103
+ def available_users(filters)
104
+ store.authors_by_record(**filters).map do |author|
105
+ { id: author[:user_id], firstName: author[:user_first_name],
106
+ lastName: author[:user_last_name], email: author[:user_email] }
107
+ end
108
+ end
109
+
110
+ # An ISO-8601 instant, or the same wall-clock forms the history filters accept, read in the request
111
+ # timezone.
112
+ def parse_state_timestamp(args)
113
+ raw = args.dig(:params, 'timestamp').to_s
114
+ raise Http::Exceptions::ValidationError, 'Missing timestamp' if raw.empty?
115
+ # A wall-clock value carries no offset, so it belongs to the request timezone. Handing it to
116
+ # Time.iso8601 would read it in the server's instead — silently, since it parses just fine.
117
+ return parse_date_boundary(raw, request_timezone(args), :start) if wall_clock?(raw)
118
+
119
+ begin
120
+ Time.iso8601(raw).utc.iso8601(3)
121
+ rescue ArgumentError
122
+ parse_date_boundary(raw, request_timezone(args), :start)
123
+ end
124
+ end
125
+
126
+ def wall_clock?(raw)
127
+ DATE_ONLY.match?(raw) || DATE_TIME.match?(raw)
128
+ end
129
+
130
+ # JSON:API `sort`: `timestamp` → oldest first, anything else (absent/unsupported) → newest first.
131
+ def parse_sort(args)
132
+ args.dig(:params, 'sort').to_s == 'timestamp' ? 'asc' : 'desc'
133
+ end
134
+
135
+ # JSON:API pagination: 1-based page[number] (default 1) and page[size] (default 20, capped at
136
+ # 100). Out-of-bound or non-numeric values fall back to the defaults rather than erroring.
137
+ def parse_pagination(args)
138
+ # `?page=foo` reaches us as a bare String, which `dig` refuses to walk into.
139
+ page = args.dig(:params, 'page')
140
+ page = {} unless page.is_a?(Hash)
141
+
142
+ size = page['size'].to_i
143
+ size = DEFAULT_PAGE_SIZE if size < 1
144
+ size = MAX_PAGE_SIZE if size > MAX_PAGE_SIZE
145
+
146
+ number = page['number'].to_i
147
+ number = 1 if number < 1
148
+
149
+ [(number - 1) * size, size]
150
+ end
151
+
152
+ def request_timezone(args)
153
+ timezone = args.dig(:params, 'timezone').to_s
154
+
155
+ timezone.empty? ? 'UTC' : timezone
156
+ end
157
+
158
+ def parse_filters(args)
159
+ timezone = request_timezone(args)
160
+
161
+ {
162
+ user_ids: parse_user_ids(args.dig(:params, 'userIds')),
163
+ fields: parse_fields(args.dig(:params, 'fields')),
164
+ search: parse_search(args.dig(:params, 'search')),
165
+ start_timestamp: parse_date_boundary(args.dig(:params, 'startDate'), timezone, :start),
166
+ end_timestamp: parse_date_boundary(args.dig(:params, 'endDate'), timezone, :end)
167
+ }.compact
168
+ end
169
+
170
+ # Free text, trimmed; blank means no filter rather than a term that matches everything.
171
+ def parse_search(raw)
172
+ term = raw.to_s.strip
173
+
174
+ term.empty? ? nil : term
175
+ end
176
+
177
+ # Comma-separated field names, kept verbatim (a name may hold a dot). Empty after parsing → no filter.
178
+ def parse_fields(raw)
179
+ return nil if raw.nil?
180
+
181
+ names = (raw.is_a?(Array) ? raw : raw.to_s.split(',')).map { |name| name.to_s.strip }.reject(&:empty?)
182
+ names.empty? ? nil : names
183
+ end
184
+
185
+ # Comma-separated integer ids; non-numeric tokens are dropped. Empty after parsing → no filter.
186
+ def parse_user_ids(raw)
187
+ return nil if raw.nil? || raw.to_s.empty?
188
+
189
+ ids = raw.to_s.split(',').map(&:strip).grep(/\A\d+\z/).map(&:to_i)
190
+ ids.empty? ? nil : ids
191
+ end
192
+
193
+ # `startDate`/`endDate` accept a bare day (`YYYY-MM-DD`) or a wall-clock datetime
194
+ # (`YYYY-MM-DD[T ]HH:mm[:ss]`), read as local time in the request timezone and returned as a UTC
195
+ # ISO instant the store can compare against stored timestamps.
196
+ def parse_date_boundary(raw, timezone, boundary)
197
+ return nil if raw.nil? || raw.to_s.empty?
198
+
199
+ zone = Time.find_zone(timezone)
200
+ raise Http::Exceptions::ValidationError, "Invalid timezone: \"#{timezone}\"" if zone.nil?
201
+
202
+ instant = begin
203
+ local_instant(zone, raw.to_s, boundary)
204
+ rescue ArgumentError
205
+ nil
206
+ end
207
+
208
+ if instant.nil?
209
+ raise Http::Exceptions::ValidationError,
210
+ "Invalid date: \"#{raw}\" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)"
211
+ end
212
+
213
+ instant.utc.iso8601(3)
214
+ end
215
+
216
+ def local_instant(zone, raw, boundary)
217
+ if DATE_ONLY.match?(raw)
218
+ day = zone.parse(raw)
219
+ # Bare day → start (00:00:00.000) or end (23:59:59.999) of that local day.
220
+ boundary == :end ? day.end_of_day : day.beginning_of_day
221
+ elsif (match = DATE_TIME.match(raw))
222
+ date, hours, minutes, seconds = match.captures
223
+ base = zone.parse("#{date}T#{hours}:#{minutes}")
224
+ if seconds
225
+ base.change(sec: seconds.to_i, usec: 0)
226
+ elsif boundary == :end
227
+ # Minutes-only end boundary stays inclusive to :59.999; start stays at :00.000.
228
+ base.change(sec: 59, usec: 999_000)
229
+ else
230
+ base
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,101 @@
1
+ module ForestAdminAgent
2
+ module Routes
3
+ module Resources
4
+ # Correlation-scoped record-history routes, mirroring the Node agent's
5
+ # `/_audit-trail/correlation/:key` and `/_audit-trail/correlations`. Registered only when
6
+ # `config.audit_trail[:database]` is set. All three routes are scoped to a single record through the
7
+ # `collection`/`recordId` query (GET) or body (POST) params and share the per-record auth.
8
+ class AuditTrailCorrelation < AbstractAuthenticatedRoute
9
+ include AuditTrailRoute
10
+
11
+ def setup_routes
12
+ return self unless store
13
+
14
+ add_route(
15
+ 'forest_audit_trail_correlation',
16
+ 'get',
17
+ '/_audit-trail/correlation/:correlation_key',
18
+ ->(args) { handle_history(args) }
19
+ )
20
+ # GET carries the keys in `correlationKeys`; POST accepts a body list to dodge URL limits.
21
+ add_route(
22
+ 'forest_audit_trail_correlations',
23
+ 'get',
24
+ '/_audit-trail/correlations',
25
+ ->(args) { handle_batch(args) }
26
+ )
27
+ add_route(
28
+ 'forest_audit_trail_correlations_batch',
29
+ 'post',
30
+ '/_audit-trail/correlations',
31
+ ->(args) { handle_batch(args) }
32
+ )
33
+
34
+ self
35
+ end
36
+
37
+ def handle_history(args = {})
38
+ collection, record_id = assert_scope(args)
39
+
40
+ history = store.list_by_correlation(
41
+ collection: collection.name,
42
+ record_id: record_id,
43
+ correlation_key: args[:params]['correlation_key']
44
+ )
45
+
46
+ { name: collection.name, content: { data: history.map { |record| serialize_record(record) } } }
47
+ end
48
+
49
+ def handle_batch(args = {})
50
+ collection, record_id = assert_scope(args)
51
+ correlation_keys = parse_correlation_keys(args)
52
+
53
+ history = if correlation_keys.empty?
54
+ []
55
+ else
56
+ store.list_by_correlations(
57
+ collection: collection.name,
58
+ record_id: record_id,
59
+ correlation_keys: correlation_keys
60
+ )
61
+ end
62
+
63
+ { name: collection.name, content: { data: history.map { |record| serialize_record(record) } } }
64
+ end
65
+
66
+ private
67
+
68
+ def assert_scope(args)
69
+ context = build(args)
70
+ name = args.dig(:params, 'collection').to_s
71
+ record_id = args.dig(:params, 'recordId').to_s
72
+
73
+ raise Http::Exceptions::ValidationError, 'Missing collection' if name.empty?
74
+ raise Http::Exceptions::ValidationError, 'Missing recordId' if record_id.empty?
75
+
76
+ collection = get_collection(context, name)
77
+ context.permissions.can?(:read, collection)
78
+ assert_record_in_scope(context, collection, record_id)
79
+
80
+ [collection, record_id]
81
+ end
82
+
83
+ def get_collection(context, name)
84
+ context.datasource.get_collection(name)
85
+ rescue ForestAdminDatasourceToolkit::Exceptions::ForestException => e
86
+ raise Http::Exceptions::NotFoundError, e.message if e.message.include?('not found')
87
+
88
+ raise
89
+ end
90
+
91
+ # Body array (POST) takes precedence, otherwise the comma-separated query param (GET).
92
+ def parse_correlation_keys(args)
93
+ raw = args.dig(:params, 'correlationKeys')
94
+ keys = raw.is_a?(Array) ? raw : raw.to_s.split(',')
95
+
96
+ keys.map { |key| key.to_s.strip }.reject(&:empty?)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,115 @@
1
+ module ForestAdminAgent
2
+ module Routes
3
+ module Resources
4
+ # Behaviour shared by every audit-trail route: they all take a packed record id straight from the
5
+ # request, so the caller's permission scope has to be checked against that record before any
6
+ # history is returned (`can?(:read, collection)` alone only proves access to the collection — a
7
+ # role restricted to a subset of the records would otherwise read the history of any of them),
8
+ # and they all serialize audit records the same way.
9
+ module AuditTrailRoute
10
+ include ForestAdminDatasourceToolkit::Components::Query
11
+
12
+ def assert_record_in_scope(context, collection, packed_id)
13
+ scoped_record(context, collection, packed_id)
14
+
15
+ nil
16
+ end
17
+
18
+ # The record as it stands, read through the caller's permission scope. nil when it no longer exists
19
+ # — a deleted record keeps its history readable, which is much of the point of an audit trail — and
20
+ # a 404 when it does exist outside that scope.
21
+ #
22
+ # Authorizing and reading are the same query on purpose: a scoped check followed by an unscoped read
23
+ # would hand back a row the check never covered, the moment the two drifted apart.
24
+ def scoped_record(context, collection, packed_id, projection = nil)
25
+ condition = ConditionTree::ConditionTreeFactory.match_records(
26
+ collection, [Utils::Id.unpack_id(collection, packed_id, with_key: true)]
27
+ )
28
+ scope = context.permissions.get_scope(collection)
29
+ in_scope = ConditionTree::ConditionTreeFactory.intersect([condition, scope])
30
+ record = first_record(context, collection, in_scope, projection || key_projection(collection))
31
+
32
+ return record if record
33
+ # Nothing in scope: either gone for good, or someone else's record. Without a scope the query
34
+ # above already answered the question.
35
+ return nil if scope.nil? || first_record(context, collection, condition, key_projection(collection)).nil?
36
+
37
+ raise Http::Exceptions::NotFoundError, 'Record does not exists'
38
+ end
39
+
40
+ # Camelize only the top-level keys — the row `id` included, which the front uses as the tiebreaker when
41
+ # merging pages ordered by (timestamp, id). Value hashes keep the keys they were stored with: a record's
42
+ # own column names, or an action answer's camelCase Forest names.
43
+ def serialize_record(record)
44
+ # `previous_record_id` stays out: it is how the agent follows a record across a rename, not something
45
+ # the payload contract carries.
46
+ record.to_h.except(:previous_record_id).transform_keys { |key| key.to_s.camelize(:lower) }
47
+ end
48
+
49
+ def store
50
+ ::ForestAdminAgent::AuditTrail.store
51
+ end
52
+
53
+ # Every id this record has been filed under, each with the moment it stopped being that id. Rows
54
+ # written before an update moved a writable primary key stay under the id they were true of, so a
55
+ # history query that asked for the current id alone would start at the rename and call that the whole
56
+ # story — and one that asked for the bare ids would sweep up whatever record holds an abandoned id now.
57
+ #
58
+ # Breadth-first, and no depth limit: every hop adds an id not already seen and there are finitely many
59
+ # of those, so skipping what we hold is both the cycle guard and the terminator. A cap would have
60
+ # truncated a record renamed often enough, which reads exactly like missing history.
61
+ def record_segments(collection, packed_id)
62
+ segments = [{ id: packed_id, until: nil, until_row: nil }]
63
+ queue = segments.dup
64
+
65
+ until queue.empty?
66
+ segment = queue.shift
67
+
68
+ store.renamed_from(collection: collection.name, record_id: segment[:id]).each do |previous|
69
+ next if segments.any? { |seen| seen[:id] == previous[:id] }
70
+
71
+ # Bounded by its own rename and by everything walked through to reach it: an id abandoned twice
72
+ # only belongs to this record up to the earlier of them.
73
+ found = earlier_bound(previous, segment).merge(id: previous[:id])
74
+ segments << found
75
+ queue << found
76
+ end
77
+ end
78
+
79
+ segments
80
+ end
81
+
82
+ # Bounds compare as the trail orders, (timestamp, row id); nil is "no bound yet", which is later than
83
+ # any of them. Through `<=>`, since Array is not Comparable and `<=` on one raises.
84
+ def earlier_bound(one, other)
85
+ return other.slice(:until, :until_row) if one[:until].nil?
86
+ return one.slice(:until, :until_row) if other[:until].nil?
87
+
88
+ pair = ->(bound) { [bound[:until], bound[:until_row].to_i] }
89
+ earlier = (pair[one] <=> pair[other]) <= 0 ? one : other
90
+
91
+ earlier.slice(:until, :until_row)
92
+ end
93
+
94
+ # What the audit trail actually records: primary keys, so a state can be identified, plus the writable
95
+ # columns. Reading read-only ones would hand them back at their present value inside an answer that
96
+ # claims to describe a past instant.
97
+ def audited_projection(collection)
98
+ writable = collection.schema[:fields].select do |_name, field|
99
+ field.type == 'Column' && !field.is_read_only
100
+ end.keys
101
+
102
+ Projection.new((ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection) + writable).uniq)
103
+ end
104
+
105
+ def key_projection(collection)
106
+ Projection.new(ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection))
107
+ end
108
+
109
+ def first_record(context, collection, condition_tree, projection)
110
+ collection.list(context.caller, Filter.new(condition_tree: condition_tree), projection).first
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -43,7 +43,7 @@ module ForestAdminAgent
43
43
  class_name: context.collection.name,
44
44
  is_collection: true,
45
45
  serializer: Serializer::ForestSerializer,
46
- include: projection.relations(only_keys: true),
46
+ include: projection.relation_include_paths,
47
47
  meta: handle_search_decorator(args[:params]['search'], records, context.collection)
48
48
  )
49
49
  }
@@ -53,7 +53,7 @@ module ForestAdminAgent
53
53
  is_collection: true,
54
54
  class_name: context.child_collection.name,
55
55
  serializer: Serializer::ForestSerializer,
56
- include: projection.relations.keys
56
+ include: projection.relation_include_paths
57
57
  )
58
58
  }
59
59
  end
@@ -38,7 +38,7 @@ module ForestAdminAgent
38
38
  class_name: context.collection.name,
39
39
  is_collection: false,
40
40
  serializer: Serializer::ForestSerializer,
41
- include: projection.relations(only_keys: true)
41
+ include: projection.relation_include_paths
42
42
  )
43
43
  }
44
44
  end