forest_admin_agent 1.45.1 → 1.46.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/AUDIT_TRAIL.md +55 -26
  3. data/forest_admin_agent.gemspec +2 -0
  4. data/lib/forest_admin_agent/audit_trail/audit_record.rb +1 -1
  5. data/lib/forest_admin_agent/audit_trail/capture.rb +1 -7
  6. data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +1 -5
  7. data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +23 -6
  8. data/lib/forest_admin_agent/audit_trail/store.rb +79 -65
  9. data/lib/forest_admin_agent/auth/oidc_client_manager.rb +1 -2
  10. data/lib/forest_admin_agent/http/router.rb +4 -2
  11. data/lib/forest_admin_agent/routes/action/actions.rb +13 -4
  12. data/lib/forest_admin_agent/routes/capabilities/collections.rb +4 -0
  13. data/lib/forest_admin_agent/routes/resources/audit_trail.rb +25 -118
  14. data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +1 -3
  15. data/lib/forest_admin_agent/routes/resources/audit_trail_query_params.rb +131 -0
  16. data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +16 -39
  17. data/lib/forest_admin_agent/routes/resources/audit_trail_timeline.rb +96 -0
  18. data/lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb +23 -9
  19. data/lib/forest_admin_agent/routes/resources/list.rb +2 -1
  20. data/lib/forest_admin_agent/routes/security/scope_invalidation.rb +2 -1
  21. data/lib/forest_admin_agent/serializer/forest_serializer.rb +51 -57
  22. data/lib/forest_admin_agent/serializer/forest_serializer_override.rb +1 -4
  23. data/lib/forest_admin_agent/services/ip_whitelist.rb +6 -8
  24. data/lib/forest_admin_agent/services/permissions.rb +81 -41
  25. data/lib/forest_admin_agent/services/sse_cache_invalidation.rb +19 -1
  26. data/lib/forest_admin_agent/utils/safe_file_cache.rb +52 -0
  27. data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
  28. data/lib/forest_admin_agent/version.rb +1 -1
  29. data/lib/forest_admin_agent.rb +2 -2
  30. metadata +33 -2
@@ -5,9 +5,8 @@ module ForestAdminAgent
5
5
  class ForestSerializer
6
6
  include JSONAPI::Serializer
7
7
 
8
- attr_accessor :attributes_map
9
- attr_accessor :to_one_associations
10
- attr_accessor :to_many_associations
8
+ TO_ONE_TYPES = %w[OneToOne ManyToOne PolymorphicManyToOne PolymorphicOneToOne].freeze
9
+ TO_MANY_TYPES = %w[OneToMany ManyToMany PolymorphicOneToMany].freeze
11
10
 
12
11
  JSONAPI::Serializer.send(:include, ForestSerializerOverride)
13
12
 
@@ -26,6 +25,50 @@ module ForestAdminAgent
26
25
 
27
26
  def self.reset_cache!
28
27
  @@primary_keys = {}
28
+ @@layouts = {}.compare_by_identity
29
+ end
30
+
31
+ # Keyed by the collection object, not its name: two datasources can expose a collection under
32
+ # the same name, and the decorator stack returns the same object for a name once built.
33
+ def self.layout_for(collection)
34
+ @@layouts ||= {}.compare_by_identity
35
+ @@layouts[collection] ||= build_layout(collection)
36
+ end
37
+
38
+ def self.build_layout(collection)
39
+ fields = collection.schema[:fields]
40
+ relation = lambda do |types|
41
+ fields.each_with_object({}) do |(name, field), layout|
42
+ next unless types.include?(field.type)
43
+
44
+ layout[name] = { attr_or_block: name, options: { include_links: true, include_data: false }.freeze }.freeze
45
+ end.freeze
46
+ end
47
+
48
+ {
49
+ fields: fields,
50
+ attributes: fields.each_with_object({}) do |(name, field), layout|
51
+ layout[name] = { attr_or_block: name, options: {}.freeze }.freeze if field.type == 'Column'
52
+ end.freeze,
53
+ to_one: relation.call(TO_ONE_TYPES),
54
+ to_many: relation.call(TO_MANY_TYPES)
55
+ }.freeze
56
+ end
57
+
58
+ def layout
59
+ @layout ||= self.class.layout_for(ForestAdminAgent::Facades::Container.datasource.get_collection(type))
60
+ end
61
+
62
+ def attributes_map
63
+ layout[:attributes]
64
+ end
65
+
66
+ def to_one_associations
67
+ layout[:to_one]
68
+ end
69
+
70
+ def to_many_associations
71
+ layout[:to_many]
29
72
  end
30
73
 
31
74
  def id
@@ -40,25 +83,7 @@ module ForestAdminAgent
40
83
  attribute_name.to_s
41
84
  end
42
85
 
43
- def add_attribute(name, options = {}, &block)
44
- @attributes_map ||= {}
45
- @attributes_map[name] = format_field(name, options)
46
- end
47
-
48
- def format_field(name, options)
49
- {
50
- attr_or_block: block_given? ? block : name,
51
- options: options,
52
- }
53
- end
54
-
55
86
  def attributes
56
- forest_collection = ForestAdminAgent::Facades::Container.datasource.get_collection(
57
- @options[:class_name].gsub('::', '__')
58
- )
59
- fields = forest_collection.schema[:fields].select { |_field_name, field| field.type == 'Column' }
60
- fields.each { |field_name, _field| add_attribute(field_name) }
61
- return {} if attributes_map.nil?
62
87
  attributes = {}
63
88
 
64
89
  attributes_map.each do |attribute_name, attr_data|
@@ -86,22 +111,13 @@ module ForestAdminAgent
86
111
  end
87
112
  end
88
113
 
89
- def add_to_one_association(name, options = {}, &block)
90
- options[:include_links] = options.fetch(:include_links, true)
91
- options[:include_data] = options.fetch(:include_data, false)
92
- @to_one_associations ||= {}
93
- @to_one_associations[name] = format_field(name, options)
94
- end
95
-
96
114
  # A PolymorphicManyToOne can't be SQL-joined, so list projections return a phantom object with no
97
115
  # primary key. Rebuild the id from the foreign key on the owner record.
98
116
  def has_one_relationship(attribute_name, attr_data)
99
117
  object = super
100
118
  return object if object.nil? # relation not projected (e.g. update/store responses)
101
119
 
102
- field = ForestAdminAgent::Facades::Container.datasource
103
- .get_collection(@options[:class_name].gsub('::', '__'))
104
- .schema[:fields][attribute_name.to_s]
120
+ field = layout[:fields][attribute_name.to_s]
105
121
  return object unless field&.type == 'PolymorphicManyToOne'
106
122
 
107
123
  foreign_key = @object[field.foreign_key]
@@ -115,9 +131,8 @@ module ForestAdminAgent
115
131
  end
116
132
 
117
133
  def has_one_relationships
118
- return {} if @to_one_associations.nil?
119
134
  data = {}
120
- @to_one_associations.each do |attribute_name, attr_data|
135
+ to_one_associations.each do |attribute_name, attr_data|
121
136
  next if !should_include_attr?(attribute_name, attr_data)
122
137
  data[attribute_name.to_sym] = attr_data
123
138
  end
@@ -125,34 +140,16 @@ module ForestAdminAgent
125
140
  end
126
141
 
127
142
  def has_many_relationships
128
- return {} if @to_many_associations.nil?
129
143
  data = {}
130
- @to_many_associations.each do |attribute_name, attr_data|
144
+ to_many_associations.each do |attribute_name, attr_data|
131
145
  next if !should_include_attr?(attribute_name, attr_data)
132
146
  data[attribute_name.to_sym] = attr_data
133
147
  end
134
148
  data
135
149
  end
136
150
 
137
- def add_to_many_association(name, options = {}, &block)
138
- options[:include_links] = options.fetch(:include_links, true)
139
- options[:include_data] = options.fetch(:include_data, false)
140
- @to_many_associations ||= {}
141
- @to_many_associations[name] = format_field(name, options)
142
- end
143
-
144
151
  def relationships
145
152
  datasource = ForestAdminAgent::Facades::Container.datasource
146
- forest_collection = datasource.get_collection(@options[:class_name].gsub('::', '__'))
147
- relations_to_many = forest_collection.schema[:fields].select do |_field_name, field|
148
- %w[OneToMany ManyToMany PolymorphicOneToMany].include?(field.type)
149
- end
150
- relations_to_one = forest_collection.schema[:fields].select do |_field_name, field|
151
- %w[OneToOne ManyToOne PolymorphicManyToOne PolymorphicOneToOne].include?(field.type)
152
- end
153
-
154
- relations_to_one.each { |field_name, _field| add_to_one_association(field_name) }
155
-
156
153
  data = {}
157
154
  has_one_relationships.each do |attribute_name, attr_data|
158
155
  formatted_attribute_name = format_name(attribute_name)
@@ -170,8 +167,7 @@ module ForestAdminAgent
170
167
  # Only include 'data' key if the relationship object exists
171
168
  # Omit 'data' key entirely when null instead of setting it to null
172
169
  unless object.nil? || (object.respond_to?(:empty?) && object.empty?)
173
- relation = datasource.get_collection(@options[:class_name].gsub('::', '__'))
174
- .schema[:fields][attribute_name.to_s]
170
+ relation = layout[:fields][attribute_name.to_s]
175
171
  options = @options.clone
176
172
  if relation.type == 'PolymorphicManyToOne'
177
173
  options[:class_name] = @object[relation.foreign_key_type_field]
@@ -191,8 +187,6 @@ module ForestAdminAgent
191
187
  end
192
188
  end
193
189
 
194
- relations_to_many.each { |field_name, _field| add_to_many_association(field_name) }
195
-
196
190
  has_many_relationships.each do |attribute_name, attr_data|
197
191
  formatted_attribute_name = format_name(attribute_name)
198
192
  data[formatted_attribute_name] = {}
@@ -208,7 +202,7 @@ module ForestAdminAgent
208
202
  if @_include_linkages.include?(formatted_attribute_name) || attr_data[:options][:include_data]
209
203
  data[formatted_attribute_name]['data'] = []
210
204
  objects = has_many_relationship(attribute_name, attr_data) || []
211
- relation = datasource.get_collection(@options[:class_name].gsub('::', '__')).schema[:fields][attribute_name.to_s]
205
+ relation = layout[:fields][attribute_name.to_s]
212
206
  options = @options.clone
213
207
  options[:class_name] = datasource.get_collection(relation.foreign_collection).name
214
208
  objects.each do |obj|
@@ -17,7 +17,6 @@ module ForestAdminAgent
17
17
  next if attribute_name == :_include
18
18
 
19
19
  serializer = JSONAPI::Serializer.find_serializer(root_object, options)
20
- serializer.relationships
21
20
  unformatted_attr_name = serializer.unformat_name(attribute_name).to_sym
22
21
 
23
22
  # We know the name of this relationship, but we don't know where it is stored internally.
@@ -55,9 +54,7 @@ module ForestAdminAgent
55
54
  # in the response.
56
55
  objects = is_collection ? object : [object]
57
56
 
58
- relation = ForestAdminAgent::Facades::Container.datasource
59
- .get_collection(options[:class_name].gsub('::', '__'))
60
- .schema[:fields][attribute_name]
57
+ relation = serializer.layout[:fields][attribute_name]
61
58
  if relation.type == 'PolymorphicManyToOne'
62
59
  relation_class_name = root_object[relation.foreign_key_type_field]
63
60
  else
@@ -1,5 +1,4 @@
1
1
  require 'ipaddress'
2
- require 'filecache'
3
2
  require 'json'
4
3
 
5
4
  module ForestAdminAgent
@@ -14,7 +13,11 @@ module ForestAdminAgent
14
13
 
15
14
  def initialize
16
15
  @forest_api = ForestAdminAgent::Http::ForestAdminApiRequester.new
17
- @cache = FileCache.new(
16
+ @cache = self.class.build_cache
17
+ end
18
+
19
+ def self.build_cache
20
+ ForestAdminAgent::Utils::SafeFileCache.new(
18
21
  'ip_whitelist',
19
22
  Facades::Container.config_from_cache[:cache_dir].to_s,
20
23
  Facades::Container.config_from_cache[:permission_expiration]
@@ -22,12 +25,7 @@ module ForestAdminAgent
22
25
  end
23
26
 
24
27
  def self.invalidate_cache
25
- cache = FileCache.new(
26
- 'ip_whitelist',
27
- Facades::Container.config_from_cache[:cache_dir].to_s,
28
- Facades::Container.config_from_cache[:permission_expiration]
29
- )
30
- cache.delete(CACHE_KEY) unless cache.get(CACHE_KEY).nil?
28
+ build_cache.delete(CACHE_KEY)
31
29
  end
32
30
 
33
31
  def use_ip_whitelist
@@ -1,4 +1,3 @@
1
- require 'filecache'
2
1
  require 'deepsort'
3
2
 
4
3
  module ForestAdminAgent
@@ -10,6 +9,9 @@ module ForestAdminAgent
10
9
  include ForestAdminDatasourceToolkit::Components::Query::ConditionTree
11
10
 
12
11
  USERS_RELOAD_INTERVAL_IN_SECONDS = 60
12
+ # Renderings live in their own domain because FileCache cannot delete by key prefix: clearing
13
+ # the domain is the only way to drop every rendering when the server does not say which one.
14
+ RENDERINGS_CACHE_DOMAIN = 'permissions-renderings'.freeze
13
15
 
14
16
  @users_reload_mutex = Mutex.new
15
17
 
@@ -18,30 +20,49 @@ module ForestAdminAgent
18
20
  def initialize(caller)
19
21
  @caller = caller
20
22
  @forest_api = ForestAdminAgent::Http::ForestAdminApiRequester.new
21
- @cache = FileCache.new(
22
- 'permissions',
23
+ @cache = self.class.build_cache('permissions')
24
+ @memo = {}
25
+ end
26
+
27
+ def rendering_cache
28
+ @rendering_cache ||= self.class.build_cache(RENDERINGS_CACHE_DOMAIN)
29
+ end
30
+
31
+ def self.build_cache(domain)
32
+ ForestAdminAgent::Utils::SafeFileCache.new(
33
+ domain,
23
34
  Facades::Container.config_from_cache[:cache_dir].to_s,
24
35
  Facades::Container.config_from_cache[:permission_expiration]
25
36
  )
26
37
  end
27
38
 
28
39
  def self.invalidate_cache(id_cache = nil)
29
- cache = FileCache.new(
30
- 'permissions',
31
- Facades::Container.config_from_cache[:cache_dir].to_s,
32
- Facades::Container.config_from_cache[:permission_expiration]
33
- )
40
+ cache = build_cache('permissions')
34
41
 
35
42
  if id_cache.nil?
36
43
  cache.clear
44
+ build_cache(RENDERINGS_CACHE_DOMAIN).clear
37
45
  @users_reload_mutex.synchronize { @users_reloaded_at = nil }
38
46
  end
39
47
 
40
- cache.delete(id_cache) unless cache.get(id_cache).nil?
48
+ cache.delete(id_cache)
41
49
 
42
50
  ForestAdminAgent::Facades::Container.logger.log('Info', "Invalidating #{id_cache} cache..")
43
51
  end
44
52
 
53
+ def self.invalidate_rendering_cache(rendering_id = nil)
54
+ cache = build_cache(RENDERINGS_CACHE_DOMAIN)
55
+
56
+ if rendering_id.nil?
57
+ cache.clear
58
+ else
59
+ cache.delete(rendering_id.to_s)
60
+ end
61
+
62
+ target = rendering_id.nil? ? 'every rendering' : "rendering #{rendering_id}"
63
+ ForestAdminAgent::Facades::Container.logger.log('Info', "Invalidating #{target} permissions cache..")
64
+ end
65
+
45
66
  def self.users_reload_allowed?
46
67
  @users_reload_mutex.synchronize do
47
68
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -71,6 +92,20 @@ module ForestAdminAgent
71
92
  is_allowed
72
93
  end
73
94
 
95
+ # The collections the caller may perform +action+ on, without raising. A sweep over every collection
96
+ # meets a denial for most roles, so a denial is retried against fresh permissions only when
97
+ # {#refetch_denied_reads?} says staleness could explain it, and then once for all of them together.
98
+ def allowed_collections(action, collections)
99
+ return collections unless permission_system?
100
+
101
+ allowed = select_allowed(action, collections, reload: false)
102
+ return allowed if allowed.size == collections.size
103
+ return allowed unless refetch_denied_reads?(collections.map(&:name), get_collections_permissions_data)
104
+
105
+ @read_permissions_refetched = true
106
+ select_allowed(action, collections, reload: true)
107
+ end
108
+
74
109
  # +root_collection_name+ is pinned to readable and never looked up: +browse+ already gates a
75
110
  # listing, +read+ a get, and the signed hash a chart.
76
111
  #
@@ -210,20 +245,19 @@ module ForestAdminAgent
210
245
  is_allowed
211
246
  end
212
247
 
213
- def can_smart_action?(request, collection, filter, allow_fetch: true)
248
+ def can_smart_action?(request, collection, filter, action_name:, allow_fetch: true)
214
249
  return true unless permission_system?
215
250
 
216
251
  user_data = get_user_data(caller.id)
217
252
  collections_data = get_collections_permissions_data(force_fetch: allow_fetch)
218
- action = find_action_from_endpoint(collection.name, request[:headers]['REQUEST_PATH'], request[:headers]['REQUEST_METHOD'])
219
253
 
220
- collection_actions = validate_smart_action_permissions(collections_data, collection, action)
254
+ collection_actions = validate_smart_action_permissions(collections_data, collection, action_name)
221
255
 
222
256
  smart_action_approval = SmartActionChecker.new(
223
257
  request[:params],
224
258
  collection,
225
259
  # The schema scope lets the checker skip select-all resolution for global actions.
226
- collection_actions[action['name'].to_sym].merge(scope: collection.schema[:actions][action['name']]&.scope),
260
+ collection_actions[action_name.to_sym].merge(scope: collection.schema[:actions][action_name]&.scope),
227
261
  caller,
228
262
  user_data[:roleId],
229
263
  filter
@@ -232,7 +266,7 @@ module ForestAdminAgent
232
266
  is_allowed = smart_action_approval.can_execute?
233
267
  ForestAdminAgent::Facades::Container.logger.log(
234
268
  'Debug',
235
- "User #{user_data[:roleId]} is #{"not" unless is_allowed} allowed to perform #{action["name"]}"
269
+ "User #{user_data[:roleId]} is #{"not" unless is_allowed} allowed to perform #{action_name}"
236
270
  )
237
271
 
238
272
  is_allowed
@@ -279,8 +313,16 @@ module ForestAdminAgent
279
313
 
280
314
  private
281
315
 
316
+ def cached(store, key, &fetch)
317
+ @memo.fetch([store, key]) { @memo[[store, key]] = store.get_or_set(key, &fetch) }
318
+ end
319
+
320
+ def forget(store, key)
321
+ @memo.delete([store, key])
322
+ end
323
+
282
324
  def cached_users
283
- cache.get_or_set('forest.users') do
325
+ cached(cache, 'forest.users') do
284
326
  response = fetch('/liana/v4/permissions/users')
285
327
  users = {}
286
328
 
@@ -295,9 +337,8 @@ module ForestAdminAgent
295
337
  end
296
338
 
297
339
  def reload_users
298
- return cached_users unless self.class.users_reload_allowed?
299
-
300
- self.class.invalidate_cache('forest.users')
340
+ self.class.invalidate_cache('forest.users') if self.class.users_reload_allowed?
341
+ forget(cache, 'forest.users')
301
342
  cached_users
302
343
  end
303
344
 
@@ -429,6 +470,13 @@ module ForestAdminAgent
429
470
  'is not exposed by this agent.'
430
471
  end
431
472
 
473
+ def select_allowed(action, collections, reload:)
474
+ user_data = get_user_data(caller.id, reload: reload)
475
+ collections_data = get_collections_permissions_data(force_fetch: reload)
476
+
477
+ collections.select { |collection| permission_allowed?(collections_data, collection, action, user_data) }
478
+ end
479
+
432
480
  def permission_allowed?(collections_data, collection, action, user_data)
433
481
  return false unless user_data_valid?(user_data)
434
482
 
@@ -442,9 +490,12 @@ module ForestAdminAgent
442
490
  end
443
491
 
444
492
  def get_collections_permissions_data(force_fetch: false)
445
- self.class.invalidate_cache('forest.collections') if force_fetch == true
493
+ if force_fetch == true
494
+ self.class.invalidate_cache('forest.collections')
495
+ forget(cache, 'forest.collections')
496
+ end
446
497
 
447
- cache.get_or_set('forest.collections') do
498
+ cached(cache, 'forest.collections') do
448
499
  response = fetch('/liana/v4/permissions/environment')
449
500
  collections = {}
450
501
 
@@ -484,9 +535,12 @@ module ForestAdminAgent
484
535
  end
485
536
 
486
537
  def get_rendering_data(rendering_id, force_fetch: false)
487
- self.class.invalidate_cache('forest.rendering') if force_fetch == true
538
+ if force_fetch == true
539
+ self.class.invalidate_rendering_cache(rendering_id)
540
+ forget(rendering_cache, rendering_id.to_s)
541
+ end
488
542
 
489
- cache.get_or_set('forest.rendering') do
543
+ cached(rendering_cache, rendering_id.to_s) do
490
544
  data = {}
491
545
  response = fetch("/liana/v4/permissions/renderings/#{rendering_id}")
492
546
 
@@ -501,26 +555,12 @@ module ForestAdminAgent
501
555
  end
502
556
 
503
557
  def permission_system?
504
- cache.get_or_set('forest.has_permission') do
558
+ cached(cache, 'forest.has_permission') do
505
559
  response = fetch('/liana/v4/permissions/environment')
506
560
  { enable: response != true }
507
561
  end[:enable]
508
562
  end
509
563
 
510
- def find_action_from_endpoint(collection_name, path, http_method)
511
- endpoint = path.partition('/forest/')[1..].join
512
- schema_file = JSON.parse(File.read(Facades::Container.config_from_cache[:schema_path]))
513
- actions = schema_file['collections']&.find { |collection| collection['name'] == collection_name }&.dig('actions')
514
-
515
- return nil if actions.nil? || actions.empty?
516
-
517
- action = actions.find { |a| a['endpoint'] == endpoint && a['httpMethod'].casecmp(http_method).zero? }
518
-
519
- raise BadRequestError, "The collection #{collection_name} does not have this smart action" if action.nil?
520
-
521
- action
522
- end
523
-
524
564
  def decode_crud_permissions(collection)
525
565
  unless collection.is_a?(Hash) && collection.key?(:collection)
526
566
  ForestAdminAgent::Facades::Container.logger.log(
@@ -693,7 +733,7 @@ module ForestAdminAgent
693
733
  has_permission
694
734
  end
695
735
 
696
- def validate_smart_action_permissions(collections_data, collection, action)
736
+ def validate_smart_action_permissions(collections_data, collection, action_name)
697
737
  collection_key = collection.name.to_sym
698
738
 
699
739
  unless collections_data.key?(collection_key)
@@ -713,13 +753,13 @@ module ForestAdminAgent
713
753
  raise ForbiddenError, "No actions configured for collection '#{collection.name}'"
714
754
  end
715
755
 
716
- action_key = action['name'].to_sym
756
+ action_key = action_name.to_sym
717
757
  unless collection_actions.key?(action_key)
718
758
  ForestAdminAgent::Facades::Container.logger.log(
719
759
  'Warn',
720
- "Smart action '#{action["name"]}' not found in permissions for collection '#{collection.name}'"
760
+ "Smart action '#{action_name}' not found in permissions for collection '#{collection.name}'"
721
761
  )
722
- raise ForbiddenError, "Smart action '#{action["name"]}' is not configured"
762
+ raise ForbiddenError, "Smart action '#{action_name}' is not configured"
723
763
  end
724
764
 
725
765
  collection_actions
@@ -9,7 +9,7 @@ module ForestAdminAgent
9
9
  MESSAGE_CACHE_KEYS = {
10
10
  'refresh-users': %w[forest.users],
11
11
  'refresh-roles': %w[forest.collections],
12
- 'refresh-renderings': %w[forest.collections forest.rendering]
12
+ 'refresh-renderings': %w[forest.collections]
13
13
  # TODO: add one for ip whitelist when server implement it
14
14
  }.freeze
15
15
 
@@ -32,6 +32,7 @@ module ForestAdminAgent
32
32
  "invalidate cache #{MESSAGE_CACHE_KEYS[event.type]} for event #{event.type}"
33
33
  )
34
34
  end
35
+ invalidate_renderings(event.data) if event.type == :'refresh-renderings'
35
36
 
36
37
  ForestAdminAgent::Facades::Container.logger.log(
37
38
  'Info',
@@ -57,6 +58,23 @@ module ForestAdminAgent
57
58
  )
58
59
  end
59
60
  end
61
+
62
+ # An event that does not name its renderings could concern any of them, so all are dropped.
63
+ def self.invalidate_renderings(data)
64
+ rendering_ids = begin
65
+ parsed = JSON.parse(data.to_s)
66
+ parsed['renderingIds'] if parsed.is_a?(Hash)
67
+ rescue JSON::ParserError
68
+ nil
69
+ end
70
+
71
+ if rendering_ids.is_a?(Array) && rendering_ids.any?
72
+ rendering_ids.each { |rendering_id| Permissions.invalidate_rendering_cache(rendering_id) }
73
+ else
74
+ Permissions.invalidate_rendering_cache
75
+ end
76
+ end
77
+ private_class_method :invalidate_renderings
60
78
  end
61
79
  end
62
80
  end
@@ -0,0 +1,52 @@
1
+ require 'filecache'
2
+ require 'securerandom'
3
+
4
+ module ForestAdminAgent
5
+ module Utils
6
+ # Same on-disk layout as FileCache, so processes running either version share one cache.
7
+ class SafeFileCache < FileCache
8
+ def get(key)
9
+ path = path_for(key)
10
+ return nil if expired?(path)
11
+
12
+ File.open(path, 'rb') { |file| Marshal.load(file) } # rubocop:disable Security/MarshalLoad
13
+ rescue Errno::ENOENT, ArgumentError, TypeError, EOFError
14
+ nil
15
+ end
16
+
17
+ def set(key, value)
18
+ path = path_for(key)
19
+ FileUtils.mkdir_p(File.dirname(path))
20
+ temp_path = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(4)}")
21
+
22
+ File.open(temp_path, 'wb') { |file| Marshal.dump(value, file) }
23
+ File.rename(temp_path, path)
24
+ rescue Errno::ENOENT
25
+ nil
26
+ ensure
27
+ FileUtils.rm_f(temp_path) if temp_path
28
+ end
29
+
30
+ def delete(key)
31
+ FileUtils.rm_f(path_for(key))
32
+ end
33
+
34
+ def clear
35
+ FileUtils.rm_rf(get_root)
36
+ FileUtils.mkdir_p(get_root)
37
+ end
38
+
39
+ private
40
+
41
+ def path_for(key)
42
+ md5 = Digest::MD5.hexdigest(key.to_s)
43
+
44
+ File.join(get_root, *md5[0...@depth].chars, md5)
45
+ end
46
+
47
+ def expired?(path)
48
+ @expiry.positive? && Time.now - File.mtime(path) >= @expiry
49
+ end
50
+ end
51
+ end
52
+ end
@@ -6,7 +6,7 @@ module ForestAdminAgent
6
6
  module Schema
7
7
  class SchemaEmitter
8
8
  LIANA_NAME = "agent-ruby"
9
- LIANA_VERSION = "1.45.1"
9
+ LIANA_VERSION = "1.46.0"
10
10
 
11
11
  def self.generate(datasource)
12
12
  datasource.collections
@@ -1,3 +1,3 @@
1
1
  module ForestAdminAgent
2
- VERSION = "1.45.1"
2
+ VERSION = "1.46.0"
3
3
  end
@@ -6,8 +6,8 @@ loader = Zeitwerk::Loader.for_gem
6
6
  loader.inflector.inflect('oauth2' => 'OAuth2')
7
7
  loader.inflector.inflect('sql' => 'Sql')
8
8
  loader.inflector.inflect('sse_cache_invalidation' => 'SSECacheInvalidation')
9
- # ActiveRecord is only needed by agents configuring an audit-trail database, and Rails eager loads
10
- # every gem loader (Zeitwerk::Loader.eager_load_all), so these files must stay strictly autoloaded.
9
+ # Rails eager loads every gem loader (Zeitwerk::Loader.eager_load_all): kept autoloaded so ActiveRecord is only
10
+ # required once an audit-trail database is configured.
11
11
  loader.do_not_eager_load("#{__dir__}/forest_admin_agent/audit_trail/sql")
12
12
  loader.setup
13
13
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.45.1
4
+ version: 1.46.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,8 +9,22 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-10-10 00:00:00.000000000 Z
12
+ date: 2026-10-16 00:00:00.000000000 Z
13
13
  dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '6.1'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '6.1'
14
28
  - !ruby/object:Gem::Dependency
15
29
  name: activesupport
16
30
  requirement: !ruby/object:Gem::Requirement
@@ -277,6 +291,20 @@ dependencies:
277
291
  - - "~>"
278
292
  - !ruby/object:Gem::Version
279
293
  version: '2.2'
294
+ - !ruby/object:Gem::Dependency
295
+ name: pg
296
+ requirement: !ruby/object:Gem::Requirement
297
+ requirements:
298
+ - - "~>"
299
+ - !ruby/object:Gem::Version
300
+ version: '1.1'
301
+ type: :runtime
302
+ prerelease: false
303
+ version_requirements: !ruby/object:Gem::Requirement
304
+ requirements:
305
+ - - "~>"
306
+ - !ruby/object:Gem::Version
307
+ version: '1.1'
280
308
  - !ruby/object:Gem::Dependency
281
309
  name: rake
282
310
  requirement: !ruby/object:Gem::Requirement
@@ -378,7 +406,9 @@ files:
378
406
  - lib/forest_admin_agent/routes/request_context.rb
379
407
  - lib/forest_admin_agent/routes/resources/audit_trail.rb
380
408
  - lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb
409
+ - lib/forest_admin_agent/routes/resources/audit_trail_query_params.rb
381
410
  - lib/forest_admin_agent/routes/resources/audit_trail_route.rb
411
+ - lib/forest_admin_agent/routes/resources/audit_trail_timeline.rb
382
412
  - lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb
383
413
  - lib/forest_admin_agent/routes/resources/count.rb
384
414
  - lib/forest_admin_agent/routes/resources/csv.rb
@@ -419,6 +449,7 @@ files:
419
449
  - lib/forest_admin_agent/utils/is_segment_query_allowed_on_connection.rb
420
450
  - lib/forest_admin_agent/utils/query_string_parser.rb
421
451
  - lib/forest_admin_agent/utils/query_validator.rb
452
+ - lib/forest_admin_agent/utils/safe_file_cache.rb
422
453
  - lib/forest_admin_agent/utils/schema/action_fields.rb
423
454
  - lib/forest_admin_agent/utils/schema/forest_value_converter.rb
424
455
  - lib/forest_admin_agent/utils/schema/frontend_filterable.rb