magick-feature-flags 1.1.3 → 1.2.2
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/README.md +114 -6
- data/app/controllers/magick/adminui/features_controller.rb +43 -0
- data/app/views/layouts/application.html.erb +849 -361
- data/app/views/magick/adminui/features/edit.html.erb +174 -71
- data/app/views/magick/adminui/features/index.html.erb +47 -48
- data/app/views/magick/adminui/features/show.html.erb +138 -109
- data/app/views/magick/adminui/stats/show.html.erb +48 -38
- data/lib/magick/dsl.rb +21 -0
- data/lib/magick/feature.rb +103 -1
- data/lib/magick/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 635570418a98cab1f8f6480a6834f4a803bd0a24d94bbdf96349f84a78e74278
|
|
4
|
+
data.tar.gz: b7d5b93af0c4fb1204b36e64561fe50452afbd85d2d115cf79aa85778346da2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f8f867de1a865f1e5deee8f2767fb834034f5f53c47c43be7182a4b06db735be412dd06bb09bf47cd901f8eb1f8c335c9adb1dae83adbebeb51822cf380aff6
|
|
7
|
+
data.tar.gz: cfce216854f22d787083a24c42e1995a313e96fbc79b4aa203d318021209e8bf4af4f28b8fa88846357adf31893c432650ceadcf54b7e1e5886a154457297ada
|
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. **
|
|
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
|
|
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
|