timcharper-declarative_authorization 0.4.1.2
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +135 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +503 -0
- data/Rakefile +43 -0
- data/app/controllers/authorization_rules_controller.rb +259 -0
- data/app/controllers/authorization_usages_controller.rb +23 -0
- data/app/helpers/authorization_rules_helper.rb +218 -0
- data/app/views/authorization_rules/_change.erb +58 -0
- data/app/views/authorization_rules/_show_graph.erb +37 -0
- data/app/views/authorization_rules/_suggestions.erb +48 -0
- data/app/views/authorization_rules/change.html.erb +169 -0
- data/app/views/authorization_rules/graph.dot.erb +68 -0
- data/app/views/authorization_rules/graph.html.erb +40 -0
- data/app/views/authorization_rules/index.html.erb +17 -0
- data/app/views/authorization_usages/index.html.erb +36 -0
- data/authorization_rules.dist.rb +20 -0
- data/config/routes.rb +7 -0
- data/garlic_example.rb +20 -0
- data/init.rb +5 -0
- data/lib/declarative_authorization.rb +15 -0
- data/lib/declarative_authorization/authorization.rb +683 -0
- data/lib/declarative_authorization/development_support/analyzer.rb +252 -0
- data/lib/declarative_authorization/development_support/change_analyzer.rb +253 -0
- data/lib/declarative_authorization/development_support/change_supporter.rb +620 -0
- data/lib/declarative_authorization/development_support/development_support.rb +243 -0
- data/lib/declarative_authorization/helper.rb +60 -0
- data/lib/declarative_authorization/in_controller.rb +623 -0
- data/lib/declarative_authorization/in_model.rb +162 -0
- data/lib/declarative_authorization/maintenance.rb +198 -0
- data/lib/declarative_authorization/obligation_scope.rb +345 -0
- data/lib/declarative_authorization/rails_legacy.rb +14 -0
- data/lib/declarative_authorization/reader.rb +472 -0
- data/test/authorization_test.rb +971 -0
- data/test/controller_filter_resource_access_test.rb +511 -0
- data/test/controller_test.rb +465 -0
- data/test/dsl_reader_test.rb +157 -0
- data/test/helper_test.rb +171 -0
- data/test/maintenance_test.rb +46 -0
- data/test/model_test.rb +1694 -0
- data/test/schema.sql +54 -0
- data/test/test_helper.rb +134 -0
- metadata +119 -0
@@ -0,0 +1,162 @@
|
|
1
|
+
# Authorization::AuthorizationInModel
|
2
|
+
require File.dirname(__FILE__) + '/authorization.rb'
|
3
|
+
require File.dirname(__FILE__) + '/obligation_scope.rb'
|
4
|
+
|
5
|
+
module Authorization
|
6
|
+
|
7
|
+
module AuthorizationInModel
|
8
|
+
|
9
|
+
# If the user meets the given privilege, permitted_to? returns true
|
10
|
+
# and yields to the optional block.
|
11
|
+
def permitted_to? (privilege, options = {}, &block)
|
12
|
+
options = {
|
13
|
+
:user => Authorization.current_user,
|
14
|
+
:object => self
|
15
|
+
}.merge(options)
|
16
|
+
Authorization::Engine.instance.permit?(privilege,
|
17
|
+
{:user => options[:user],
|
18
|
+
:object => options[:object]},
|
19
|
+
&block)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Works similar to the permitted_to? method, but doesn't accept a block
|
23
|
+
# and throws the authorization exceptions, just like Engine#permit!
|
24
|
+
def permitted_to! (privilege, options = {} )
|
25
|
+
options = {
|
26
|
+
:user => Authorization.current_user,
|
27
|
+
:object => self
|
28
|
+
}.merge(options)
|
29
|
+
Authorization::Engine.instance.permit!(privilege,
|
30
|
+
{:user => options[:user],
|
31
|
+
:object => options[:object]})
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.included(base) # :nodoc:
|
35
|
+
#base.extend(ClassMethods)
|
36
|
+
base.module_eval do
|
37
|
+
scopes[:with_permissions_to] = lambda do |parent_scope, *args|
|
38
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
39
|
+
privilege = (args[0] || :read).to_sym
|
40
|
+
privileges = [privilege]
|
41
|
+
context =
|
42
|
+
if options[:context]
|
43
|
+
options[:context]
|
44
|
+
elsif parent_scope.respond_to?(:proxy_reflection)
|
45
|
+
parent_scope.proxy_reflection.klass.name.tableize.to_sym
|
46
|
+
elsif parent_scope.respond_to?(:decl_auth_context)
|
47
|
+
parent_scope.decl_auth_context
|
48
|
+
else
|
49
|
+
parent_scope.name.tableize.to_sym
|
50
|
+
end
|
51
|
+
|
52
|
+
user = options[:user] || Authorization.current_user
|
53
|
+
|
54
|
+
engine = options[:engine] || Authorization::Engine.instance
|
55
|
+
engine.permit!(privileges, :user => user, :skip_attribute_test => true,
|
56
|
+
:context => context)
|
57
|
+
|
58
|
+
obligation_scope_for( privileges, :user => user,
|
59
|
+
:context => context, :engine => engine, :model => parent_scope)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Builds and returns a scope with joins and conditions satisfying all obligations.
|
63
|
+
def self.obligation_scope_for( privileges, options = {} )
|
64
|
+
options = {
|
65
|
+
:user => Authorization.current_user,
|
66
|
+
:context => nil,
|
67
|
+
:model => self,
|
68
|
+
:engine => nil,
|
69
|
+
}.merge(options)
|
70
|
+
engine = options[:engine] || Authorization::Engine.instance
|
71
|
+
|
72
|
+
obligation_scope = ObligationScope.new( options[:model], {} )
|
73
|
+
engine.obligations( privileges, :user => options[:user], :context => options[:context] ).each do |obligation|
|
74
|
+
obligation_scope.parse!( obligation )
|
75
|
+
end
|
76
|
+
|
77
|
+
obligation_scope.scope
|
78
|
+
end
|
79
|
+
|
80
|
+
# Named scope for limiting query results according to the authorization
|
81
|
+
# of the current user. If no privilege is given, :+read+ is assumed.
|
82
|
+
#
|
83
|
+
# User.with_permissions_to
|
84
|
+
# User.with_permissions_to(:update)
|
85
|
+
# User.with_permissions_to(:update, :context => :users)
|
86
|
+
#
|
87
|
+
# As in the case of other named scopes, this one may be chained:
|
88
|
+
# User.with_permission_to.find(:all, :conditions...)
|
89
|
+
#
|
90
|
+
# Options
|
91
|
+
# [:+context+]
|
92
|
+
# Context for the privilege to be evaluated in; defaults to the
|
93
|
+
# model's table name.
|
94
|
+
# [:+user+]
|
95
|
+
# User to be used for gathering obligations; defaults to the
|
96
|
+
# current user.
|
97
|
+
#
|
98
|
+
def self.with_permissions_to (*args)
|
99
|
+
scopes[:with_permissions_to].call(self, *args)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Activates model security for the current model. Then, CRUD operations
|
103
|
+
# are checked against the authorization of the current user. The
|
104
|
+
# privileges are :+create+, :+read+, :+update+ and :+delete+ in the
|
105
|
+
# context of the model. By default, :+read+ is not checked because of
|
106
|
+
# performance impacts, especially with large result sets.
|
107
|
+
#
|
108
|
+
# class User < ActiveRecord::Base
|
109
|
+
# using_access_control
|
110
|
+
# end
|
111
|
+
#
|
112
|
+
# If an operation is not permitted, a Authorization::AuthorizationError
|
113
|
+
# is raised.
|
114
|
+
#
|
115
|
+
# To activate model security on all models, call using_access_control
|
116
|
+
# on ActiveRecord::Base
|
117
|
+
# ActiveRecord::Base.using_access_control
|
118
|
+
#
|
119
|
+
# Available options
|
120
|
+
# [:+context+] Specify context different from the models table name.
|
121
|
+
# [:+include_read+] Also check for :+read+ privilege after find.
|
122
|
+
#
|
123
|
+
def self.using_access_control (options = {})
|
124
|
+
options = {
|
125
|
+
:context => nil,
|
126
|
+
:include_read => false
|
127
|
+
}.merge(options)
|
128
|
+
|
129
|
+
class_eval do
|
130
|
+
[:create, :update, [:destroy, :delete]].each do |action, privilege|
|
131
|
+
send(:"before_#{action}") do |object|
|
132
|
+
Authorization::Engine.instance.permit!(privilege || action,
|
133
|
+
:object => object, :context => options[:context])
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
if options[:include_read]
|
138
|
+
# after_find is only called if after_find is implemented
|
139
|
+
after_find do |object|
|
140
|
+
Authorization::Engine.instance.permit!(:read, :object => object,
|
141
|
+
:context => options[:context])
|
142
|
+
end
|
143
|
+
|
144
|
+
if Rails.version < "3"
|
145
|
+
def after_find; end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def self.using_access_control?
|
150
|
+
true
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# Returns true if the model is using model security.
|
156
|
+
def self.using_access_control?
|
157
|
+
false
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,198 @@
|
|
1
|
+
# Authorization::Maintenance
|
2
|
+
require File.dirname(__FILE__) + '/authorization.rb'
|
3
|
+
|
4
|
+
module Authorization
|
5
|
+
# Provides a few maintenance methods for modifying data without enforcing
|
6
|
+
# authorization.
|
7
|
+
module Maintenance
|
8
|
+
# Disables access control for the given block. Appropriate for
|
9
|
+
# maintenance operation at the Rails console or in test case setup.
|
10
|
+
#
|
11
|
+
# For use in the Rails console:
|
12
|
+
# require "vendor/plugins/declarative_authorization/lib/maintenance"
|
13
|
+
# include Authorization::Maintenance
|
14
|
+
#
|
15
|
+
# without_access_control do
|
16
|
+
# SomeModel.find(:first).save
|
17
|
+
# end
|
18
|
+
def without_access_control (&block)
|
19
|
+
Authorization::Maintenance.without_access_control(&block)
|
20
|
+
end
|
21
|
+
|
22
|
+
# A class method variant of without_access_control. Thus, one can call
|
23
|
+
# Authorization::Maintenance::without_access_control do
|
24
|
+
# ...
|
25
|
+
# end
|
26
|
+
def self.without_access_control
|
27
|
+
previous_state = Authorization.ignore_access_control
|
28
|
+
begin
|
29
|
+
Authorization.ignore_access_control(true)
|
30
|
+
yield
|
31
|
+
ensure
|
32
|
+
Authorization.ignore_access_control(previous_state)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Sets the current user for the declarative authorization plugin to the
|
37
|
+
# given one for the execution of the supplied block. Suitable for tests
|
38
|
+
# on certain users.
|
39
|
+
def with_user (user, &block)
|
40
|
+
Authorization::Maintenance.with_user(user, &block)
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.with_user (user)
|
44
|
+
prev_user = Authorization.current_user
|
45
|
+
Authorization.current_user = user
|
46
|
+
yield
|
47
|
+
ensure
|
48
|
+
Authorization.current_user = prev_user
|
49
|
+
end
|
50
|
+
|
51
|
+
# Module for grouping usage-related helper methods
|
52
|
+
module Usage
|
53
|
+
# Delivers a hash of {ControllerClass => usage_info_hash},
|
54
|
+
# where usage_info_hash has the form of
|
55
|
+
def self.usages_by_controller
|
56
|
+
# load each application controller
|
57
|
+
begin
|
58
|
+
Dir.foreach(File.join(RAILS_ROOT, %w{app controllers})) do |entry|
|
59
|
+
if entry =~ /^\w+_controller\.rb$/
|
60
|
+
require File.join(RAILS_ROOT, %w{app controllers}, entry)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
rescue Errno::ENOENT
|
64
|
+
end
|
65
|
+
controllers = []
|
66
|
+
ObjectSpace.each_object(Class) do |obj|
|
67
|
+
controllers << obj if obj.ancestors.include?(ActionController::Base) and
|
68
|
+
!%w{ActionController::Base ApplicationController}.include?(obj.name)
|
69
|
+
end
|
70
|
+
|
71
|
+
controllers.inject({}) do |memo, controller|
|
72
|
+
catchall_permissions = []
|
73
|
+
permission_by_action = {}
|
74
|
+
controller.all_filter_access_permissions.each do |controller_permissions|
|
75
|
+
catchall_permissions << controller_permissions if controller_permissions.actions.include?(:all)
|
76
|
+
controller_permissions.actions.reject {|action| action == :all}.each do |action|
|
77
|
+
permission_by_action[action] = controller_permissions
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
actions = controller.public_instance_methods(false) - controller.hidden_actions.to_a
|
82
|
+
memo[controller] = actions.inject({}) do |actions_memo, action|
|
83
|
+
action_sym = action.to_sym
|
84
|
+
actions_memo[action_sym] =
|
85
|
+
if permission_by_action[action_sym]
|
86
|
+
{
|
87
|
+
:privilege => permission_by_action[action_sym].privilege,
|
88
|
+
:context => permission_by_action[action_sym].context,
|
89
|
+
:controller_permissions => [permission_by_action[action_sym]]
|
90
|
+
}
|
91
|
+
elsif !catchall_permissions.empty?
|
92
|
+
{
|
93
|
+
:privilege => catchall_permissions[0].privilege,
|
94
|
+
:context => catchall_permissions[0].context,
|
95
|
+
:controller_permissions => catchall_permissions
|
96
|
+
}
|
97
|
+
else
|
98
|
+
{}
|
99
|
+
end
|
100
|
+
actions_memo
|
101
|
+
end
|
102
|
+
memo
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# TestHelper provides assert methods and controller request methods which
|
109
|
+
# take authorization into account and set the current user to a specific
|
110
|
+
# one.
|
111
|
+
#
|
112
|
+
# Defines get_with, post_with, get_by_xhr_with etc. for methods
|
113
|
+
# get, post, put, delete each with the signature
|
114
|
+
# get_with(user, action, params = {}, session = {}, flash = {})
|
115
|
+
#
|
116
|
+
# Use it by including it in your TestHelper:
|
117
|
+
# require File.expand_path(File.dirname(__FILE__) +
|
118
|
+
# "/../vendor/plugins/declarative_authorization/lib/maintenance")
|
119
|
+
# class Test::Unit::TestCase
|
120
|
+
# include Authorization::TestHelper
|
121
|
+
# ...
|
122
|
+
#
|
123
|
+
# def admin
|
124
|
+
# # create admin user
|
125
|
+
# end
|
126
|
+
# end
|
127
|
+
#
|
128
|
+
# class SomeControllerTest < ActionController::TestCase
|
129
|
+
# def test_should_get_index
|
130
|
+
# ...
|
131
|
+
# get_with admin, :index, :param_1 => "param value"
|
132
|
+
# ...
|
133
|
+
# end
|
134
|
+
# end
|
135
|
+
#
|
136
|
+
# Note: get_with etc. do two things to set the user for the request:
|
137
|
+
# Authorization.current_user is set and session[:user], session[:user_id]
|
138
|
+
# are set appropriately. If you determine the current user in a different
|
139
|
+
# way, these methods might not work for you.
|
140
|
+
module TestHelper
|
141
|
+
include Authorization::Maintenance
|
142
|
+
|
143
|
+
# Analogue to the Ruby's assert_raise method, only executing the block
|
144
|
+
# in the context of the given user.
|
145
|
+
def assert_raise_with_user (user, *args, &block)
|
146
|
+
assert_raise(*args) do
|
147
|
+
with_user(user, &block)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Test helper to test authorization rules. E.g.
|
152
|
+
# with_user a_normal_user do
|
153
|
+
# should_not_be_allowed_to :update, :conferences
|
154
|
+
# should_not_be_allowed_to :read, an_unpublished_conference
|
155
|
+
# should_be_allowed_to :read, a_published_conference
|
156
|
+
# end
|
157
|
+
def should_be_allowed_to (privilege, object_or_context)
|
158
|
+
options = {}
|
159
|
+
options[object_or_context.is_a?(Symbol) ? :context : :object] = object_or_context
|
160
|
+
assert_nothing_raised do
|
161
|
+
Authorization::Engine.instance.permit!(privilege, options)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
# See should_be_allowed_to
|
166
|
+
def should_not_be_allowed_to (privilege, object_or_context)
|
167
|
+
options = {}
|
168
|
+
options[object_or_context.is_a?(Symbol) ? :context : :object] = object_or_context
|
169
|
+
assert !Authorization::Engine.instance.permit?(privilege, options)
|
170
|
+
end
|
171
|
+
|
172
|
+
def request_with (user, method, xhr, action, params = {},
|
173
|
+
session = {}, flash = {})
|
174
|
+
session = session.merge({:user => user, :user_id => user.id})
|
175
|
+
with_user(user) do
|
176
|
+
if xhr
|
177
|
+
xhr method, action, params, session, flash
|
178
|
+
else
|
179
|
+
send method, action, params, session, flash
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def self.included (base)
|
185
|
+
[:get, :post, :put, :delete].each do |method|
|
186
|
+
base.class_eval <<-EOV, __FILE__, __LINE__
|
187
|
+
def #{method}_with (user, *args)
|
188
|
+
request_with(user, #{method.inspect}, false, *args)
|
189
|
+
end
|
190
|
+
|
191
|
+
def #{method}_by_xhr_with (user, *args)
|
192
|
+
request_with(user, #{method.inspect}, true, *args)
|
193
|
+
end
|
194
|
+
EOV
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
@@ -0,0 +1,345 @@
|
|
1
|
+
module Authorization
|
2
|
+
# The +ObligationScope+ class parses any number of obligations into joins and conditions.
|
3
|
+
#
|
4
|
+
# In +ObligationScope+ parlance, "association paths" are one-dimensional arrays in which each
|
5
|
+
# element represents an attribute or association (or "step"), and "leads" to the next step in the
|
6
|
+
# association path.
|
7
|
+
#
|
8
|
+
# Suppose we have this path defined in the context of model Foo:
|
9
|
+
# +{ :bar => { :baz => { :foo => { :attr => is { user } } } } }+
|
10
|
+
#
|
11
|
+
# To parse this path, +ObligationScope+ evaluates each step in the context of the preceding step.
|
12
|
+
# The first step is evaluated in the context of the parent scope, the second step is evaluated in
|
13
|
+
# the context of the first, and so forth. Every time we encounter a step representing an
|
14
|
+
# association, we make note of the fact by storing the path (up to that point), assigning it a
|
15
|
+
# table alias intended to match the one that will eventually be chosen by ActiveRecord when
|
16
|
+
# executing the +find+ method on the scope.
|
17
|
+
#
|
18
|
+
# +@table_aliases = {
|
19
|
+
# [] => 'foos',
|
20
|
+
# [:bar] => 'bars',
|
21
|
+
# [:bar, :baz] => 'bazzes',
|
22
|
+
# [:bar, :baz, :foo] => 'foos_bazzes' # Alias avoids collisions with 'foos' (already used)
|
23
|
+
# }+
|
24
|
+
#
|
25
|
+
# At the "end" of each path, we expect to find a comparison operation of some kind, generally
|
26
|
+
# comparing an attribute of the most recent association with some other value (such as an ID,
|
27
|
+
# constant, or array of values). When we encounter a step representing a comparison, we make
|
28
|
+
# note of the fact by storing the path (up to that point) and the comparison operation together.
|
29
|
+
# (Note that individual obligations' conditions are kept separate, to allow their conditions to
|
30
|
+
# be OR'ed together in the generated scope options.)
|
31
|
+
#
|
32
|
+
# +@obligation_conditions[<obligation>][[:bar, :baz, :foo]] = [
|
33
|
+
# [ :attr, :is, <user.id> ]
|
34
|
+
# ]+
|
35
|
+
#
|
36
|
+
# TODO update doc for Relations:
|
37
|
+
# After successfully parsing an obligation, all of the stored paths and conditions are converted
|
38
|
+
# into scope options (stored in +proxy_options+ as +:joins+ and +:conditions+). The resulting
|
39
|
+
# scope may then be used to find all scoped objects for which at least one of the parsed
|
40
|
+
# obligations is fully met.
|
41
|
+
#
|
42
|
+
# +@proxy_options[:joins] = { :bar => { :baz => :foo } }
|
43
|
+
# @proxy_options[:conditions] = [ 'foos_bazzes.attr = :foos_bazzes__id_0', { :foos_bazzes__id_0 => 1 } ]+
|
44
|
+
#
|
45
|
+
class ObligationScope < ActiveRecord::NamedScope::Scope
|
46
|
+
def initialize (model, options)
|
47
|
+
@finder_options = {}
|
48
|
+
super(model, options)
|
49
|
+
end
|
50
|
+
|
51
|
+
def scope
|
52
|
+
if Rails.version < "3"
|
53
|
+
self
|
54
|
+
else
|
55
|
+
# for Rails < 3: scope, after setting proxy_options
|
56
|
+
self.klass.scoped(@finder_options)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Consumes the given obligation, converting it into scope join and condition options.
|
61
|
+
def parse!( obligation )
|
62
|
+
@current_obligation = obligation
|
63
|
+
@join_table_joins = Set.new
|
64
|
+
obligation_conditions[@current_obligation] ||= {}
|
65
|
+
follow_path( obligation )
|
66
|
+
|
67
|
+
rebuild_condition_options!
|
68
|
+
rebuild_join_options!
|
69
|
+
end
|
70
|
+
|
71
|
+
protected
|
72
|
+
|
73
|
+
# Parses the next step in the association path. If it's an association, we advance down the
|
74
|
+
# path. Otherwise, it's an attribute, and we need to evaluate it as a comparison operation.
|
75
|
+
def follow_path( steps, past_steps = [] )
|
76
|
+
if steps.is_a?( Hash )
|
77
|
+
steps.each do |step, next_steps|
|
78
|
+
path_to_this_point = [past_steps, step].flatten
|
79
|
+
reflection = reflection_for( path_to_this_point ) rescue nil
|
80
|
+
if reflection
|
81
|
+
follow_path( next_steps, path_to_this_point )
|
82
|
+
else
|
83
|
+
follow_comparison( next_steps, past_steps, step )
|
84
|
+
end
|
85
|
+
end
|
86
|
+
elsif steps.is_a?( Array ) && steps.length == 2
|
87
|
+
if reflection_for( past_steps )
|
88
|
+
follow_comparison( steps, past_steps, :id )
|
89
|
+
else
|
90
|
+
follow_comparison( steps, past_steps[0..-2], past_steps[-1] )
|
91
|
+
end
|
92
|
+
else
|
93
|
+
raise "invalid obligation path #{[past_steps, steps].inspect}"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def top_level_model
|
98
|
+
if Rails.version < "3"
|
99
|
+
@proxy_scope
|
100
|
+
else
|
101
|
+
self.klass
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def finder_options
|
106
|
+
Rails.version < "3" ? @proxy_options : @finder_options
|
107
|
+
end
|
108
|
+
|
109
|
+
# At the end of every association path, we expect to see a comparison of some kind; for
|
110
|
+
# example, +:attr => [ :is, :value ]+.
|
111
|
+
#
|
112
|
+
# This method parses the comparison and creates an obligation condition from it.
|
113
|
+
def follow_comparison( steps, past_steps, attribute )
|
114
|
+
operator = steps[0]
|
115
|
+
value = steps[1..-1]
|
116
|
+
value = value[0] if value.length == 1
|
117
|
+
|
118
|
+
add_obligation_condition_for( past_steps, [attribute, operator, value] )
|
119
|
+
end
|
120
|
+
|
121
|
+
# Adds the given expression to the current obligation's indicated path's conditions.
|
122
|
+
#
|
123
|
+
# Condition expressions must follow the format +[ <attribute>, <operator>, <value> ]+.
|
124
|
+
def add_obligation_condition_for( path, expression )
|
125
|
+
raise "invalid expression #{expression.inspect}" unless expression.is_a?( Array ) && expression.length == 3
|
126
|
+
add_obligation_join_for( path )
|
127
|
+
obligation_conditions[@current_obligation] ||= {}
|
128
|
+
( obligation_conditions[@current_obligation][path] ||= Set.new ) << expression
|
129
|
+
end
|
130
|
+
|
131
|
+
# Adds the given path to the list of obligation joins, if we haven't seen it before.
|
132
|
+
def add_obligation_join_for( path )
|
133
|
+
map_reflection_for( path ) if reflections[path].nil?
|
134
|
+
end
|
135
|
+
|
136
|
+
# Returns the model associated with the given path.
|
137
|
+
def model_for (path)
|
138
|
+
reflection = reflection_for(path)
|
139
|
+
|
140
|
+
if reflection.respond_to?(:proxy_reflection)
|
141
|
+
reflection.proxy_reflection.klass
|
142
|
+
elsif reflection.respond_to?(:klass)
|
143
|
+
reflection.klass
|
144
|
+
else
|
145
|
+
reflection
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Returns the reflection corresponding to the given path.
|
150
|
+
def reflection_for(path, for_join_table_only = false)
|
151
|
+
@join_table_joins << path if for_join_table_only and !reflections[path]
|
152
|
+
reflections[path] ||= map_reflection_for( path )
|
153
|
+
end
|
154
|
+
|
155
|
+
# Returns a proper table alias for the given path. This alias may be used in SQL statements.
|
156
|
+
def table_alias_for( path )
|
157
|
+
table_aliases[path] ||= map_table_alias_for( path )
|
158
|
+
end
|
159
|
+
|
160
|
+
# Attempts to map a reflection for the given path. Raises if already defined.
|
161
|
+
def map_reflection_for( path )
|
162
|
+
raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
|
163
|
+
|
164
|
+
reflection = path.empty? ? top_level_model : begin
|
165
|
+
parent = reflection_for( path[0..-2] )
|
166
|
+
if !parent.respond_to?(:proxy_reflection) and parent.respond_to?(:klass)
|
167
|
+
parent.klass.reflect_on_association( path.last )
|
168
|
+
else
|
169
|
+
parent.reflect_on_association( path.last )
|
170
|
+
end
|
171
|
+
rescue
|
172
|
+
parent.reflect_on_association( path.last )
|
173
|
+
end
|
174
|
+
raise "invalid path #{path.inspect}" if reflection.nil?
|
175
|
+
|
176
|
+
reflections[path] = reflection
|
177
|
+
map_table_alias_for( path ) # Claim a table alias for the path.
|
178
|
+
|
179
|
+
# Claim alias for join table
|
180
|
+
# TODO change how this is checked
|
181
|
+
if !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
182
|
+
join_table_path = path[0..-2] + [reflection.options[:through]]
|
183
|
+
reflection_for(join_table_path, true)
|
184
|
+
end
|
185
|
+
|
186
|
+
reflection
|
187
|
+
end
|
188
|
+
|
189
|
+
# Attempts to map a table alias for the given path. Raises if already defined.
|
190
|
+
def map_table_alias_for( path )
|
191
|
+
return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
|
192
|
+
|
193
|
+
reflection = reflection_for( path )
|
194
|
+
table_alias = reflection.table_name
|
195
|
+
if table_aliases.values.include?( table_alias )
|
196
|
+
max_length = reflection.active_record.connection.table_alias_length
|
197
|
+
# Rails seems to pluralize reflection names
|
198
|
+
table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
|
199
|
+
end
|
200
|
+
while table_aliases.values.include?( table_alias )
|
201
|
+
if table_alias =~ /\w(_\d+?)$/
|
202
|
+
table_index = $1.succ
|
203
|
+
table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
|
204
|
+
else
|
205
|
+
table_alias = "#{table_alias[0..(max_length-3)]}_2"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
table_aliases[path] = table_alias
|
209
|
+
end
|
210
|
+
|
211
|
+
# Returns a hash mapping obligations to zero or more condition path sets.
|
212
|
+
def obligation_conditions
|
213
|
+
@obligation_conditions ||= {}
|
214
|
+
end
|
215
|
+
|
216
|
+
# Returns a hash mapping paths to reflections.
|
217
|
+
def reflections
|
218
|
+
# lets try to get the order of joins right
|
219
|
+
@reflections ||= ActiveSupport::OrderedHash.new
|
220
|
+
end
|
221
|
+
|
222
|
+
# Returns a hash mapping paths to proper table aliases to use in SQL statements.
|
223
|
+
def table_aliases
|
224
|
+
@table_aliases ||= {}
|
225
|
+
end
|
226
|
+
|
227
|
+
# Parses all of the defined obligation conditions and defines the scope's :conditions option.
|
228
|
+
def rebuild_condition_options!
|
229
|
+
conds = []
|
230
|
+
binds = {}
|
231
|
+
used_paths = Set.new
|
232
|
+
delete_paths = Set.new
|
233
|
+
obligation_conditions.each_with_index do |array, obligation_index|
|
234
|
+
obligation, conditions = array
|
235
|
+
obligation_conds = []
|
236
|
+
conditions.each do |path, expressions|
|
237
|
+
model = model_for( path )
|
238
|
+
table_alias = table_alias_for(path)
|
239
|
+
parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
|
240
|
+
expressions.each do |expression|
|
241
|
+
attribute, operator, value = expression
|
242
|
+
# prevent unnecessary joins:
|
243
|
+
if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
|
244
|
+
attribute_name = :"#{path.last}_id"
|
245
|
+
attribute_table_alias = table_alias_for(path[0..-2])
|
246
|
+
used_paths << path[0..-2]
|
247
|
+
delete_paths << path
|
248
|
+
else
|
249
|
+
attribute_name = model.columns_hash["#{attribute}_id"] && :"#{attribute}_id" ||
|
250
|
+
model.columns_hash[attribute.to_s] && attribute ||
|
251
|
+
:id
|
252
|
+
attribute_table_alias = table_alias
|
253
|
+
used_paths << path
|
254
|
+
end
|
255
|
+
bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
|
256
|
+
|
257
|
+
sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
|
258
|
+
"#{parent_model.connection.quote_table_name(attribute_name)}"
|
259
|
+
if value.nil? and [:is, :is_not].include?(operator)
|
260
|
+
obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
|
261
|
+
else
|
262
|
+
attribute_operator = case operator
|
263
|
+
when :contains, :is then "= :#{bindvar}"
|
264
|
+
when :does_not_contain, :is_not then "<> :#{bindvar}"
|
265
|
+
when :is_in, :intersects_with then "IN (:#{bindvar})"
|
266
|
+
when :is_not_in then "NOT IN (:#{bindvar})"
|
267
|
+
else raise AuthorizationUsageError, "Unknown operator: #{operator}"
|
268
|
+
end
|
269
|
+
obligation_conds << "#{sql_attribute} #{attribute_operator}"
|
270
|
+
binds[bindvar] = attribute_value(value)
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
obligation_conds << "1=1" if obligation_conds.empty?
|
275
|
+
conds << "(#{obligation_conds.join(' AND ')})"
|
276
|
+
end
|
277
|
+
(delete_paths - used_paths).each {|path| reflections.delete(path)}
|
278
|
+
|
279
|
+
finder_options[:conditions] = [ conds.join( " OR " ), binds ]
|
280
|
+
end
|
281
|
+
|
282
|
+
def attribute_value (value)
|
283
|
+
value.class.respond_to?(:descends_from_active_record?) && value.class.descends_from_active_record? && value.id ||
|
284
|
+
value.is_a?(Array) && value[0].class.respond_to?(:descends_from_active_record?) && value[0].class.descends_from_active_record? && value.map( &:id ) ||
|
285
|
+
value
|
286
|
+
end
|
287
|
+
|
288
|
+
# Parses all of the defined obligation joins and defines the scope's :joins or :includes option.
|
289
|
+
# TODO: Support non-linear association paths. Right now, we just break down the longest path parsed.
|
290
|
+
def rebuild_join_options!
|
291
|
+
joins = (finder_options[:joins] || []) + (finder_options[:includes] || [])
|
292
|
+
|
293
|
+
reflections.keys.each do |path|
|
294
|
+
next if path.empty? or @join_table_joins.include?(path)
|
295
|
+
|
296
|
+
existing_join = joins.find do |join|
|
297
|
+
existing_path = join_to_path(join)
|
298
|
+
min_length = [existing_path.length, path.length].min
|
299
|
+
existing_path.first(min_length) == path.first(min_length)
|
300
|
+
end
|
301
|
+
|
302
|
+
if existing_join
|
303
|
+
if join_to_path(existing_join).length < path.length
|
304
|
+
joins[joins.index(existing_join)] = path_to_join(path)
|
305
|
+
end
|
306
|
+
else
|
307
|
+
joins << path_to_join(path)
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
case obligation_conditions.length
|
312
|
+
when 0 then
|
313
|
+
# No obligation conditions means we don't have to mess with joins or includes at all.
|
314
|
+
when 1 then
|
315
|
+
finder_options[:joins] = joins
|
316
|
+
finder_options.delete( :include )
|
317
|
+
else
|
318
|
+
finder_options.delete( :joins )
|
319
|
+
finder_options[:include] = joins
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
def path_to_join (path)
|
324
|
+
case path.length
|
325
|
+
when 0 then nil
|
326
|
+
when 1 then path[0]
|
327
|
+
else
|
328
|
+
hash = { path[-2] => path[-1] }
|
329
|
+
path[0..-3].reverse.each do |elem|
|
330
|
+
hash = { elem => hash }
|
331
|
+
end
|
332
|
+
hash
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
def join_to_path (join)
|
337
|
+
case join
|
338
|
+
when Symbol
|
339
|
+
[join]
|
340
|
+
when Hash
|
341
|
+
[join.keys.first] + join_to_path(join[join.keys.first])
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|