verifica 1.0.0

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