Troles

Troles aims to be a complete (end-all) roles solution for Ruby and is also targeted to be used with Rails 3. As of early June it is still under development but getting close to first release. The project currently consists of:

  • Trole – for single role strategies
  • Troles – for many roles strategies
  • TroleGroups – for groups of roles

The Troles project uses role caching to optimize performance! The roles list cache of a role subject (fx a user) is only updated (retrieved from data store) when the roles of the role subject changes!

Note: Troles is a full redesign of roles generic and company, using lessons learned. Troles uses a much cleaner design. It is aimed at being easy to extend and easy to create adapters for etc.

Role strategies

The following lists the role strategies to be supported

  • Single – one role from a set of valid roles
  • Many – many roles from a set of valid roles

Single role strategies

Schemas:

  • Boolean field on the User class (admin role)
  • String field on the User class
  • One reference to a Role
  • Embeds one Role (document store only)

Field stored in the data store: trole

The field is named trole, in order not to conflict with the method #role used in the Role DSL.

Multiple roles strategies

Schemas

  • Integer (bitmap) field on the User class
  • String of comma delimited roles on User class
  • References to multiple Roles
  • Embeds multiple Roles

Field stored in the datastore: troles

The field is named troles, in order not to conflict with the method #roles used in the Role DSL.

These strategies can be grouped and named as follows:

Single role:

  • bit_one
  • string_one
  • ref_one
  • embed_one (document store)

Multiple roles

  • bit_many
  • string_many
  • ref_many
  • embed_many (document store)

These strategies can be implemented for any data store using any schema format.

Using roles strategies with Users and User accounts

Roles are assigned to role subject classes such as User or UserAccount (YES, Devise can have multiple user accounts!). The class that has a role strategy assigned is referred to as the role subject class. Different role subject classes can have different role strategies!

When using Devise this could translate fx into a UserAccount with a “many roles” strategy and an AdminAccount with a “single role” strategy (or vice versa).

Example:

require 'troles'
require 'troles/macros'

class UserAccount
  troles_strategy(:string_many).configure!
end

class AdminAccount
  troles_strategy(:bit_one, :static => true).configure!
end  

The special troles macros must currently be enabled my requiring the troles/macros file. If the troles macros are not included like this, the troles DSL can be made available for an individual class by including a specific Strategy. Using the macros like in the above example is much easier and is recommended.

Macro options

The :static => true options is used to indicate, that the role can not be changed after being initially set. But we are getting ahead of ourselves… (more on this later). Troles can easily be extended to support other macro options if needed. Note: This static roles functionality is currently in-progress… (used to work but under change).

Roles API

The Roles API can be divided into:

  • Core
  • Event
  • Cache
  • Read
  • Write
  • Validation
  • Operations object

There is an equivalent Trole API for single role strategies.

Event/Cache API

The User class should have an event trigger after save to react when the roles have changed. If the roles were changed, an even should be sent to an Event Manager to handle this. The event manager can have subscribers to events.

Also any write event to the datastore should be predicated on #static_roles? not being true for the user (thus ensuring guest roles are never updated).

User.after_save: update_roles # event handler

Roles Read API

This API operates directly on a user, fx user#has_role?

user.has_role? :admin
user.is_role? :editor
user.has_any_roles? :editor, :admin  

Roles Write API

This API operates directly on a user, fx user#has_role?

user.add_role :admin
user.remove_role :editor

Roles Operations object

The Roles Operations object is available on user#roles

user.roles + :admin
user.roles - :editor
user.roles << [:editor, :admin]
user.roles.clear!  

Creating a custom Data Store Adapter (DSA)

An adapter almost always requires a custom Config class implementation. Look at the troles/common/config.rb to see what functionality is available that you can use.

Note that :single role strategies always have the namespace ‘Trole’ whereas for :many it is ‘Troles’. This convention is used throughout troles and breaking this convention will thus break the troles functionality.

A custom Config class for :single role strategies using Mongoid could look sth. like this:

module Trole::Mongoid
  class Config < Troles::Common::Config  
    def initialize subject, options = {}
      super
    end
  
    def configure_relation
      case strategy
      when :ref_one
        has_one_for subject_class, object_model
        belongs_to_for object_model, subject_class
      when :embed_one
        embeds_one_for subject_class, object_model
      end
    end
    
    def configure_field
      type = case strategy
      when :bit_one
        Boolean
      when :string_one
        String
      end
      subject_class.send(:field, role_field, type) if type      
    end

As you can see, you have several nice convenience methods available to help set up these somewhat complex model relationships! See the schema_helpers.rb file for more details!

Example: Config class for :many roles strategies with Mongoid

module Troles::Mongoid
  class Config < Troles::Common::Config  

    def initialize subject_class, options = {}
      super
    end
  
    def configure_relation
      case strategy
        ...
      end
    end
    
    def configure_field
      type = case strategy
      when :bit_many
        Integer
      when :string_many
        String
      end
      subject_class.send(:field, role_field, type: => type) if type      
    end

Creating a custom Strategy Storage Adapter (SSA)

Often you need to define Storage class implementations for some of the strategies, especially those that reference or embed other models.

You must implement the following methods:

- #display_roles (returns symbol list of roles) - #clear! (clear the roles in the datastore) - #set_roles (set the roles in the datastore) - #set_role (as #set_roles)

Note: #set_role is only required for :single role strategies. In this case #set_roles is not required, as the superclass implementation simply calls #set_role with the first role.

Example custom SSA (encrypted role string):

module Trole::Storage
  module EncryptedStringOne < BaseOne
    def initialize role_subject        
      super
    end

    def display_roles
      decrypted_value.split(',')
    end

    # saves the role of the role subject in the data store
    # @param [Symbol] the role name
    def set_role role
      set_encrypted_ds_field role.to_s
    end  

    # sets the role to its default state
    def clear!
      set_encrypted_ds_field ""
    end  

    protected

    def decrypted_value
      ds_field_value.decrypt!
    end

    def set_encrypted_value value
      set_ds_field value.encrypt! 
    end
  end
end

Custom Storage for an ORM (or data store)

In some cases it is useful to rewrite part of the base Storage functionality. One such method is #find_roles.

module Troles::Storage
  class BaseMany < Troles::Common::Storage
    def find_roles *roles
      role_model.where(:name => roles.flatten).all
    end  
  ...
end

Active Record and Mongoid both implement the above API, so no need to customize this method in the Storage adapter. For most other ORMs/data stores you will likely have to write your own logic to achieve this.

module Troles::MongoidStorage
  class RefMany < Troles::Storage::BaseMany

    def find_roles *roles
      # my own custom datastore logic!!!
    end
  ...  

Custom data marshaller

You can also use a custom Marshaller to store the roles in a non-standard format. This is used for the BitMany strategy, which has a special Marshaller::BitMask class which handles conversion between a list of symbols to an Integer bitmap representing it (relative to a valid roles list!). You can create your own Marshaller, fx to encrypt the roles info or whatever you like!

module Troles::Marshaller
  class Encryption < Generic
    def initialize subject
      super
    end

    # convert marshalled value into roles symbols list
    def read
      ...      
    end

    # convert roles symbols list into value to be marshalled
    def write *roles
      ...
    end
  end
end

Using a custom Marshaller in a Storage implementation

The following example is taken from the BitOne Storage implementation that is part of troles:

require 'troles/common/marshaller'

module Trole::Storage 
  class BitOne < BaseOne
    # display the role as a list of one symbol
    # see Troles::Marshaller::Bitmask    
    # @return [Array<Symbol>] roles list
    def display_roles
      raise "BitOne requires exactly two valid roles, was: #{valid_roles}" if !(valid_roles.size == 2)
      [bitmask.read].flatten
    end
      
    # saves the role for the role subject in the data store
    # @param [Symbol] role name
    def set_role role
      num = bitmask.write role
      set_ds_field(num == 1 ? false : true) # boolean field in Data store
    end  

    # Clears the role state of the role subject 
    def clear!
      set_ds_field false
    end
  
    protected

    def bitmask
      @bitmask ||= Troles::Common::Marshaller::Bitmask.new role_subject
    end
  ...        

Note that the same BitMask marshaller is also reused in the BitMany storage!

The #bitmask method returns an instance of the Bitmask marshaller, instantiated with the role_subject (the instance that has the #role_list method, typically the user or user account). To use the Encryption marshaller in an Encryption storage we could create a #marshaller method:

def marshaller
  @marshaller ||= Troles::Marshaller::Encryption.new role_subject
end  

Then use marshaller.write(*roles) in the set_xxxx methods and marshaller.read in #display_roles method of the storage, as needed.

Custom troles strategy

To add a new strategy, you can optionally create a special Strategy module that acts as the module included by the troles macro. Using standard naming convention it will be found and used by the troles_strategy macro method :) If you don’t create a specialized Strategy module, troles will just use the BaseOne or BaseMany depending on the singularity (one or many) of the strategy.

Using naming conventions, the strategy will always try to use a matching storage class (substitute Strategy for Storage). If you don’t define a custom Strategy module or Storage class, troles will just use a generic implementation.

Try to keep it simple and start by defining only a Config class and then see if you need to implement a custom Storage class. You rarely need to implement a custom Strategy module or custom API implementations.

Here is an example of a custom Strategy implementation for EncryptedStringMany that simply wraps the BaseMany strategy implementation. This example demonstrates how you can easily override functionality with custom implementations by including modules “on top”.

module Troles::Strategy
  module EncryptedStringMany

    # What to add to the role subject class when this role strategy is included
    # @param [Class] the role subject class to  
    def self.included(base)
      base.send :include, BaseMany
      base.send :include, InstanceMethods      
      base.extend ClassMethods            
    end

    module ClassMethods
      # .. my custom class methods
    end
    
    module InstanceMethods
      # .. my custom instance methods
    end
  end
end  

Creating a Base strategy for an Adapter

In some cases you need a custom Base strategy that contains common functionality shared among strategies with the same singularity (one or many).

Example: BaseMany, used as the base for all Many roles strategies

module Troles::Mongoid
  module Strategy    
    module BaseMany
      # @param [Class] the role subject class for which to include the Role strategy (fx User Account)
      #
      def self.included(base)
        base.send :include, Troles::Strategy::BaseMany        

        # base.send :include, InstanceMethods      
        # base.extend ClassMethods            
      end
    end
  end
end

To use your adapter, simply pass an extra option to the troles_strategy macro:

User.troles_strategy(:bit_one, :orm => :mongoid).configure!

This even allows you to use different ORM role strategies/storages for different user accounts simultaneously!!!

Using the :auto_load option will ‘auto load’ (i.e require) the orm adapter from the built-in catalog of adapters that come with troles. You can include a specific custom (or 3rd party) adapter manually. In the future it will be possible to configure troles with adapters and specify how/where to load them from as part of this configuration!

User.troles_strategy(:bit_one, :orm => :mongoid, :auto_load => true).configure!

You can also specify some of the options relevant to model configuration on the call to #configure if you like ;)

User.troles_strategy(:bit_one, :orm => :active_record, :auto_load => true).configure! :role_model => 'Troll'

The troles_strategy macro will yield the Config object if you pass it a block. This allows you to configure your stategy with troles inside the block and then call configure! on end of the block. Using all this in combination, you could configure it all doing sth. like this:

require 'my/own/active_record/adapter'  
  
User.troles_strategy :bit_one, :orm => :active_record do |c|
  c.auto_load = false
  c.valid_roles = [:troll_commander, :troll_warrior]

  c.auto_config[:relations] = false # to take over control of setting up model relationships

end.configure! :role_model => 'Troll', :role_join_model => 'UserTroll'

Enjoy :)

Global configuration

The following is a list of the global Troles common configuration options:

  • default_orm
  • auto_load (true|false)
  • log_on (true|false)
  • auto_config[:models] (true|false)
  • auto_config[:fields] (true|false)
  • auto_config[:relations] (true|false)

Examples:

Troles::Common::Config.default_orm = :mongoid

Troles uses this as the ORM setting in case you don’t specify the orm when you configure a troles strategy.

Troles::Common::Config.auto_load = true

Ensures the adapter is autoloaded from the troles internal /adapter folder. Leave this to false (defalt) if you have rolled your own or use a 3rd party adapter.

Troles::Common::Config.log_on = true

Turns on some logging to make it easier to debug what goes on behind the curtain (note: to be improved…).

Troles::Common::Config.auto_config[:models] = false

Disables troles auto configuration of models, allowing you full control.

Troles::Common::Config.auto_config[:relations] = false

Disables troles auto configuration of model relationships (has_many, belongs_to and such), allowing you full control.

Troles::Common::Config.auto_config[:fields] = false

Disables troles auto configuration of model fields (for data stores such as Data Mapper, Mongoid etc that have the concept of data fields), allowing you full control.

Note: For all the global on/off options you can opt to use same option on an individual strategy basis as part of an individual strategy configuration.

In the strategy Config class they can be used like this

if auto_config?(:fields)

Other notes on Application-User control

troles will be part of a larger project under development that will go under the name “dancing tango with trolls”. This will be a rework of cream and cancan-permits that will target use in apllications with multiple user accounts and multiple sub applications. In this new system, dancing will be the replacement of cream and tango the replacement of cancan-permits. I hope to give a talk on RubyConf 2011 about this system.

Guest users

From the Devise wiki

“In some applications, it’s useful to have a guest User object to pass around even before the (human) user has registered or logged in. Normally, you want this guest user to persist as long as the browser session persists.

Our approach is to create a guest user object in the database and store its id in session[:guest_user_id]. When (and if) the user registers or logs in, we delete the guest user and clear the session variable. A helper function, current_or_guest_user, returns guest_user if the user is not logged in and current_user if the user is logged in."

module ApplicationHelper
  ...
  # if user is logged in, return current_user, else return guest_user
  def current_or_guest_user
    if current_user
      if session[:guest_user_id]
        logging_in
        guest_user.destroy
        session[:guest_user_id] = nil
      end
      current_user
    else
      guest_user
    end
  end

  # find guest_user object associated with the current session, 
  # creating one as needed
  def guest_user
    guest_user_id = session[:guest_user_id] ||= User.create(:name => "guest").id
    User.find(guest_user_id)
  end

  # called (once) when the user logs in, insert any code your application needs
  # to hand off from guest_user to current_user.
  def logging_in
  end      
  ...
end  

In the new system, I propose the following:

# this will make the current user the guest user account for the given scope!
def sign_in_guest scope, options = {}
  warden.set_user(guest_user.account, options.merge!(:scope => scope))
end

#   sign_in :user, @user                      # sign_in(scope, resource)
#   sign_in @user                             # sign_in(resource)
#   sign_in @user, :event => :authentication  # sign_in(resource, options)
#   sign_in @user, :bypass => true            # sign_in(resource, options)
# 
def sign_in(resource_or_scope, *args)
  ...
  post_sign_in resource, scope
end

def post_sign_in resource, scope
  user = warden.user(scope)
  return if !user

  user.transfer_guest(guest_user) if guest_user? && user.respond_to?(:transfer)
  session[:guest_user] = nil
end

def guest_user?
  !session[:guest_user].nil?
end

def guest_user
  session[:guest_user] ||= GuestUser.new
end  
class GuestUserAccount
  troles_strategy(:static_one, :role => :guest) do |c|
    # c.valid_roles = [:guest] not needed!
  end.configure!

  attr_accessor :guest

  def user
    guest
  end
  
  def initialize
    roles.set_default!
  end
end

Here the special :static_many strategy is used, which means that whatever the role_list is first set to can never change for that user.

module BaseAccount
  # transfer guest settings to logged_in user/account
  def transfer_guest(guest_user)
  end  
end

class UserAccount
  include Mongoid::Document

  troles_strategy(:bit_many, :orm => :mongoid) do |c|
    c.valid_roles = [:user, :admin, :guest]
  end.configure!
end

class BloggerAccount
  include Mongoid::Document

  troles_strategy(:string_many, :orm => :mongoid) do |c|
    c.valid_roles = [:blogger, :editor, :admin]
  end.configure!
end  

And here the User setup:

module BaseUser
end

class User
  include Mongoid::Document
  include BaseUser

  has_one :user_account
  has_one :admin_account
  has_one :blogger_account
  ...
end  
class GuestUser
  include BaseUser

  def 
    @guest_user_account ||= GuestUserAccount.new
  end
end  

The Guest class should be set up to only have relationship to those special guest user accounts. The Guest class should implement the same API as User but can stub or hardcode much functionality in order to act as a guest and not a “real” user.

The guest user should (usually) not have any user info persisted, so it makes sense to use a generic account that is in-memory.

License

This project rocks and uses MIT-LICENSE.