verifica 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8df98ff228b89e7701f41df3d5617d93183e83c32cdae5c879e347c615f367d7
4
+ data.tar.gz: 956259725d32e5a6208a03f2a07d682d6facbb1dcaae107687ce59cd1cd8363c
5
+ SHA512:
6
+ metadata.gz: d3329c9e126220d43a8e9b4a7923541b30139e74f565387bed6012acea9760239d1b5850d19ee2963e63252ddde3008e39486ac66438e6e9215afe18de0d4256
7
+ data.tar.gz: 1dab1e7bab877e5f63298240db13270c6f46c238643f508037e81f524a99a54f3613e3515690e54886ac032b371d394622e51d5d33d92528ddab8560d7babb2e
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2023-01-19
11
+
12
+ Initial public release
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022-2023 Maxim Gurin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,449 @@
1
+ [![CI](https://github.com/maximgurin/verifica/actions/workflows/ci.yml/badge.svg)](https://github.com/maximgurin/verifica/actions/workflows/ci.yml)
2
+ [![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
+ [![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
+ ![GitHub](https://img.shields.io/github/license/maximgurin/verifica)
5
+
6
+ # Verifica
7
+
8
+ Verifica is Ruby's most scalable authorization solution ready to handle sophisticated authorization rules.
9
+
10
+ - Framework and database agnostic
11
+ - Scalable. Start from 10, grow to 10M records in the database while having the same authorization architecture
12
+ - Supports any actor in your application. Traditional `current_user`, external service, API client, you name it
13
+ - No global state. Only local, immutable objects
14
+ - Plain old Ruby, zero dependencies, no magic
15
+
16
+ Verifica is designed around Access Control List. ACL powers a straightforward and unified authorization flow
17
+ for any user and resource, regardless of how complex the authorization rules are.
18
+
19
+ *Note: Verifica is a new open-source gem, so you may wonder if it's reliable. Internally,
20
+ this solution has been battle-tested in several B2B products, including one with over 15M database records.
21
+ But anyway, trust nothing. DYOR.*
22
+
23
+ ## Why Verifica? Isn't Pundit or CanCanCan enough?
24
+
25
+ Let's say you working on a video platform application:
26
+
27
+ - You have 10M videos in the database
28
+ - 7 types of user roles
29
+ - 20 rules defining who is allowed to access the video
30
+ - Rules require querying other entities too (video author settings, author's organization settings, etc.)
31
+
32
+ Given all these, *how do you even find a list of videos available for `current_user`?*
33
+ Bunch of `if/elsif` and enormous SQL query with many joins? Is there a better way? Verifica shines for this kind of problem.
34
+ In the [Real-world example with Rails](#real-world-example-with-rails) you can see the solution in detail.
35
+
36
+ ## Basic example
37
+
38
+ ```ruby
39
+ require 'verifica'
40
+
41
+ User = Struct.new(:id, :role, keyword_init: true) do
42
+ # Verifica expects each security subject to respond to #subject_id, #subject_type, and #subject_sids
43
+ alias_method :subject_id, :id
44
+ def subject_type = :user
45
+
46
+ def subject_sids(**)
47
+ role == "root" ? ["root"] : ["authenticated", "user:#{id}"]
48
+ end
49
+ end
50
+
51
+ Video = Struct.new(:id, :author_id, :public, keyword_init: true) do
52
+ # Verifica expects each secured resource to respond to #resource_id, and #resource_type
53
+ alias_method :resource_id, :id
54
+ def resource_type = :video
55
+ end
56
+
57
+ video_acl_provider = lambda do |video, **|
58
+ Verifica::Acl.build do |acl|
59
+ acl.allow "root", [:read, :write, :delete, :comment]
60
+ acl.allow "user:#{video.author_id}", [:read, :write, :delete, :comment]
61
+
62
+ if video.public
63
+ acl.allow "authenticated", [:read, :comment]
64
+ end
65
+ end
66
+ end
67
+
68
+ authorizer = Verifica.authorizer do |config|
69
+ config.register_resource :video, [:read, :write, :delete, :comment], video_acl_provider
70
+ end
71
+
72
+ public_video = Video.new(id: 1, author_id: 1000, public: true)
73
+ private_video = Video.new(id: 2, author_id: 1000, public: true)
74
+
75
+ superuser = User.new(id: 777, role: "root")
76
+ video_author = User.new(id: 1000, role: "user")
77
+ other_user = User.new(id: 2000, role: "user")
78
+
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
87
+
88
+ authorizer.authorized?(other_user, public_video, :comment)
89
+ # true
90
+
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
+ ```
94
+
95
+ ## Installation
96
+
97
+ **Required Ruby version >= 3.0**
98
+
99
+ Install the gem and add to the application's Gemfile by executing:
100
+
101
+ ```bash
102
+ $ bundle add verifica
103
+ ```
104
+
105
+ ## Core concepts
106
+
107
+ Get a high-level overview of Verifica's core concepts and architecture before diving into usage nuances.
108
+ Verifica may appear complex initially, but it prioritizes explicitness, flexibility, and scalability over nice looking magic.
109
+ Here is an explanation of each component:
110
+
111
+ ### Subject
112
+
113
+ Security subject is a user, process, or system granted access to specific resources.
114
+ In most applications the subject is currently authenticated user, aka `current_user`.
115
+
116
+ In code a subject could be represented by any object that responds to `#subject_id`, `#subject_type`, and `#subject_sids`.
117
+
118
+ ```ruby
119
+ class User
120
+ def subject_id
121
+ 123
122
+ end
123
+
124
+ def subject_type
125
+ :user
126
+ end
127
+
128
+ def subject_sids
129
+ ["root"] # see Security Identifier section below to understand what is this for
130
+ end
131
+ end
132
+ ```
133
+
134
+ ### Resource
135
+
136
+ Resource refers to anything that requires protection.
137
+ In most applications resources are entities stored in the database, such as Post, Comment, User, etc.
138
+
139
+ In code a resource could be represented by any object that responds to `#resource_id` and `#resource_type`.
140
+
141
+ ```ruby
142
+ class Post
143
+ def resource_id
144
+ 1
145
+ end
146
+
147
+ def resource_type
148
+ :post
149
+ end
150
+ end
151
+ ```
152
+
153
+ ### Action
154
+
155
+ Action that Subject can perform on a protected Resource. Represented as a Symbol in code,
156
+ it could be traditional `:read`, `:write`, `:delete` or more domain specific `:comment`, `:publish`, etc.
157
+
158
+ ### Security Identifier
159
+
160
+ SID is a value used to identify and differentiate Subjects
161
+ and assign access rights based on the subject's attributes like role, organization, group, or country.
162
+
163
+ In code SID could be represented by immutable string (other objects work too, equality check is the only requirement).
164
+ Each subject has one or more SIDs.
165
+
166
+ ```ruby
167
+ superuser.subject_sids # => ["root"]
168
+ moderator_user.subject_sids # => ["user:321", "role:moderator"]
169
+ regular_user.subject_sids # => ["authenticated", "user:123", "country:UA"]
170
+ organization_user.subject_sids # => ["authenticated", "user:456", "country:UA", "org:789"]
171
+ anonymous_user.subject_sids # => ["anonymous", "country:UA"]
172
+ ```
173
+
174
+ ### Access Control List
175
+
176
+ ACL consists of Access Control Entries (ACEs) and defines which actions are allowed or denied for particular SIDs.
177
+ ACL is associated with a specific protected resource in your system.
178
+
179
+ ```ruby
180
+ video_acl = Verifica::Acl.build do |acl|
181
+ acl.allow "authenticated", [:read, :comment]
182
+ acl.deny "country:US", [:read]
183
+ end
184
+
185
+ video_acl.to_a
186
+ # =>
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">]
190
+ ```
191
+
192
+ ### AclProvider
193
+
194
+ AclProvider is an object that responds to `#call(resource, **)` and returns ACL for the given resource.
195
+
196
+ ```ruby
197
+ class VideoAclProvider
198
+ def call(video, **context)
199
+ Verifica::Acl.build do |acl|
200
+ acl.allow "user:#{video.author_id}", [:read, :write, :delete, :comment]
201
+
202
+ if video.public?
203
+ acl.allow "authenticated", [:read, :comment]
204
+ end
205
+ end
206
+ end
207
+ end
208
+ ```
209
+
210
+ ### Authorizer
211
+
212
+ 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.
215
+
216
+ Check the [Basic example](#basic-example) above to see how it all plays together.
217
+
218
+ ## Real-world example with Rails
219
+
220
+ Demo: https://verifica-rails-example.maximgurin.com
221
+
222
+ Let's say you started working on your *next big thing* idea — a video hosting application.
223
+ In the beginning, you have only 2 user types and straightforward rules:
224
+
225
+ - *Admins* can see all videos
226
+ - *Users* can see their own videos and public videos of other users
227
+
228
+ ```ruby
229
+ class Video
230
+ scope :available_for, ->(user) do
231
+ where(public: true).or(where(author_id: user.id)) unless user.admin?
232
+ end
233
+ end
234
+
235
+ class VideosController
236
+ def index
237
+ @videos = Video.available_for(current_user)
238
+ end
239
+ end
240
+ ```
241
+
242
+ Time goes by and 4 years later you have:
243
+
244
+ - 10M records in the videos table. Organization and personal user accounts
245
+ - 4 roles: *Admin*, *Moderator*, *Organization Admin*, *User*
246
+ - Video drafts available only to their authors
247
+ - Internal videos available only for members of the author's organization
248
+ - Country restrictions, either in the *allowlist* or *denylist* modes
249
+ - Distribution Settings entity with one-to-many relation to Videos
250
+ - Distribution mode: *public*, *internal*, or *private*
251
+ - Countries *allowlist* or *denylist*
252
+ - Organization-wide country restrictions overrides Distribution Settings
253
+ - *Organization Admins* can see private videos of their org members
254
+ - *Admins* and *Moderators* can see all videos, regardless of country restrictions
255
+
256
+ Wow, that's a pretty extensive list of requirements. Easy to get lost!
257
+ Now the most exciting part. How do you implement `Video.available_for` method with so many details to consider?
258
+ Videos table is big, so you can't use SQL joins to, let's say, check the video author's organization or other dependencies.
259
+ And even if you can, a query with so many joins and conditions would be write-only anyway :)
260
+
261
+ Here is how this challenge could be resolved using Verifica and ACL:
262
+
263
+ ```ruby
264
+ # app/acl_providers/video_acl_provider.rb
265
+
266
+ class VideoAclProvider
267
+ include Verifica::Sid
268
+
269
+ POSSIBLE_ACTIONS = [:read, :write, :delete].freeze
270
+
271
+ def call(video, **)
272
+ Verifica::Acl.build do |acl|
273
+ acl.allow root_sid, POSSIBLE_ACTIONS
274
+ acl.allow user_sid(video.author_id), POSSIBLE_ACTIONS
275
+ acl.allow role_sid("moderator"), [:read, :delete]
276
+
277
+ next if video.draft?
278
+
279
+ ds = video.distribution_setting
280
+ author_org = video.author.organization
281
+ allowed_countries = author_org&.allow_countries || ds.allow_countries
282
+ denied_countries = author_org&.deny_countries || ds.deny_countries
283
+
284
+ # ...and 30 more lines to handle all our requirements
285
+ end
286
+ end
287
+ end
288
+ ```
289
+
290
+ ```ruby
291
+ # config/initializers/verifica.rb
292
+
293
+ require "verifica"
294
+
295
+ # Quick and dirty way for simplicity
296
+ # In the real app, you could use DI container to hold configured Verifica::Authorizer instance
297
+ Rails.configuration.after_initialize do
298
+ AUTHORIZER = Verifica.authorizer do |config|
299
+ config.register_resource :video, VideoAclProvider::POSSIBLE_ACTIONS, VideoAclProvider.new
300
+ end
301
+ end
302
+ ```
303
+
304
+ ```ruby
305
+ # app/models/user.rb
306
+
307
+ class User < ApplicationRecord
308
+ include Verifica::Sid
309
+
310
+ alias_method :subject_id, :id
311
+
312
+ def subject_type = :user
313
+
314
+ def subject_sids(**)
315
+ case role
316
+ when "root"
317
+ [root_sid]
318
+ when "moderator"
319
+ [user_sid(id), role_sid("moderator")]
320
+ when "user"
321
+ sids = [authenticated_sid, user_sid(id), "country:#{country}"]
322
+ organization_id.try { |org_id| sids.push(organization_sid(org_id)) }
323
+ sids
324
+ when "organization_admin"
325
+ sids = [authenticated_sid, user_sid(id), "country:#{country}"]
326
+ sids.push(organization_sid(organization_id))
327
+ sids.push(role_sid("organization_admin:#{organization_id}"))
328
+ else
329
+ throw RuntimeError("Unsupported user role: #{role}")
330
+ end
331
+ end
332
+ end
333
+ ```
334
+
335
+ What we've done:
336
+
337
+ - Configured `Verifica::Authorizer` object. It's available as `AUTHORIZER` constant anywhere in the app
338
+ - Registered `:video` type as a secured resource. `VideoAclProvider` defines rules, who can do what
339
+ - Configured `User` to be a security Subject. Each user has list of Security Identifiers depending on the role and other attributes
340
+
341
+ Now, a few last steps and the challenge resolved:
342
+
343
+ ```ruby
344
+ # db/migrate/20230113203815_add_read_sids_to_videos.rb
345
+
346
+ # For simplicity, we are adding two String array columns directly to videos table.
347
+ # In the real app, you could use something like ElasticSearch to hold videos with these companion columns
348
+ class AddReadSidsToVideos < ActiveRecord::Migration[7.0]
349
+ def change
350
+ add_column :videos, :read_allow_sids, :string, null: false, array: true, default: [], index: true
351
+ add_column :videos, :read_deny_sids, :string, null: false, array: true, default: [], index: true
352
+ end
353
+ end
354
+ ```
355
+
356
+ ```ruby
357
+ # app/models/user.rb
358
+
359
+ class Video < ApplicationRecord
360
+ attr_accessor :allowed_actions
361
+ alias_method :resource_id, :id
362
+
363
+ before_save :update_read_acl
364
+
365
+ def resource_type = :video
366
+
367
+ def update_read_acl
368
+ acl = AUTHORIZER.resource_acl(self)
369
+ self.read_allow_sids = acl.allowed_sids(:read)
370
+ self.read_deny_sids = acl.denied_sids(:read)
371
+ end
372
+
373
+ # And finally, this is our goal. Straightforward implementation regardless of how complex the rules are.
374
+ scope :available_for, ->(user) do
375
+ sids = user.subject_sids
376
+ where("read_allow_sids && ARRAY[?]::varchar[]", sids).where.not("read_deny_sids && ARRAY[?]::varchar[]", sids)
377
+ end
378
+ end
379
+ ```
380
+
381
+ ```ruby
382
+ # app/controllers/videos_controller
383
+
384
+ class VideosController
385
+ def index
386
+ @videos = Video.available_for(current_user).order(:name).limit(50)
387
+ end
388
+
389
+ def show
390
+ @video = Video.find(params[:id])
391
+
392
+ # upon successful authorization helper object is returned with a bunch of useful info
393
+ auth_result = AUTHORIZER.authorize(current_user, @video, :read)
394
+
395
+ # add list of allowed actions so the frontend knows whether show "Edit" and "Delete" buttons, for example
396
+ @video.allowed_actions = auth_result.allowed_actions
397
+ end
398
+
399
+ def destroy
400
+ video = Video.find(params[:id])
401
+ AUTHORIZER.authorize(current_user, video, :delete)
402
+ video.destroy
403
+ end
404
+ end
405
+ ```
406
+
407
+ Voila, we're done! So now, no matter how sophisticated our authorization rules are,
408
+ we have a clear method to find available videos for any user.
409
+ No conditions, no special handling for superusers as everyone goes through the unified mechanism.
410
+
411
+ **Important points not covered in this example but needed in the real app**:
412
+
413
+ - **Dependency change handling.** If country restrictions changed on the organization level you need to
414
+ run a background job to find all affected videos and update `read_allow_sids`, `read_deny_sids` columns.
415
+ Same applies to Distribution Settings and other dependencies.
416
+ - **Rules change handling.** If implementation of `VideoAclProvider` changed you need to run a background job
417
+ 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
+
421
+ See also:
422
+
423
+ - Live demo - https://verifica-rails-example.maximgurin.com
424
+ - Full source code - https://github.com/maximgurin/verifica-rails-example
425
+
426
+ ## Development
427
+
428
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
429
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
430
+
431
+ To install this gem onto your local machine, run `bundle exec rake install`.
432
+ To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`,
433
+ which will create a git tag for the version, push git commits and the created tag,
434
+ and push the `.gem` file to [rubygems.org](https://rubygems.org).
435
+
436
+ ## Contributing
437
+
438
+ Bug reports and pull requests are welcome on GitHub at https://github.com/maximgurin/verifica.
439
+ This project is intended to be a safe, welcoming space for collaboration, and contributors are expected
440
+ to adhere to the [code of conduct](https://github.com/maximgurin/verifica/blob/master/CODE_OF_CONDUCT.md).
441
+
442
+ ## License
443
+
444
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
445
+
446
+ ## Code of Conduct
447
+
448
+ Everyone interacting in the Verifica project's codebases, issue trackers, chat rooms and mailing lists is
449
+ expected to follow the [code of conduct](https://github.com/maximgurin/verifica/blob/master/CODE_OF_CONDUCT.md).
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verifica
4
+ # Access Control Entry (ACE)
5
+ #
6
+ # ACE is a minimal unit of the Access Control List (ACL) that defines whether or not a specific action
7
+ # is allowed for a particular Security Identifier (SID).
8
+ #
9
+ # @see Acl
10
+ # @see Sid
11
+ #
12
+ # @api public
13
+ class Ace
14
+ # @return [String] Security Identifier (SID)
15
+ #
16
+ # @api public
17
+ attr_reader :sid
18
+
19
+ # @return [Symbol] Action which is allowed or denied
20
+ #
21
+ # @api public
22
+ attr_reader :action
23
+
24
+ # Creates a new Access Control Entry with immutable state
25
+ #
26
+ # @param sid [String] Security Identifier (SID), typically String,
27
+ # but could be any object with implemented equality methods and #hash
28
+ # @param action [Symbol, String] action which is allowed or denied for given SID
29
+ # @param allow [Boolean] allow or deny given action for given SID
30
+ #
31
+ # @api public
32
+ def initialize(sid, action, allow)
33
+ @sid = sid.dup.freeze
34
+ @action = action.to_sym
35
+ @allow = allow
36
+ freeze
37
+ end
38
+
39
+ # @return [Boolean] true if the action is allowed
40
+ #
41
+ # @api public
42
+ def allow?
43
+ @allow
44
+ end
45
+
46
+ # @return [Boolean] true if the action is denied
47
+ #
48
+ # @api public
49
+ def deny?
50
+ !allow?
51
+ end
52
+
53
+ # @return [Hash] a new hash representing +self+
54
+ #
55
+ # @api public
56
+ def to_h
57
+ {sid: @sid, action: @action, allow: @allow}
58
+ end
59
+
60
+ def to_s
61
+ to_h.to_s
62
+ end
63
+
64
+ def ==(other)
65
+ eql?(other)
66
+ end
67
+
68
+ def eql?(other)
69
+ self.class == other.class &&
70
+ @sid == other.sid &&
71
+ @action == other.action &&
72
+ @allow == other.allow?
73
+ end
74
+
75
+ def hash
76
+ [self.class, @sid, @action, @allow].hash
77
+ end
78
+ end
79
+ end