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
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.
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.
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.
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.
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
endIf 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 add-ons will be in their own gem allowing use of Authorize outside of Rails.
In the controllers we could wrap the above and do something like:
def create
authorize 'view_study_organisations'
# ...
endThis 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
endWe could also have an after_action filter to check that authorize has been called at least once per action as a safety net.
- authorized('view_study_organisations') do- if authorized?('view_study_organisations')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')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.
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
endA full list of events is: on_role_{created, updated, deleted}, on_task_{created, updated, deleted}, on_access_{denided, permitted}.
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
endAuthorize.configure do |config|
config.role_repo = MyRoleRepo.new
endThe 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.
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)
endHowever 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...
endAuthorize.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
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.