magick-feature-flags 1.1.3 → 1.2.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: e896cf3753f151fb2f1082a231c9af82095e3caea7716642a9f535e60d36e9e4
4
- data.tar.gz: 24992a605474319bd831fbd60a6d955ba05de266d7419f60065fef2a2d32d2d1
3
+ metadata.gz: 7f87e02cd09ce5fb4e837f66c5117d2ca709918cd4bcc1f1fe808ceaafe7e049
4
+ data.tar.gz: 5ed85473443dd9e065c2a7298030a361b1fe965376c68085b0355d57124c04e2
5
5
  SHA512:
6
- metadata.gz: ba376fb1fea1613bd7841fd4a56b8ed30833ab91c6563fad61abde4127caba802330b9bf14b9b0a52094addd7c58b92cb85d30af2b78c283647121de69fee19c
7
- data.tar.gz: cfe7ad50afee3b0c7b192a1bb8a5b035834672f21d29627f67a3eda2939330880806686c655ccc6d17d4875c7d6fd029fc1f1732ee68562cc520373a2d54190f
6
+ metadata.gz: 29402326b70df322e48ae798571ee500a12eafc964545ed0e1a2f15e56bce6197254084b3c9eb75cadd22a61e2583c74fbbb41d0c50f478f733e561d8c41917f
7
+ data.tar.gz: a8d84d507bab0fd1e02eb3583830c059f3cdd5d475a137bfe952a85194fc66f2e5a8f8b548096722664ae0a92325087d265b17aa3f1d12e6d3f3f5176c212ca5
data/README.md CHANGED
@@ -6,6 +6,7 @@ A performant and memory-efficient feature toggle gem for Ruby and Rails applicat
6
6
 
7
7
  - **Multiple Feature Types**: Boolean, string, and number feature flags
8
8
  - **Flexible Targeting**: Enable features for specific users, groups, roles, tags, or percentages
9
+ - **Exclusions**: Exclude specific users, groups, roles, tags, or IPs — exclusions always take priority over inclusions
9
10
  - **Dual Backend**: Memory adapter (fast) with Redis fallback (persistent)
10
11
  - **Rails Integration**: Seamless integration with Rails, including request store caching
11
12
  - **DSL Support**: Define features in a Ruby DSL file (`config/features.rb`)
@@ -193,6 +194,76 @@ feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
193
194
  feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
194
195
  ```
195
196
 
197
+ ### Feature Exclusions
198
+
199
+ Exclusions let you block specific users, groups, roles, tags, or IP addresses from a feature — even if they match an inclusion rule. **Exclusions always take priority over inclusions.**
200
+
201
+ ```ruby
202
+ feature = Magick[:new_dashboard]
203
+
204
+ # Exclude specific users
205
+ feature.exclude_user(456)
206
+ feature.exclude_user(789)
207
+
208
+ # Exclude specific tags
209
+ feature.exclude_tag('legacy_tier')
210
+ feature.exclude_tag('banned')
211
+
212
+ # Exclude specific groups
213
+ feature.exclude_group('suspended_users')
214
+
215
+ # Exclude specific roles
216
+ feature.exclude_role('guest')
217
+
218
+ # Exclude IP addresses (supports CIDR notation)
219
+ feature.exclude_ip_addresses(['10.0.0.0/8', '192.168.1.100'])
220
+
221
+ # Remove exclusions
222
+ feature.remove_user_exclusion(456)
223
+ feature.remove_tag_exclusion('legacy_tier')
224
+ feature.remove_group_exclusion('suspended_users')
225
+ feature.remove_role_exclusion('guest')
226
+ feature.remove_ip_exclusion # Removes all IP exclusions
227
+ ```
228
+
229
+ **Exclusions win over inclusions:**
230
+
231
+ ```ruby
232
+ feature = Magick[:premium_features]
233
+ feature.enable # Enabled globally for everyone
234
+
235
+ feature.exclude_user(123)
236
+ Magick.enabled?(:premium_features, user_id: 123) # => false (excluded)
237
+ Magick.enabled?(:premium_features, user_id: 456) # => true (not excluded)
238
+
239
+ # Even percentage targeting is overridden
240
+ feature.enable_percentage_of_users(100) # 100% of users
241
+ feature.exclude_user(123)
242
+ Magick.enabled?(:premium_features, user_id: 123) # => false (still excluded)
243
+ ```
244
+
245
+ **Exclusions in DSL (`config/features.rb`):**
246
+
247
+ ```ruby
248
+ boolean_feature :new_dashboard, default: true
249
+
250
+ # Exclude problematic users
251
+ exclude_user :new_dashboard, 'user_123'
252
+ exclude_user :new_dashboard, 'user_456'
253
+
254
+ # Exclude legacy tiers
255
+ exclude_tag :new_dashboard, 'legacy_tier'
256
+
257
+ # Exclude groups
258
+ exclude_group :new_dashboard, 'banned_users'
259
+
260
+ # Exclude roles
261
+ exclude_role :new_dashboard, 'suspended'
262
+
263
+ # Exclude IPs
264
+ exclude_ip_addresses :new_dashboard, '10.0.0.0/8'
265
+ ```
266
+
196
267
  ### Checking Feature Enablement with Objects
197
268
 
198
269
  You can check if a feature is enabled for an object (like a User model) and its fields:
@@ -258,6 +329,12 @@ result = feature.enable_for_role('admin') # => true
258
329
  result = feature.enable_for_tag('premium') # => true
259
330
  result = feature.enable_percentage_of_users(25) # => true
260
331
  result = feature.set_value(true) # => true
332
+
333
+ # Exclusion methods also return true
334
+ result = feature.exclude_user(456) # => true
335
+ result = feature.exclude_tag('banned') # => true
336
+ result = feature.exclude_group('blocked') # => true
337
+ result = feature.exclude_role('guest') # => true
261
338
  ```
262
339
 
263
340
  ### DSL Configuration
@@ -303,6 +380,13 @@ boolean_feature :premium_feature,
303
380
 
304
381
  # Add dependencies after feature definition
305
382
  add_dependency(:another_feature, :required_feature)
383
+
384
+ # Exclusions - block specific users/groups/roles/tags
385
+ exclude_user :new_dashboard, 'user_123'
386
+ exclude_tag :new_dashboard, 'legacy_tier'
387
+ exclude_group :new_dashboard, 'banned_users'
388
+ exclude_role :new_dashboard, 'suspended'
389
+ exclude_ip_addresses :new_dashboard, '10.0.0.0/8'
306
390
  ```
307
391
 
308
392
  ### In Controllers
@@ -443,8 +527,8 @@ end
443
527
 
444
528
  Magick uses a dual-adapter strategy:
445
529
 
446
- 1. **Memory Adapter**: Fast, in-memory storage with TTL support
447
- 2. **Redis Adapter**: Persistent storage for distributed systems (optional)
530
+ 1. **Memory Adapter**: Fast, in-memory storage with TTL support and JSON serialization
531
+ 2. **Redis Adapter**: Persistent storage for distributed systems (optional), uses SCAN instead of KEYS and pipelined bulk operations
448
532
 
449
533
  The registry automatically falls back from memory to Redis if a feature isn't found in memory. When features are updated:
450
534
  - Both adapters are updated simultaneously
@@ -589,6 +673,7 @@ Once mounted, visit `/magick` in your browser to access the Admin UI.
589
673
  - **Role Targeting**: Select roles from a configured list (checkboxes)
590
674
  - **Tag Targeting**: Select tags from a dynamically loaded list (checkboxes)
591
675
  - **User Targeting**: Enter user IDs (comma-separated)
676
+ - **Exclusions**: Exclude users, roles, and tags from a feature (exclusions override inclusions)
592
677
  - **Visual Display**: See all active targeting rules with badges
593
678
  - **Edit Features**: Update feature values (boolean, string, number) directly from the UI
594
679
  - **Statistics**: View performance metrics and usage statistics for each feature
@@ -641,7 +726,12 @@ The Admin UI provides a comprehensive targeting interface:
641
726
  - Add or remove users dynamically
642
727
  - Clear all user targeting by leaving the field empty
643
728
 
644
- 4. **Visual Feedback**:
729
+ 4. **Exclusion Targeting**:
730
+ - Exclude specific users (comma-separated IDs), roles, and tags
731
+ - Exclusions always take priority over inclusions
732
+ - Managed through the same targeting form
733
+
734
+ 5. **Visual Feedback**:
645
735
  - All targeting rules are displayed as badges in the feature details view
646
736
  - Easy to see which roles/tags/users have access to each feature
647
737
 
@@ -663,7 +753,27 @@ The Admin UI provides the following routes:
663
753
 
664
754
  **Security:**
665
755
 
666
- The Admin UI is a basic Rails Engine without built-in authentication. **You should add authentication/authorization** before mounting it in production. For example:
756
+ The Admin UI includes a built-in authentication hook via `require_role`. Configure it to gate access:
757
+
758
+ ```ruby
759
+ # config/initializers/magick.rb
760
+ Rails.application.config.after_initialize do
761
+ Magick::AdminUI.configure do |config|
762
+ # Option 1: Lambda-based authentication (recommended)
763
+ config.require_role = ->(controller) {
764
+ # Return true to allow, false to deny (returns 403 Forbidden)
765
+ controller.current_user&.admin?
766
+ }
767
+
768
+ # Option 2: Check for a specific role
769
+ config.require_role = ->(controller) {
770
+ controller.current_user&.role == 'admin'
771
+ }
772
+ end
773
+ end
774
+ ```
775
+
776
+ You can also gate access at the routing level:
667
777
 
668
778
  ```ruby
669
779
  # config/routes.rb
@@ -680,8 +790,6 @@ Rails.application.routes.draw do
680
790
  end
681
791
  ```
682
792
 
683
- Or use a before_action in your ApplicationController if you mount it at the application level.
684
-
685
793
  **Note:** The Admin UI is optional and only loaded when explicitly enabled in configuration. It requires Rails to be available.
686
794
 
687
795
  ### Feature Types
@@ -208,6 +208,49 @@ module Magick
208
208
  @feature.disable_percentage_of_requests if current_targeting[:percentage_requests]
209
209
  end
210
210
 
211
+ # Handle excluded user IDs
212
+ if targeting_params[:excluded_user_ids].present?
213
+ excluded_user_ids = targeting_params[:excluded_user_ids].split(',').map(&:strip).reject(&:blank?)
214
+ current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
215
+
216
+ (current_excluded_users - excluded_user_ids).each do |user_id|
217
+ @feature.remove_user_exclusion(user_id) if user_id.present?
218
+ end
219
+
220
+ (excluded_user_ids - current_excluded_users).each do |user_id|
221
+ @feature.exclude_user(user_id) if user_id.present?
222
+ end
223
+ elsif targeting_params.key?(:excluded_user_ids) && targeting_params[:excluded_user_ids].blank?
224
+ current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
225
+ current_excluded_users.each do |user_id|
226
+ @feature.remove_user_exclusion(user_id) if user_id.present?
227
+ end
228
+ end
229
+
230
+ # Handle excluded roles
231
+ current_excluded_roles = current_targeting[:excluded_roles].is_a?(Array) ? current_targeting[:excluded_roles] : (current_targeting[:excluded_roles] ? [current_targeting[:excluded_roles]] : [])
232
+ selected_excluded_roles = Array(targeting_params[:excluded_roles]).reject(&:blank?)
233
+
234
+ (current_excluded_roles - selected_excluded_roles).each do |role|
235
+ @feature.remove_role_exclusion(role) if role.present?
236
+ end
237
+
238
+ (selected_excluded_roles - current_excluded_roles).each do |role|
239
+ @feature.exclude_role(role) if role.present?
240
+ end
241
+
242
+ # Handle excluded tags
243
+ current_excluded_tags = current_targeting[:excluded_tags].is_a?(Array) ? current_targeting[:excluded_tags] : (current_targeting[:excluded_tags] ? [current_targeting[:excluded_tags]] : [])
244
+ selected_excluded_tags = Array(targeting_params[:excluded_tags]).reject(&:blank?)
245
+
246
+ (current_excluded_tags - selected_excluded_tags).each do |tag|
247
+ @feature.remove_tag_exclusion(tag) if tag.present?
248
+ end
249
+
250
+ (selected_excluded_tags - current_excluded_tags).each do |tag|
251
+ @feature.exclude_tag(tag) if tag.present?
252
+ end
253
+
211
254
  # After all targeting updates, ensure we're using the registered feature instance
212
255
  # and reload it to get the latest state from adapter
213
256
  feature_name = @feature.name.to_s
data/lib/magick/dsl.rb CHANGED
@@ -58,6 +58,27 @@ module Magick
58
58
  Magick[feature_name].enable_for_custom_attribute(attribute_name, values, operator: operator)
59
59
  end
60
60
 
61
+ # Exclusion DSL methods
62
+ def exclude_user(feature_name, user_id)
63
+ Magick[feature_name].exclude_user(user_id)
64
+ end
65
+
66
+ def exclude_tag(feature_name, tag_name)
67
+ Magick[feature_name].exclude_tag(tag_name)
68
+ end
69
+
70
+ def exclude_group(feature_name, group_name)
71
+ Magick[feature_name].exclude_group(group_name)
72
+ end
73
+
74
+ def exclude_role(feature_name, role_name)
75
+ Magick[feature_name].exclude_role(role_name)
76
+ end
77
+
78
+ def exclude_ip_addresses(feature_name, *ip_addresses)
79
+ Magick[feature_name].exclude_ip_addresses(ip_addresses)
80
+ end
81
+
61
82
  def set_variants(feature_name, variants)
62
83
  Magick[feature_name].set_variants(variants)
63
84
  end
@@ -109,6 +109,9 @@ module Magick
109
109
 
110
110
  # Fast path: skip targeting checks if targeting is empty (most common case)
111
111
  unless @_targeting_empty
112
+ # Check exclusions FIRST — exclusions always take priority over inclusions
113
+ return false if excluded?(context)
114
+
112
115
  # Check date/time range targeting
113
116
  return false if targeting[:date_range] && !date_range_active?(targeting[:date_range])
114
117
 
@@ -264,6 +267,58 @@ module Magick
264
267
  true
265
268
  end
266
269
 
270
+ # --- Exclusion methods ---
271
+
272
+ def exclude_user(user_id)
273
+ enable_targeting(:excluded_users, user_id)
274
+ true
275
+ end
276
+
277
+ def remove_user_exclusion(user_id)
278
+ disable_targeting(:excluded_users, user_id)
279
+ true
280
+ end
281
+
282
+ def exclude_tag(tag_name)
283
+ enable_targeting(:excluded_tags, tag_name)
284
+ true
285
+ end
286
+
287
+ def remove_tag_exclusion(tag_name)
288
+ disable_targeting(:excluded_tags, tag_name)
289
+ true
290
+ end
291
+
292
+ def exclude_group(group_name)
293
+ enable_targeting(:excluded_groups, group_name)
294
+ true
295
+ end
296
+
297
+ def remove_group_exclusion(group_name)
298
+ disable_targeting(:excluded_groups, group_name)
299
+ true
300
+ end
301
+
302
+ def exclude_role(role_name)
303
+ enable_targeting(:excluded_roles, role_name)
304
+ true
305
+ end
306
+
307
+ def remove_role_exclusion(role_name)
308
+ disable_targeting(:excluded_roles, role_name)
309
+ true
310
+ end
311
+
312
+ def exclude_ip_addresses(ip_addresses)
313
+ enable_targeting(:excluded_ip_addresses, Array(ip_addresses))
314
+ true
315
+ end
316
+
317
+ def remove_ip_exclusion
318
+ disable_targeting(:excluded_ip_addresses)
319
+ true
320
+ end
321
+
267
322
  def enable_percentage_of_users(percentage)
268
323
  @targeting[:percentage_users] = percentage.to_f
269
324
  save_targeting
@@ -719,6 +774,10 @@ module Magick
719
774
  # Normalize targeting keys (handle both string and symbol keys)
720
775
  target = targeting.transform_keys(&:to_sym)
721
776
 
777
+ # If only exclusion keys exist (no inclusion targeting), treat as globally enabled
778
+ inclusion_keys = target.keys.reject { |k| k.to_s.start_with?('excluded_') }
779
+ return true if inclusion_keys.empty?
780
+
722
781
  # Check user targeting
723
782
  if context[:user_id] && target[:user]
724
783
  user_list = target[:user].is_a?(Array) ? target[:user] : [target[:user]]
@@ -963,9 +1022,52 @@ module Magick
963
1022
  dependent_features
964
1023
  end
965
1024
 
1025
+ def excluded?(context)
1026
+ target = targeting.transform_keys(&:to_sym)
1027
+
1028
+ # Check excluded users
1029
+ if context[:user_id] && target[:excluded_users]
1030
+ excluded_list = target[:excluded_users].is_a?(Array) ? target[:excluded_users] : [target[:excluded_users]]
1031
+ return true if excluded_list.include?(context[:user_id].to_s)
1032
+ end
1033
+
1034
+ # Check excluded groups
1035
+ if context[:group] && target[:excluded_groups]
1036
+ excluded_list = target[:excluded_groups].is_a?(Array) ? target[:excluded_groups] : [target[:excluded_groups]]
1037
+ return true if excluded_list.include?(context[:group].to_s)
1038
+ end
1039
+
1040
+ # Check excluded roles
1041
+ if context[:role] && target[:excluded_roles]
1042
+ excluded_list = target[:excluded_roles].is_a?(Array) ? target[:excluded_roles] : [target[:excluded_roles]]
1043
+ return true if excluded_list.include?(context[:role].to_s)
1044
+ end
1045
+
1046
+ # Check excluded tags
1047
+ if context[:tags] && target[:excluded_tags]
1048
+ context_tags = Array(context[:tags]).map(&:to_s)
1049
+ excluded_tags = target[:excluded_tags].is_a?(Array) ? target[:excluded_tags].map(&:to_s) : [target[:excluded_tags].to_s]
1050
+ return true if (context_tags & excluded_tags).any?
1051
+ end
1052
+
1053
+ # Check excluded IP addresses
1054
+ if context[:ip_address] && target[:excluded_ip_addresses]
1055
+ begin
1056
+ require 'ipaddr'
1057
+ excluded_ips = Array(target[:excluded_ip_addresses])
1058
+ client_ip = IPAddr.new(context[:ip_address])
1059
+ return true if excluded_ips.any? { |ip_str| IPAddr.new(ip_str).include?(client_ip) }
1060
+ rescue IPAddr::InvalidAddressError, ArgumentError
1061
+ # Invalid IP, not excluded
1062
+ end
1063
+ end
1064
+
1065
+ false
1066
+ end
1067
+
966
1068
  def enable_targeting(type, value)
967
1069
  case type
968
- when :date_range, :custom_attributes, :complex_conditions, :variants
1070
+ when :date_range, :custom_attributes, :complex_conditions, :variants, :excluded_ip_addresses
969
1071
  # These types store structured data directly (Hash or Array of Hashes)
970
1072
  @targeting[type] = value
971
1073
  when :percentage_users, :percentage_requests
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.1.3'
4
+ VERSION = '1.2.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.3
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov