Skip to content

Instantly share code, notes, and snippets.

@krisleech
Last active February 1, 2016 18:19
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save krisleech/2594b9614bfaae76d9a6 to your computer and use it in GitHub Desktop.
Save krisleech/2594b9614bfaae76d9a6 to your computer and use it in GitHub Desktop.

Task Orientated Authorization

Actor <-> Role <-> Task

  • Not a CRUD only solution
  • Tasks can describe anything
  • Roles and/or Policies
  • Actors are typically users, but can be any object
  • Actors are not polluted with authentication concerns
  • Tasks can be added at runtime

Tasks

All permissions are based around a task.

Task(key: string, label: string, description: text, namespace: string)

All tasks are namespaced. Namespaces will typically refer to different parts of the application. They can also be path-like such as "core/organisations".

A task is not specific to a page, UI element, controller or request. It is a higher level concept, such as a use case.

Authorize.tasks # => [...]
Authorize.tasks.put('view_study_organisations', label: 'View Study Organisations', ns: 'core')
Authorize.tasks.delete('view_study_organisations', ns: 'core')

The "view_study_organisations" task would include any feature needed to perform task, e.g. searching organisations.

Actor

Typically the actor will be a user, but it could be any object which responds to #id and #class. The id field is configurable should you want to use a different attribute such as uuid.

Roles

Role(key: string, label: string)

Authorize.roles.create('admin', label: 'Administrator')
Authorize.roles.delete('admin')

Authorise.allow(role,    to: 'view_study_organisations', ns: 'core')
Authorise.disallow(role, to: 'view_study_organisations', ns: 'core')

Authorize.assign(user, to: role)
Authorize.assign(user, to: role)

Role is polymorphically assosiated to Actor via ActorRole(actor_uuid: string, role_key: string). This is a one way relationship, no relationship is added to the actor.

The actor_uuid is a combination of actor class and id.

Allowed

Authorise.can?(user,    'view_study_organisations', ns: 'core')
Authorise.cannot?(user, 'view_study_organisations', ns: 'core')

Will raise if task does not exist, otherwise returns a boolean.

Policies

In cases where you need to finer grained control based on instances you can add a policy such as:

module Policies
  class RemoveStudyOrganisationPolicy
    def initialize(user, organisation)
      @user = user
      @organisation = organisation
    end
  
    def allowed?
      @user.admin? && @organisation.active?
    end
  end
end

If you call Authorize.can? with a policy as an additional argument it use a policy in addition to checking the task is allowed.

policy = Policies::RemoveStudyOrganisation.new(user, organisation)
Authorise.can?(user, 'remove_study_organisation', policy)

Rails

Rails add-ons will be in their own gem allowing use of Authorize outside of Rails.

Controllers

In the controllers we could wrap the above and do something like:

def create
  authorize 'view_study_organisations'
  # ...
end

This would check current_user against the given task. If it is not allowed, by default, an exception is raised.

We could also add a macro such as:

class OrganisationsController
  authorise create: 'view_study_organisations'
  
  def create
    # ...
  end
end

We could also have an after_action filter to check that authorize has been called at least once per action as a safety net.

Views

- authorized('view_study_organisations') do
- if authorized?('view_study_organisations')

Sugar

class User
  include Authorise::ActorSugar
end

user.can?('remove_study_organisation', ns: 'core')

user.roles # => [...]
user.has_role?('admin')

user.tasks # => [...]
user.has_task?('remove_study_organisation', ns: 'core')

Optimisation

Checking a user has a task via a role is slow due to SQL joins. When ever a task is added to a Role we could build a Permission model which links a user directly to a task. This could be done when a Task is added to a Role. A "denormalizer" can then build the Permission model.

Permission(actor_uuid: string, task_key: string)

The actor_uuid is a combination of actor class and id, or an uuid if the actor supports it.

This table can be indexed and would be quick to query. The array of task_keys could even be cached in memory for each user.

Subscribing to events

You can subscribe listeners to significant events:

Authorize.subscribe(AuthLogger.new(Rails.logger), prefix: 'on')

class AuthLogger
  def initialize(logger)
    @logger = logger
  end
  
  def on_role_created(role)
    @logger.info "Auth role created: #{role.label}"
  end
  
  def on_task_created(task)
    @logger.info "Auth task created: #{role.task}"
  end
  
  def on_access_denided(user, task_name, *subjects)
    subjects = subjects.map { |s| "#{s.class.name} #{s.id}" }.join(',')
    @logger.info "Auth denided for #{user.email} to #{task_name} for #{subjects}"
  end
end

A full list of events is: on_role_{created, updated, deleted}, on_task_{created, updated, deleted}, on_access_{denided, permitted}.

Persistance

The persistence for Role and Task is pluggable. By default ActiveRecord/SQL is used. For testing an in-memory adapter can be used. Either pass a symbol (which is mapped to a class namespaced in Authorize::Repos::Role) or an object/class.

Authorize.configure do |config|
  config.role_repo = :in_memory # Authorize::Repos::Role::InMemory
end
Authorize.configure do |config|
  config.role_repo = MyRoleRepo.new
end

The given object/class must respond to all, get(role_id), put(role) and delete(role). The query methods must return Authorize::Role objects and command method must return self.

Extending the Role model

Maybe you want to add additional behaviour to the Role model. One thing to note is that because the actor, usually User, is untouched by Authorize there is no user.roles method available. If you are willing to add the dependency to your actor you could do:

def roles
  Authorize::Role.find_by_user(self)
end

However we would not recommend this and if you wish to add additional behaviour to Authorize::Role model you do not monkey patch it either. Instead create your own Role model and synchronise it with Authorize::Role via the emitted events, for example:

class Role
  belongs_to :user
end

class User
  has_many :roles
end

Authorize.subscribe(RoleSyncListener.new)

class RoleSyncListener
  def on_role_updated(role)
    user_role = Role.find_or_initalize_by_key(role.key)
    user_role.update_attributes(role.attributes)
  end
  
   # repeat for other events...
end

Namespace scoping

Authorize.with_ns(:core) do |authorize|
  # all methods are the same but no need to specify the namespace

  authorize.put('some_task', label: 'Some task')
  authorize.can?(user, 'some_task')
end
@krisleech
Copy link
Author

Tasks could be self-referential so having a parent gives access to child tasks. This would be an alternative to having to check user has "manage organisations" or "view_organisations" to view organisations. It is implied that to manage also allows viewing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment