eluvia-base 3.39.0 → 3.40.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 691157713c8474d969d76eaee1106d331696a45b9458ee64a563762391e43ed3
4
- data.tar.gz: 280074f623f0c865a349edaf49c277875c6eeb28374ab8062b0f2aff5f061d67
3
+ metadata.gz: dcf7c769d60f4347beee0d63aff1e1e8944e5fb2412985f531465dd833cfe58e
4
+ data.tar.gz: dc7491fcac983ef84e9f33396139370ce94fb65ce8718742acc4806d4e35e0b6
5
5
  SHA512:
6
- metadata.gz: 1e202828e32b3c665d109ec6584c68fe4ff047b46a9af05b3119c68118d692e306ab3deac2534bc46779dcfc3e5a51b5af057e21940f0aea56ee076f6df9c8c9
7
- data.tar.gz: 6eff59b834d789c738f7499d14770bc89cd2793d38ac337c2c7baf158b166170c94a38f55e7d4a6cad31cee49ba1937f81c1f9e15a9acd6748d711b8eb02356b
6
+ metadata.gz: 1a8eff124c578195160772b542e7ece4419b76ada79214f3b7d7ba3cb5c24768249b8d8c24ea207e37a33e6dc5d4d3b7196f83d369df428ae9724b36702af1e4
7
+ data.tar.gz: 2df71bbb4195fde554b62bc8052d02894be13b14c652325f08af43b94d4b23385499bf0dbe5847ae16786519e59de04be8b6d8a65f4e734f8202bc99f793a55a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.40.0] — 2026-09-23
9
+
10
+ ### Added
11
+ - `Eluvia::ActiveRecord::EnvFilterable` — a generic, model-agnostic filter driven by deploy-time
12
+ config (typically a base64-encoded JSON ENV var). `env_filter_fields` declares the column
13
+ whitelist (the only part ever interpolated into SQL; all values stay bound parameters),
14
+ `parse_env_filter_config` decodes the base64 JSON, and `apply_env_filter(scope, config)` applies
15
+ the AND-combined `field`/`operator`/`value` conditions to a scope. Operators: `eq`, `not_eq`,
16
+ `in`, `not_in`, `like`, `not_like` (case-insensitive substring; negative operators are
17
+ NULL-safe). Extracted from the payments service so services need only declare their whitelist
18
+ (VD-2811).
19
+
8
20
  ## [3.39.0] — 2026-09-18
9
21
 
10
22
  ### Added
data/README.md CHANGED
@@ -276,7 +276,53 @@ end
276
276
  If `upload_key` is present on the assigned `Eluvia::File` but no finalizer is registered, a
277
277
  `Eluvia::Errors::StandardError` is raised.
278
278
 
279
- ### 2.7. Translations
279
+ ### 2.7. ENV-driven scope restriction
280
+
281
+ `Eluvia::ActiveRecord::EnvFilterable` applies a deploy-time restriction — typically a base64-encoded
282
+ JSON blob in an ENV var — to any scope. It is generic and model-agnostic: a model only declares which
283
+ columns the config may reference, and the config lists `field` / `operator` / `value` conditions that
284
+ are AND-combined. Only declared fields are ever interpolated into SQL (as a column name); every value
285
+ stays a bound parameter, so the config cannot inject SQL. Conditions with an unknown field or operator
286
+ are silently ignored.
287
+
288
+ ```ruby
289
+
290
+ class BankTransaction < ApplicationRecord
291
+ include Eluvia::ActiveRecord::EnvFilterable
292
+
293
+ # Only these columns may appear in a filter config.
294
+ env_filter_fields %i[id direction variable_symbol date amount is_hidden]
295
+ end
296
+ ```
297
+
298
+ Read and apply the config (e.g. from a Pundit `policy_scope`):
299
+
300
+ ```ruby
301
+
302
+ config = BankTransaction.parse_env_filter_config(ENV['ADMIN_BANK_TRANSACTIONS_FILTER_B64'])
303
+ scope = BankTransaction.apply_env_filter(BankTransaction.all, config)
304
+ ```
305
+
306
+ `parse_env_filter_config` returns `nil` for a blank/missing ENV var (which disables the filter) and
307
+ raises `ArgumentError` on malformed input, so a broken deploy config fails loudly instead of silently
308
+ letting everything through. The config shape is:
309
+
310
+ ```json
311
+ {
312
+ "conditions": [
313
+ { "field": "direction", "operator": "eq", "value": "in" },
314
+ { "field": "is_hidden", "operator": "eq", "value": false },
315
+ { "field": "variable_symbol", "operator": "like", "value": ["12", "34"] }
316
+ ]
317
+ }
318
+ ```
319
+
320
+ Operators: `eq` / `not_eq` (equality), `in` / `not_in` (membership; value may be a scalar or array),
321
+ `like` / `not_like` (case-insensitive `ILIKE '%…%'` substring match; value may be a scalar or array —
322
+ `like` matches any, `not_like` excludes any). The negative operators (`not_eq`, `not_in`, `not_like`)
323
+ are NULL-safe: a row whose value is `NULL` is kept.
324
+
325
+ ### 2.8. Translations
280
326
 
281
327
  All validation error messages raised by this gem (ordering, filtering, fieldset and pagination errors) go through
282
328
  `I18n.t` with an English `default:`, so the gem works out of the box without any locale setup. In addition,
@@ -0,0 +1,110 @@
1
+ require 'base64'
2
+
3
+ module Eluvia
4
+ module ActiveRecord
5
+ # Generic, model-agnostic filter driven by deploy-time configuration (typically a base64-encoded
6
+ # JSON blob in an ENV var). The config is a list of `field` / `operator` / `value` conditions,
7
+ # AND-combined, that narrow a scope — e.g. to restrict what an admin interface may ever see.
8
+ #
9
+ # Only fields declared via `env_filter_fields` are ever interpolated into SQL (as a column name);
10
+ # every value is a bound parameter, so the config cannot be used to inject arbitrary SQL. Unknown
11
+ # fields and operators are silently ignored.
12
+ #
13
+ # @example Declaring the whitelist on a model
14
+ # class BankTransaction < ApplicationRecord
15
+ # include Eluvia::ActiveRecord::EnvFilterable
16
+ # env_filter_fields %i[id direction variable_symbol date amount is_hidden]
17
+ # end
18
+ #
19
+ # @example Applying it to a scope (e.g. from a Pundit `policy_scope`)
20
+ # config = BankTransaction.parse_env_filter_config(ENV['ADMIN_BANK_TRANSACTIONS_FILTER_B64'])
21
+ # BankTransaction.apply_env_filter(scope, config)
22
+ module EnvFilterable
23
+ extend ::ActiveSupport::Concern
24
+
25
+ class_methods do
26
+
27
+ # Declares (with arguments) or returns (without arguments) the fields that may appear in an
28
+ # env filter config. Only these column names are ever interpolated into SQL. Names are stored
29
+ # as strings so they compare directly against the config's `field` values.
30
+ #
31
+ # @param field_names [Array<String, Symbol>] whitelisted column names
32
+ # @return [Array<String>] the declared whitelist
33
+ def env_filter_fields(*field_names)
34
+ if field_names.empty?
35
+ @_env_filter_fields ||= []
36
+ else
37
+ @_env_filter_fields = field_names.flatten.map(&:to_s)
38
+ end
39
+ end
40
+
41
+ # Decodes a base64-encoded JSON filter config (typically read straight from an ENV var) into a
42
+ # Hash. Returns `nil` for blank input so a missing ENV var disables the filter. Raises
43
+ # `ArgumentError` on malformed input so a broken deploy-time config fails loudly rather than
44
+ # silently letting everything through.
45
+ #
46
+ # @param encoded [String, nil] base64-encoded JSON, or `nil`/blank
47
+ # @return [Hash, nil]
48
+ def parse_env_filter_config(encoded)
49
+ return nil if encoded.blank?
50
+
51
+ JSON.parse(Base64.strict_decode64(encoded.strip))
52
+ rescue ArgumentError, JSON::ParserError => e
53
+ raise ArgumentError, "invalid env filter config: #{e.message}"
54
+ end
55
+
56
+ # Applies an env filter config to +scope+. Config is a Hash with a `"conditions"` array; each
57
+ # condition has `"field"`, `"operator"` and `"value"`. All conditions are AND-combined. A blank
58
+ # config returns the scope unchanged; conditions with an unknown field or operator are ignored.
59
+ #
60
+ # Operators:
61
+ # eq / not_eq equality (not_eq keeps NULLs)
62
+ # in / not_in membership; value may be a scalar or array (not_in keeps NULLs)
63
+ # like / not_like case-insensitive substring match; value may be a scalar or an array
64
+ # (like → matches any; not_like → excludes any, keeps NULLs)
65
+ #
66
+ # @param scope [ActiveRecord::Relation]
67
+ # @param config [Hash, nil]
68
+ # @return [ActiveRecord::Relation]
69
+ def apply_env_filter(scope, config)
70
+ return scope if config.blank?
71
+
72
+ Array(config['conditions']).each do |cond|
73
+ field = cond['field'].to_s
74
+ op = cond['operator'].to_s
75
+ next unless env_filter_fields.include?(field)
76
+
77
+ col = "#{table_name}.#{field}" # field is whitelisted → safe to interpolate
78
+ val = cond['value']
79
+
80
+ scope =
81
+ case op
82
+ when 'eq'
83
+ scope.where(field => val)
84
+ when 'not_eq'
85
+ scope.where("#{col} IS NULL OR #{col} <> ?", val)
86
+ when 'in'
87
+ scope.where(field => Array(val))
88
+ when 'not_in'
89
+ scope.where("#{col} IS NULL OR #{col} NOT IN (?)", Array(val))
90
+ when 'like'
91
+ Array(val).each_with_index.reduce(scope) do |s, (v, i)|
92
+ clause = scope.where("#{col} ILIKE ('%' || ? || '%')", v)
93
+ i.zero? ? clause : s.or(clause)
94
+ end
95
+ when 'not_like'
96
+ Array(val).reduce(scope) do |s, v|
97
+ s.where("#{col} IS NULL OR #{col} NOT ILIKE ('%' || ? || '%')", v)
98
+ end
99
+ else
100
+ scope
101
+ end
102
+ end
103
+
104
+ scope
105
+ end
106
+
107
+ end
108
+ end
109
+ end
110
+ end
data/lib/eluvia-base.rb CHANGED
@@ -20,6 +20,7 @@ require 'eluvia/active_model/object_attribute'
20
20
  require 'eluvia/active_model/range_attribute' # depends on object_attribute
21
21
 
22
22
  # Active record
23
+ require 'eluvia/active_record/env_filterable'
23
24
  require 'eluvia/active_record/file_array_attribute'
24
25
  require 'eluvia/active_record/file_attribute'
25
26
  require 'eluvia/active_record/filtering'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eluvia-base
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.39.0
4
+ version: 3.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matěj Outlý
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-18 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rest-client
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.19'
69
+ - !ruby/object:Gem::Dependency
70
+ name: base64
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.2'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: ostruct
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -128,6 +142,7 @@ files:
128
142
  - lib/eluvia/active_model/enum_attribute.rb
129
143
  - lib/eluvia/active_model/object_attribute.rb
130
144
  - lib/eluvia/active_model/range_attribute.rb
145
+ - lib/eluvia/active_record/env_filterable.rb
131
146
  - lib/eluvia/active_record/file_array_attribute.rb
132
147
  - lib/eluvia/active_record/file_attribute.rb
133
148
  - lib/eluvia/active_record/filtering.rb