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.
- checksums.yaml +4 -4
- data/AUDIT_TRAIL.md +55 -26
- data/forest_admin_agent.gemspec +2 -0
- data/lib/forest_admin_agent/audit_trail/audit_record.rb +1 -1
- data/lib/forest_admin_agent/audit_trail/capture.rb +1 -7
- data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +1 -5
- data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +23 -6
- data/lib/forest_admin_agent/audit_trail/store.rb +79 -65
- data/lib/forest_admin_agent/auth/oidc_client_manager.rb +1 -2
- data/lib/forest_admin_agent/http/router.rb +4 -2
- data/lib/forest_admin_agent/routes/action/actions.rb +13 -4
- data/lib/forest_admin_agent/routes/capabilities/collections.rb +4 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail.rb +25 -118
- data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +1 -3
- data/lib/forest_admin_agent/routes/resources/audit_trail_query_params.rb +131 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +16 -39
- data/lib/forest_admin_agent/routes/resources/audit_trail_timeline.rb +96 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb +23 -9
- data/lib/forest_admin_agent/routes/resources/list.rb +2 -1
- data/lib/forest_admin_agent/routes/security/scope_invalidation.rb +2 -1
- data/lib/forest_admin_agent/serializer/forest_serializer.rb +51 -57
- data/lib/forest_admin_agent/serializer/forest_serializer_override.rb +1 -4
- data/lib/forest_admin_agent/services/ip_whitelist.rb +6 -8
- data/lib/forest_admin_agent/services/permissions.rb +81 -41
- data/lib/forest_admin_agent/services/sse_cache_invalidation.rb +19 -1
- data/lib/forest_admin_agent/utils/safe_file_cache.rb +52 -0
- 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 +2 -2
- metadata +33 -2
|
@@ -5,9 +5,8 @@ module ForestAdminAgent
|
|
|
5
5
|
class ForestSerializer
|
|
6
6
|
include JSONAPI::Serializer
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
22
|
-
|
|
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 =
|
|
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)
|
|
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,
|
|
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[
|
|
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 #{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
493
|
+
if force_fetch == true
|
|
494
|
+
self.class.invalidate_cache('forest.collections')
|
|
495
|
+
forget(cache, 'forest.collections')
|
|
496
|
+
end
|
|
446
497
|
|
|
447
|
-
cache
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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 '#{
|
|
760
|
+
"Smart action '#{action_name}' not found in permissions for collection '#{collection.name}'"
|
|
721
761
|
)
|
|
722
|
-
raise ForbiddenError, "Smart action '#{
|
|
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
|
|
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
|
data/lib/forest_admin_agent.rb
CHANGED
|
@@ -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
|
-
#
|
|
10
|
-
#
|
|
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.
|
|
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-
|
|
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
|