module Padrino
class AccessControlError < StandardError; end
# This module give to a padrino application an access control functionality like:
#
# class EcommerceDemo < Padrino::Application
# enable :authentication
# set :login_page, "/login" # or your login page
# enable :store_location # if you want know what is the page that need authentication
# set :use_orm, :active_record # or :data_mapper, :mongo_mapper
#
# access_control.roles_for :any do
# role.require_login "/cart"
# role.require_login "/account"
# role.allow "/account/create"
# end
# end
#
# In the EcommerceDemo, we only require logins for all paths that start with "/cart" like:
#
# - "/cart/add"
# - "/cart/empty"
# - "/cart/checkout"
#
# same thing for "/account" so we require a login for:
#
# - "/account"
# - "/account/edit"
# - "/account/update"
#
# but if we call "/account/create" we don't need to be logged in our site for do that.
# In EcommerceDemo example we set redirect_back_or_default so if a unlogged
# user try to access "/account/edit" will be redirected to "/login" when login is done will be
# redirected to "/account/edit".
#
# If we need something more complex aka roles/permissions we can do that in the same simple way
#
# class AdminDemo < Padrino::Application
# enable :authentication
# set :login_page, "/sessions/new" # or your page
#
# access_control.roles_for :any do |role|
# role.allow "/sessions"
# end
#
# access_control.roles_for :admin do |role, account|
# role.allow "/"
# role.deny "/posts"
# end
#
# access_control.roles_for :editor do |role, account|
# role.allow "/posts"
# end
# end
#
# If a user logged with role admin can:
#
# - Access to all paths that start with "/session" like "/sessions/{new,create}"
# - Access to any page except those that start with "/posts"
#
# If a user logged with role editor can:
#
# - Access to all paths that start with "/session" like "/sessions/{new,create}"
# - Access only to paths that start with "/posts" like "/post/{new,edit,destroy}"
#
# Finally we have another good fatures, the possibility in the same time we build role build also tree.
# Figure this scenario: in my admin every account need their own menu, so an Account with role editor have
# a menu different than an Account with role admin.
#
# So:
#
# class AdminDemo < Padrino::Application
# enable :authentication
# set :redirect_to_default, "/" # or your page
#
# access_control.roles_for :any do |role|
# role.allow "/sessions"
# end
#
# access_control.roles_for :admin do |role, current_account|
#
# role.project_module :settings do |project|
# project.menu :accounts, "/accounts" do |accounts|
# accounts.add :new, "/accounts/new" do |account|
# account.add :administrator, "/account/new/?role=administrator"
# account.add :editor, "/account/new/?role=editor"
# end
# end
# project.menu :spam_rules, "/manage_spam"
# end
#
# role.project_module :categories do |project|
# current_account.categories.each do |category|
# project.menu category.name, "/categories/#{category.id}.js"
# end
# end
# end
#
# access_control.roles_for :editor do |role, current_account|
#
# role.project_module :posts do |posts|
# post.menu :list, "/posts"
# post.menu :new, "/posts/new"
# end
# end
#
# In this example when we build our menu tree we are also defining roles so:
#
# An Admin Account have access to:
#
# - All paths that start with "/sessions"
# - All paths that start with "/accounts"
# - All paths that start with "/manage_spam"
#
# An Editor Account have access to:
#
# - All paths that start with "/posts"
#
# Remember that you always deny a specific actions or allow globally others.
#
# Remember that when you define role_for :a_role, you have also access to the Model Account.
#
module AccessControl
def self.registered(app)
app.helpers Padrino::Admin::Helpers
app.before { login_required }
if app.respond_to?(:use_orm)
Padrino::Admin::Adapters.register(app.use_orm)
else
raise Padrino::ApplicationSetupError, "You must define in your app the setting :use_orm!"
end
end
class Base
class << self
attr_reader :roles
def inherited(base) #:nodoc:
base.class_eval("@@cache={}; @authorizations=[]; @roles=[]; @mappers=[]")
base.send(:cattr_reader, :cache)
super
end
# We map project modules for a given role or roles
def roles_for(*roles, &block)
raise Padrino::AccessControlError, "Role #{role} must be present and must be a symbol!" if roles.any? { |r| !r.kind_of?(Symbol) } || roles.empty?
raise Padrino::AccessControlError, "You can't merge :any with other roles" if roles.size > 1 && roles.any? { |r| r == :any }
if roles == [:any]
@authorizations << Authorization.new(&block)
else
raise Padrino::AccessControlError, "For use custom roles you need to define an Account Class" unless defined?(Account)
@roles.concat(roles)
@mappers << Proc.new { |account| Mapper.new(account, *roles, &block) }
end
end
# Returns (allowed && denied paths).
# If an account given we also give allowed & denied paths for their role.
def auths(account=nil)
if account
cache[account.id] ||= Auths.new(@authorizations, @mappers, account)
else
cache[:any] ||= Auths.new(@authorizations)
end
end
end
end
class Auths
attr_reader :allowed, :denied, :project_modules
def initialize(authorizations, mappers=nil, account=nil)
@allowed, @denied = [], []
unless authorizations.empty?
@allowed = authorizations.collect(&:allowed).flatten
@denied = authorizations.collect(&:denied).flatten
end
if mappers && !mappers.empty?
maps = mappers.collect { |m| m.call(account) }.reject { |m| !m.allowed? }
@allowed.concat(maps.collect(&:allowed).flatten)
@denied.concat(maps.collect(&:denied).flatten)
@project_modules = maps.collect(&:project_modules).flatten.uniq
else
@project_modules = []
end
@allowed.uniq!
@denied.uniq!
end
def can?(request_path)
return true if @allowed.empty?
request_path = "/" if request_path.blank?
@allowed.any? { |path| request_path =~ /^#{path}/ } && !cannot?(request_path)
end
def cannot?(request_path)
return false if @denied.empty?
request_path = "/" if request_path.blank?
@denied.any? { |path| request_path =~ /^#{path}/ }
end
end
class Authorization
attr_reader :allowed, :denied
def initialize(&block)
@allowed = []
@denied = []
yield self
end
def allow(path)
@allowed << path unless @allowed.include?(path)
end
def require_login(path)
@denied << path unless @denied.include?(path)
end
alias :deny :require_login
end
class Mapper
attr_reader :project_modules, :roles, :denied
def initialize(account, *roles, &block) #:nodoc:
@project_modules = []
@allowed = []
@denied = []
@roles = roles
@account = account.dup
yield(self, @account)
end
# Create a new project module
def project_module(name, path=nil, &block)
@project_modules << ProjectModule.new(name, path, &block)
end
# Globally allow an paths for the current role
def allow(path)
@allowed << path unless @allowed.include?(path)
end
# Globally deny an pathsfor the current role
def deny(path)
@denied << path unless @allowed.include?(path)
end
# Return true if role is included in given roles
def allowed?
@roles.any? { |r| r == @account.role.to_s.downcase.to_sym }
end
# Return allowed paths
def allowed
@project_modules.each { |pm| @allowed.concat(pm.allowed) }
@allowed.uniq
end
end
class ProjectModule
attr_reader :name, :menus, :path
def initialize(name, path=nil, options={}, &block)#:nodoc:
@name = name
@options = options
@allowed = []
@menus = []
@path = path
@allowed << path if path
yield self
end
# Build a new menu and automaitcally add the action on the allowed actions.
def menu(name, path=nil, options={}, &block)
@menus << Menu.new(name, path, options, &block)
end
# Return allowed controllers
def allowed
@menus.each { |m| @allowed.concat(m.allowed) }
@allowed.uniq
end
# Return the original name or try to translate or humanize the symbol
def human_name
@name.is_a?(Symbol) ? I18n.t("admin.menus.#{@name}", :default => @name.to_s.humanize) : @name
end
# Return symbol for the given project module
def uid
@name.to_s.downcase.gsub(/[^a-z0-9]+/, '').gsub(/-+$/, '').gsub(/^-+$/, '').to_sym
end
# Return ExtJs Config for this project module
def config
options = @options.merge(:text => human_name)
options.merge!(:menu => @menus.collect(&:config)) if @menus.size > 0
options.merge!(:handler => ExtJs::Variable.new("function(){ Admin.app.load('#{path}') }")) if @path
options
end
end
class Menu
attr_reader :name, :options, :items, :path
def initialize(name, path=nil, options={}, &block) #:nodoc:
@name = name
@path = path
@options = options
@allowed = []
@items = []
@allowed << path if path
yield self if block_given?
end
# Add a new submenu to the menu
def add(name, path=nil, options={}, &block)
@items << Menu.new(name, path, options, &block)
end
# Return allowed controllers
def allowed
@items.each { |i| @allowed.concat(i.allowed) }
@allowed.uniq
end
# Return the original name or try to translate or humanize the symbol
def human_name
@name.is_a?(Symbol) ? I18n.t("admin.menus.#{@name}", :default => @name.to_s.humanize) : @name
end
# Return a unique id for the given project module
def uid
@name.to_s.downcase.gsub(/[^a-z0-9]+/, '').gsub(/-+$/, '').gsub(/^-+$/, '').to_sym
end
# Return ExtJs Config for this menu
def config
if @path.blank? && @items.empty?
options = human_name
else
options = @options.merge(:text => human_name)
options.merge!(:menu => @items.collect(&:config)) if @items.size > 0
options.merge!(:handler => ExtJs::Variable.new("function(){ Admin.app.load('#{path}') }")) if @path
end
options
end
end
end
end