moat 0.0.8 → 0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -1,10 +1,3 @@
1
- # -*- ruby -*-
2
-
3
- require 'rubygems'
4
- require 'hoe'
5
-
6
- Hoe.spec 'moat' do
7
- developer('Bryan Woods', 'bryanwoods4e@gmail.com')
8
- end
9
-
10
- # vim: syntax=ruby
1
+ require "rspec/core/rake_task"
2
+ RSpec::Core::RakeTask.new(:spec)
3
+ task default: :spec
@@ -1,41 +1,117 @@
1
1
  module Moat
2
- FILENAME = "#{ENV['HOME']}/.moatfile"
3
- SITES = {}
2
+ POLICY_CLASS_SUFFIX = "Policy".freeze
4
3
 
5
- def set(credentials)
6
- SITES[credentials[:site]] = {
7
- :username => credentials[:username],
8
- :password => credentials[:password]
9
- }
4
+ class Error < StandardError; end
5
+ class PolicyNotAppliedError < Error; end
6
+ class NotFoundError < Error; end
7
+ class ActionNotFoundError < NotFoundError
8
+ attr_reader :action, :resource, :policy, :user
9
+
10
+ def initialize(options = {})
11
+ return super if options.is_a?(String)
12
+ @action = options[:action]
13
+ @resource = options[:resource]
14
+ @policy = options[:policy]
15
+ @user = options[:user]
16
+
17
+ message = options.fetch(:message) { "#{policy.name}##{action}" }
18
+ super(message)
19
+ end
20
+ end
21
+
22
+ class PolicyNotFoundError < NotFoundError; end
23
+ class NotAuthorizedError < Error
24
+ attr_reader :action, :resource, :policy, :user
25
+
26
+ def initialize(options = {})
27
+ return super if options.is_a?(String)
28
+ @action = options[:action]
29
+ @resource = options[:resource]
30
+ @policy = options[:policy]
31
+ @user = options[:user]
32
+
33
+ message = options.fetch(:message) { "unauthorized #{policy.name}##{action} for #{resource}" }
34
+ super(message)
35
+ end
36
+ end
37
+
38
+ def policy_filter(scope, action = action_name, user: moat_user, policy: find_policy(scope))
39
+ apply_policy(scope, action, user: user, policy: policy::Filter)
40
+ end
41
+
42
+ def authorize(resource, action = "#{action_name}?", user: moat_user, policy: find_policy(resource))
43
+ if apply_policy(resource, action, user: user, policy: policy::Authorization)
44
+ resource
45
+ else
46
+ fail NotAuthorizedError, action: action, resource: resource, policy: policy, user: user
47
+ end
10
48
  end
11
49
 
12
- def username(site)
13
- SITES[site][:username]
50
+ def moat_user
51
+ current_user
14
52
  end
15
53
 
16
- def password(site)
17
- SITES[site][:password]
54
+ def verify_policy_applied
55
+ fail PolicyNotAppliedError unless @_moat_policy_applied
18
56
  end
19
57
 
20
- def save_passwords
21
- File.open(FILENAME, "w") {|file| file.puts SITES.to_yaml }
58
+ def skip_verify_policy_applied
59
+ @_moat_policy_applied = true
22
60
  end
23
61
 
24
- def load_passwords
25
- if File.exists?(FILENAME)
26
- SITES.merge!(YAML::load(File.open(FILENAME)))
62
+ private
63
+
64
+ alias policy_applied skip_verify_policy_applied
65
+
66
+ def apply_policy(scope_or_resource, action, user:, policy:)
67
+ policy_instance = policy.new(user, scope_or_resource)
68
+ fail(ActionNotFoundError, action: action, policy: policy) unless policy_instance.respond_to?(action)
69
+
70
+ policy_applied
71
+ policy_instance.public_send(action)
72
+ end
73
+
74
+ def find_policy(object)
75
+ policy = if object.nil?
76
+ nil
77
+ elsif object.respond_to?(:policy_class)
78
+ object.policy_class
79
+ elsif object.class.respond_to?(:policy_class)
80
+ object.class.policy_class
27
81
  else
28
- {}
82
+ infer_policy(object)
83
+ end
84
+
85
+ policy || fail(PolicyNotFoundError)
86
+ end
87
+
88
+ # Infer the policy from the object's type. If it is not found from the
89
+ # object's class directly, go up the ancestor chain.
90
+ def infer_policy(object)
91
+ initial_policy_inference_class = policy_inference_class(object)
92
+ policy_inference_class = initial_policy_inference_class
93
+ while policy_inference_class
94
+ policy = load_policy_from_class(policy_inference_class)
95
+ return policy if policy
96
+ policy_inference_class = policy_inference_class.superclass
29
97
  end
98
+
99
+ fail PolicyNotFoundError, initial_policy_inference_class.name
30
100
  end
31
101
 
32
- def generate(length = 9)
33
- lowercase_letters = ('a'..'z').to_a
34
- uppercase_letters = ('A'..'Z').to_a
35
- numbers = ('0'..'9').to_a
36
- alphanumeric_array = lowercase_letters + uppercase_letters + numbers
37
- alphanumeric_array.shuffle[0..length].join
102
+ def load_policy_from_class(klass)
103
+ Object.const_get("#{policy_inference_class(klass).name}#{POLICY_CLASS_SUFFIX}")
104
+ rescue NameError
105
+ nil
38
106
  end
39
107
 
108
+ def policy_inference_class(object)
109
+ if object.respond_to?(:model) # For ActiveRecord::Relation
110
+ object.model
111
+ elsif object.is_a?(Class)
112
+ object
113
+ else
114
+ object.class
115
+ end
116
+ end
40
117
  end
41
-
@@ -0,0 +1,202 @@
1
+ module Moat
2
+ module RSpec
3
+ module PolicyMatchers
4
+ extend ::RSpec::Matchers::DSL
5
+
6
+ matcher :permit_all_authorizations do
7
+ match do |policy_class|
8
+ @incorrectly_denied = policy_authorizations - permitted_authorizations(policy_class)
9
+ @incorrectly_denied.empty?
10
+ end
11
+ failure_message do
12
+ generate_failure_message(incorrectly_denied: @incorrectly_denied)
13
+ end
14
+
15
+ match_when_negated do
16
+ false
17
+ end
18
+ failure_message_when_negated do
19
+ "Cannot negate `permit_all_authorizations`: Use `only_permit_authorizations` instead"
20
+ end
21
+ end
22
+
23
+ matcher :deny_all_authorizations do
24
+ match do |policy_class|
25
+ @incorrectly_permitted = permitted_authorizations(policy_class)
26
+ @incorrectly_permitted.empty?
27
+ end
28
+ failure_message do
29
+ generate_failure_message(incorrectly_permitted: @incorrectly_permitted)
30
+ end
31
+
32
+ match_when_negated do
33
+ false
34
+ end
35
+ failure_message_when_negated do
36
+ "Cannot negate `deny_all_authorizations`: Use `only_permit_authorizations` instead"
37
+ end
38
+ end
39
+
40
+ matcher :only_permit_authorizations do |*authorizations_to_permit|
41
+ match do |policy_class|
42
+ permitted_authorizations = permitted_authorizations(policy_class)
43
+ @incorrectly_permitted = permitted_authorizations - authorizations_to_permit
44
+ @incorrectly_denied = authorizations_to_permit - permitted_authorizations
45
+ @incorrectly_permitted.empty? && @incorrectly_denied.empty?
46
+ end
47
+ failure_message do
48
+ generate_failure_message(
49
+ incorrectly_permitted: @incorrectly_permitted,
50
+ incorrectly_denied: @incorrectly_denied
51
+ )
52
+ end
53
+
54
+ match_when_negated do
55
+ false
56
+ end
57
+ failure_message_when_negated do
58
+ "Cannot negate `only_permit_authorizations`: Specify all permitted authorizations instead"
59
+ end
60
+ end
61
+
62
+ matcher :permit_through_all_filters do
63
+ match do |policy_class|
64
+ @incorrectly_denied = policy_filters - permitted_through_filters(policy_class)
65
+ @incorrectly_denied.empty?
66
+ end
67
+ failure_message do
68
+ generate_failure_message(incorrectly_denied: @incorrectly_denied)
69
+ end
70
+
71
+ match_when_negated do
72
+ false
73
+ end
74
+ failure_message_when_negated do
75
+ "Cannot negate `permit_through_all_filters`: Use `only_permit_through_filters` instead"
76
+ end
77
+ end
78
+
79
+ matcher :deny_through_all_filters do
80
+ match do |policy_class|
81
+ @incorrectly_permitted = permitted_through_filters(policy_class)
82
+ @incorrectly_permitted.empty?
83
+ end
84
+ failure_message do
85
+ generate_failure_message(incorrectly_permitted: @incorrectly_permitted)
86
+ end
87
+
88
+ match_when_negated do
89
+ false
90
+ end
91
+ failure_message_when_negated do
92
+ "Cannot negate `deny_through_all_filters`: Use `only_permit_through_filters` instead"
93
+ end
94
+ end
95
+
96
+ matcher :only_permit_through_filters do |*filter_whitelist|
97
+ match do |policy_class|
98
+ permitted_through_filters = permitted_through_filters(policy_class)
99
+ @incorrectly_permitted = permitted_through_filters - filter_whitelist
100
+ @incorrectly_denied = filter_whitelist - permitted_through_filters
101
+ @incorrectly_permitted.empty? && @incorrectly_denied.empty?
102
+ end
103
+ failure_message do
104
+ generate_failure_message(
105
+ incorrectly_denied: @incorrectly_denied,
106
+ incorrectly_permitted: @incorrectly_permitted
107
+ )
108
+ end
109
+
110
+ match_when_negated do
111
+ false
112
+ end
113
+ failure_message_when_negated do
114
+ "Cannot negate `only_permit_through_filters`: Specify permitted filters instead"
115
+ end
116
+ end
117
+
118
+ def generate_failure_message(incorrectly_permitted: [], incorrectly_denied: [])
119
+ failure_descriptions = []
120
+ unless incorrectly_permitted.empty?
121
+ failure_descriptions << "Incorrectly permitted to #{role}: #{incorrectly_permitted.to_sentence}"
122
+ end
123
+ unless incorrectly_denied.empty?
124
+ failure_descriptions << "Incorrectly denied to #{role}: #{incorrectly_denied.to_sentence}"
125
+ end
126
+ failure_descriptions.join("\n")
127
+ end
128
+
129
+ def role
130
+ ::RSpec.current_example.metadata.fetch(:role)
131
+ end
132
+
133
+ def permitted_authorizations(policy_class)
134
+ policy_instance = policy_class::Authorization.new(public_send(role), policy_example_resource)
135
+ policy_authorizations.select do |authorization|
136
+ policy_instance.public_send(authorization)
137
+ end
138
+ end
139
+
140
+ def permitted_through_filters(policy_class)
141
+ policy_instance = policy_class::Filter.new(public_send(role), policy_example_resource.class.all)
142
+ policy_filters.select do |filter|
143
+ policy_instance.public_send(filter).include?(policy_example_resource)
144
+ end
145
+ end
146
+ end
147
+
148
+ module PolicyExampleGroup
149
+ include Moat::RSpec::PolicyMatchers
150
+
151
+ def self.included(base_class)
152
+ base_class.metadata[:type] = :policy
153
+
154
+ class << base_class
155
+ def roles(*roles, &block)
156
+ roles.each do |role|
157
+ describe(role.to_s, role: role, caller: caller) { instance_eval(&block) }
158
+ end
159
+ end
160
+ alias_method :role, :roles
161
+
162
+ def resource(&block)
163
+ fail ArgumentError, "#resource called without a block" if block.nil?
164
+ let(:policy_example_resource) { instance_eval(&block) }
165
+ end
166
+
167
+ def policy_filters(*filters)
168
+ let(:policy_filters) { filters }
169
+ end
170
+
171
+ def policy_authorizations(*authorizations)
172
+ let(:policy_authorizations) { authorizations }
173
+ end
174
+ end
175
+
176
+ base_class.class_eval do
177
+ subject { described_class }
178
+
179
+ let(:policy_authorizations) do
180
+ fail NotImplementedError, "List of policy_authorizations undefined"
181
+ end
182
+
183
+ let(:policy_filters) do
184
+ fail NotImplementedError, "List of policy_filters undefined"
185
+ end
186
+
187
+ let(:policy_example_resource) do
188
+ fail NotImplementedError, "A resource has not been defined"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ RSpec.configure do |config|
197
+ config.include(
198
+ Moat::RSpec::PolicyExampleGroup,
199
+ type: :policy,
200
+ file_path: %r{spec/policies}
201
+ )
202
+ end
@@ -0,0 +1,3 @@
1
+ module Moat
2
+ VERSION = "0.1".freeze
3
+ end
@@ -1,17 +1,20 @@
1
- require 'rubygems'
1
+ require File.expand_path("lib/moat/version", __dir__)
2
2
 
3
- SPEC = Gem::Specification.new do |s|
4
- s.name = "moat"
5
- s.version = "0.0.8"
6
- s.author = "Bryan Woods"
7
- s.email = "bryanwoods4e@gmail.com"
8
- s.platform = Gem::Platform::RUBY
9
- s.description = "Brain-dead simple password storage. Improved."
10
- s.summary = "Brain-dead simple password storage. Improved."
11
- s.rubyforge_project = "moat"
12
- s.homepage = "http://github.com/bryanwoods/moat"
13
- s.files = Dir.glob("**/*")
14
- s.executables << "moat"
15
- s.require_path = "lib"
16
- s.has_rdoc = false
3
+ Gem::Specification.new do |gem|
4
+ gem.name = "moat"
5
+ gem.version = Moat::VERSION
6
+ gem.license = "MIT"
7
+ gem.authors = ["Poll Everywhere"]
8
+ gem.email = ["geeks@polleverywhere.com"]
9
+ gem.homepage = "https://github.com/polleverywhere/moat"
10
+ gem.summary = "A small authorization library"
11
+ gem.description = "Moat is an small authorization library built for Ruby (primarily Rails) web applications"
12
+ gem.files = `git ls-files`.split("\n")
13
+ gem.test_files = `git ls-files -- spec/*`.split("\n")
14
+ gem.require_paths = ["lib"]
15
+ gem.extra_rdoc_files = ["README.md"]
16
+ gem.rdoc_options = ["--main", "README.md"]
17
+
18
+ gem.add_development_dependency("rspec", "~> 3.5")
19
+ gem.add_development_dependency("rubocop", "~> 0.57.2")
17
20
  end
@@ -0,0 +1,296 @@
1
+ # require "spec_helper"
2
+ require_relative "../lib/moat"
3
+
4
+ # Will typically be a Rails controller
5
+ class MoatConsumerClass
6
+ include Moat
7
+
8
+ def current_user
9
+ @current_user ||= Object.new
10
+ end
11
+
12
+ def action_name
13
+ :read
14
+ end
15
+ end
16
+
17
+ class IntegerPolicy
18
+ class Filter
19
+ attr_reader :user, :scope
20
+
21
+ def initialize(user, scope)
22
+ @user = user
23
+ @scope = scope
24
+ end
25
+
26
+ def read
27
+ if user == "specified user"
28
+ scope
29
+ else
30
+ scope.select(&:even?)
31
+ end
32
+ end
33
+
34
+ def show
35
+ scope.select(&:odd?)
36
+ end
37
+ end
38
+
39
+ class Authorization
40
+ attr_reader :resource, :user
41
+
42
+ def initialize(user, resource)
43
+ @user = user
44
+ @resource = resource
45
+ end
46
+
47
+ def read?
48
+ resource.even? || user == "specified user"
49
+ end
50
+
51
+ def show?
52
+ resource.odd?
53
+ end
54
+ end
55
+ end
56
+
57
+ class OtherIntegerPolicy
58
+ class Filter
59
+ attr_reader :user, :scope
60
+
61
+ def initialize(user, scope)
62
+ @user = user
63
+ @scope = scope
64
+ end
65
+
66
+ def read
67
+ scope.select(&:odd?)
68
+ end
69
+
70
+ def show
71
+ scope.select(&:even?)
72
+ end
73
+ end
74
+
75
+ class Authorization
76
+ attr_reader :user, :resource
77
+
78
+ def initialize(user, resource)
79
+ @user = user
80
+ @resource = resource
81
+ end
82
+
83
+ def read?
84
+ resource.odd?
85
+ end
86
+
87
+ def show?
88
+ resource.even?
89
+ end
90
+ end
91
+ end
92
+
93
+ describe Moat do
94
+ let(:moat_consumer) { MoatConsumerClass.new }
95
+
96
+ describe "#moat_user" do
97
+ it "returns the value of #current_user" do
98
+ expect(moat_consumer.moat_user).to eql(moat_consumer.current_user)
99
+ end
100
+ end
101
+
102
+ describe "#policy_filter" do
103
+ it "fails if scope is nil" do
104
+ expect { moat_consumer.policy_filter(nil) }.
105
+ to raise_error(Moat::PolicyNotFoundError)
106
+ end
107
+
108
+ it "fails if a corresponding policy can't be found" do
109
+ expect { moat_consumer.policy_filter(Hash) }.
110
+ to raise_error(Moat::PolicyNotFoundError, "Hash")
111
+ expect { moat_consumer.policy_filter({}) }.
112
+ to raise_error(Moat::PolicyNotFoundError, "Hash")
113
+ end
114
+
115
+ it "fails if a corresponding action can't be found" do
116
+ expect { moat_consumer.policy_filter([1, 2, 3], :invalid_action, policy: IntegerPolicy) }.
117
+ to raise_error(Moat::ActionNotFoundError, "IntegerPolicy::Filter#invalid_action")
118
+ end
119
+
120
+ it "returns the value of applying a policy scope filter to the original scope" do
121
+ expect(moat_consumer.policy_filter([1, 2, 3, 4, 5], policy: IntegerPolicy)).to eql([2, 4])
122
+ end
123
+
124
+ it "uses specified action" do
125
+ expect(moat_consumer.policy_filter([2, 3], :show, policy: IntegerPolicy)).to eql([3])
126
+ end
127
+
128
+ it "uses specified policy" do
129
+ expect(moat_consumer.policy_filter([2, 3], policy: OtherIntegerPolicy)).
130
+ to eql([3])
131
+ end
132
+
133
+ it "uses specified user" do
134
+ expect(moat_consumer.policy_filter([2, 3], user: "specified user", policy: IntegerPolicy)).to eql([2, 3])
135
+ end
136
+ end
137
+
138
+ describe "#authorize" do
139
+ it "fails if resource is nil" do
140
+ expect { moat_consumer.authorize(nil) }.
141
+ to raise_error(Moat::PolicyNotFoundError)
142
+ end
143
+
144
+ it "fails if a corresponding policy can't be found" do
145
+ expect { moat_consumer.authorize(Hash) }.
146
+ to raise_error(Moat::PolicyNotFoundError, "Hash")
147
+ expect { moat_consumer.authorize({}) }.
148
+ to raise_error(Moat::PolicyNotFoundError, "Hash")
149
+ end
150
+
151
+ it "fails if a corresponding action can't be found" do
152
+ expect { moat_consumer.authorize([1, 2, 3], :invalid_action?, policy: IntegerPolicy) }.
153
+ to raise_error(Moat::ActionNotFoundError, "IntegerPolicy::Authorization#invalid_action?")
154
+ end
155
+
156
+ it "fails when the value of calling the policy method is false" do
157
+ expect { moat_consumer.authorize(3) }.
158
+ to raise_error(Moat::NotAuthorizedError, "unauthorized IntegerPolicy#read? for 3")
159
+ end
160
+
161
+ it "returns the initial resource value when the value of calling the policy method is true" do
162
+ expect(moat_consumer.authorize(4)).to eql(4)
163
+ end
164
+
165
+ it "uses specified action" do
166
+ expect(moat_consumer.authorize(3, :show?)).to eql(3)
167
+ end
168
+
169
+ it "uses specified policy" do
170
+ expect(moat_consumer.authorize(3, policy: OtherIntegerPolicy)).to eql(3)
171
+ end
172
+
173
+ it "uses specified user" do
174
+ expect(moat_consumer.authorize(3, user: "specified user")).to eql(3)
175
+ end
176
+ end
177
+
178
+ describe "#verify_policy_applied" do
179
+ context "authorize called" do
180
+ it "does not raise an exception" do
181
+ moat_consumer.authorize(4)
182
+ expect { moat_consumer.verify_policy_applied }.not_to raise_error
183
+ end
184
+ end
185
+ context "policy_filter called" do
186
+ it "does not raise an exception" do
187
+ moat_consumer.policy_filter([1, 2], policy: IntegerPolicy)
188
+ expect { moat_consumer.verify_policy_applied }.not_to raise_error
189
+ end
190
+ end
191
+ context "neither authorize nor policy_filter called" do
192
+ it "raises an exception" do
193
+ expect { moat_consumer.verify_policy_applied }.
194
+ to raise_error(Moat::PolicyNotAppliedError)
195
+ end
196
+ end
197
+ end
198
+
199
+ describe "#skip_verify_policy_applied" do
200
+ it "does not raise an exception when the policy was not applied" do
201
+ moat_consumer.skip_verify_policy_applied
202
+ expect { moat_consumer.verify_policy_applied }.not_to raise_error
203
+ end
204
+ end
205
+
206
+ describe "policy lookup" do
207
+ class FakePolicy
208
+ class Filter
209
+ attr_reader :scope
210
+
211
+ def initialize(_user, scope)
212
+ @scope = scope
213
+ end
214
+
215
+ def read
216
+ scope.to_a
217
+ end
218
+ end
219
+ end
220
+
221
+ it "allows an object to specify a policy class" do
222
+ class DefinesPolicyClass
223
+ def self.to_a
224
+ [1, 2]
225
+ end
226
+
227
+ def self.policy_class
228
+ FakePolicy
229
+ end
230
+ end
231
+
232
+ expect(moat_consumer.policy_filter(DefinesPolicyClass)).to eql([1, 2])
233
+ end
234
+
235
+ it "allows an object's class to specify a policy class" do
236
+ class DefinesPolicyClass
237
+ def self.policy_class
238
+ FakePolicy
239
+ end
240
+
241
+ def to_a
242
+ [3, 4]
243
+ end
244
+ end
245
+
246
+ expect(moat_consumer.policy_filter(DefinesPolicyClass.new)).to eql([3, 4])
247
+ end
248
+
249
+ it "infers a policy if object is a class" do
250
+ class Fake
251
+ def self.to_a
252
+ [5, 6]
253
+ end
254
+ end
255
+
256
+ expect(moat_consumer.policy_filter(Fake)).to eql([5, 6])
257
+ end
258
+
259
+ it "infers a policy from an object's ancestor" do
260
+ class Fake
261
+ def self.to_a
262
+ [7, 8]
263
+ end
264
+ end
265
+ class FakeChild < Fake
266
+ end
267
+
268
+ expect(moat_consumer.policy_filter(FakeChild)).to eql([7, 8])
269
+ end
270
+
271
+ it "infers a policy from an object's class's ancestor" do
272
+ class Fake
273
+ end
274
+ class FakeChild < Fake
275
+ def to_a
276
+ [9, 10]
277
+ end
278
+ end
279
+
280
+ expect(moat_consumer.policy_filter(FakeChild.new)).to eql([9, 10])
281
+ end
282
+
283
+ it "infers a policy from an object's `model` method" do
284
+ class DefinesModelName
285
+ def self.model
286
+ Fake
287
+ end
288
+
289
+ def self.to_a
290
+ [11, 12]
291
+ end
292
+ end
293
+ expect(moat_consumer.policy_filter(DefinesModelName)).to eql([11, 12])
294
+ end
295
+ end
296
+ end