verifica 1.0.0

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