verifica 1.0.0 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
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: []