verifica 1.0.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8df98ff228b89e7701f41df3d5617d93183e83c32cdae5c879e347c615f367d7
4
- data.tar.gz: 956259725d32e5a6208a03f2a07d682d6facbb1dcaae107687ce59cd1cd8363c
3
+ metadata.gz: f38a13446f6e2b3eb7e118a1d97d5369aca85116a3bde44d7dd1f07bfe013f7a
4
+ data.tar.gz: ae82a49612de5e13ed32992a13c34a3af57e50b18772c0651194459b5a187010
5
5
  SHA512:
6
- metadata.gz: d3329c9e126220d43a8e9b4a7923541b30139e74f565387bed6012acea9760239d1b5850d19ee2963e63252ddde3008e39486ac66438e6e9215afe18de0d4256
7
- data.tar.gz: 1dab1e7bab877e5f63298240db13270c6f46c238643f508037e81f524a99a54f3613e3515690e54886ac032b371d394622e51d5d33d92528ddab8560d7babb2e
6
+ metadata.gz: fcaee3d318dbada1fe7f4c135bbd38219b8031126e1e0d29b5c125a15f1867e8305fb4ec5ca91a2b4eddc4040ad49d24af9e79fb6a36ddab115f2d50db70d5fe
7
+ data.tar.gz: f0a4392ff578a8444b5f922ae7f3844bd14126ceb5570bf2e9ded02463a3ff63ec69d68b248e5b4241dfa10db4ab537b6fd35b2073e1d4d070f76d74f2c6e896
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.2] - 2023-10-25
11
+
12
+ ### Changed
13
+
14
+ - Make `Authorizer#authorization_result` public for even more usage flexibility
15
+
16
+ ## [1.0.1] - 2023-04-15
17
+
18
+ ### Added
19
+
20
+ - `Sid#country_sid` and `Sid#group_sid` helper methods
21
+
10
22
  ## [1.0.0] - 2023-01-19
11
23
 
12
24
  Initial public release
data/README.md CHANGED
@@ -1,4 +1,6 @@
1
+ [![Gem Version](https://badge.fury.io/rb/verifica.svg)](https://badge.fury.io/rb/verifica)
1
2
  [![CI](https://github.com/maximgurin/verifica/actions/workflows/ci.yml/badge.svg)](https://github.com/maximgurin/verifica/actions/workflows/ci.yml)
3
+ [![Yard Docs](http://img.shields.io/badge/yard-docs-blue.svg)](http://rubydoc.info/github/maximgurin/verifica)
2
4
  [![Codacy Badge](https://app.codacy.com/project/badge/Grade/457e56b0bb514539844a94d85abe99f9)](https://www.codacy.com/gh/maximgurin/verifica/dashboard?utm_source=github.com&utm_medium=referral&utm_content=maximgurin/verifica&utm_campaign=Badge_Grade)
3
5
  [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/457e56b0bb514539844a94d85abe99f9)](https://www.codacy.com/gh/maximgurin/verifica/dashboard?utm_source=github.com&utm_medium=referral&utm_content=maximgurin/verifica&utm_campaign=Badge_Coverage)
4
6
  ![GitHub](https://img.shields.io/github/license/maximgurin/verifica)
@@ -18,11 +20,11 @@ for any user and resource, regardless of how complex the authorization rules are
18
20
 
19
21
  *Note: Verifica is a new open-source gem, so you may wonder if it's reliable. Internally,
20
22
  this solution has been battle-tested in several B2B products, including one with over 15M database records.
21
- But anyway, trust nothing. DYOR.*
23
+ But DYOR anyway.*
22
24
 
23
25
  ## Why Verifica? Isn't Pundit or CanCanCan enough?
24
26
 
25
- Let's say you working on a video platform application:
27
+ Let's say you are working on a video platform application:
26
28
 
27
29
  - You have 10M videos in the database
28
30
  - 7 types of user roles
@@ -36,7 +38,7 @@ In the [Real-world example with Rails](#real-world-example-with-rails) you can s
36
38
  ## Basic example
37
39
 
38
40
  ```ruby
39
- require 'verifica'
41
+ require "verifica"
40
42
 
41
43
  User = Struct.new(:id, :role, keyword_init: true) do
42
44
  # Verifica expects each security subject to respond to #subject_id, #subject_type, and #subject_sids
@@ -70,26 +72,32 @@ authorizer = Verifica.authorizer do |config|
70
72
  end
71
73
 
72
74
  public_video = Video.new(id: 1, author_id: 1000, public: true)
73
- private_video = Video.new(id: 2, author_id: 1000, public: true)
75
+ private_video = Video.new(id: 2, author_id: 1000, public: false)
74
76
 
75
77
  superuser = User.new(id: 777, role: "root")
76
78
  video_author = User.new(id: 1000, role: "user")
77
79
  other_user = User.new(id: 2000, role: "user")
78
80
 
79
- authorizer.authorized?(superuser, private_video, :delete)
80
- # true
81
-
82
- authorizer.authorized?(video_author, private_video, :delete)
83
- # true
84
-
85
- authorizer.authorized?(other_user, private_video, :read)
86
- # false
81
+ authorizer.authorized?(superuser, private_video, :delete) # => true
82
+ authorizer.authorized?(video_author, private_video, :delete) # => true
83
+ authorizer.authorized?(other_user, private_video, :read) # => false
84
+ authorizer.authorized?(other_user, public_video, :comment) # => true
87
85
 
88
- authorizer.authorized?(other_user, public_video, :comment)
89
- # true
86
+ begin
87
+ # raises Verifica::AuthorizationError: Authorization FAILURE. Subject 'user' id='2000'. Resource 'video' id='1'. Action 'write'
88
+ authorizer.authorize(other_user, public_video, :write)
89
+ rescue Verifica::AuthorizationError => e
90
+ e.explain # => Long-form explanation of why action is not authorized, your debugging friend
91
+ end
90
92
 
91
- authorizer.authorize(other_user, public_video, :write)
92
- # raises Verifica::AuthorizationError: Authorization FAILURE. Subject 'user' id='2000'. Resource 'video' id='1'. Action 'write'
93
+ # #authorization_result returns a special object with a bunch of useful info
94
+ auth_result = authorizer.authorization_result(superuser, private_video, :delete)
95
+ auth_result.success? # => true
96
+ auth_result.subject_id # => 777
97
+ auth_result.resource_type # => :video
98
+ auth_result.action # => :delete
99
+ auth_result.allowed_actions # => [:read, :write, :delete, :comment]
100
+ auth_result.explain # => Long-form explanation of why action is authorized
93
101
  ```
94
102
 
95
103
  ## Installation
@@ -120,11 +128,11 @@ class User
120
128
  def subject_id
121
129
  123
122
130
  end
123
-
131
+
124
132
  def subject_type
125
133
  :user
126
134
  end
127
-
135
+
128
136
  def subject_sids
129
137
  ["root"] # see Security Identifier section below to understand what is this for
130
138
  end
@@ -143,7 +151,7 @@ class Post
143
151
  def resource_id
144
152
  1
145
153
  end
146
-
154
+
147
155
  def resource_type
148
156
  :post
149
157
  end
@@ -184,9 +192,9 @@ end
184
192
 
185
193
  video_acl.to_a
186
194
  # =>
187
- # [#<Verifica::Ace:0x00007fab1955dd60 @action=:view, @allow=true, @sid="authenticated">,
188
- # #<Verifica::Ace:0x00007fab1955dd10 @action=:comment, @allow=true, @sid="authenticated">,
189
- # #<Verifica::Ace:0x00007fab1955dc48 @action=:view, @allow=false, @sid="country:US">]
195
+ # [#<Verifica::Ace:0x00007fab1955dd60 @action=:read, @allow=true, @sid="authenticated">,
196
+ # #<Verifica::Ace:0x00007fab1955dd10 @action=:comment, @allow=true, @sid="authenticated">,
197
+ # #<Verifica::Ace:0x00007fab1955dc48 @action=:read, @allow=false, @sid="country:US">]
190
198
  ```
191
199
 
192
200
  ### AclProvider
@@ -210,8 +218,8 @@ end
210
218
  ### Authorizer
211
219
 
212
220
  And finally, Authorizer, the heart of Verifica. It couples all concepts above into an isolated container with no global state.
213
- Each Authorizer has a list of resource types registered with their companion AclProviders.
214
- And most importantly, Authorizer has several methods to check the Subject's rights to perform a specific action on a given resource.
221
+ Each Authorizer has a list of resource types registered with their companion AclProviders and
222
+ several methods to check the Subject's rights to perform a specific action on a given resource.
215
223
 
216
224
  Check the [Basic example](#basic-example) above to see how it all plays together.
217
225
 
@@ -280,7 +288,7 @@ class VideoAclProvider
280
288
  author_org = video.author.organization
281
289
  allowed_countries = author_org&.allow_countries || ds.allow_countries
282
290
  denied_countries = author_org&.deny_countries || ds.deny_countries
283
-
291
+
284
292
  # ...and 30 more lines to handle all our requirements
285
293
  end
286
294
  end
@@ -318,11 +326,11 @@ class User < ApplicationRecord
318
326
  when "moderator"
319
327
  [user_sid(id), role_sid("moderator")]
320
328
  when "user"
321
- sids = [authenticated_sid, user_sid(id), "country:#{country}"]
329
+ sids = [authenticated_sid, user_sid(id), country_sid(country)]
322
330
  organization_id.try { |org_id| sids.push(organization_sid(org_id)) }
323
331
  sids
324
332
  when "organization_admin"
325
- sids = [authenticated_sid, user_sid(id), "country:#{country}"]
333
+ sids = [authenticated_sid, user_sid(id), country_sid(country)]
326
334
  sids.push(organization_sid(organization_id))
327
335
  sids.push(role_sid("organization_admin:#{organization_id}"))
328
336
  else
@@ -354,7 +362,7 @@ end
354
362
  ```
355
363
 
356
364
  ```ruby
357
- # app/models/user.rb
365
+ # app/models/video.rb
358
366
 
359
367
  class Video < ApplicationRecord
360
368
  attr_accessor :allowed_actions
@@ -363,7 +371,7 @@ class Video < ApplicationRecord
363
371
  before_save :update_read_acl
364
372
 
365
373
  def resource_type = :video
366
-
374
+
367
375
  def update_read_acl
368
376
  acl = AUTHORIZER.resource_acl(self)
369
377
  self.read_allow_sids = acl.allowed_sids(:read)
@@ -383,12 +391,16 @@ end
383
391
 
384
392
  class VideosController
385
393
  def index
386
- @videos = Video.available_for(current_user).order(:name).limit(50)
394
+ @videos = Video
395
+ .includes(:distribution_setting, author: [:organization])
396
+ .available_for(current_user)
397
+ .order(:name)
398
+ .limit(50)
387
399
  end
388
-
400
+
389
401
  def show
390
402
  @video = Video.find(params[:id])
391
-
403
+
392
404
  # upon successful authorization helper object is returned with a bunch of useful info
393
405
  auth_result = AUTHORIZER.authorize(current_user, @video, :read)
394
406
 
@@ -415,8 +427,6 @@ run a background job to find all affected videos and update `read_allow_sids`, `
415
427
  Same applies to Distribution Settings and other dependencies.
416
428
  - **Rules change handling.** If implementation of `VideoAclProvider` changed you need to run a background job
417
429
  to update `read_allow_sids`, `read_deny_sids` columns for all videos.
418
- - **Cache, N+1 problem.** `VideoAclProvider` retrieves a chain of records associated with each video which leads to
419
- N+1 problem in a naive implementation.
420
430
 
421
431
  See also:
422
432
 
data/lib/verifica/acl.rb CHANGED
@@ -54,7 +54,6 @@ module Verifica
54
54
 
55
55
  allow_deny[:allowed_sids].freeze
56
56
  allow_deny[:denied_sids].freeze
57
- allow_deny.freeze
58
57
  end
59
58
 
60
59
  @allowed_actions.freeze
@@ -154,7 +153,7 @@ module Verifica
154
153
 
155
154
  # @example
156
155
  # acl = Verifica::Acl.build { |acl| acl.allow "root", [:read, :write] }
157
- # acl.to_a.map(:to_h)
156
+ # acl.to_a.map(&:to_h)
158
157
  # # => [{:sid=>"root", :action=>:read, :allow=>true}, {:sid=>"root", :action=>:write, :allow=>true}]
159
158
  #
160
159
  # @return [Array<Ace>] a new array representing +self+
@@ -73,6 +73,11 @@ module Verifica
73
73
 
74
74
  # The same as {#authorize} but returns true/false instead of rising an exception
75
75
  #
76
+ # @param subject (see #authorize)
77
+ # @param resource (see #authorize)
78
+ # @param action (see #authorize)
79
+ # @param context (see #authorize)
80
+ #
76
81
  # @return [Boolean] true if +action+ on +resource+ is authorized for +subject+
77
82
  # @raise [Error] if +resource.resource_type+ isn't registered in +self+
78
83
  #
@@ -81,9 +86,31 @@ module Verifica
81
86
  authorization_result(subject, resource, action, **context).success?
82
87
  end
83
88
 
84
- # @param subject [Object] subject of the authorization (e.g. current user, external service)
85
- # @param resource [Object] resource to get allowed actions for, should respond to +#resource_type+
86
- # @param **context (see #authorize)
89
+ # The same as {#authorize} but returns a special result object instead of rising an exception
90
+ #
91
+ # @param subject (see #authorize)
92
+ # @param resource (see #authorize)
93
+ # @param action (see #authorize)
94
+ # @param context (see #authorize)
95
+ #
96
+ # @return [AuthorizationResult] authorization result with all details
97
+ # @raise [Error] if +resource.resource_type+ isn't registered in +self+
98
+ #
99
+ # @api public
100
+ def authorization_result(subject, resource, action, **context)
101
+ action = action.to_sym
102
+ possible_actions = config_by_resource(resource).possible_actions
103
+ unless possible_actions.include?(action)
104
+ raise Error, "'#{action}' action is not registered as possible for '#{resource.resource_type}' resource"
105
+ end
106
+
107
+ acl = resource_acl(resource, **context)
108
+ AuthorizationResult.new(subject, resource, action, acl, **context)
109
+ end
110
+
111
+ # @param subject (see #authorize)
112
+ # @param resource (see #authorize)
113
+ # @param context (see #authorize)
87
114
  #
88
115
  # @return [Array<Symbol>] array of actions allowed for +subject+ or empty array if none
89
116
  # @raise [Error] if +resource.resource_type+ isn't registered in +self+
@@ -128,7 +155,7 @@ module Verifica
128
155
  @resources.key?(resource_type.to_sym)
129
156
  end
130
157
 
131
- # @param resource [Object] resource to get ACL for, should respond to +#resource_type+
158
+ # @param resource (see #authorize)
132
159
  # @param context [Hash] arbitrary keyword arguments to forward to +acl_provider.call+
133
160
  #
134
161
  # @return [Acl] Access Control List for +resource+
@@ -141,6 +168,7 @@ module Verifica
141
168
  def resource_acl(resource, **context)
142
169
  config = config_by_resource(resource)
143
170
  acl = config.acl_provider.call(resource, **context)
171
+ # trade-off flexibility to increase robustness here by requiring a specific type
144
172
  unless acl.is_a?(Verifica::Acl)
145
173
  type = resource.resource_type
146
174
  raise Error, "'#{type}' resource acl_provider should respond to #call with Acl object but got '#{acl.class}'"
@@ -172,16 +200,5 @@ module Verifica
172
200
 
173
201
  resource_config(type)
174
202
  end
175
-
176
- private def authorization_result(subject, resource, action, **context)
177
- action = action.to_sym
178
- possible_actions = config_by_resource(resource).possible_actions
179
- unless possible_actions.include?(action)
180
- raise Error, "'#{action}' action is not registered as possible for '#{resource.resource_type}' resource"
181
- end
182
-
183
- acl = resource_acl(resource, **context)
184
- AuthorizationResult.new(subject, resource, action, acl, **context)
185
- end
186
203
  end
187
204
  end
data/lib/verifica/sid.rb CHANGED
@@ -211,5 +211,66 @@ module Verifica
211
211
 
212
212
  "org:#{organization_id}".freeze
213
213
  end
214
+
215
+ # Security Identifier of the subject who is a member of the group with given +group_id+
216
+ #
217
+ # @note (see #user_sid)
218
+ #
219
+ # @example
220
+ # class PostAclProvider
221
+ # include Verifica::Sid
222
+ #
223
+ # def call(post, **)
224
+ # Verifica::Acl.build do |acl|
225
+ # post.editor_groups.each do |group_id|
226
+ # acl.allow group_sid(group_id), [:edit, :delete]
227
+ # end
228
+ #
229
+ # # ...
230
+ # end
231
+ # end
232
+ # end
233
+ #
234
+ # @return [String]
235
+ #
236
+ # @api public
237
+ def group_sid(group_id)
238
+ if group_id.nil?
239
+ raise ArgumentError, "Nil 'group_id' is unsafe. Use empty string if you absolutely need this behavior"
240
+ end
241
+
242
+ "group:#{group_id}".freeze
243
+ end
244
+
245
+ # Security Identifier of the subject whose country is the country with given +country_id+
246
+ #
247
+ # @note (see #user_sid)
248
+ #
249
+ # @example
250
+ # class PostAclProvider
251
+ # include Verifica::Sid
252
+ #
253
+ # def call(post, **)
254
+ # Verifica::Acl.build do |acl|
255
+ # acl.allow authenticated_sid, [:read, :comment]
256
+ # post.banned_countries.each do |country_id|
257
+ # acl.deny country_sid(country_id), [:read, :comment]
258
+ # end
259
+ #
260
+ # # ...
261
+ # end
262
+ # end
263
+ # end
264
+ #
265
+ # @return [String]
266
+ #
267
+ # @api public
268
+ def country_sid(country_id)
269
+ if country_id.nil?
270
+ raise ArgumentError, "Nil 'country_id' is unsafe. Use empty string if you absolutely need this behavior"
271
+ end
272
+
273
+ "country:#{country_id}".freeze
274
+ end
214
275
  end
215
276
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Verifica
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.2"
5
5
  end
data/lib/verifica.rb CHANGED
@@ -20,7 +20,7 @@ require_relative "verifica/version"
20
20
  # (who can do what for any given resource) and execution (can +current_user+ delete this post?).
21
21
  #
22
22
  # @example
23
- # require 'verifica'
23
+ # require "verifica"
24
24
  #
25
25
  # User = Struct.new(:id, :role, keyword_init: true) do
26
26
  # # Verifica expects each security subject to respond to #subject_id, #subject_type, and #subject_sids
@@ -54,26 +54,32 @@ require_relative "verifica/version"
54
54
  # end
55
55
  #
56
56
  # public_video = Video.new(id: 1, author_id: 1000, public: true)
57
- # private_video = Video.new(id: 2, author_id: 1000, public: true)
57
+ # private_video = Video.new(id: 2, author_id: 1000, public: false)
58
58
  #
59
59
  # superuser = User.new(id: 777, role: "root")
60
60
  # video_author = User.new(id: 1000, role: "user")
61
61
  # other_user = User.new(id: 2000, role: "user")
62
62
  #
63
- # authorizer.authorized?(superuser, private_video, :delete)
64
- # # true
63
+ # authorizer.authorized?(superuser, private_video, :delete) # => true
64
+ # authorizer.authorized?(video_author, private_video, :delete) # => true
65
+ # authorizer.authorized?(other_user, private_video, :read) # => false
66
+ # authorizer.authorized?(other_user, public_video, :comment) # => true
65
67
  #
66
- # authorizer.authorized?(video_author, private_video, :delete)
67
- # # true
68
- #
69
- # authorizer.authorized?(other_user, private_video, :read)
70
- # # false
71
- #
72
- # authorizer.authorized?(other_user, public_video, :comment)
73
- # # true
68
+ # begin
69
+ # # raises Verifica::AuthorizationError: Authorization FAILURE. Subject 'user' id='2000'. Resource 'video' id='1'. Action 'write'
70
+ # authorizer.authorize(other_user, public_video, :write)
71
+ # rescue Verifica::AuthorizationError => e
72
+ # e.explain # => Long-form explanation of why action is not authorized, your debugging friend
73
+ # end
74
74
  #
75
- # authorizer.authorize(other_user, public_video, :write)
76
- # # raises Verifica::AuthorizationError: Authorization FAILURE. Subject 'user' id='2000'. Resource 'video' id='1'. Action 'write'
75
+ # # #authorization_result returns a special object with a bunch of useful info
76
+ # auth_result = authorizer.authorization_result(superuser, private_video, :delete)
77
+ # auth_result.success? # => true
78
+ # auth_result.subject_id # => 777
79
+ # auth_result.resource_type # => :video
80
+ # auth_result.action # => :delete
81
+ # auth_result.allowed_actions # => [:read, :write, :delete, :comment]
82
+ # auth_result.explain # => Long-form explanation of why action is authorized
77
83
  #
78
84
  # @api public
79
85
  module Verifica
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verifica
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maxim Gurin
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-01-19 00:00:00.000000000 Z
11
+ date: 2023-10-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -105,7 +105,7 @@ metadata:
105
105
  source_code_uri: https://github.com/maximgurin/verifica
106
106
  bug_tracker_uri: https://github.com/maximgurin/verifica/issues
107
107
  rubygems_mfa_required: 'true'
108
- post_install_message:
108
+ post_install_message:
109
109
  rdoc_options: []
110
110
  require_paths:
111
111
  - lib
@@ -120,8 +120,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
120
120
  - !ruby/object:Gem::Version
121
121
  version: '0'
122
122
  requirements: []
123
- rubygems_version: 3.4.3
124
- signing_key:
123
+ rubygems_version: 3.4.19
124
+ signing_key:
125
125
  specification_version: 4
126
126
  summary: The most scalable authorization solution for Ruby
127
127
  test_files: []