acts_as_joinable 1.1.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.
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: []