cancancan 1.7.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.
- checksums.yaml +15 -0
- data/CHANGELOG.rdoc +427 -0
- data/CONTRIBUTING.md +11 -0
- data/Gemfile +23 -0
- data/LICENSE +20 -0
- data/README.rdoc +161 -0
- data/Rakefile +18 -0
- data/init.rb +1 -0
- data/lib/cancan.rb +13 -0
- data/lib/cancan/ability.rb +324 -0
- data/lib/cancan/controller_additions.rb +397 -0
- data/lib/cancan/controller_resource.rb +286 -0
- data/lib/cancan/exceptions.rb +50 -0
- data/lib/cancan/inherited_resource.rb +20 -0
- data/lib/cancan/matchers.rb +14 -0
- data/lib/cancan/model_adapters/abstract_adapter.rb +56 -0
- data/lib/cancan/model_adapters/active_record_adapter.rb +180 -0
- data/lib/cancan/model_adapters/data_mapper_adapter.rb +34 -0
- data/lib/cancan/model_adapters/default_adapter.rb +7 -0
- data/lib/cancan/model_adapters/mongoid_adapter.rb +54 -0
- data/lib/cancan/model_additions.rb +31 -0
- data/lib/cancan/rule.rb +147 -0
- data/lib/cancancan.rb +1 -0
- data/lib/generators/cancan/ability/USAGE +4 -0
- data/lib/generators/cancan/ability/ability_generator.rb +11 -0
- data/lib/generators/cancan/ability/templates/ability.rb +32 -0
- data/spec/README.rdoc +28 -0
- data/spec/cancan/ability_spec.rb +455 -0
- data/spec/cancan/controller_additions_spec.rb +141 -0
- data/spec/cancan/controller_resource_spec.rb +553 -0
- data/spec/cancan/exceptions_spec.rb +58 -0
- data/spec/cancan/inherited_resource_spec.rb +60 -0
- data/spec/cancan/matchers_spec.rb +29 -0
- data/spec/cancan/model_adapters/active_record_adapter_spec.rb +358 -0
- data/spec/cancan/model_adapters/data_mapper_adapter_spec.rb +118 -0
- data/spec/cancan/model_adapters/default_adapter_spec.rb +7 -0
- data/spec/cancan/model_adapters/mongoid_adapter_spec.rb +226 -0
- data/spec/cancan/rule_spec.rb +52 -0
- data/spec/matchers.rb +13 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +77 -0
- metadata +126 -0
@@ -0,0 +1,50 @@
|
|
1
|
+
module CanCan
|
2
|
+
# A general CanCan exception
|
3
|
+
class Error < StandardError; end
|
4
|
+
|
5
|
+
# Raised when behavior is not implemented, usually used in an abstract class.
|
6
|
+
class NotImplemented < Error; end
|
7
|
+
|
8
|
+
# Raised when removed code is called, an alternative solution is provided in message.
|
9
|
+
class ImplementationRemoved < Error; end
|
10
|
+
|
11
|
+
# Raised when using check_authorization without calling authorized!
|
12
|
+
class AuthorizationNotPerformed < Error; end
|
13
|
+
|
14
|
+
# This error is raised when a user isn't allowed to access a given controller action.
|
15
|
+
# This usually happens within a call to ControllerAdditions#authorize! but can be
|
16
|
+
# raised manually.
|
17
|
+
#
|
18
|
+
# raise CanCan::AccessDenied.new("Not authorized!", :read, Article)
|
19
|
+
#
|
20
|
+
# The passed message, action, and subject are optional and can later be retrieved when
|
21
|
+
# rescuing from the exception.
|
22
|
+
#
|
23
|
+
# exception.message # => "Not authorized!"
|
24
|
+
# exception.action # => :read
|
25
|
+
# exception.subject # => Article
|
26
|
+
#
|
27
|
+
# If the message is not specified (or is nil) it will default to "You are not authorized
|
28
|
+
# to access this page." This default can be overridden by setting default_message.
|
29
|
+
#
|
30
|
+
# exception.default_message = "Default error message"
|
31
|
+
# exception.message # => "Default error message"
|
32
|
+
#
|
33
|
+
# See ControllerAdditions#authorized! for more information on rescuing from this exception
|
34
|
+
# and customizing the message using I18n.
|
35
|
+
class AccessDenied < Error
|
36
|
+
attr_reader :action, :subject
|
37
|
+
attr_writer :default_message
|
38
|
+
|
39
|
+
def initialize(message = nil, action = nil, subject = nil)
|
40
|
+
@message = message
|
41
|
+
@action = action
|
42
|
+
@subject = subject
|
43
|
+
@default_message = I18n.t(:"unauthorized.default", :default => "You are not authorized to access this page.")
|
44
|
+
end
|
45
|
+
|
46
|
+
def to_s
|
47
|
+
@message || @default_message
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module CanCan
|
2
|
+
# For use with Inherited Resources
|
3
|
+
class InheritedResource < ControllerResource # :nodoc:
|
4
|
+
def load_resource_instance
|
5
|
+
if parent?
|
6
|
+
@controller.send :association_chain
|
7
|
+
@controller.instance_variable_get("@#{instance_name}")
|
8
|
+
elsif new_actions.include? @params[:action].to_sym
|
9
|
+
resource = @controller.send :build_resource
|
10
|
+
assign_attributes(resource)
|
11
|
+
else
|
12
|
+
@controller.send :resource
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def resource_base
|
17
|
+
@controller.send :end_of_association_chain
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
rspec_module = defined?(RSpec::Core) ? 'RSpec' : 'Spec' # for RSpec 1 compatability
|
2
|
+
Kernel.const_get(rspec_module)::Matchers.define :be_able_to do |*args|
|
3
|
+
match do |ability|
|
4
|
+
ability.can?(*args)
|
5
|
+
end
|
6
|
+
|
7
|
+
failure_message_for_should do |ability|
|
8
|
+
"expected to be able to #{args.map(&:inspect).join(" ")}"
|
9
|
+
end
|
10
|
+
|
11
|
+
failure_message_for_should_not do |ability|
|
12
|
+
"expected not to be able to #{args.map(&:inspect).join(" ")}"
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module CanCan
|
2
|
+
module ModelAdapters
|
3
|
+
class AbstractAdapter
|
4
|
+
def self.inherited(subclass)
|
5
|
+
@subclasses ||= []
|
6
|
+
@subclasses << subclass
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.adapter_class(model_class)
|
10
|
+
@subclasses.detect { |subclass| subclass.for_class?(model_class) } || DefaultAdapter
|
11
|
+
end
|
12
|
+
|
13
|
+
# Used to determine if the given adapter should be used for the passed in class.
|
14
|
+
def self.for_class?(member_class)
|
15
|
+
false # override in subclass
|
16
|
+
end
|
17
|
+
|
18
|
+
# Override if you need custom find behavior
|
19
|
+
def self.find(model_class, id)
|
20
|
+
model_class.find(id)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Used to determine if this model adapter will override the matching behavior for a hash of conditions.
|
24
|
+
# If this returns true then matches_conditions_hash? will be called. See Rule#matches_conditions_hash
|
25
|
+
def self.override_conditions_hash_matching?(subject, conditions)
|
26
|
+
false
|
27
|
+
end
|
28
|
+
|
29
|
+
# Override if override_conditions_hash_matching? returns true
|
30
|
+
def self.matches_conditions_hash?(subject, conditions)
|
31
|
+
raise NotImplemented, "This model adapter does not support matching on a conditions hash."
|
32
|
+
end
|
33
|
+
|
34
|
+
# Used to determine if this model adapter will override the matching behavior for a specific condition.
|
35
|
+
# If this returns true then matches_condition? will be called. See Rule#matches_conditions_hash
|
36
|
+
def self.override_condition_matching?(subject, name, value)
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
# Override if override_condition_matching? returns true
|
41
|
+
def self.matches_condition?(subject, name, value)
|
42
|
+
raise NotImplemented, "This model adapter does not support matching on a specific condition."
|
43
|
+
end
|
44
|
+
|
45
|
+
def initialize(model_class, rules)
|
46
|
+
@model_class = model_class
|
47
|
+
@rules = rules
|
48
|
+
end
|
49
|
+
|
50
|
+
def database_records
|
51
|
+
# This should be overridden in a subclass to return records which match @rules
|
52
|
+
raise NotImplemented, "This model adapter does not support fetching records from the database."
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
module CanCan
|
2
|
+
module ModelAdapters
|
3
|
+
class ActiveRecordAdapter < AbstractAdapter
|
4
|
+
def self.for_class?(model_class)
|
5
|
+
model_class <= ActiveRecord::Base
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.override_condition_matching?(subject, name, value)
|
9
|
+
name.kind_of?(MetaWhere::Column) if defined? MetaWhere
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.matches_condition?(subject, name, value)
|
13
|
+
subject_value = subject.send(name.column)
|
14
|
+
if name.method.to_s.ends_with? "_any"
|
15
|
+
value.any? { |v| meta_where_match? subject_value, name.method.to_s.sub("_any", ""), v }
|
16
|
+
elsif name.method.to_s.ends_with? "_all"
|
17
|
+
value.all? { |v| meta_where_match? subject_value, name.method.to_s.sub("_all", ""), v }
|
18
|
+
else
|
19
|
+
meta_where_match? subject_value, name.method, value
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.meta_where_match?(subject_value, method, value)
|
24
|
+
case method.to_sym
|
25
|
+
when :eq then subject_value == value
|
26
|
+
when :not_eq then subject_value != value
|
27
|
+
when :in then value.include?(subject_value)
|
28
|
+
when :not_in then !value.include?(subject_value)
|
29
|
+
when :lt then subject_value < value
|
30
|
+
when :lteq then subject_value <= value
|
31
|
+
when :gt then subject_value > value
|
32
|
+
when :gteq then subject_value >= value
|
33
|
+
when :matches then subject_value =~ Regexp.new("^" + Regexp.escape(value).gsub("%", ".*") + "$", true)
|
34
|
+
when :does_not_match then !meta_where_match?(subject_value, :matches, value)
|
35
|
+
else raise NotImplemented, "The #{method} MetaWhere condition is not supported."
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Returns conditions intended to be used inside a database query. Normally you will not call this
|
40
|
+
# method directly, but instead go through ModelAdditions#accessible_by.
|
41
|
+
#
|
42
|
+
# If there is only one "can" definition, a hash of conditions will be returned matching the one defined.
|
43
|
+
#
|
44
|
+
# can :manage, User, :id => 1
|
45
|
+
# query(:manage, User).conditions # => { :id => 1 }
|
46
|
+
#
|
47
|
+
# If there are multiple "can" definitions, a SQL string will be returned to handle complex cases.
|
48
|
+
#
|
49
|
+
# can :manage, User, :id => 1
|
50
|
+
# can :manage, User, :manager_id => 1
|
51
|
+
# cannot :manage, User, :self_managed => true
|
52
|
+
# query(:manage, User).conditions # => "not (self_managed = 't') AND ((manager_id = 1) OR (id = 1))"
|
53
|
+
#
|
54
|
+
def conditions
|
55
|
+
if @rules.size == 1 && @rules.first.base_behavior
|
56
|
+
# Return the conditions directly if there's just one definition
|
57
|
+
tableized_conditions(@rules.first.conditions).dup
|
58
|
+
else
|
59
|
+
@rules.reverse.inject(false_sql) do |sql, rule|
|
60
|
+
merge_conditions(sql, tableized_conditions(rule.conditions).dup, rule.base_behavior)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def tableized_conditions(conditions, model_class = @model_class)
|
66
|
+
return conditions unless conditions.kind_of? Hash
|
67
|
+
conditions.inject({}) do |result_hash, (name, value)|
|
68
|
+
if value.kind_of? Hash
|
69
|
+
value = value.dup
|
70
|
+
association_class = model_class.reflect_on_association(name).class_name.constantize
|
71
|
+
nested = value.inject({}) do |nested,(k,v)|
|
72
|
+
if v.kind_of? Hash
|
73
|
+
value.delete(k)
|
74
|
+
nested[k] = v
|
75
|
+
else
|
76
|
+
result_hash[model_class.reflect_on_association(name).table_name.to_sym] = value
|
77
|
+
end
|
78
|
+
nested
|
79
|
+
end
|
80
|
+
result_hash.merge!(tableized_conditions(nested,association_class))
|
81
|
+
else
|
82
|
+
result_hash[name] = value
|
83
|
+
end
|
84
|
+
result_hash
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns the associations used in conditions for the :joins option of a search.
|
89
|
+
# See ModelAdditions#accessible_by
|
90
|
+
def joins
|
91
|
+
joins_hash = {}
|
92
|
+
@rules.each do |rule|
|
93
|
+
merge_joins(joins_hash, rule.associations_hash)
|
94
|
+
end
|
95
|
+
clean_joins(joins_hash) unless joins_hash.empty?
|
96
|
+
end
|
97
|
+
|
98
|
+
def database_records
|
99
|
+
if override_scope
|
100
|
+
@model_class.scoped.merge(override_scope)
|
101
|
+
elsif @model_class.respond_to?(:where) && @model_class.respond_to?(:joins)
|
102
|
+
mergeable_conditions = @rules.select {|rule| rule.unmergeable? }.blank?
|
103
|
+
if mergeable_conditions
|
104
|
+
@model_class.where(conditions).includes(joins)
|
105
|
+
else
|
106
|
+
@model_class.where(*(@rules.map(&:conditions))).includes(joins)
|
107
|
+
end
|
108
|
+
else
|
109
|
+
@model_class.scoped(:conditions => conditions, :joins => joins)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def override_scope
|
116
|
+
conditions = @rules.map(&:conditions).compact
|
117
|
+
if defined?(ActiveRecord::Relation) && conditions.any? { |c| c.kind_of?(ActiveRecord::Relation) }
|
118
|
+
if conditions.size == 1
|
119
|
+
conditions.first
|
120
|
+
else
|
121
|
+
rule = @rules.detect { |rule| rule.conditions.kind_of?(ActiveRecord::Relation) }
|
122
|
+
raise Error, "Unable to merge an Active Record scope with other conditions. Instead use a hash or SQL for #{rule.actions.first} #{rule.subjects.first} ability."
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def merge_conditions(sql, conditions_hash, behavior)
|
128
|
+
if conditions_hash.blank?
|
129
|
+
behavior ? true_sql : false_sql
|
130
|
+
else
|
131
|
+
conditions = sanitize_sql(conditions_hash)
|
132
|
+
case sql
|
133
|
+
when true_sql
|
134
|
+
behavior ? true_sql : "not (#{conditions})"
|
135
|
+
when false_sql
|
136
|
+
behavior ? conditions : false_sql
|
137
|
+
else
|
138
|
+
behavior ? "(#{conditions}) OR (#{sql})" : "not (#{conditions}) AND (#{sql})"
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def false_sql
|
144
|
+
sanitize_sql(['?=?', true, false])
|
145
|
+
end
|
146
|
+
|
147
|
+
def true_sql
|
148
|
+
sanitize_sql(['?=?', true, true])
|
149
|
+
end
|
150
|
+
|
151
|
+
def sanitize_sql(conditions)
|
152
|
+
@model_class.send(:sanitize_sql, conditions)
|
153
|
+
end
|
154
|
+
|
155
|
+
# Takes two hashes and does a deep merge.
|
156
|
+
def merge_joins(base, add)
|
157
|
+
add.each do |name, nested|
|
158
|
+
if base[name].is_a?(Hash)
|
159
|
+
merge_joins(base[name], nested) unless nested.empty?
|
160
|
+
else
|
161
|
+
base[name] = nested
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Removes empty hashes and moves everything into arrays.
|
167
|
+
def clean_joins(joins_hash)
|
168
|
+
joins = []
|
169
|
+
joins_hash.each do |name, nested|
|
170
|
+
joins << (nested.empty? ? name : {name => clean_joins(nested)})
|
171
|
+
end
|
172
|
+
joins
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
ActiveRecord::Base.class_eval do
|
179
|
+
include CanCan::ModelAdditions
|
180
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module CanCan
|
2
|
+
module ModelAdapters
|
3
|
+
class DataMapperAdapter < AbstractAdapter
|
4
|
+
def self.for_class?(model_class)
|
5
|
+
model_class <= DataMapper::Resource
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.find(model_class, id)
|
9
|
+
model_class.get(id)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.override_conditions_hash_matching?(subject, conditions)
|
13
|
+
conditions.any? { |k,v| !k.kind_of?(Symbol) }
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.matches_conditions_hash?(subject, conditions)
|
17
|
+
collection = DataMapper::Collection.new(subject.query, [ subject ])
|
18
|
+
!!collection.first(conditions)
|
19
|
+
end
|
20
|
+
|
21
|
+
def database_records
|
22
|
+
scope = @model_class.all(:conditions => ["0 = 1"])
|
23
|
+
cans, cannots = @rules.partition { |r| r.base_behavior }
|
24
|
+
return scope if cans.empty?
|
25
|
+
# apply unions first, then differences. this mean cannot overrides can
|
26
|
+
cans.each { |r| scope += @model_class.all(:conditions => r.conditions) }
|
27
|
+
cannots.each { |r| scope -= @model_class.all(:conditions => r.conditions) }
|
28
|
+
scope
|
29
|
+
end
|
30
|
+
end # class DataMapper
|
31
|
+
end # module ModelAdapters
|
32
|
+
end # module CanCan
|
33
|
+
|
34
|
+
DataMapper::Model.append_extensions(CanCan::ModelAdditions::ClassMethods)
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module CanCan
|
2
|
+
module ModelAdapters
|
3
|
+
class MongoidAdapter < AbstractAdapter
|
4
|
+
def self.for_class?(model_class)
|
5
|
+
model_class <= Mongoid::Document
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.override_conditions_hash_matching?(subject, conditions)
|
9
|
+
conditions.any? do |k,v|
|
10
|
+
key_is_not_symbol = lambda { !k.kind_of?(Symbol) }
|
11
|
+
subject_value_is_array = lambda do
|
12
|
+
subject.respond_to?(k) && subject.send(k).is_a?(Array)
|
13
|
+
end
|
14
|
+
|
15
|
+
key_is_not_symbol.call || subject_value_is_array.call
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.matches_conditions_hash?(subject, conditions)
|
20
|
+
# To avoid hitting the db, retrieve the raw Mongo selector from
|
21
|
+
# the Mongoid Criteria and use Mongoid::Matchers#matches?
|
22
|
+
subject.matches?( subject.class.where(conditions).selector )
|
23
|
+
end
|
24
|
+
|
25
|
+
def database_records
|
26
|
+
if @rules.size == 0
|
27
|
+
@model_class.where(:_id => {'$exists' => false, '$type' => 7}) # return no records in Mongoid
|
28
|
+
elsif @rules.size == 1 && @rules[0].conditions.is_a?(Mongoid::Criteria)
|
29
|
+
@rules[0].conditions
|
30
|
+
else
|
31
|
+
# we only need to process can rules if
|
32
|
+
# there are no rules with empty conditions
|
33
|
+
rules = @rules.reject { |rule| rule.conditions.empty? && rule.base_behavior }
|
34
|
+
process_can_rules = @rules.count == rules.count
|
35
|
+
|
36
|
+
rules.inject(@model_class.all) do |records, rule|
|
37
|
+
if process_can_rules && rule.base_behavior
|
38
|
+
records.or rule.conditions
|
39
|
+
elsif !rule.base_behavior
|
40
|
+
records.excludes rule.conditions
|
41
|
+
else
|
42
|
+
records
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# simplest way to add `accessible_by` to all Mongoid Documents
|
52
|
+
module Mongoid::Document::ClassMethods
|
53
|
+
include CanCan::ModelAdditions::ClassMethods
|
54
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module CanCan
|
2
|
+
|
3
|
+
# This module adds the accessible_by class method to a model. It is included in the model adapters.
|
4
|
+
module ModelAdditions
|
5
|
+
module ClassMethods
|
6
|
+
# Returns a scope which fetches only the records that the passed ability
|
7
|
+
# can perform a given action on. The action defaults to :index. This
|
8
|
+
# is usually called from a controller and passed the +current_ability+.
|
9
|
+
#
|
10
|
+
# @articles = Article.accessible_by(current_ability)
|
11
|
+
#
|
12
|
+
# Here only the articles which the user is able to read will be returned.
|
13
|
+
# If the user does not have permission to read any articles then an empty
|
14
|
+
# result is returned. Since this is a scope it can be combined with any
|
15
|
+
# other scopes or pagination.
|
16
|
+
#
|
17
|
+
# An alternative action can optionally be passed as a second argument.
|
18
|
+
#
|
19
|
+
# @articles = Article.accessible_by(current_ability, :update)
|
20
|
+
#
|
21
|
+
# Here only the articles which the user can update are returned.
|
22
|
+
def accessible_by(ability, action = :index)
|
23
|
+
ability.model_adapter(self, action).database_records
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.included(base)
|
28
|
+
base.extend ClassMethods
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|