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
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 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'
# ...
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.
- 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
end
A 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
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
.
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
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
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.