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.
- checksums.yaml +4 -4
- data/AUDIT_TRAIL.md +347 -0
- data/lib/forest_admin_agent/audit_trail/action_capture.rb +99 -0
- data/lib/forest_admin_agent/audit_trail/audit_record.rb +16 -0
- data/lib/forest_admin_agent/audit_trail/capture.rb +229 -0
- data/lib/forest_admin_agent/audit_trail/diff.rb +156 -0
- data/lib/forest_admin_agent/audit_trail/record_state.rb +43 -0
- data/lib/forest_admin_agent/audit_trail/recording.rb +57 -0
- data/lib/forest_admin_agent/audit_trail/snapshots.rb +85 -0
- data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +53 -0
- data/lib/forest_admin_agent/audit_trail/sql/audit_log.rb +16 -0
- data/lib/forest_admin_agent/audit_trail/sql/field_filter.rb +58 -0
- data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +53 -0
- data/lib/forest_admin_agent/audit_trail/sql/migrator.rb +117 -0
- data/lib/forest_admin_agent/audit_trail/sql/text_search.rb +78 -0
- data/lib/forest_admin_agent/audit_trail/store.rb +253 -0
- data/lib/forest_admin_agent/audit_trail.rb +68 -0
- data/lib/forest_admin_agent/builder/agent_factory.rb +20 -0
- data/lib/forest_admin_agent/http/correlation_id.rb +37 -0
- data/lib/forest_admin_agent/http/correlation_id_middleware.rb +29 -0
- data/lib/forest_admin_agent/http/router.rb +6 -0
- data/lib/forest_admin_agent/routes/action/actions.rb +76 -1
- data/lib/forest_admin_agent/routes/capabilities/collections.rb +11 -1
- data/lib/forest_admin_agent/routes/resources/audit_trail.rb +237 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +101 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +115 -0
- data/lib/forest_admin_agent/routes/resources/list.rb +1 -1
- data/lib/forest_admin_agent/routes/resources/related/list_related.rb +1 -1
- data/lib/forest_admin_agent/routes/resources/show.rb +1 -1
- data/lib/forest_admin_agent/serializer/forest_serializer_override.rb +14 -13
- data/lib/forest_admin_agent/utils/caller_parser.rb +4 -0
- data/lib/forest_admin_agent/utils/query_string_parser.rb +59 -10
- data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
- data/lib/forest_admin_agent/version.rb +1 -1
- data/lib/forest_admin_agent.rb +4 -0
- 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 =
|
|
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.
|
|
46
|
+
include: projection.relation_include_paths,
|
|
47
47
|
meta: handle_search_decorator(args[:params]['search'], records, context.collection)
|
|
48
48
|
)
|
|
49
49
|
}
|