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.
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "ace"
5
+ require_relative "acl_builder"
6
+
7
+ module Verifica
8
+ # Access Control List (ACL)
9
+ #
10
+ # Access Control List consists of Access Control Entities (ACEs) and defines which
11
+ # actions are allowed or denied for particular Security Identifiers (SIDs).
12
+ #
13
+ # ACL is typically associated with a resource (e.g. Post, Comment, Order) and specifies
14
+ # which users (or external services, or API clients) are allowed to do what actions on the given resource.
15
+ #
16
+ # @see Ace
17
+ # @see Sid
18
+ #
19
+ # @api public
20
+ class Acl
21
+ # Creates a new {AclBuilder} and yields it to the given block.
22
+ #
23
+ # @example
24
+ # acl = Verifica::Acl.build do |acl|
25
+ # acl.allow "anonymous", [:read]
26
+ # acl.allow "authenticated", [:read, :comment]
27
+ # acl.deny "country:US", [:read, :comment]
28
+ # end
29
+ #
30
+ # @return [Acl] Access Control List created by builder
31
+ #
32
+ # @api public
33
+ def self.build
34
+ builder = AclBuilder.new
35
+ yield builder
36
+ builder.build
37
+ end
38
+
39
+ attr_reader :aces
40
+ protected :aces
41
+
42
+ # Creates a new Access Control List with immutable state.
43
+ # @note Use {.build} instead of this constructor directly.
44
+ #
45
+ # @param aces [Array<Ace>] list of Access Control Entries
46
+ #
47
+ # @api public
48
+ def initialize(aces)
49
+ @aces = Set.new(aces).freeze
50
+ @allow_deny_by_action = prepare_index.freeze
51
+ @allowed_actions = Set.new
52
+ @allow_deny_by_action.each do |action, allow_deny|
53
+ @allowed_actions.add(action) unless allow_deny[:allowed_sids].empty?
54
+
55
+ allow_deny[:allowed_sids].freeze
56
+ allow_deny[:denied_sids].freeze
57
+ allow_deny.freeze
58
+ end
59
+
60
+ @allowed_actions.freeze
61
+ freeze
62
+ end
63
+
64
+ # Checks whether the action is allowed for given Security Identifiers.
65
+ # For action to be allowed all 3 conditions should be met:
66
+ #
67
+ # * ACL and SIDs are not empty
68
+ # * ACL contains at least one entry that allow given action for any of the SIDs
69
+ # * ACL contains no entries that deny given action for any of the SIDs
70
+ #
71
+ # @param action [Symbol, String] action to check
72
+ # @param sids [Array<String>, Set<String>] list of Security Identifiers to match for
73
+ #
74
+ # @return [Boolean] true if action is allowed
75
+ #
76
+ # @api public
77
+ def action_allowed?(action, sids)
78
+ return false if empty? || sids.empty?
79
+
80
+ action = action.to_sym
81
+ allow_deny = @allow_deny_by_action[action]
82
+
83
+ return false if allow_deny.nil? || !@allowed_actions.include?(action)
84
+
85
+ sids = sids.to_set
86
+ allow_deny[:allowed_sids].intersect?(sids) && !allow_deny[:denied_sids].intersect?(sids)
87
+ end
88
+
89
+ # The opposite of {#action_allowed?}
90
+ #
91
+ # @api public
92
+ def action_denied?(action, sids)
93
+ !action_allowed?(action, sids)
94
+ end
95
+
96
+ # @note Checking allowed SIDs isn't enough to determine whether the action is allowed.
97
+ # You need to always check {#denied_sids} as well.
98
+ #
99
+ # @param action (see #action_allowed?)
100
+ #
101
+ # @return [Array<String>] array of Security Identifiers allowed for a given action or empty array if none
102
+ #
103
+ # @api public
104
+ def allowed_sids(action)
105
+ sids = @allow_deny_by_action.dig(action.to_sym, :allowed_sids)
106
+ sids.nil? ? EMPTY_ARRAY : sids.to_a
107
+ end
108
+
109
+ # @note Checking denied SIDs isn't enough to determine whether the action is allowed.
110
+ # You need to always check {#allowed_sids} as well.
111
+ #
112
+ # @param action (see #action_allowed?)
113
+ #
114
+ # @return [Array<String>] array of Security Identifiers denied for a given action or empty array if none
115
+ #
116
+ # @api public
117
+ def denied_sids(action)
118
+ sids = @allow_deny_by_action.dig(action.to_sym, :denied_sids)
119
+ sids.nil? ? EMPTY_ARRAY : sids.to_a
120
+ end
121
+
122
+ # @param sids (see #action_allowed?)
123
+ #
124
+ # @return [Array<Symbol>] array of actions allowed for given Security Identifiers or empty array if none
125
+ #
126
+ # @api public
127
+ def allowed_actions(sids)
128
+ return EMPTY_ARRAY if sids.empty?
129
+
130
+ @allowed_actions.select { |action| action_allowed?(action, sids) }
131
+ end
132
+
133
+ # Creates a new {AclBuilder}, adds existing entries to it and yields it to the given block.
134
+ # Use this method to extend an existing ACL with additional entries
135
+ #
136
+ # @example
137
+ # base_acl = Verifica::Acl.build do |acl|
138
+ # acl.allow "superuser", [:read, :write, :delete]
139
+ # end
140
+ #
141
+ # extended_acl = base_acl.build do |acl|
142
+ # acl.allow "anonymous", [:read]
143
+ # acl.allow "authenticated", [:read, :comment]
144
+ # end
145
+ #
146
+ # @return [Acl] new Access Control List created by builder
147
+ #
148
+ # @api public
149
+ def build
150
+ builder = AclBuilder.new(to_a)
151
+ yield builder
152
+ builder.build
153
+ end
154
+
155
+ # @example
156
+ # acl = Verifica::Acl.build { |acl| acl.allow "root", [:read, :write] }
157
+ # acl.to_a.map(:to_h)
158
+ # # => [{:sid=>"root", :action=>:read, :allow=>true}, {:sid=>"root", :action=>:write, :allow=>true}]
159
+ #
160
+ # @return [Array<Ace>] a new array representing +self+
161
+ #
162
+ # @api public
163
+ def to_a
164
+ @aces.to_a
165
+ end
166
+
167
+ # @return [Boolean] true if there are no entries in +self+
168
+ #
169
+ # @api public
170
+ def empty?
171
+ @aces.empty?
172
+ end
173
+
174
+ # @return [Integer] the count of entries in +self+
175
+ #
176
+ # @api public
177
+ def length
178
+ @aces.length
179
+ end
180
+ alias_method :size, :length
181
+
182
+ def to_s
183
+ @aces.map(&:to_h).to_s
184
+ end
185
+
186
+ def ==(other)
187
+ eql?(other)
188
+ end
189
+
190
+ def eql?(other)
191
+ self.class == other.class &&
192
+ @aces == other.aces
193
+ end
194
+
195
+ def hash
196
+ [self.class, @aces].hash
197
+ end
198
+
199
+ private def prepare_index
200
+ @aces.each_with_object({}) do |ace, index|
201
+ action = ace.action
202
+ allow_deny = index.fetch(action) { {allowed_sids: Set.new, denied_sids: Set.new}.freeze }
203
+ allow_deny[ace.allow? ? :allowed_sids : :denied_sids].add(ace.sid)
204
+ index[action] = allow_deny
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verifica
4
+ # Builder that holds mutable list of Access Control Entries and methods to add new entries
5
+ #
6
+ # @see Acl.build Usage examples
7
+ #
8
+ # @api public
9
+ class AclBuilder
10
+ # @note Use {Acl.build} or {Acl#build} instead of this constructor directly
11
+ #
12
+ # @api public
13
+ def initialize(initial_aces = EMPTY_ARRAY)
14
+ @aces = initial_aces.dup
15
+ freeze
16
+ end
17
+
18
+ # Add Access Control Entries that allow particular actions for the given Security Identifier
19
+ #
20
+ # @example
21
+ # builder = AclBuilder.new
22
+ # .allow("anonymous", [:read])
23
+ # .allow("root", [:read, :write, :delete])
24
+ # acl = builder.build
25
+ #
26
+ # @param sid [String] Security Identifier
27
+ # @param actions [Enumerable<Symbol>, Enumerable<String>] list of actions allowed for the given SID
28
+ #
29
+ # @return [self]
30
+ #
31
+ # @api public
32
+ def allow(sid, actions)
33
+ @aces.concat(actions.map { |action| Ace.new(sid, action, true) })
34
+ self
35
+ end
36
+
37
+ # Add Access Control Entries that deny particular actions for the given Security Identifier
38
+ #
39
+ # @example
40
+ # builder = Verifica::AclBuilder.new
41
+ # .deny("country:US", [:read, :comment])
42
+ # .deny("country:CA", [:read, :comment])
43
+ # acl = builder.build
44
+ #
45
+ # @param sid [String] Security Identifier
46
+ # @param actions [Enumerable<Symbol>, Enumerable<String>] list of actions denied for the given SID
47
+ #
48
+ # @return [self]
49
+ #
50
+ # @api public
51
+ def deny(sid, actions)
52
+ @aces.concat(actions.map { |action| Ace.new(sid, action, false) })
53
+ self
54
+ end
55
+
56
+ # @return [Acl] a new, immutable Access Control List
57
+ #
58
+ # @api public
59
+ def build
60
+ Acl.new(@aces)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verifica
4
+ # Outcome of the authorization, either successful or failed.
5
+ # Memoizes the state of variables that affected the decision. Could show why the authorization
6
+ # was successful or failed even if the concerned objects have changed.
7
+ #
8
+ # @see Authorizer#authorize
9
+ #
10
+ # @api public
11
+ class AuthorizationResult
12
+ # @return [Object] subject of the authorization (e.g. current user, external service)
13
+ #
14
+ # @api public
15
+ attr_reader :subject
16
+
17
+ # @return [Object] subject ID returned by +subject.subject_id+
18
+ #
19
+ # @api public
20
+ attr_reader :subject_id
21
+
22
+ # @return [Symbol, nil] subject type returned by +subject.subject_type+
23
+ #
24
+ # @api public
25
+ attr_reader :subject_type
26
+
27
+ # @return [Array<String>] array of subject Security Identifiers returned by +subject.subject_sids+
28
+ #
29
+ # @api public
30
+ attr_reader :subject_sids
31
+
32
+ # @return [Object] resource on which {#subject} attempted to perform {#action}
33
+ #
34
+ # @api public
35
+ attr_reader :resource
36
+
37
+ # @return [Object] resource ID returned by +resource.resource_id+
38
+ #
39
+ # @api public
40
+ attr_reader :resource_id
41
+
42
+ # @return [Symbol] resource type returned by resource#resource_type
43
+ #
44
+ # @api public
45
+ attr_reader :resource_type
46
+
47
+ # @return [Symbol] action that {#subject} attempted to perform on the {#resource}
48
+ #
49
+ # @api public
50
+ attr_reader :action
51
+
52
+ # @return [Acl] Access Control List returned by ACL provider registered for this {#resource_type} in {Authorizer}
53
+ #
54
+ # @api public
55
+ attr_reader :acl
56
+
57
+ # @return [Hash] any additional keyword arguments that have been passed to the authorization call
58
+ #
59
+ # @see Authorizer#authorize
60
+ #
61
+ # @api public
62
+ attr_reader :context
63
+
64
+ # @api private
65
+ def initialize(subject, resource, action, acl, **context)
66
+ @subject = subject
67
+ sids = Verifica.subject_sids(subject, **context)
68
+ @subject_sids = sids.map { _1.dup.freeze }.freeze
69
+ @subject_id = subject.subject_id.dup.freeze
70
+ @subject_type = subject.subject_type&.to_sym
71
+ @resource = resource
72
+ @resource_id = resource.resource_id.dup.freeze
73
+ @resource_type = resource.resource_type.to_sym
74
+ @action = action
75
+ @acl = acl
76
+ @context = context
77
+ @success = acl.action_allowed?(action, @subject_sids)
78
+ freeze
79
+ end
80
+
81
+ # @return [Boolean] true if given {#action} is allowed for given {#subject}
82
+ #
83
+ # @api public
84
+ def success?
85
+ @success
86
+ end
87
+
88
+ # @return [Boolean] true if given {#action} is denied for given {#subject}
89
+ #
90
+ # @api public
91
+ def failure?
92
+ !success?
93
+ end
94
+
95
+ # @see Acl#allowed_actions
96
+ #
97
+ # @return [Array<Symbol>] array of actions allowed for given {#subject} or empty array if none
98
+ #
99
+ # @api public
100
+ def allowed_actions
101
+ acl.allowed_actions(subject_sids)
102
+ end
103
+
104
+ # @return [String] human-readable description of authorization result. Includes subject, resource, and outcome
105
+ #
106
+ # @api public
107
+ def message
108
+ status = success? ? "SUCCESS" : "FAILURE"
109
+ "Authorization #{status}. Subject '#{subject_type}' id='#{subject_id}'. Resource '#{resource_type}' " \
110
+ "id='#{resource_id}'. Action '#{action}'"
111
+ end
112
+
113
+ # @return [String] detailed, human-readable description of authorization result.
114
+ # Includes subject, resource, resource ACL, and explains the reason why authorization was successful or failed.
115
+ # Extremely useful for debugging.
116
+ #
117
+ # @api public
118
+ def explain
119
+ <<~MESSAGE
120
+ #{message}
121
+
122
+ \s\sSubject SIDs (#{subject_sids.empty? ? "empty" : subject_sids.size}):
123
+ \s\s\s\s#{subject_sids}
124
+
125
+ \s\sContext:
126
+ \s\s\s\s#{context}
127
+
128
+ \s\sResource ACL (#{acl.empty? ? "empty" : acl.size}):
129
+ #{acl.to_a.map { "\s\s\s\s#{_1}" }.join("\n")}
130
+
131
+ Reason: #{reason_message}
132
+ MESSAGE
133
+ end
134
+
135
+ private def reason_message
136
+ if success?
137
+ sids = acl.allowed_sids(action).intersection(subject_sids).to_a
138
+ return "subject SID(s) #{sids} allowed for '#{action}' action. No SIDs denied among subject SIDs"
139
+ end
140
+
141
+ return "resource ACL is empty, no actions allowed for any subject" if acl.empty?
142
+ return "subject SIDs are empty, no actions allowed for any resource" if subject_sids.empty?
143
+
144
+ denied = acl.denied_sids(action).intersection(subject_sids).to_a
145
+ if denied.empty?
146
+ "among #{subject_sids.size} subject SID(s), none is listed as allowed for '#{action}' action"
147
+ else
148
+ "subject SID(s) #{denied} denied for '#{action}' action. Denied SIDs always win regardless of allowed SIDs"
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verifica
4
+ # @api private
5
+ def self.subject_sids(subject, **context)
6
+ if subject.nil?
7
+ raise Error, "Subject should not be nil"
8
+ end
9
+
10
+ sids = subject.subject_sids(**context)
11
+ unless sids.is_a?(Array) || sids.is_a?(Set)
12
+ raise Error, "Expected subject to respond to #subject_sids with Array or Set of SIDs but got '#{sids.class}'"
13
+ end
14
+
15
+ sids
16
+ end
17
+
18
+ # Authorizer is the heart of Verifica. It's an isolated container with no global state
19
+ # which has a list of resource types registered with their companion AclProviders.
20
+ #
21
+ # Authorizer pairs great with Dependency Injection or can be configured and passed in a way that is compatible with your framework.
22
+ #
23
+ # @example (see Verifica)
24
+ #
25
+ # @see Verifica.authorizer
26
+ # @see Configuration
27
+ # @see ResourceConfiguration
28
+ #
29
+ # @api public
30
+ class Authorizer
31
+ # @note Use {Verifica.authorizer} instead of this constructor directly
32
+ #
33
+ # @api public
34
+ def initialize(resource_configs)
35
+ @resources = index_resources(resource_configs).freeze
36
+ freeze
37
+ end
38
+
39
+ # Checks the authorization of a subject to perform an action on a resource
40
+ #
41
+ # * The +subject+ is asked for its Security Identifiers (SIDs) by +subject.subject_sids+
42
+ # * The +resource+ is asked for its type by +resource.resource_type+
43
+ # * ACL provider registered for this resource type is asked for {Acl} by +#call(resource, **context)+
44
+ # * ACL is checked whether the +action+ is allowed for the subject SIDs
45
+ #
46
+ # @example
47
+ # def show
48
+ # post = Post.find(params[:id])
49
+ # authorizer.authorize(current_user, post, :read)
50
+ #
51
+ # render json: post
52
+ # end
53
+ #
54
+ # @param subject [Object] subject of the authorization (e.g. current user, external service)
55
+ # @param resource [Object] resource to authorize for, should respond to +#resource_type+
56
+ # @param action [Symbol, String] action that +subject+ attempts to perform on the +resource+
57
+ # @param context [Hash] arbitrary keyword arguments to forward to +subject.subject_sids+ and +acl_provider.call+
58
+ #
59
+ # @return [AuthorizationResult] authorization result with all details if authorization is successful
60
+ # @raise [AuthorizationError] if +subject+ isn't authorized to perform +action+ on the given +resource+
61
+ # @raise [Error] if +resource.resource_type+ isn't registered in +self+
62
+ #
63
+ # @see Acl#action_allowed? How ACL is checked whether the action is allowed
64
+ # @see Configuration#register_resource
65
+ #
66
+ # @api public
67
+ def authorize(subject, resource, action, **context)
68
+ result = authorization_result(subject, resource, action, **context)
69
+ raise AuthorizationError, result if result.failure?
70
+
71
+ result
72
+ end
73
+
74
+ # The same as {#authorize} but returns true/false instead of rising an exception
75
+ #
76
+ # @return [Boolean] true if +action+ on +resource+ is authorized for +subject+
77
+ # @raise [Error] if +resource.resource_type+ isn't registered in +self+
78
+ #
79
+ # @api public
80
+ def authorized?(subject, resource, action, **context)
81
+ authorization_result(subject, resource, action, **context).success?
82
+ end
83
+
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)
87
+ #
88
+ # @return [Array<Symbol>] array of actions allowed for +subject+ or empty array if none
89
+ # @raise [Error] if +resource.resource_type+ isn't registered in +self+
90
+ #
91
+ # @see Configuration#register_resource
92
+ # @see Acl#allowed_actions
93
+ #
94
+ # @api public
95
+ def allowed_actions(subject, resource, **context)
96
+ acl = resource_acl(resource, **context)
97
+ sids = Verifica.subject_sids(subject)
98
+ acl.allowed_actions(sids)
99
+ end
100
+
101
+ # @param resource_type [Symbol, String] type of the resource
102
+ #
103
+ # @return [ResourceConfiguration] configuration for +resource_type+
104
+ # @raise [Error] if +resource_type+ isn't registered in +self+
105
+ #
106
+ # @see resource_type?
107
+ # @see Configuration#register_resource
108
+ #
109
+ # @api public
110
+ def resource_config(resource_type)
111
+ resource_type = resource_type.to_sym
112
+ config = @resources[resource_type]
113
+ if config.nil?
114
+ raise Error, "Unknown resource '#{resource_type}'. Did you forget to register this resource type?"
115
+ end
116
+
117
+ config
118
+ end
119
+
120
+ # @param resource_type (see #resource_config)
121
+ #
122
+ # @return [Boolean] true if +resource_type+ is registered in +self+
123
+ #
124
+ # @see Configuration#register_resource
125
+ #
126
+ # @api public
127
+ def resource_type?(resource_type)
128
+ @resources.key?(resource_type.to_sym)
129
+ end
130
+
131
+ # @param resource [Object] resource to get ACL for, should respond to +#resource_type+
132
+ # @param context [Hash] arbitrary keyword arguments to forward to +acl_provider.call+
133
+ #
134
+ # @return [Acl] Access Control List for +resource+
135
+ # @raise [Error] if +resource_type+ isn't registered in +self+
136
+ # @raise [Error] if ACL provider for this resource type doesn't respond to +#call(resource, **)+ with {Acl} object
137
+ #
138
+ # @see Configuration#register_resource
139
+ #
140
+ # @api public
141
+ def resource_acl(resource, **context)
142
+ config = config_by_resource(resource)
143
+ acl = config.acl_provider.call(resource, **context)
144
+ unless acl.is_a?(Verifica::Acl)
145
+ type = resource.resource_type
146
+ raise Error, "'#{type}' resource acl_provider should respond to #call with Acl object but got '#{acl.class}'"
147
+ end
148
+
149
+ acl
150
+ end
151
+
152
+ private def index_resources(resource_configs)
153
+ resource_configs.each_with_object({}) do |config, by_type|
154
+ type = config.resource_type
155
+ if by_type.key?(type)
156
+ raise Error, "'#{type}' resource registered multiple times. Probably code copy-paste and a bug?"
157
+ end
158
+
159
+ by_type[type] = config
160
+ end
161
+ end
162
+
163
+ private def config_by_resource(resource)
164
+ if resource.nil?
165
+ raise Error, "Resource should not be nil"
166
+ end
167
+
168
+ type = resource.resource_type
169
+ if type.nil?
170
+ raise Error, "Resource should respond to #resource_type with non-nil type"
171
+ end
172
+
173
+ resource_config(type)
174
+ 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
+ end
187
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource_configuration"
4
+
5
+ module Verifica
6
+ # Configuration object for {Authorizer}, holds list of registered resources and other params
7
+ #
8
+ # @see Verifica.authorizer Usage examples
9
+ #
10
+ # @api public
11
+ class Configuration
12
+ # @return [Array<ResourceConfiguration>] array of registered resources
13
+ #
14
+ # @api public
15
+ attr_reader :resources
16
+
17
+ # @note Use {Verifica.authorizer} instead of this constructor directly
18
+ #
19
+ # @api public
20
+ def initialize
21
+ @resources = []
22
+ end
23
+
24
+ # Register a new resource supported by {Authorizer}
25
+ #
26
+ # @see Verifica.authorizer Usage examples
27
+ #
28
+ # @param type [Symbol, String] type of the resource
29
+ # @param possible_actions [Enumerable<Symbol>, Enumerable<String>] list of actions possible for this resource type
30
+ # @param acl_provider [#call] Access Control List provider for this resource type.
31
+ # Could be any object that responds to +#call(resource, **)+ and returns {Acl}
32
+ #
33
+ # @return [self]
34
+ #
35
+ # @api public
36
+ def register_resource(type, possible_actions, acl_provider)
37
+ resources << ResourceConfiguration.new(type, possible_actions, acl_provider)
38
+ self
39
+ end
40
+ end
41
+ end