simple_authorize 0.1.0 → 1.0.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleAuthorize
4
+ # Configuration options for SimpleAuthorize
4
5
  class Configuration
5
6
  # Default error message shown to users when not authorized
6
7
  attr_accessor :default_error_message
@@ -14,11 +15,31 @@ module SimpleAuthorize
14
15
  # Custom redirect path for unauthorized access
15
16
  attr_accessor :unauthorized_redirect_path
16
17
 
18
+ # Enable policy caching for performance optimization (opt-in)
19
+ attr_accessor :enable_policy_cache
20
+
21
+ # Enable instrumentation for authorization events (default: true)
22
+ attr_accessor :enable_instrumentation
23
+
24
+ # Include detailed error information in API responses (default: false)
25
+ attr_accessor :api_error_details
26
+
27
+ # Enable I18n support for error messages (default: false)
28
+ attr_accessor :i18n_enabled
29
+
30
+ # I18n scope for translations (default: 'simple_authorize')
31
+ attr_accessor :i18n_scope
32
+
17
33
  def initialize
18
34
  @default_error_message = "You are not authorized to perform this action."
19
35
  @auto_verify = false
20
36
  @current_user_method = :current_user
21
37
  @unauthorized_redirect_path = nil
38
+ @enable_policy_cache = false
39
+ @enable_instrumentation = true
40
+ @api_error_details = false
41
+ @i18n_enabled = false
42
+ @i18n_scope = "simple_authorize"
22
43
  end
23
44
  end
24
45
 
@@ -13,17 +13,73 @@ module SimpleAuthorize
13
13
  def initialize(options = {})
14
14
  if options.is_a?(String)
15
15
  # Handle plain string message
16
- super(options)
16
+ super
17
17
  else
18
18
  # Handle hash options
19
19
  @query = options[:query]
20
20
  @record = options[:record]
21
21
  @policy = options[:policy]
22
22
 
23
- message = options[:message] || "not allowed to #{@query} this #{@record.class}"
23
+ message = options[:message] || build_error_message
24
24
  super(message)
25
25
  end
26
26
  end
27
+
28
+ private
29
+
30
+ def build_error_message
31
+ # Return default message if I18n is disabled
32
+ return "not allowed to #{@query} this #{@record.class}" unless i18n_enabled?
33
+
34
+ # Try to find translation with fallback chain
35
+ translate_error || default_i18n_message
36
+ end
37
+
38
+ def i18n_enabled?
39
+ SimpleAuthorize.configuration.i18n_enabled
40
+ end
41
+
42
+ def translate_error
43
+ return nil unless defined?(I18n)
44
+ return nil unless @policy&.class
45
+
46
+ # Extract action name from query (remove trailing ?)
47
+ action = @query.to_s.delete_suffix("?")
48
+ policy_class_name = @policy.class.name
49
+ return nil unless policy_class_name
50
+
51
+ policy_name = policy_class_name.underscore
52
+
53
+ # Try specific policy + action translation
54
+ key = "#{i18n_scope}.policies.#{policy_name}.#{action}.denied"
55
+ translation = I18n.t(key, **translation_options, default: nil)
56
+ return translation if translation.present?
57
+
58
+ nil
59
+ rescue StandardError
60
+ # If any error occurs during translation lookup, return nil to use default
61
+ nil
62
+ end
63
+
64
+ def translation_options
65
+ {
66
+ record_type: @record&.class&.name || "record",
67
+ record_id: @record.respond_to?(:id) ? @record.id : nil,
68
+ action: @query.to_s.delete_suffix("?"),
69
+ user_role: @policy&.user&.role || "user"
70
+ }.compact
71
+ end
72
+
73
+ def default_i18n_message
74
+ return "not allowed to #{@query} this #{@record.class}" unless defined?(I18n)
75
+
76
+ I18n.t("#{i18n_scope}.errors.not_authorized",
77
+ default: "You are not authorized to perform this action")
78
+ end
79
+
80
+ def i18n_scope
81
+ SimpleAuthorize.configuration.i18n_scope
82
+ end
27
83
  end
28
84
 
29
85
  class PolicyNotDefinedError < StandardError; end
@@ -54,9 +110,15 @@ module SimpleAuthorize
54
110
  query ||= "#{action_name}?"
55
111
  @_policy = policy(record, policy_class: policy_class)
56
112
 
57
- unless @_policy.public_send(query)
58
- raise NotAuthorizedError.new(query: query, record: record, policy: @_policy)
59
- end
113
+ authorized = @_policy.public_send(query)
114
+ error = nil
115
+
116
+ error = NotAuthorizedError.new(query: query, record: record, policy: @_policy) unless authorized
117
+
118
+ # Emit instrumentation event
119
+ instrument_authorization(record, query, @_policy.class, authorized, error) if instrumentation_enabled?
120
+
121
+ raise error if error
60
122
 
61
123
  @authorization_performed = true
62
124
  record
@@ -70,11 +132,19 @@ module SimpleAuthorize
70
132
  # Get or instantiate policy for a record
71
133
  def policy(record, policy_class: nil, namespace: nil)
72
134
  policy_class ||= if namespace
73
- policy_class_for(record, namespace: namespace)
135
+ policy_class_for(record, namespace: namespace)
136
+ else
137
+ policy_class_for(record)
138
+ end
139
+
140
+ # Return cached policy if caching is enabled
141
+ if SimpleAuthorize.configuration.enable_policy_cache
142
+ policy_cache_key = build_policy_cache_key(record, policy_class)
143
+ @_policy_cache ||= {}
144
+ @_policy_cache[policy_cache_key] ||= policy_class.new(authorized_user, record)
74
145
  else
75
- policy_class_for(record)
146
+ policy_class.new(authorized_user, record)
76
147
  end
77
- policy_class.new(authorized_user, record)
78
148
  rescue NameError
79
149
  raise PolicyNotDefinedError, "unable to find policy `#{policy_class}` for `#{record}`"
80
150
  end
@@ -89,9 +159,21 @@ module SimpleAuthorize
89
159
  @policy_scoping_performed = true
90
160
 
91
161
  policy_scope_class ||= policy_scope_class_for(scope)
92
- policy_scope_class.new(authorized_user, scope).resolve
93
- rescue NameError
94
- raise PolicyNotDefinedError, "unable to find scope `#{policy_scope_class}` for `#{scope}`"
162
+ result = nil
163
+ error = nil
164
+
165
+ begin
166
+ result = policy_scope_class.new(authorized_user, scope).resolve
167
+ rescue NameError
168
+ error = PolicyNotDefinedError.new("unable to find scope `#{policy_scope_class}` for `#{scope}`")
169
+ end
170
+
171
+ # Emit instrumentation event
172
+ instrument_policy_scope(scope, policy_scope_class, error) if instrumentation_enabled?
173
+
174
+ raise error if error
175
+
176
+ result
95
177
  end
96
178
 
97
179
  # Ensure scope exists, raising if not found
@@ -114,6 +196,42 @@ module SimpleAuthorize
114
196
  end
115
197
  end
116
198
 
199
+ # Get visible attributes for a record
200
+ def visible_attributes(record, action = nil)
201
+ action ||= action_name
202
+ policy = policy(record)
203
+ method_name = "visible_attributes_for_#{action}"
204
+
205
+ if policy.respond_to?(method_name)
206
+ policy.public_send(method_name)
207
+ elsif policy.respond_to?(:visible_attributes)
208
+ policy.visible_attributes
209
+ else
210
+ []
211
+ end
212
+ end
213
+
214
+ # Get editable attributes for a record
215
+ def editable_attributes(record, action = nil)
216
+ action ||= action_name
217
+ policy = policy(record)
218
+ method_name = "editable_attributes_for_#{action}"
219
+
220
+ if policy.respond_to?(method_name)
221
+ policy.public_send(method_name)
222
+ elsif policy.respond_to?(:editable_attributes)
223
+ policy.editable_attributes
224
+ else
225
+ []
226
+ end
227
+ end
228
+
229
+ # Filter a hash of attributes to only include visible ones
230
+ def filter_attributes(record, attributes)
231
+ visible = visible_attributes(record)
232
+ attributes.select { |key, _value| visible.include?(key.to_sym) }
233
+ end
234
+
117
235
  # Automatically build permitted params from policy
118
236
  def policy_params(record, param_key = nil)
119
237
  param_key ||= record.model_name.param_key
@@ -123,12 +241,14 @@ module SimpleAuthorize
123
241
  # Verify that authorization was performed
124
242
  def verify_authorized
125
243
  return if authorization_performed?
244
+
126
245
  raise AuthorizationNotPerformedError, "#{self.class}##{action_name} is missing authorization"
127
246
  end
128
247
 
129
248
  # Verify that scoping was performed for index actions
130
249
  def verify_policy_scoped
131
250
  return if policy_scoped?
251
+
132
252
  raise PolicyScopingNotPerformedError, "#{self.class}##{action_name} is missing policy scope"
133
253
  end
134
254
 
@@ -157,11 +277,17 @@ module SimpleAuthorize
157
277
  current_user
158
278
  end
159
279
 
280
+ # Clear the policy cache
281
+ def clear_policy_cache
282
+ @_policy_cache = nil
283
+ end
284
+
160
285
  # Reset authorization tracking (useful in tests)
161
286
  def reset_authorization
162
287
  @authorization_performed = nil
163
288
  @policy_scoping_performed = nil
164
289
  @_policy = nil
290
+ clear_policy_cache
165
291
  end
166
292
 
167
293
  # Support for headless policies (policies without a model)
@@ -169,9 +295,15 @@ module SimpleAuthorize
169
295
  query ||= "#{action_name}?"
170
296
  policy = policy_class.new(authorized_user, nil)
171
297
 
172
- unless policy.public_send(query)
173
- raise NotAuthorizedError.new(query: query, record: policy_class, policy: policy)
174
- end
298
+ authorized = policy.public_send(query)
299
+ error = nil
300
+
301
+ error = NotAuthorizedError.new(query: query, record: policy_class, policy: policy) unless authorized
302
+
303
+ # Emit instrumentation event (with nil record for headless policies)
304
+ instrument_authorization(nil, query, policy_class, authorized, error) if instrumentation_enabled?
305
+
306
+ raise error if error
175
307
 
176
308
  @authorization_performed = true
177
309
  true
@@ -180,11 +312,43 @@ module SimpleAuthorize
180
312
  # Check if user can perform action without raising
181
313
  def allowed_to?(action, record, policy_class: nil)
182
314
  policy = policy(record, policy_class: policy_class)
183
- policy.public_send("#{action}?")
315
+ query = action.to_s.end_with?("?") ? action.to_s : "#{action}?"
316
+ policy.public_send(query)
184
317
  rescue PolicyNotDefinedError
185
318
  false
186
319
  end
187
320
 
321
+ # Batch Authorization Methods
322
+
323
+ # Authorize all records or raise on first failure
324
+ def authorize_all(records, query = nil, policy_class: nil)
325
+ query ||= "#{action_name}?"
326
+
327
+ records.each do |record|
328
+ authorize(record, query, policy_class: policy_class)
329
+ end
330
+
331
+ records
332
+ end
333
+
334
+ # Return only authorized records
335
+ def authorized_records(records, query = nil, policy_class: nil)
336
+ query ||= "#{action_name}?"
337
+
338
+ records.select do |record|
339
+ allowed_to?(query, record, policy_class: policy_class)
340
+ end
341
+ end
342
+
343
+ # Partition records into [authorized, unauthorized]
344
+ def partition_records(records, query = nil, policy_class: nil)
345
+ query ||= "#{action_name}?"
346
+
347
+ records.partition do |record|
348
+ allowed_to?(query, record, policy_class: policy_class)
349
+ end
350
+ end
351
+
188
352
  # Get all allowed actions for a record
189
353
  def allowed_actions(record)
190
354
  policy = policy(record)
@@ -215,35 +379,165 @@ module SimpleAuthorize
215
379
  handle_unauthorized(exception)
216
380
  end
217
381
 
382
+ # Check if current request is an API request (JSON/XML)
383
+ def api_request?
384
+ return false unless respond_to?(:request)
385
+
386
+ # Check request format
387
+ return true if request.respond_to?(:format) && (request.format.json? || request.format.xml?)
388
+
389
+ # Check Accept header
390
+ if request.respond_to?(:headers) && request.headers["Accept"]
391
+ accept = request.headers["Accept"].to_s
392
+ return true if accept.include?("application/json") || accept.include?("application/xml")
393
+ end
394
+
395
+ # Check Content-Type header
396
+ if request.respond_to?(:headers) && request.headers["Content-Type"]
397
+ content_type = request.headers["Content-Type"].to_s
398
+ return true if content_type.include?("application/json") || content_type.include?("application/xml")
399
+ end
400
+
401
+ false
402
+ end
403
+
404
+ # Handle API authorization errors with JSON response
405
+ def handle_api_authorization_error(exception)
406
+ status = exception.record.nil? || authorized_user.nil? ? 401 : 403
407
+ message = SimpleAuthorize.configuration.default_error_message
408
+
409
+ body = {
410
+ error: "not_authorized",
411
+ message: message
412
+ }
413
+
414
+ # Add detailed information if configured
415
+ if SimpleAuthorize.configuration.api_error_details
416
+ body[:query] = exception.query.to_s
417
+ body[:record_type] = exception.record&.class&.name
418
+ body[:policy] = exception.policy&.class&.name
419
+ end
420
+
421
+ {
422
+ status: status,
423
+ content_type: "application/json",
424
+ body: body
425
+ }
426
+ end
427
+
428
+ # Build an API error response
429
+ def api_error_response(message:, status: 403, details: nil)
430
+ body = {
431
+ error: "not_authorized",
432
+ message: message
433
+ }
434
+
435
+ body[:details] = details if details
436
+
437
+ {
438
+ status: status,
439
+ content_type: "application/json",
440
+ body: body
441
+ }
442
+ end
443
+
218
444
  protected
219
445
 
446
+ # Build a cache key for a policy instance
447
+ # The key is based on user, record, and policy class to ensure proper scoping
448
+ def build_policy_cache_key(record, policy_class)
449
+ user_key = authorized_user&.id || authorized_user.object_id
450
+ record_key = if record.respond_to?(:id) && record.id.present?
451
+ "#{record.class.name}-#{record.id}"
452
+ else
453
+ "#{record.class.name}-#{record.object_id}"
454
+ end
455
+ policy_key = policy_class.name
456
+
457
+ "#{user_key}/#{record_key}/#{policy_key}"
458
+ end
459
+
460
+ # Check if instrumentation is enabled
461
+ def instrumentation_enabled?
462
+ SimpleAuthorize.configuration.enable_instrumentation
463
+ end
464
+
465
+ # Emit authorization instrumentation event
466
+ def instrument_authorization(record, query, policy_class, authorized, error)
467
+ ActiveSupport::Notifications.instrument("authorize.simple_authorize",
468
+ build_instrumentation_payload(record, query, policy_class, authorized,
469
+ error))
470
+ end
471
+
472
+ # Emit policy scope instrumentation event
473
+ def instrument_policy_scope(scope, policy_scope_class, error)
474
+ ActiveSupport::Notifications.instrument("policy_scope.simple_authorize",
475
+ build_scope_payload(scope, policy_scope_class, error))
476
+ end
477
+
478
+ # Build payload for authorization events
479
+ def build_instrumentation_payload(record, query, policy_class, authorized, error)
480
+ payload = {
481
+ user: authorized_user,
482
+ user_id: authorized_user&.id,
483
+ record: record,
484
+ record_id: record.respond_to?(:id) ? record&.id : nil,
485
+ record_class: record&.class&.name,
486
+ query: query.to_s,
487
+ policy_class: policy_class,
488
+ authorized: authorized,
489
+ error: error
490
+ }
491
+
492
+ # Add controller and action info if available
493
+ payload[:controller] = controller_name if respond_to?(:controller_name)
494
+ payload[:action] = action_name if respond_to?(:action_name)
495
+
496
+ payload
497
+ end
498
+
499
+ # Build payload for policy scope events
500
+ def build_scope_payload(scope, policy_scope_class, error)
501
+ payload = {
502
+ user: authorized_user,
503
+ user_id: authorized_user&.id,
504
+ scope: scope,
505
+ policy_scope_class: policy_scope_class,
506
+ error: error
507
+ }
508
+
509
+ # Add controller and action info if available
510
+ payload[:controller] = controller_name if respond_to?(:controller_name)
511
+ payload[:action] = action_name if respond_to?(:action_name)
512
+
513
+ payload
514
+ end
515
+
220
516
  def policy_class_for(record, namespace: nil)
221
517
  klass = record.class
222
518
  record_class = if record.is_a?(Class)
223
- record.name
224
- elsif record.respond_to?(:model_name)
225
- record.model_name.to_s
226
- elsif klass.respond_to?(:model_name)
227
- klass.model_name.to_s
228
- else
229
- klass.name
230
- end
519
+ record.name
520
+ elsif record.respond_to?(:model_name)
521
+ record.model_name.to_s
522
+ elsif klass.respond_to?(:model_name)
523
+ klass.model_name.to_s
524
+ else
525
+ klass.name
526
+ end
231
527
 
232
528
  policy_class_name = if namespace
233
- "#{namespace.to_s.camelize}::#{record_class}Policy"
234
- else
235
- "#{record_class}Policy"
236
- end
529
+ "#{namespace.to_s.camelize}::#{record_class}Policy"
530
+ else
531
+ "#{record_class}Policy"
532
+ end
237
533
 
238
534
  begin
239
535
  policy_class_name.constantize
240
536
  rescue NameError
241
537
  # Fall back to non-namespaced policy if namespaced one doesn't exist
242
- if namespace
243
- "#{record_class}Policy".constantize
244
- else
245
- raise
246
- end
538
+ raise unless namespace
539
+
540
+ "#{record_class}Policy".constantize
247
541
  end
248
542
  end
249
543
 
@@ -259,7 +553,15 @@ module SimpleAuthorize
259
553
 
260
554
  # Handle authorization errors
261
555
  def handle_unauthorized(exception = nil)
262
- flash[:alert] = "You are not authorized to perform this action."
556
+ # Handle API requests with JSON response
557
+ if api_request? && exception.is_a?(NotAuthorizedError)
558
+ response = handle_api_authorization_error(exception)
559
+ render json: response[:body], status: response[:status]
560
+ return
561
+ end
562
+
563
+ # Handle traditional HTML requests with redirect
564
+ flash[:alert] = SimpleAuthorize.configuration.default_error_message
263
565
  safe_redirect_path = safe_referrer_path || root_path
264
566
 
265
567
  if exception
@@ -278,11 +580,7 @@ module SimpleAuthorize
278
580
  request_uri = URI.parse(request.url)
279
581
 
280
582
  # Only allow referrers from the same host
281
- if referrer_uri.host == request_uri.host
282
- referrer_uri.path
283
- else
284
- nil
285
- end
583
+ referrer_uri.path if referrer_uri.host == request_uri.host
286
584
  rescue URI::InvalidURIError
287
585
  nil
288
586
  end
@@ -39,6 +39,28 @@ module SimpleAuthorize
39
39
  false
40
40
  end
41
41
 
42
+ # Attribute-level authorization
43
+
44
+ # Returns array of attributes visible to the user
45
+ def visible_attributes
46
+ []
47
+ end
48
+
49
+ # Returns array of attributes editable by the user
50
+ def editable_attributes
51
+ []
52
+ end
53
+
54
+ # Check if a specific attribute is visible
55
+ def attribute_visible?(attribute)
56
+ visible_attributes.include?(attribute.to_sym)
57
+ end
58
+
59
+ # Check if a specific attribute is editable
60
+ def attribute_editable?(attribute)
61
+ editable_attributes.include?(attribute.to_sym)
62
+ end
63
+
42
64
  # Helper methods
43
65
  protected
44
66
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleAuthorize
4
+ # Railtie for automatic integration with Rails
5
+ class Railtie < Rails::Railtie
6
+ initializer "simple_authorize.configure" do
7
+ ActiveSupport.on_load(:action_controller) do
8
+ # Make SimpleAuthorize::Controller available as Authorization for backwards compatibility
9
+ ::Authorization = SimpleAuthorize::Controller unless defined?(::Authorization)
10
+ # Make SimpleAuthorize::Policy available as ApplicationPolicy for backwards compatibility
11
+ ::ApplicationPolicy = SimpleAuthorize::Policy unless defined?(::ApplicationPolicy)
12
+ end
13
+ end
14
+
15
+ # Generate initializer template
16
+ generators do
17
+ require_relative "../generators/simple_authorize/install/install_generator"
18
+ end
19
+ end
20
+ end