module Zena module Acts =begin rdoc == Secure model Read, write and publication access to an node is defined with four elements: one user and three groups. link://rwp_groups.png === Definitions : [inherit] Defines how the groups propagate. If +inherit+ is set to '1', the node inherits rwp groups from it's reference. If +inherit+ is set to '0', the node has custom rwp groups. When set to '-1', the node is becomes private and all rwp groups are set to '0'. [read] This means that the node can be seen. [write] This means that new versions can be proposed for the node as well as new sub-pages, documents, events, etc. Basically can write = can add content. If a user has write access to a #Tag, this means he can add nodes to this #Tag (#Tag available as a category for other nodes). [publish] This means that the content viewed by all can be altered by 1. publishing new versions 2. changing the node itself (name, groups, location, categories, etc) 3. removing the node and/or sub-nodes 4. people with this access can see nodes that are not published yet [manage] This is for nodes that have not yet been published or for private nodes A. private node 1. can 'publish' node (it is not really published as the node is private...) 2. can 'unpublish' (make this node a 'not published yet') 3. can change node itself (cannot change groups) 4. can destroy B. node not published yet only : 5. make an node private (sets all groups to 0) or revert node to default groups (same as parent or project) if node not published yet 5. can see node (edition = personal redaction or latest version) === Who can do what (OBSOLTE: NEEDS UPDATE) [read] * super user * owner * members of +read_group+ if the node is published and the current date is greater or equal to the publication date * members of +drive_group+ if +max_status+ >= prop [write] * super user * owner * members of +write_group+ if node is published and the current date is greater or equal to the publication date [publish] * super user * members of +drive_group+ if +max_status+ >= prop * owner if member of +drive_group+ [manage] * owner if +max_status+ <= red * owner if private === Misc * A user can only set a group in which he/she belongs. * Only people from the 'admin' group can change an node's owner. * Setting all groups to _public_ transforms the node into a wiki. * A user who belongs to the 'admin' group (id=2), automatically belongs to all other groups. === Usage In the controller : require 'lib/acts_as_secure' class PagesController < ApplicationController before_filter :set_logged_in_user acts_as_secure def show @page = secure { Page.find(params[:id]) } end private def set_logged_in_user # .. get user session[:user] = @user[:id] end #FIXME: correct doc. In the model : require 'lib/acts_as_secure' class Page < ActiveRecord::Base acts_as_secure_node end In the helpers (if you intend to use secure find there...) require 'lib/acts_as_secure' module ApplicationHelper include Zena::Acts::Secure # ... end Just doing the above will filter all result according to the logged in user. =end module SecureNode # this is called when the module is extended into the Node class def acts_as_secure_node belongs_to :rgroup, :class_name=>'Group', :foreign_key=>'rgroup_id' belongs_to :wgroup, :class_name=>'Group', :foreign_key=>'wgroup_id' belongs_to :dgroup, :class_name=>'Group', :foreign_key=>'dgroup_id' belongs_to :user before_validation :secure_reference_before_validation # we move all before_validation on update and create here so that it is triggered before multiversion's before_validation before_validation :secure_before_validation validate :record_must_be_secured #validate {|r| r.errors.add(:base, 'record not secured') unless r.instance_variable_get(:@visitor)} validate_on_update {|r| r.errors.add('site_id', 'cannot change') if r.site_id_changed? } validate_on_create :secure_on_create validate_on_update :secure_on_update before_save :secure_before_save after_save :secure_after_save before_destroy :secure_on_destroy include Zena::Acts::SecureNode::InstanceMethods extend Zena::Acts::SecureNode::ClassMethods end module InstanceMethods def record_must_be_secured errors.add(:base, 'record not secured') unless @visitor == Thread.current[:visitor] end # Store visitor to produce scope when needed and to retrieve correct editions. def visitor=(visitor) @visitor = visitor self end # Return true if the node can be viewed by all (public) def public? can_read?(visitor.site.anon,visitor.site.anon.group_ids) # visible by anonymous end # Return true if the node is not a reference for any other nodes def empty? return true if new_record? 0 == self.class.count_by_sql("SELECT COUNT(*) FROM #{self.class.table_name} WHERE #{ref_field} = #{self[:id]}") end # people who can read: # * super user # * members of +read_group+ if the node is published and the current date is greater or equal to the publication date # * members of +write_group+ def can_read?(vis = visitor, ugps=visitor.group_ids) ( vis.is_su? ) || # super user ( ugps.include?(rgroup_id) && publish_from && Time.now >= publish_from ) || ( ugps.include?(wgroup_id) ) end # people who can write: # * super user # * members of +write_group+ if there status is at least 'user'. def can_write?(vis=visitor, ugps=visitor.group_ids) ( vis.is_su? ) || # super user ( ugps.include?(wgroup_id) && visitor.user?) # write group end def can_see_redactions?(ugps = visitor.group_ids) visitor.group_ids.include?(wgroup_id) end # The node has just been created so the creator can still delete it # or move it around. def draft?(vis=visitor) !publish_from && visitor.id == user_id && visitor.user? && visitor.id == version.user_id && versions.count == 1 end # The node has just been created so the creator can still delete it # or move it around. def draft_was_true?(vis=visitor) !publish_from_was && visitor.id == user_id_was && visitor.user? && visitor.id == version.user_id_was && versions.count == 1 end # Can alter node (move around, name, rwp groups, etc). # * super user # * members of +drive_group+ if member status is at least 'user' def can_drive?(vis=visitor, ugps=visitor.group_ids) ( vis.is_su? ) || # super user ( vis.user? && (ugps.include?(dgroup_id) || draft?) ) end # 'can_drive?' before attribute change def can_drive_was_true?(vis=visitor, ugps=visitor.group_ids) ( vis.is_su? ) || # super user ( vis.user? && (ugps.include?(dgroup_id_was) || draft_was_true?) ) end # 'can_drive?' without draft? exceptions def full_drive?(vis=visitor, ugps=visitor.group_ids) ( vis.is_su? ) || # super user ( vis.user? && ugps.include?(dgroup_id) ) end # 'full_drive?' before attribute change def full_drive_was_true?(vis=visitor, ugps=visitor.group_ids) ( vis.is_su? ) || # super user ( vis.user? && ugps.include?(dgroup_id_was) ) end def secure_before_validation if new_record? secure_before_validation_on_create else secure_before_validation_on_update end end def secure_before_validation_on_create # set defaults before validation self[:site_id] = visitor.site.id self[:user_id] = visitor.id self[:ref_lang] = visitor.lang [:rgroup_id, :wgroup_id, :dgroup_id, :skin].each do |sym| # not defined => inherit self[sym] ||= ref[sym] end if inherit.nil? if rgroup_id == ref.rgroup_id && wgroup_id == ref.wgroup_id && dgroup_id == ref.dgroup_id self[:inherit] = 1 else self[:inherit] = 0 end end true end def secure_before_validation_on_update self[:kpath] = self.vclass.kpath if vclass_id_changed? or type_changed? true end # Make sure the reference object (the one from which this object inherits) exists before validating. def secure_reference_before_validation if ref == nil errors.add(ref_field, "invalid reference") return false end true end # 1. validate the presence of a valid project (one in which the visitor has write access and project<>self !) # 2. validate the presence of a valid reference (project or parent) (in which the visitor has write access and ref<>self !) # 3. validate +drive_group+ value (same as parent or ref.can_drive? and valid) # 4. validate +rw groups+ : # a. if can_drive? : valid groups # b. else inherit or private # 5. validate the rest def secure_on_create case inherit when 1 # force inheritance self[:rgroup_id] = ref.rgroup_id self[:wgroup_id] = ref.wgroup_id self[:dgroup_id] = ref.dgroup_id self[:skin ] = ref.skin when 0 # custom access rights if ref.full_drive? errors.add('rgroup_id', "unknown group") unless visitor.group_ids.include?(rgroup_id) errors.add('wgroup_id', "unknown group") unless visitor.group_ids.include?(wgroup_id) errors.add('dgroup_id', "unknown group") unless visitor.group_ids.include?(dgroup_id) else errors.add('inherit', "custom access rights not allowed") errors.add('rgroup_id', "you cannot change this") unless rgroup_id == ref.rgroup_id errors.add('wgroup_id', "you cannot change this") unless wgroup_id == ref.wgroup_id errors.add('dgroup_id', "you cannot change this") unless dgroup_id == ref.dgroup_id errors.add('skin' , "you cannot change this") unless skin == ref.skin end else errors.add(:inherit, "bad inheritance mode") end end # 1. if dgroup changed from old, make sure user could do this and new group is valid # 2. if owner changed from old, make sure only a user in 'admin' can do this # 3. error if user cannot publish nor manage # 4. parent/project changed ? verify 'publish access to new *and* old' # 5. validate +rw groups+ : # a. can change to 'inherit' if can_drive? or can_drive? and max_status < pub and does not have children # b. can change to 'private' if can_drive? # c. can change to 'custom' if can_drive? # 6. validate the rest def secure_on_update return true unless changed? if !can_drive_was_true? errors.add(:base, 'You do not have the rights to do this.') unless errors[:base] return false end if user_id_changed? if visitor.is_admin? # only admin can change owners unless secure(User) { User.find_by_id(user_id) } errors.add(:user_id, 'unknown user') end else errors.add(:user_id, 'Only admins can change owners') end end return false unless ref_field_valid? # verify groups if inherit_changed? && !full_drive_was_true? errors.add(:inherit, 'cannot be changed') else case inherit when 1 # inherit rights [:rgroup_id, :wgroup_id, :dgroup_id, :skin].each do |sym| if self.send("#{sym}_changed?") && self[sym] != ref[sym] # manual change of value not allowed without changing inherit mode if !full_drive_was_true? errors.add(sym.to_s, 'cannot be changed') else errors.add(sym.to_s, 'cannot be changed without changing inherit mode') end else # in case parent changed, keep in sync self[sym] = ref[sym] end end when 0 # custom rights [:rgroup_id, :wgroup_id, :dgroup_id].each do |sym| if self.send("#{sym}_changed?") && !visitor.group_ids.include?(self[sym]) errors.add(sym.to_s, 'unknown group') end end else errors.add('inherit', 'bad inheritance mode') end end end # Prepare after save callbacks def secure_before_save @needs_inheritance_spread = !new_record? && (rgroup_id_changed? || wgroup_id_changed? || dgroup_id_changed? || skin_changed?) true end # Verify validity of the reference field. def ref_field_valid? return true unless ref_field_id_changed? # reference changed if published_in_heirs_was_true? # node or some children node was published, moves must be made with drive rights in both # source and destination if ref_field_id == self.id || secure_drive(ref_class) { ref_class.count(:conditions => ['id IN (?)', [ref_field_id, ref_field_id_was]]) != 2 } errors.add(ref_field, "invalid reference") return false end else # node was not visible to others, we need write access to both source and destination if ref_field_id == self.id || secure_write(ref_class) { ref_class.count(:conditions => ['id IN (?)', [ref_field_id, ref_field_id_was]]) != 2 } errors.add(ref_field, "invalid reference") return false end end in_circular_reference? ? false : true end # Make sure there is no circular reference # (any way to do this faster ?) def in_circular_reference? loop_ids = [self[:id]] curr_ref = ref_field_id in_loop = false while curr_ref != 0 if loop_ids.include?(curr_ref) # detect loops in_loop = true break end loop_ids << curr_ref curr_ref = Zena::Db.fetch_row("SELECT #{ref_field} FROM #{self.class.table_name} WHERE id=#{curr_ref}").to_i end errors.add(ref_field, 'circular reference') if in_loop in_loop end def secure_on_destroy if new_record? || can_drive_was_true? unless empty? errors.add(:base, 'cannot be removed (contains subpages or data)') false else true end else errors.add(:base, 'You do not have the rights to do this.') false end end # Reference to validate access rights def ref # new record and self as reference (creating root node) return self if ref_field == :id && new_record? if !@ref || (@ref.id != ref_field_id) # no ref or ref changed @ref = secure(ref_class) { ref_class.find_by_id(ref_field_id) } end if @ref && (self.new_record? || (:id == ref_field) || (self[:id] != @ref[:id] )) # reference is accepted only if it is not the same as self or self is root (ref_field==:id set by Node) @ref.freeze else nil end end protected def secure_after_save spread_inheritance if @needs_inheritance_spread true end # When the rwp groups are changed, spread this change to the 'children' with # inheritance mode set to '1'. 17.2s # FIXME: make a single pass for spread_inheritance and update section_id and project_id ? # FIXME: should also remove cached pages... def spread_inheritance(i = self[:id]) base_class.connection.execute "UPDATE nodes SET rgroup_id='#{rgroup_id}', wgroup_id='#{wgroup_id}', dgroup_id='#{dgroup_id}', skin='#{skin}' WHERE #{ref_field(false)}='#{i}' AND inherit='1'" ids = nil # FIXME: remove 'with_exclusive_scope' once scopes are clarified and removed from 'secure' base_class.send(:with_exclusive_scope) do ids = Zena::Db.fetch_ids("SELECT id FROM #{base_class.table_name} WHERE #{ref_field(true)} = '#{i.to_i}' AND inherit='1'") end ids.each { |i| spread_inheritance(i) } end # Return true if a heir is published. def published_in_heirs? pub = publish_from return true if pub heirs.each do |h| break if pub = h.published_in_heirs? end return pub end # Return true if a heir is published. def published_in_heirs_was_true? pub = publish_from_was return true if pub heirs.each do |h| break if pub = h.published_in_heirs? end return pub end private # List of elements using the current element as a reference. Used to update # the rwp groups if they inherit from the reference. Can be overwritten by sub-classes. def heirs # FIXME: remove 'with_exclusive_scope' once scopes are clarified and removed from 'secure' base_class.send(:with_exclusive_scope) do base_class.find(:all, :conditions=>["#{ref_field(true)} = ? AND inherit='1'" , self[:id] ] ) || [] end end # Reference class. Must be overwritten by sub-classes. def ref_class self.class end # Must be overwritten. def base_class self.class end # Reference foreign_key. Can be overwritten by sub-classes. def ref_field(for_heirs=false) :reference_id end def ref_field_id self[ref_field] end def ref_field_id_was self.send(:"#{ref_field}_was") end def ref_field_id_changed? self.send(:"#{ref_field}_changed?") end end # InstanceMethods module ClassMethods # kpath is a class shortcut to avoid tons of 'OR type = Page OR type = Document' # we build this path with the first letter of each class. The example bellow # shows how the kpath is built: # class hierarchy # Node --> N # Note --> NN Page --> NP # Document Form Section # NPD NPF NPP # So now, to get all Pages, your sql becomes : WHERE kpath LIKE 'NP%' # to get all Documents : WHERE kpath LIKE 'NPD%' # all pages without Documents : WHERE kpath LIKE 'NP%' AND NOT LIKE 'NPD%' def kpath @@kpath[self] ||= superclass == ActiveRecord::Base ? ksel : (superclass.kpath + ksel) end # 'from' and 'joins' are removed: this method is used when receiving calls from zafu. Changing the source table removes # the secure scope. def clean_options(options) options.reject do |k,v| ! [ :conditions, :select, :include, :offset, :limit, :order, :lock ].include?(k) end end # kpath selector for the current class def ksel self.to_s[0..0] end @@kpath = {} # Replace Rails subclasses normal behavior def type_condition " #{table_name}.kpath LIKE '#{kpath}%' " end end # ClassMethods end #SecureNode # ============================================= SECURE =============== module Secure # protect access to site_id : should not be changed by users # def site_id=(i) # raise Zena::AccessViolation, "#{self.class.to_s} '#{self.id}': tried to change 'site_id' to '#{i}'." # end # Set current visitor def visitor=(visitor) Thread.current[:visitor] = visitor end # Secure scope for read access def secure_scope(table_name) if visitor.is_su? "#{table_name}.site_id = #{visitor.site.id}" else # site_id AND... "#{table_name}.site_id = #{visitor.site.id} AND ("+ # READER if published "(#{table_name}.rgroup_id IN (#{visitor.group_ids.join(',')}) AND #{table_name}.publish_from <= #{Zena::Db::NOW} ) OR " + # OR writer "#{table_name}.wgroup_id IN (#{visitor.group_ids.join(',')}))" end end def secure_write_scope scope = {:nodes => {:site_id => visitor.site[:id]}} scope[:nodes] = {:wgroup_id => visitor.group_ids} unless visitor.is_su? scope end # these methods are not actions that can be called from the web !! protected # secure find with scope (for read/write or publish access). def secure_with_scope(klass, node_find_scope) if ((klass.send(:scoped_methods)[0] || {})[:create] || {})[:visitor] # we are already in secure scope: this scope is the new 'exclusive' scope. last_scope = klass.send(:scoped_methods).shift end scope = {:create => { :visitor => visitor }} find = scope[:find] ||= {} if klass.ancestors.include?(Zena::Acts::SecureNode::InstanceMethods) find[:conditions] = node_find_scope elsif klass.ancestors.include?(::Version) ntbl = ::Node.table_name find[:joins] = :node find[:readonly] = false if node_find_scope =~ /publish_from/ # read, we need to rewrite with node's table name find[:conditions] = secure_scope(ntbl) else find[:conditions] = node_find_scope end elsif klass.column_names.include?('site_id') find[:conditions] = {klass.table_name => {:site_id => visitor.site[:id]}} elsif klass.ancestors.include?(::Site) find[:conditions] = {klass.table_name => {:id => visitor.site[:id]}} end # FIXME: 'with_scope' is protected now. Can we live with something cleaner like this ? # class AR::Base # def self.secure_find(...) # ... # end # end # # or better: # :conditions => '#{secure_scope}' (dynamically evaluated: single quotes) result = klass.send(:with_scope, scope) { yield } klass.send(:scoped_methods).unshift last_scope if last_scope secure_result(klass,result) end def secure_result(klass,result) if result && result != [] if result.kind_of?(Array) if result.first.kind_of?(::Node) id_map, ids = construct_id_map(result) ::Version.find(ids).each do |v| if r = id_map[v.id] r.version = v end end end elsif result.kind_of?(::Node) visitor.visit(result) end result else nil end end # Take an array of records and return a 2-tuple: a hash of # version_id to record and a list of version ids. This method also # secures the node by calling visitor.visit(node). def construct_id_map(records) map = {} v_ids = [] records.each do |r| visitor.visit(r) v_id = r.version_id map[v_id] = r v_ids << v_id end [map, v_ids] end # Secure for read/create. # [read] # * super user # * owner # * members of +read_group+ if the node is published and the current date is greater or equal to the publication date # * members of +drive_group+ if +max_status+ >= prop # The options hash is used internally by zena when maintaining parent to children inheritance and should not be used for other purpose if you do not want to break secure access. def secure(klass, opts={}, &block) # FIXME: why doesn't secure look like secure_write and secure_drive ? # klass.ancestors.include? should not belong here ! # using the same: # secure_with_scope(klass, nil, &block) # for all should work. if opts[:secure] == false yield else secure_with_scope(klass, secure_scope(klass.table_name), &block) end rescue ActiveRecord::RecordNotFound # Rails generated exceptions # TODO: monitor how often this happens and replace the finders concerned nil end def secure!(klass, opts={}, &block) unless res = secure(klass, opts={}, &block) raise ActiveRecord::RecordNotFound end res end # Secure scope for write access. # [write] # * super user # * owner # * members of +write_group+ if node is published and the current date is greater or equal to the publication date def secure_write(obj, &block) scope = {:nodes => {:site_id => visitor.site[:id]}} scope[:nodes] = {:wgroup_id => visitor.group_ids} unless visitor.is_su? secure_with_scope(obj, scope, &block) rescue ActiveRecord::RecordNotFound # Rails generated exceptions # TODO: monitor how often this happens and replace the finders concerned nil end # Find a node with write access. Raises an exception on failure. def secure_write!(obj, &block) unless res = secure_write(obj, &block) raise ActiveRecord::RecordNotFound end res end # Secure scope for publish or management access. This scope is a little looser then 'secure' (read access) concerning redactions # and 'not published yet' nodes. This is not a bug, such an access is needed to delete old nodes for example. # [publish] # * super user # * members of +drive_group+ # * owner if member of +drive_group+ or private # # [manage] # * owner if +max_status+ <= red # * owner if private def secure_drive(obj, &block) # scope = if visitor.is_su? # super user # "site_id = #{visitor.site.id}" # else # "site_id = #{visitor.site.id} AND dgroup_id IN (#{visitor.group_ids.join(',')})" # end scope = { :nodes => {:site_id => visitor.site.id } } scope[:nodes][:dgroup_id] = visitor.group_ids unless visitor.is_su? secure_with_scope(obj, scope, &block) rescue ActiveRecord::RecordNotFound # Rails generated exceptions # TODO: monitor how often this happens and replace the finders concerned nil end # Find nodes with 'drive' authorization. Raises an exception on failure. def secure_drive!(obj, &block) if res = secure_drive(obj, &block) res else raise ActiveRecord::RecordNotFound end end def driveable? respond_to?(:dgroup_id) end end end # This exception handles all flagrant access violations or tentatives (like suppression of _su_ user) class AccessViolation < StandardError end # This exception occurs when a visitor is needed but none was provided. class RecordNotSecured < StandardError end # This exception occurs when corrupt data in encountered (infinit loops, etc) class InvalidRecord < StandardError end end ### ============== GLOBAL METHODS ACCESSIBLE TO ALL OBJECTS ============== ###### # Return the current site. Raise an error if the visitor is not set. def current_site visitor.site end # Return the current visitor. Raise an error if the visitor is not set. # For controllers, this method must be redefined in Application def visitor Thread.current[:visitor] || Zena::RecordNotSecured.new("Visitor not set, record not secured.") end