moat 0.0.8 → 0.1

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.
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