acts_as_joinable 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,53 @@
1
+ == ActsAsJoinable
2
+
3
+ This plugin adds access control to objects by giving them members, each with configurable permissions.
4
+
5
+ == Overview
6
+
7
+ Access control is achieved through the addition of three *acts_as* extensions and four models.
8
+
9
+ == ActsAs Extensions
10
+
11
+ acts_as_joinable - Added to the primary model to which access control is being added.
12
+ acts_as_joinable_component - Added to all of the models which need to inherit their permissions from their parent joinable.
13
+ acts_as_member - Added to a User model which can join joinables.
14
+
15
+ == Models
16
+
17
+ === DefaultPermissionSet
18
+
19
+ A set of permissions which any user automatically receives after joining the joinable.
20
+ The owner of a joinable can configure these. This allows joinables to be open to all Users.
21
+
22
+ The permissions contained by the DefaultPermissionSet specify the *access model* for the joinable.
23
+ There are three access models:
24
+
25
+ * open - Users can join this joinable and receive a membership without submitting a MembershipRequest
26
+ * closed - Users need to submit a MembershipRequest or accept a MembershipInvitation in order to join this joinable.
27
+ * private - Users can *only* be added to the joinable after accepting a MembershipInvitation
28
+
29
+ The current access model is determined solely by the permission level of the DefaultPermissionSet.
30
+ If the DefaultPermissionSet has a permission level which allows users to view the contents of the joinable.
31
+ Then the access model is *open*. If the permission level allows any user to find the joinable, the access
32
+ model is *closed*. If the permission level doesn't allow users not in the joinable to find the joinable,
33
+ the access model is *private*.
34
+
35
+ === MembershipRequest
36
+
37
+ Any user can submit a request to join any joinable which they can see. The managers of the joinable can
38
+ then accept that request (and choose an appropriate permission level for the user) or deny it.
39
+
40
+ === MembershipInvitation
41
+
42
+ The managers of a joinable can invite users to join a joinable. The invitation includes the permissions that
43
+ the user will receive upon acceptance.
44
+
45
+ NOTE: A user cannot use the permissions given in the invitation until they accept the invitation.
46
+
47
+ === Membership
48
+
49
+ Represents a set of permissions for a specific user in a specific joinable. Can be created in 3 ways:
50
+
51
+ * Through the accepting of a MembershipRequest by the managers of a joinable
52
+ * Through the accepting of a MembershipInvitation by the invitee
53
+ * If the joinable has an *open* access model, a User can create a Membership directly
@@ -0,0 +1,40 @@
1
+ class DefaultPermissionSet < ActiveRecord::Base
2
+ include Joinable::PermissionsAttributeWrapper
3
+
4
+ belongs_to :joinable, :polymorphic => true
5
+
6
+ after_update :raise_existing_member_permissions
7
+
8
+ def access_model
9
+ if has_permission?(:view)
10
+ return 'open'
11
+ elsif has_permission?(:find)
12
+ return 'closed'
13
+ else
14
+ return 'private'
15
+ end
16
+ end
17
+
18
+ def access_model=(model)
19
+ case model.to_s
20
+ when 'open'
21
+ # Additional permissions are set explicitly so just grant the find and view permissions
22
+ self.grant_permissions([:find, :view])
23
+ when 'closed'
24
+ self.permissions = [:find]
25
+ when 'private'
26
+ self.permissions = []
27
+ else
28
+ raise "Access model invalid: #{model}"
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def raise_existing_member_permissions
35
+ (joinable.memberships + joinable.membership_invitations).each do |membership|
36
+ membership.permissions = membership.permissions + permissions
37
+ membership.save!
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,59 @@
1
+ class Membership < ActiveRecord::Base
2
+ include Joinable::PermissionsAttributeWrapper
3
+
4
+ belongs_to :joinable, :polymorphic => true
5
+ belongs_to :user
6
+
7
+ before_save :prevent_locked_permission_changes, :normalize_owner_permissions
8
+
9
+ validates_presence_of :user_id
10
+ validates_uniqueness_of :user_id, :scope => [:joinable_type, :joinable_id] # Ensure that a User has only one Membership per Joinable
11
+ after_create :destroy_remnant_invitations_and_requests
12
+
13
+ attr_accessor :initiator, :locked
14
+
15
+ def locked?
16
+ locked == 'true'
17
+ end
18
+
19
+ def owner?
20
+ user == joinable.user
21
+ end
22
+
23
+ def permissions_locked?(current_user)
24
+ # Don't allow any changes to your own permissions
25
+ if current_user.eql?(user)
26
+ return true
27
+ # Don't allow any changes to the owner's permissions
28
+ elsif owner?
29
+ return true
30
+ else
31
+ return false
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def prevent_locked_permission_changes
38
+ reload if locked?
39
+ end
40
+
41
+ def normalize_owner_permissions
42
+ if owner?
43
+ self.permissions = joinable_type.constantize.permissions
44
+ else
45
+ self.permissions = permissions.reject {|level| level == :own}
46
+ end
47
+ end
48
+
49
+ def destroy_remnant_invitations_and_requests
50
+ # Make sure invitation is the first to be destroyed, for feed purposes.
51
+ if invitation = joinable.membership_invitation_for(user)
52
+ invitation.destroy
53
+ end
54
+
55
+ if request = joinable.membership_request_for(user)
56
+ request.destroy
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,42 @@
1
+ class MembershipInvitation < ActiveRecord::Base
2
+ include Joinable::PermissionsAttributeWrapper
3
+
4
+ belongs_to :joinable, :polymorphic => true
5
+ belongs_to :user
6
+ belongs_to :initiator, :class_name => "User"
7
+ validates_presence_of :user_id
8
+ validates_uniqueness_of :user_id, :scope => [:joinable_type, :joinable_id]
9
+
10
+ after_create :match_to_request_or_send_email
11
+
12
+ def accept(current_user)
13
+ return false unless current_user == user
14
+ create_associated_membership_on_accept(current_user)
15
+ end
16
+
17
+ def decline(current_user)
18
+ return false unless current_user == user
19
+ destroy_self_on_decline(current_user)
20
+ end
21
+
22
+ private
23
+
24
+ def create_associated_membership_on_accept(current_user)
25
+ Membership.create(:joinable => joinable, :user => user, :permissions => permissions)
26
+ end
27
+
28
+ def destroy_self_on_decline(current_user)
29
+ destroy
30
+ end
31
+
32
+ # When a User has already made a request and then someone invites this User to join the Joinable
33
+ # accept the invitation automatically on behalf of the user, otherwise notify the user
34
+ # that they have an invite waiting.
35
+ def match_to_request_or_send_email
36
+ if joinable.membership_request_for?(user)
37
+ accept(user)
38
+ else
39
+ UserMailer.invited_to_project_email(self).deliver
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ class MembershipRequest < ActiveRecord::Base
2
+ belongs_to :joinable, :polymorphic => true
3
+ belongs_to :user
4
+
5
+ validates_presence_of :user_id
6
+ validates_uniqueness_of :user_id, :scope => [:joinable_type, :joinable_id]
7
+
8
+ scope :for, lambda {|user|
9
+ joins("INNER JOIN memberships ON membership_requests.joinable_type = memberships.joinable_type AND membership_requests.joinable_id = memberships.joinable_id")
10
+ .where("memberships.user_id = ? AND 'manage' = ANY(memberships.permissions)", user.id)
11
+ }
12
+
13
+ after_create :match_to_invitation_or_send_email
14
+
15
+ def grant(current_user, permissions)
16
+ membership = create_associated_membership_on_grant(current_user, permissions)
17
+ UserMailer.project_membership_request_accepted_email(current_user, membership).deliver
18
+ end
19
+
20
+ private
21
+
22
+ def create_associated_membership_on_grant(current_user, permissions)
23
+ Membership.create(:joinable => joinable, :user => user, :permissions => permissions)
24
+ end
25
+
26
+ # If a user requests to join a joinable, make sure their isn't a matching invitation
27
+ # to join the joinable. If there is, then automatically accept the user into the joinable.
28
+ # Otherwise notify the managers of the project that their is a new request.
29
+ def match_to_invitation_or_send_email
30
+ if invitation = joinable.membership_invitation_for(user)
31
+ invitation.accept(invitation.user)
32
+ else
33
+ UserMailer.project_membership_request_created_email(joinable.who_can?(:manage), self).deliver
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,6 @@
1
+ class PermissionLink < ActiveRecord::Base
2
+ belongs_to :joinable, :polymorphic => true
3
+ belongs_to :component, :polymorphic => true
4
+
5
+ validates_uniqueness_of :component_id, :scope => [:joinable_type, :joinable_id, :component_type]
6
+ end
@@ -0,0 +1,28 @@
1
+ # GEM DEPENDENCIES
2
+ require 'postgres_ext'
3
+
4
+ require 'joinable/acts_as_permissable'
5
+ require 'joinable/acts_as_joinable'
6
+ require 'joinable/acts_as_joinable_component'
7
+ require 'joinable/acts_as_member'
8
+
9
+ require 'joinable/permissions_attribute_wrapper'
10
+
11
+ module ActsAsJoinable
12
+ class Engine < Rails::Engine
13
+ initializer "acts_as_joinable.init" do
14
+ ActiveRecord::Base.send :extend, Joinable::ActsAsJoinable::ActMethod
15
+ ActiveRecord::Base.send :extend, Joinable::ActsAsJoinableComponent::ActMethod
16
+ ActiveRecord::Base.send :extend, Joinable::ActsAsMember::ActMethod
17
+ end
18
+
19
+ config.to_prepare do
20
+ if defined?(ActsAsFeedable::Engine)
21
+ require 'joinable/feedable_extensions'
22
+ FeedableExtensions.add
23
+ else
24
+ puts "[ActsAsJoinable] ActsAsFeedable not loaded. Skipping extensions."
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,326 @@
1
+ module Joinable #:nodoc:
2
+ module ActsAsJoinable
3
+ module ActMethod
4
+ # Takes one option :component_permissions - a list of all of the permissions grouped by the component they affect
5
+ # eg. [{:labels => [:view, :apply, :remove, :create, :delete]}]
6
+ #
7
+ # The grouped permissions are unpacked to create distinct permissions (eg. view_labels, apply_labels, ...)
8
+ # These unpacked permissions are put into an array with the singular permissions (eg. find)
9
+ # and stored in a *permissions* class variable.
10
+ #
11
+ # In addition, The grouped permissions are stored in a separate *component_permissions_hashes* class variable.
12
+ #
13
+ # NOTE: The permissions are passed in-order because in the view we expect to find certain permission patterns.
14
+ # eg. the simple project permission level is determined by looking for a string of permissions that span
15
+ # several components, (labels, writeboards, files, etc...).
16
+ # TODO: Remove the aforementioned order dependency
17
+ def acts_as_joinable(options = {})
18
+ extend ClassMethods unless (class << self; included_modules; end).include?(ClassMethods)
19
+ include InstanceMethods unless included_modules.include?(InstanceMethods)
20
+
21
+ options.assert_valid_keys :component_permissions
22
+ self.component_permissions_hashes = options[:component_permissions]
23
+
24
+ self.permissions = [:find, :view]
25
+ add_flattened_component_permissions(options[:component_permissions])
26
+ self.permissions += [:manage, :own]
27
+ end
28
+
29
+ private
30
+
31
+ # Add explicit permissions to the permissions class accessor
32
+ # for each of the entries in the component_permission hashes
33
+ #
34
+ # eg. {:labels => [:view, :apply, :remove, :create, :delete]} becomes
35
+ # [:view_labels, :apply_labels, :remove_labels, :create_labels, :delete_labels]
36
+ # and is added to self.permissions
37
+ def add_flattened_component_permissions(component_permissions_hashes)
38
+ for component_permissions_hash in component_permissions_hashes
39
+ component_permissions_hash.each do |component_name, component_permissions|
40
+ component_permissions.each { |component_permission| self.permissions << "#{component_permission}_#{component_name}".to_sym }
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ module ClassMethods
47
+ include Joinable::ActsAsPermissable::ClassMethods
48
+
49
+ def self.extended(base)
50
+ base.cattr_accessor :permissions, :component_permissions_hashes
51
+
52
+ base.has_many :membership_invitations, :as => :joinable, :dependent => :destroy, :before_add => :add_initiator
53
+ base.has_many :membership_requests, :as => :joinable, :dependent => :destroy
54
+ base.has_many :memberships, :as => :joinable, :dependent => :destroy, :order => "id ASC", :before_remove => :add_initiator
55
+
56
+ base.has_many :invitees, :class_name => "User", :through => :membership_invitations, :source => :user
57
+ base.has_many :requestees, :class_name => "User", :through => :membership_requests, :source => :user
58
+ base.has_many :members, :class_name => "User", :through => :memberships, :source => :user
59
+
60
+ base.has_many :non_owner_memberships, :as => :joinable, :class_name => 'Membership', :conditions => "permissions NOT LIKE '%own%'"
61
+ base.has_one :owner_membership, :as => :joinable, :class_name => 'Membership', :conditions => "permissions LIKE '%own%'"
62
+
63
+ base.has_many :permission_links, :as => :joinable, :dependent => :destroy
64
+
65
+ base.has_one :default_permission_set, :as => :joinable, :dependent => :destroy
66
+
67
+ base.after_create :add_owner_membership
68
+
69
+ base.class_eval do
70
+ # Return all *joinables* that a User is a member of with the appropriate permissions
71
+ scope :with_permission, lambda {|user, permission| where(with_permission_sql(user, permission)) }
72
+
73
+ #scope :open, lambda { where(default_permission_set_permission_exists_sql(joinable_type, joinable_id, 'find')) }
74
+
75
+ # TODO: Why is this NULLS LAST? Probably because we want the results in some specific order when joined with users, but couldn't we order manually in the find?
76
+ scope :with_member, lambda {|user| joins(:memberships).where(:memberships => {:user_id => (user.is_a?(User) ? user.id : user)}).order("memberships.created_at DESC NULLS LAST") }
77
+ end
78
+
79
+ base.accepts_nested_attributes_for :default_permission_set
80
+ base.accepts_nested_attributes_for :membership_invitations, :allow_destroy => true
81
+ base.accepts_nested_attributes_for :memberships, :allow_destroy => true, :reject_if => proc { |attributes| attributes['locked'] == 'true' }
82
+ end
83
+
84
+ def permissions_string
85
+ permissions.join(" ")
86
+ end
87
+
88
+ # Simple Permission Strings - Permission strings for four basic levels of permissions - viewer, collaborator, manager, owner
89
+ # =============================================
90
+ # Member can view everything but modify nothing
91
+ def viewer_permissions_string
92
+ viewer_permissions.join(" ")
93
+ end
94
+
95
+ def viewer_permissions
96
+ permissions.select { |permission| permission == :find || permission.to_s.starts_with?("view") }
97
+ end
98
+
99
+ # Member can view everything and modify everything except members
100
+ def collaborator_permissions_string
101
+ collaborator_permissions.join(" ")
102
+ end
103
+
104
+ def collaborator_permissions
105
+ permissions - [:manage, :own]
106
+ end
107
+
108
+ # Member can view everything, modify everything, and manage membership
109
+ def manager_permissions_string
110
+ (permissions - [:own]).join(" ")
111
+ end
112
+
113
+ # Member started the joinable
114
+ def owner_permissions_string
115
+ permissions_string
116
+ end
117
+ # =============================
118
+ # End Simple Permission Strings
119
+
120
+ # Returns the SQL necessary to find all joinables for which the user
121
+ # has a membership with a specific permission.
122
+ #
123
+ # Permissions which require special handling:
124
+ #
125
+ # * find - In addition to memberships, invitations and default permission sets are checked for the permission. This is because
126
+ # a joinable should be able to be found once an invitation has been extended or if it is findable by default. (even if the user isn't a member of it).
127
+ #
128
+ # * view_* - This is a class of permissions that start with the word 'view'. When determining if a user can view any aspect of a joinable, we also check
129
+ # if the project is open.
130
+ #
131
+ # * join - This is a faux permission. A user has permission to join a joinable if they have an invitation to view it or if it is viewable by default.
132
+ #
133
+ # * collaborate - This is a faux permission. A user has permission to collaborate if they have any additional permissions above the standard viewer permissions.
134
+ def with_permission_sql(user, permission, options = {})
135
+ permission = permission.to_sym
136
+
137
+ case user
138
+ when String
139
+ user_id = user
140
+ when
141
+ user_id = user.id
142
+ end
143
+
144
+ joinable_type = options[:type_column] || name
145
+ joinable_id = options[:id_column] || table_name + ".id"
146
+
147
+ if permission == :find
148
+ "#{membership_permission_exists_sql(user_id, joinable_type, joinable_id, 'find')} OR #{membership_invitation_permission_exists_sql(user_id, joinable_type, joinable_id, 'find')} OR #{default_permission_set_permission_exists_sql(joinable_type, joinable_id, 'find')}"
149
+ elsif permission.to_s.starts_with?('view')
150
+ "#{membership_permission_exists_sql(user_id, joinable_type, joinable_id, permission)} OR #{default_permission_set_permission_exists_sql(joinable_type, joinable_id, permission)}"
151
+ elsif permission == :join
152
+ "#{membership_invitation_permission_exists_sql(user_id, joinable_type, joinable_id, 'view')} OR #{default_permission_set_permission_exists_sql(joinable_type, joinable_id, 'view')}"
153
+ elsif permission.to_s.starts_with?('join_and_')
154
+ default_permission_set_permission_exists_sql(joinable_type, joinable_id, permission.to_s.gsub('join_and_', ''))
155
+ elsif permission == :collaborate
156
+ "EXISTS (SELECT id FROM memberships WHERE memberships.joinable_type = '#{joinable_type}' AND memberships.joinable_id = #{joinable_id} AND memberships.user_id = #{user_id} AND memberships.permissions && '{#{(collaborator_permissions - viewer_permissions).join(",")}}')"
157
+ else
158
+ membership_permission_exists_sql(user_id, joinable_type, joinable_id, permission)
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def membership_permission_exists_sql(user_id, joinable_type, joinable_id, permission)
165
+ "EXISTS (SELECT id FROM memberships WHERE memberships.joinable_type = '#{joinable_type}' AND memberships.joinable_id = #{joinable_id} AND memberships.user_id = #{user_id} AND #{permission_sql_condition('memberships.permissions', permission)})"
166
+ end
167
+
168
+ def membership_invitation_permission_exists_sql(user_id, joinable_type, joinable_id, permission)
169
+ "EXISTS (SELECT id FROM membership_invitations WHERE membership_invitations.joinable_type = '#{joinable_type}' AND membership_invitations.joinable_id = #{joinable_id} AND membership_invitations.user_id = #{user_id} AND #{permission_sql_condition('membership_invitations.permissions', permission)})"
170
+ end
171
+
172
+ def default_permission_set_permission_exists_sql(joinable_type, joinable_id, permission)
173
+ "EXISTS (SELECT id FROM default_permission_sets WHERE default_permission_sets.joinable_type = '#{joinable_type}' AND default_permission_sets.joinable_id = #{joinable_id} AND #{permission_sql_condition('default_permission_sets.permissions', permission)})"
174
+ end
175
+ end
176
+
177
+ module InstanceMethods
178
+ include Joinable::ActsAsPermissable::InstanceMethods
179
+
180
+ # Override attributes= to make sure that the user and initiator attributes are initialized before
181
+ # the membership_invitation and membership before_add callbacks are triggered
182
+ # since they reference these attributes
183
+ def attributes=(attributes_hash)
184
+ super(ActiveSupport::OrderedHash[attributes_hash.symbolize_keys.sort_by {|a| [:user, :user_id, :initiator].include?(a.first) ? 0 : 1}])
185
+ end
186
+
187
+ def acts_like_joinable?
188
+ true
189
+ end
190
+
191
+ attr_accessor :cached_membership_request, :cached_membership_invitation, :cached_membership, :initiator
192
+
193
+ # Get the membership request (if any) for a specific
194
+ # user. This method also supports caching of a membership
195
+ # request in order to facilitate eager loading.
196
+ #
197
+ # eg. For all of the projects on the projects index page,
198
+ # we want to do something similar to
199
+ # Project.all(:include => :membership_requests).
200
+ #
201
+ # We can't do exactly that however because we only want the membership_requests
202
+ # related to the current_user, not all users.
203
+ #
204
+ # Instead, we fake it by doing a separate query
205
+ # which gets all the user's membership_requests related to
206
+ # all the projects being displayed. We then cache the request relevant to
207
+ # this project in the cached_membership_request instance variable for later use
208
+ # by the view.
209
+ def membership_request_for(user)
210
+ if cached_membership_request != nil
211
+ cached_membership_request
212
+ else
213
+ membership_requests.where(:user_id => user.id).first
214
+ end
215
+ end
216
+
217
+ # Find out whether this Joinable has a membership request for a certain user and return true, else false
218
+ def membership_request_for?(user)
219
+ !membership_request_for(user).nil?
220
+ end
221
+
222
+
223
+ # Get the membership invitation (if any) for a specific
224
+ # user. This method also supports caching of a membership
225
+ # request in order to facilitate eager loading.
226
+ #NOTE: See :membership_request_for documentation for an in depth example of this type of behaviour
227
+ def membership_invitation_for(user)
228
+ if cached_membership_invitation != nil
229
+ cached_membership_invitation
230
+ else
231
+ membership_invitations.where(:user_id => user.id).first
232
+ end
233
+ end
234
+
235
+ # Find out whether this Joinable has a membership invitation for a certain user and return true, else false
236
+ def membership_invitation_for?(user)
237
+ !membership_invitation_for(user).nil?
238
+ end
239
+
240
+ # Get the membership (if any) for a specific
241
+ # user. This method also supports caching of a membership
242
+ # request in order to facilitate eager loading.
243
+ #NOTE: See :membership_request_for documentation for an in depth example of this type of behaviour
244
+ def membership_for(user)
245
+ if cached_membership != nil
246
+ cached_membership
247
+ else
248
+ memberships.where(:user_id => user.id).first
249
+ end
250
+ end
251
+
252
+ # Find out whether this Joinable has a membership for a certain user and return true, else false
253
+ def membership_for?(user)
254
+ !membership_for(user).nil?
255
+ end
256
+
257
+ # Returns the timestamp of the last time the memberships were updated for this joinable
258
+ def memberships_updated_at
259
+ memberships.maximum(:updated_at)
260
+ end
261
+
262
+ delegate :access_model, :to => :default_permission_set
263
+
264
+ def access_model=(model)
265
+ default_permission_set.access_model = model
266
+ end
267
+
268
+ # Returns true or false depending on whether or not the user has the specified permission for this object.
269
+ # Will cache the result if uncached.
270
+ def check_permission(user, permission_name)
271
+ permission_name = permission_name.to_s.dup
272
+
273
+ # Generate a cache path based on the factors that affect the user's permissions
274
+ # If User has membership
275
+ # - depends on permissions
276
+ # Elsif User has been invited
277
+ # - depends on existence of invitation and the default permissions (when checking the view permission)
278
+ # Else User doesn't have any membership
279
+ # - depends on default permissions of the joinable
280
+ if membership = memberships.where(:user_id => user.id).first
281
+ key = "membership_#{membership.updated_at.to_f}"
282
+ elsif self.membership_invitations.where(:user_id => user.id).exists?
283
+ key = "default_permissions_#{self.default_permission_set.updated_at.to_f}_invitation_exists"
284
+ else
285
+ key = "default_permissions_#{self.default_permission_set.updated_at.to_f}"
286
+ end
287
+
288
+ cache_path = "permissions/#{self.class.table_name}/#{self.id}/user_#{user.id}_#{key}"
289
+
290
+ if defined?(RAILS_CACHE)
291
+ permissions = RAILS_CACHE.read(cache_path)
292
+ if permissions && (value = permissions[permission_name]) != nil
293
+ return value
294
+ end
295
+ end
296
+
297
+ # The permission isn't cached yet, so cache it
298
+ value = self.class.with_permission(user, permission_name).exists?(self.id)
299
+
300
+ if defined?(RAILS_CACHE)
301
+ if permissions
302
+ permissions = permissions.dup
303
+ permissions[permission_name] = value
304
+ else
305
+ permissions = {permission_name => value}
306
+ end
307
+ RAILS_CACHE.write(cache_path, permissions)
308
+ end
309
+ return value
310
+ end
311
+
312
+ private
313
+
314
+ # Adds an initiator to a membership or invitation to possibly use in feed generation
315
+ def add_initiator(membership)
316
+ membership.initiator = (initiator || user)
317
+ end
318
+
319
+ # Adds an permission entry with full access to the object by the user associated with the object if one does not already exist
320
+ def add_owner_membership
321
+ Membership.create(:joinable => self, :user => user, :permissions => self.class.permissions_string) unless Membership.where(:joinable_type => self.class.to_s, :joinable_id => self.id, :user_id => user.id).exists?
322
+ end
323
+ end
324
+ end
325
+ end
326
+
@@ -0,0 +1,223 @@
1
+ module Joinable #:nodoc:
2
+ module ActsAsJoinableComponent
3
+ module ActMethod
4
+ # Inherits permissions of an a target object through the permission_links table
5
+ # An entry in the permission_links table is calculated by tracing the proxy object through to the target
6
+ # Takes a hash of params that specify which attached object the proxy should inherit its permissions from
7
+ def acts_as_joinable_component(options = {})
8
+ extend ClassMethods unless (class << self; included_modules; end).include?(ClassMethods)
9
+ include InstanceMethods unless included_modules.include?(InstanceMethods)
10
+
11
+ options.assert_valid_keys :polymorphic, :parent, :view_permission
12
+
13
+ class << self
14
+ attr_accessor :view_permission
15
+ end
16
+
17
+ self.view_permission = options[:view_permission]
18
+
19
+ # If we inherit permissions from multiple types of objects (polymorphic)
20
+ if options[:polymorphic]
21
+ parent_klass = options[:parent] + '_type.constantize'
22
+ parent_id = options[:parent] + '_id'
23
+ # Else if we are not, and inherit permissions from only one type of object
24
+ else
25
+ parent_klass = options[:parent].camelize
26
+ parent_id = options[:parent] + '_id'
27
+ end
28
+
29
+ # Rescue in case we haven't got a parent
30
+ # TODO: this could probably be done better
31
+ class_eval <<-EOV
32
+ def next_link
33
+ begin
34
+ #{parent_klass}.find_by_id(#{parent_id})
35
+ rescue
36
+ nil
37
+ end
38
+ end
39
+ EOV
40
+ end
41
+ end
42
+
43
+ module ClassMethods
44
+ include Joinable::ActsAsPermissable::ClassMethods
45
+
46
+ def self.extended(base)
47
+ base.has_one :permission_link, :as => :component, :dependent => :destroy
48
+ base.after_create :find_joinable_and_create_permission_link
49
+
50
+ base.class_eval do
51
+ scope :with_permission, lambda { |user, permission| select("#{table_name}.*").where(with_permission_sql(user, permission)) }
52
+ end
53
+ end
54
+
55
+ # Returns the SQL necessary to find all components for which there is no associated joinable or
56
+ # the user has a membership with a specific permission.
57
+ #
58
+ # Permissions which require special handling:
59
+ #
60
+ # * view_* - This is a class of permissions that start with the word 'view'. When determining if a user can view any aspect of a joinable, we also check
61
+ # if the project is open.
62
+ #
63
+ # * join_and_* - This is a class of permissions that start with the words 'join_and_'. When determining if a user will have a certain permission
64
+ # after they join a project, we need to check the default_permission_set of the project.
65
+ def with_permission_sql(user, permission, options = {})
66
+ permission = permission.to_s
67
+
68
+ case user
69
+ when String
70
+ user_id = user
71
+ else
72
+ user_id = user.id
73
+ end
74
+
75
+ component_type = options[:type_column] || name
76
+ component_id = options[:id_column] || table_name + ".id"
77
+
78
+ permission_without_join_and_prefix = permission.gsub('join_and_', '')
79
+ comparison_permission = permission_without_join_and_prefix == 'view' ? "permission_links.component_view_permission" : "'#{permission_without_join_and_prefix}'"
80
+
81
+ if permission.starts_with?('view')
82
+ "#{no_inherited_permissions_exist_sql(component_type, component_id)} OR #{membership_permission_exists_sql(user_id, component_type, component_id, comparison_permission)} OR #{default_permission_set_permission_exists_sql(component_type, component_id, comparison_permission)}"
83
+ elsif permission.starts_with?('join_and_')
84
+ default_permission_set_permission_exists_sql(component_type, component_id, comparison_permission)
85
+ else
86
+ "#{no_inherited_permissions_exist_sql(component_type, component_id)} OR #{membership_permission_exists_sql(user_id, component_type, component_id, comparison_permission)}"
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ # All components that don't have a permission link to a joinable
93
+ def no_inherited_permissions_exist_sql(component_type, component_id)
94
+ "NOT EXISTS (SELECT * FROM permission_links WHERE permission_links.component_type = '#{component_type}' AND permission_links.component_id = #{component_id})"
95
+ end
96
+
97
+ # All components that have an associated membership with a specific permission
98
+ #
99
+ # The view permission requires special handling because it may be customized in the permission_link.
100
+ # For more information see the *recurse_to_inherit_custom_view_permission* method.
101
+ def membership_permission_exists_sql(user_id, component_type, component_id, comparison_permission)
102
+ "EXISTS (SELECT * FROM memberships
103
+ INNER JOIN permission_links ON memberships.joinable_type = permission_links.joinable_type
104
+ AND memberships.joinable_id = permission_links.joinable_id
105
+ WHERE permission_links.component_type = '#{component_type}'
106
+ AND permission_links.component_id = #{component_id}
107
+ AND memberships.user_id = #{user_id}
108
+ AND #{comparison_permission} = ANY(memberships.permissions))"
109
+ end
110
+
111
+ def default_permission_set_permission_exists_sql(component_type, component_id, comparison_permission)
112
+ "EXISTS (SELECT * FROM default_permission_sets
113
+ INNER JOIN permission_links ON default_permission_sets.joinable_type = permission_links.joinable_type
114
+ AND default_permission_sets.joinable_id = permission_links.joinable_id
115
+ WHERE permission_links.component_type = '#{component_type}'
116
+ AND permission_links.component_id = #{component_id}
117
+ AND #{comparison_permission} = ANY(default_permission_sets.permissions))"
118
+ end
119
+ end
120
+
121
+ module InstanceMethods
122
+ include Joinable::ActsAsPermissable::InstanceMethods
123
+
124
+ def acts_like_joinable_component?
125
+ true
126
+ end
127
+
128
+ # Used by unsaved joinable_components to return a list of users
129
+ # who will be able to view the component once it is saved.
130
+ # Useful for outputting information to the user while they
131
+ # are creating a new component.
132
+ def who_will_be_able_to_view?
133
+ User.find_by_sql("SELECT users.*
134
+ FROM users JOIN memberships ON users.id = memberships.user_id
135
+ WHERE memberships.joinable_type = '#{joinable.class.to_s}'
136
+ AND memberships.joinable_id = #{joinable.id}
137
+ AND #{self.class.permission_sql_condition('memberships.permissions', recurse_to_inherit_custom_view_permission)}")
138
+ end
139
+
140
+ def check_permission(user, permission)
141
+ # You can't ask to join joinable_components so the find permission is actually the view permission
142
+ permission = :view if permission == :find
143
+
144
+ if new_record?
145
+ if joinable.acts_like?(:joinable)
146
+ permission = recurse_to_inherit_custom_view_permission if permission == :view
147
+ joinable.check_permission(user, permission)
148
+ else
149
+ # The component isn't contained by a joinable so it is public.
150
+ true
151
+ end
152
+ else
153
+ self.class.with_permission(user, permission).exists?(id)
154
+ end
155
+ end
156
+
157
+ # Returns the object that we should inherit permissions from
158
+ #
159
+ # Recurses until the target is reached,
160
+ # if we reach a target that does not act as a joinable, call method again if it is a joinable component,
161
+ # else fall out as the chain has no valid endpoint (eg. feed -> discussion -> item)
162
+ def joinable
163
+ if permission_link.present?
164
+ permission_link.joinable
165
+ else
166
+ parent = next_link
167
+
168
+ # Our target is now joinable therefore our target is at the end (eg. feed -> discussion -> [project])
169
+ if parent && parent.acts_like?(:joinable)
170
+ return parent
171
+
172
+ # Our target is a joinable_component therefore our target somewhere between the beginning and the end (eg. feed -> [discussion] -> ??? -> project)
173
+ elsif parent && parent.acts_like?(:joinable_component)
174
+ return parent.joinable
175
+
176
+ # We've fallen out because there was either no target or the target was not joinable or a joinable_component
177
+ else
178
+ return parent
179
+ end
180
+ end
181
+ end
182
+
183
+ # inherited_view_permission is calculated by ascending up the chain of joinable components
184
+ # while view permission only takes into account the current joinable component.
185
+ # inherited_view_permission is for external use while view_permission should only be used internally.
186
+ def inherited_view_permission
187
+ permission_link.try(:component_view_permission)
188
+ end
189
+
190
+ def view_permission
191
+ klass_view_permission = self.class.view_permission
192
+ klass_view_permission = klass_view_permission.call(self) if klass_view_permission.respond_to?(:call)
193
+
194
+ # Allow view_permission to be set at the instance level
195
+ return @view_permission || klass_view_permission
196
+ end
197
+ attr_writer :view_permission
198
+
199
+
200
+ # Recurse up the tree to see if any of the intervening joinable_components have a customized view permission
201
+ # In that case, inherit that customized view permission. This allows searches of the form
202
+ # Feed.with_permission(:view) where feeds belong to joinable_components with custom view permissions.
203
+ # The query will then be able to return only the feeds which belong to joinable components that are viewable by the user
204
+ def recurse_to_inherit_custom_view_permission
205
+ parent = next_link
206
+
207
+ # If we've reached the last component in the chain or if this component provides a view permission
208
+ if parent.acts_like?(:joinable) || self.view_permission
209
+ return self.view_permission || :view
210
+ elsif parent.acts_like?(:joinable_component)
211
+ return parent.recurse_to_inherit_custom_view_permission
212
+ else
213
+ return nil
214
+ end
215
+ end
216
+
217
+ # Creates a link to the joinable that this component is associated with, if there is one.
218
+ def find_joinable_and_create_permission_link
219
+ self.create_permission_link(:joinable => joinable, :component_view_permission => recurse_to_inherit_custom_view_permission) if joinable.acts_like?(:joinable)
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,43 @@
1
+ # Adds useful methods to the User model which includes it.
2
+ module Joinable #:nodoc:
3
+ module ActsAsMember
4
+ module ActMethod
5
+ def acts_as_member
6
+ extend ClassMethods unless (class << self; included_modules; end).include?(ClassMethods)
7
+ include InstanceMethods unless included_modules.include?(InstanceMethods)
8
+ end
9
+ end
10
+
11
+ module ClassMethods
12
+ def self.extended(base)
13
+ base.has_many :memberships, :dependent => :destroy
14
+ base.has_many :membership_requests, :dependent => :destroy
15
+ base.has_many :membership_invitations, :dependent => :destroy
16
+ end
17
+ end
18
+
19
+ module InstanceMethods
20
+ # FIXME: no need to call permission_to? on non-permissables.
21
+ # We should remove this extra code.
22
+ def permission_to?(permission, record)
23
+ if record.acts_like?(:permissable)
24
+ record.check_permission(self, permission)
25
+ else
26
+ if record.acts_like?(:visible_only_to_owner)
27
+ record.user == self
28
+ else
29
+ true
30
+ end
31
+ end
32
+ end
33
+
34
+ def no_permission_to?(permission, record)
35
+ !permission_to?(permission, record)
36
+ end
37
+
38
+ def membership_requests_for_managed_joinables
39
+ MembershipRequest.for(self)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,29 @@
1
+ # Abstract Module that is included in both joinable and joinable_component. Includes useful methods that both share.
2
+ module Joinable #:nodoc:
3
+ module ActsAsPermissable
4
+ module ClassMethods
5
+ def find_with_privacy(record_id, user, options = {})
6
+ record = find(record_id)
7
+
8
+ raise ActiveRecord::RecordNotFound, (options[:error_message] || "Couldn't find #{name}") unless user.permission_to?(:find, record)
9
+
10
+ return record
11
+ end
12
+
13
+ def permission_sql_condition(column, permission)
14
+ "'#{permission}' = ANY(#{column})"
15
+ end
16
+ end
17
+
18
+ module InstanceMethods
19
+ def acts_like_permissable?
20
+ true
21
+ end
22
+
23
+ # Returns a list of users who either do or do not have the specified permission.
24
+ def who_can?(permission)
25
+ User.find_by_sql("SELECT * FROM users AS u1 WHERE #{self.class.with_permission_sql('u1.id', permission, :id_column => id)}")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ module FeedableExtensions
2
+ def self.add
3
+ Feed.class_eval do
4
+ # Filter feeds about public joinables that you haven't joined, unless the feed is actually about you
5
+ scope :without_unjoined, lambda {|joinable_type, user|
6
+ where("feeds.scoping_object_type IS NULL OR
7
+ feeds.scoping_object_type != '#{joinable_type}' OR
8
+ (feeds.feedable_type = 'User' AND feeds.feedable_id = #{user.id}) OR
9
+ EXISTS (SELECT * FROM memberships WHERE memberships.joinable_type = '#{joinable_type}' AND memberships.joinable_id = feeds.scoping_object_id AND memberships.user_id = ?)", user.id)
10
+ }
11
+
12
+ acts_as_joinable_component :parent => 'permission_inheritance_target', :polymorphic => true, :view_permission => lambda {|feed| :find if feed.feedable.acts_like?(:joinable) }
13
+
14
+ # The scoping_object becomes the parent if the feed is delegated to a non-permissable or the feedable is deleted
15
+ # eg. a user (non-permissible) leaves a project, the parent of the feed is the project since a user isn't a permissable
16
+ # eg. a writeboard is destroyed, the parent of the feed is now the project
17
+ def permission_inheritance_target_type
18
+ if feedable.acts_like?(:permissable)
19
+ feedable_type
20
+ else
21
+ scoping_object_type
22
+ end
23
+ end
24
+
25
+ def permission_inheritance_target_id
26
+ if feedable.acts_like?(:permissable)
27
+ feedable_id
28
+ else
29
+ scoping_object_id
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,164 @@
1
+ # Included in models which have a permissions field (Membership, MembershipInvitation, DefaultPermissionSet)
2
+ # Wraps the permissions field to make it appear to the outside world as an array of symbols rather than the
3
+ # string that it is stored in the database as. Also adds helpers for creating complex forms
4
+ # to configure permissions and validations to ensure consistent ordering of permissions in the database.
5
+ module Joinable #:nodoc:
6
+ module PermissionsAttributeWrapper
7
+ def self.included(base)
8
+ base.before_save :verify_and_sort_permissions
9
+ end
10
+
11
+ def permissions_string
12
+ self[:permissions].join(' ')
13
+ end
14
+
15
+ # Returns an array of the permissions as symbols
16
+ def permissions
17
+ self[:permissions].collect(&:to_sym)
18
+ end
19
+
20
+ def permissions=(permissions)
21
+ case permissions
22
+ when String
23
+ self[:permissions] = permissions.split(' ')
24
+ when Array
25
+ self[:permissions] = permissions
26
+ else
27
+ raise "Permissions were not passed to permissions writer in the appropriate format"
28
+ end
29
+ end
30
+
31
+ # Used by advanced permission forms which group permissions by their associated component
32
+ # or using a single check box per permission.
33
+ def permission_attributes=(permissions)
34
+ self.permissions = [] # Reset permissions in anticipation for re-population
35
+
36
+ permissions.each do |key, value|
37
+ key, value = key.dup, value.dup
38
+
39
+ # Component Permissions
40
+ if key.ends_with? "_permissions"
41
+ grant_permissions(value)
42
+ # Singular Permission
43
+ elsif key.chomp! "_permission"
44
+ grant_permissions(key) if value.to_i != 0
45
+ end
46
+ end
47
+ end
48
+
49
+ # Returns an array of the permissions allowed by the joinable
50
+ def allowed_permissions
51
+ if self[:joinable_type]
52
+ self[:joinable_type].constantize.permissions
53
+ else
54
+ raise "Cannot get allowed access levels because permission is not attached to a permissable yet: #{inspect}"
55
+ end
56
+ end
57
+
58
+ # Returns true if the object has all the permissions specified by +levels+
59
+ def has_permission?(*levels)
60
+ if levels.all? { |level| permissions.include? level.to_sym }
61
+ return true
62
+ else
63
+ return false
64
+ end
65
+ end
66
+
67
+ # Returns true if none of the permissions specified by +levels+ are present
68
+ def doesnt_have_permission?(*levels)
69
+ if permissions - levels == permissions
70
+ return true
71
+ else
72
+ return false
73
+ end
74
+ end
75
+
76
+ # Returns true if the object has an empty permission set
77
+ def no_permissions?
78
+ permissions.empty?
79
+ end
80
+
81
+ # Returns true if the object only has the permissions in +levels+
82
+ def only_permission_to?(*levels)
83
+ if permissions - levels == []
84
+ return true
85
+ else
86
+ return false
87
+ end
88
+ end
89
+
90
+ def grant_permissions(permissions_to_grant)
91
+ case permissions_to_grant
92
+ when String
93
+ permissions_to_grant = permissions_to_grant.split(' ').collect(&:to_sym)
94
+ when Symbol
95
+ permissions_to_grant = [permissions_to_grant]
96
+ end
97
+
98
+ self.permissions += permissions_to_grant
99
+ end
100
+
101
+ private
102
+
103
+ # Verifies that all the access levels are valid for the attached permissible
104
+ # Makes sure no permissions are duplicated
105
+ # Enforces the order of access levels in the access attribute using the order of the permissions array
106
+ def verify_and_sort_permissions
107
+ # DefaultPermissionSet is allowed to have blank permissions (private joinable), the other models need at least find and view
108
+ self.permissions += [:find, :view] unless is_a?(DefaultPermissionSet)
109
+
110
+ raise "Invalid permissions: #{(permissions - allowed_permissions).inspect}. Must be one of #{allowed_permissions.inspect}" unless permissions.all? {|permission| allowed_permissions.include? permission}
111
+
112
+ self.permissions = permissions.uniq.sort_by { |permission| allowed_permissions.index(permission) }
113
+ end
114
+
115
+ # Adds readers for component permission groups and single permissions
116
+ #
117
+ # Used by advanced permission forms to determine how which options to select
118
+ # in the various fields. (eg. which option of f.select :labels_permissions to choose)
119
+ def method_missing(method_name, *args)
120
+ # add permission_for accessors and mutators
121
+
122
+ # NOTE: Don't mess with the method_name variable (e.g. change it to a string)
123
+ # since upstream methods might assume it is a symbol.
124
+ # NOTE: Ensure we enforce some characters before the '_permission' suffix because Rails 3 creates
125
+ if respond_to?(:joinable_type) && joinable_type.present?
126
+ if method_name.to_s =~ /.+_permissions/
127
+ return component_permissions_reader(method_name)
128
+ elsif method_name.to_s =~ /.+_permission/
129
+ return single_permission_reader(method_name)
130
+ else
131
+ super
132
+ end
133
+ else
134
+ super
135
+ end
136
+ end
137
+
138
+ # Get a string of all of the permissions the object has for a specific joinable component
139
+ # eg. labels_permissions # returns 'view_labels apply_labels remove_labels'
140
+ def component_permissions_reader(method_name)
141
+ for component_permissions_hash in joinable_type.constantize.component_permissions_hashes
142
+ component_permissions_hash.each do |component_name, component_permissions|
143
+ if method_name.to_s == "#{component_name}_permissions"
144
+ return component_permissions.collect {|permission| "#{permission}_#{component_name}"}.select {|permission| has_permission?(permission)}.join(" ")
145
+ end
146
+ end
147
+ end
148
+
149
+ raise "Unknown component_permissions_reader #{method_name.inspect}"
150
+ end
151
+
152
+ # Access a single permission
153
+ # eg. manage_permission # returns true if we can manage
154
+ def single_permission_reader(method_name)
155
+ for permission in joinable_type.constantize.permissions
156
+ if method_name.to_s == "#{permission}_permission"
157
+ return has_permission?(permission)
158
+ end
159
+ end
160
+
161
+ raise "Unknown single_permission_reader #{method_name.inspect}"
162
+ end
163
+ end
164
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts_as_joinable
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Ryan Wallace
9
+ - Nicholas Jakobsen
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2012-04-30 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: postgres_ext
17
+ requirement: !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ~>
21
+ - !ruby/object:Gem::Version
22
+ version: 0.2.2
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ none: false
27
+ requirements:
28
+ - - ~>
29
+ - !ruby/object:Gem::Version
30
+ version: 0.2.2
31
+ description: Adds access control to objects by giving them members, each with configurable
32
+ permissions.
33
+ email: technical@rrnpilot.org
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - app/models/default_permission_set.rb
39
+ - app/models/membership.rb
40
+ - app/models/membership_invitation.rb
41
+ - app/models/membership_request.rb
42
+ - app/models/permission_link.rb
43
+ - lib/acts_as_joinable.rb
44
+ - lib/joinable/acts_as_joinable.rb
45
+ - lib/joinable/acts_as_joinable_component.rb
46
+ - lib/joinable/acts_as_member.rb
47
+ - lib/joinable/acts_as_permissable.rb
48
+ - lib/joinable/feedable_extensions.rb
49
+ - lib/joinable/permissions_attribute_wrapper.rb
50
+ - README.rdoc
51
+ homepage: http://github.com/rrn/acts_as_joinable
52
+ licenses: []
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ none: false
59
+ requirements:
60
+ - - ! '>='
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ none: false
65
+ requirements:
66
+ - - ! '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubyforge_project:
71
+ rubygems_version: 1.8.25
72
+ signing_key:
73
+ specification_version: 3
74
+ summary: An easy to use permissions system
75
+ test_files: []