Only users with attract-user role can use Attract
Subscribers without that role still have read-only access to Attract, assuming they have access to the project at all. NOTE: this only handles the web interface. API calls are still governed by the nodes permission system, which doesn't currently allow these kinds of role-based user checks.
This commit is contained in:
@@ -9,6 +9,7 @@ from pillar.web.nodes.routes import url_for_node
|
|||||||
|
|
||||||
import pillarsdk
|
import pillarsdk
|
||||||
|
|
||||||
|
import attract.auth
|
||||||
import attract.tasks
|
import attract.tasks
|
||||||
import attract.shots_and_assets
|
import attract.shots_and_assets
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class AttractExtension(PillarExtension):
|
|||||||
self._log = logging.getLogger('%s.AttractExtension' % __name__)
|
self._log = logging.getLogger('%s.AttractExtension' % __name__)
|
||||||
self.task_manager = attract.tasks.TaskManager()
|
self.task_manager = attract.tasks.TaskManager()
|
||||||
self.shot_manager = attract.shots_and_assets.ShotAssetManager()
|
self.shot_manager = attract.shots_and_assets.ShotAssetManager()
|
||||||
|
self.auth = attract.auth.Auth()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
|
107
attract/auth.py
Normal file
107
attract/auth.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import enum
|
||||||
|
import flask
|
||||||
|
|
||||||
|
import attr
|
||||||
|
import bson
|
||||||
|
|
||||||
|
from pillar import attrs_extra
|
||||||
|
|
||||||
|
# Having either of these roles is minimum requirement for using Attract.
|
||||||
|
ROLES_REQUIRED_TO_USE_ATTRACT = {'attract-user', 'admin'}
|
||||||
|
ROLES_REQUIRED_TO_VIEW_ATTRACT = {'admin', 'subscriber', 'demo'}
|
||||||
|
|
||||||
|
# Having any of these methods on a project means you can use Attract.
|
||||||
|
# Prerequisite: the project is set up for Attract and has a Manager assigned to it.
|
||||||
|
PROJECT_METHODS_TO_USE_ATTRACT = {'PUT'}
|
||||||
|
|
||||||
|
|
||||||
|
class Actions(enum.Enum):
|
||||||
|
VIEW = 'view'
|
||||||
|
USE = 'use'
|
||||||
|
|
||||||
|
|
||||||
|
# Required roles for a given action.
|
||||||
|
req_roles = {
|
||||||
|
Actions.VIEW: ROLES_REQUIRED_TO_VIEW_ATTRACT,
|
||||||
|
Actions.USE: ROLES_REQUIRED_TO_USE_ATTRACT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s
|
||||||
|
class Auth(object):
|
||||||
|
"""Handles authorization for Attract."""
|
||||||
|
|
||||||
|
_log = attrs_extra.log('%s.Auth' % __name__)
|
||||||
|
Actions = Actions # this allows using current_attract.auth.Actions
|
||||||
|
|
||||||
|
def current_user_is_attract_user(self) -> bool:
|
||||||
|
"""Returns True iff the current user has Attract User role."""
|
||||||
|
|
||||||
|
from pillar.api.utils.authentication import current_user_id
|
||||||
|
|
||||||
|
return self.user_is_attract_user(current_user_id())
|
||||||
|
|
||||||
|
def user_is_attract_user(self, user_id: bson.ObjectId) -> bool:
|
||||||
|
"""Returns True iff the user has Attract User role."""
|
||||||
|
|
||||||
|
from pillar import current_app
|
||||||
|
|
||||||
|
assert isinstance(user_id, bson.ObjectId)
|
||||||
|
|
||||||
|
# TODO: move role checking code to Pillar.
|
||||||
|
users_coll = current_app.db('users')
|
||||||
|
user = users_coll.find_one({'_id': user_id}, {'roles': 1})
|
||||||
|
if not user:
|
||||||
|
self._log.debug('user_is_attract_user: User %s not found', user_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_roles = set(user.get('roles', []))
|
||||||
|
require_roles = set(ROLES_REQUIRED_TO_USE_ATTRACT)
|
||||||
|
|
||||||
|
intersection = require_roles.intersection(user_roles)
|
||||||
|
return bool(intersection)
|
||||||
|
|
||||||
|
def current_user_may(self, action: Actions) -> bool:
|
||||||
|
"""Returns True iff the user is authorised to use/view Attract on the current project.
|
||||||
|
|
||||||
|
Requires that determine_user_rights() was called before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
attract_rights = flask.g.attract_rights
|
||||||
|
except AttributeError:
|
||||||
|
self._log.error('current_user_may() called without previous call '
|
||||||
|
'to current_user_rights()')
|
||||||
|
return False
|
||||||
|
|
||||||
|
return action in attract_rights
|
||||||
|
|
||||||
|
def determine_user_rights(self, project_id: bson.ObjectId):
|
||||||
|
"""Updates g.attract_rights to reflect the current user's usage rights.
|
||||||
|
|
||||||
|
g.attract_rights is a frozenset that contains zero or more Actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pillar.api.utils.authorization import user_matches_roles
|
||||||
|
from pillar.api.utils.authentication import current_user_id
|
||||||
|
from pillar.api.projects.utils import user_rights_in_project
|
||||||
|
|
||||||
|
user_id = current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
self._log.debug('Anonymous user never has access to Attract.')
|
||||||
|
flask.g.attract_rights = frozenset()
|
||||||
|
return
|
||||||
|
|
||||||
|
rights = set()
|
||||||
|
for action in Actions:
|
||||||
|
roles = req_roles[action]
|
||||||
|
if user_matches_roles(roles):
|
||||||
|
rights.add(action)
|
||||||
|
|
||||||
|
# TODO Sybren: possibly split this up into a manager-fetching func + authorisation func.
|
||||||
|
# TODO: possibly store the user rights on the current project in the current_user object?
|
||||||
|
allowed_on_proj = user_rights_in_project(project_id)
|
||||||
|
if not allowed_on_proj.intersection(PROJECT_METHODS_TO_USE_ATTRACT):
|
||||||
|
rights.discard(Actions.USE)
|
||||||
|
|
||||||
|
flask.g.attract_rights = frozenset(rights)
|
@@ -6,6 +6,7 @@ import flask_login
|
|||||||
import werkzeug.exceptions as wz_exceptions
|
import werkzeug.exceptions as wz_exceptions
|
||||||
|
|
||||||
from pillar.auth import current_web_user as current_user
|
from pillar.auth import current_web_user as current_user
|
||||||
|
from pillar.api.utils import str2id
|
||||||
from pillar.web.utils import attach_project_pictures
|
from pillar.web.utils import attach_project_pictures
|
||||||
import pillar.web.subquery
|
import pillar.web.subquery
|
||||||
from pillar.web.system_util import pillar_api
|
from pillar.web.system_util import pillar_api
|
||||||
@@ -130,6 +131,13 @@ def attract_project_view(extra_project_projections: dict=None, extension_props=F
|
|||||||
if not is_attract:
|
if not is_attract:
|
||||||
return error_project_not_setup_for_attract()
|
return error_project_not_setup_for_attract()
|
||||||
|
|
||||||
|
# Check user access.
|
||||||
|
auth = current_attract.auth
|
||||||
|
auth.determine_user_rights(str2id(project['_id']))
|
||||||
|
if not auth.current_user_may(auth.Actions.VIEW):
|
||||||
|
log.info('User %s not allowed to use Attract', current_user)
|
||||||
|
raise wz_exceptions.Forbidden()
|
||||||
|
|
||||||
if extension_props:
|
if extension_props:
|
||||||
pprops = project.extension_props.attract
|
pprops = project.extension_props.attract
|
||||||
return wrapped(project, pprops, *args, **kwargs)
|
return wrapped(project, pprops, *args, **kwargs)
|
||||||
|
@@ -32,6 +32,8 @@ def for_project(project, attract_props, task_id=None, asset_id=None):
|
|||||||
attract_props['task_types'][node_type_name],
|
attract_props['task_types'][node_type_name],
|
||||||
project, attract_props, task_id, asset_id)
|
project, attract_props, task_id, asset_id)
|
||||||
|
|
||||||
|
can_create = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
||||||
|
|
||||||
return render_template('attract/assets/for_project.html',
|
return render_template('attract/assets/for_project.html',
|
||||||
assets=assets,
|
assets=assets,
|
||||||
tasks_for_assets=tasks_for_assets,
|
tasks_for_assets=tasks_for_assets,
|
||||||
@@ -39,7 +41,9 @@ def for_project(project, attract_props, task_id=None, asset_id=None):
|
|||||||
open_task_id=task_id,
|
open_task_id=task_id,
|
||||||
open_asset_id=asset_id,
|
open_asset_id=asset_id,
|
||||||
project=project,
|
project=project,
|
||||||
attract_props=attract_props)
|
attract_props=attract_props,
|
||||||
|
can_create_task=can_create,
|
||||||
|
can_create_asset=can_create)
|
||||||
|
|
||||||
|
|
||||||
@perproject_blueprint.route('/<asset_id>')
|
@perproject_blueprint.route('/<asset_id>')
|
||||||
@@ -50,12 +54,15 @@ def view_asset(project, attract_props, asset_id):
|
|||||||
|
|
||||||
asset, node_type = routes_common.view_node(project, asset_id, node_type_asset['name'])
|
asset, node_type = routes_common.view_node(project, asset_id, node_type_asset['name'])
|
||||||
|
|
||||||
|
auth = current_attract.auth
|
||||||
|
can_edit = auth.current_user_may(auth.Actions.USE) and 'PUT' in asset.allowed_methods
|
||||||
|
|
||||||
return render_template('attract/assets/view_asset_embed.html',
|
return render_template('attract/assets/view_asset_embed.html',
|
||||||
asset=asset,
|
asset=asset,
|
||||||
project=project,
|
project=project,
|
||||||
asset_node_type=node_type,
|
asset_node_type=node_type,
|
||||||
attract_props=attract_props,
|
attract_props=attract_props,
|
||||||
can_edit='PUT' in asset.allowed_methods)
|
can_edit=can_edit)
|
||||||
|
|
||||||
|
|
||||||
@perproject_blueprint.route('/<asset_id>', methods=['POST'])
|
@perproject_blueprint.route('/<asset_id>', methods=['POST'])
|
||||||
|
@@ -39,6 +39,7 @@ def for_project(project, attract_props, task_id=None, shot_id=None):
|
|||||||
for shot in shots
|
for shot in shots
|
||||||
if shot.properties.used_in_edit),
|
if shot.properties.used_in_edit),
|
||||||
}
|
}
|
||||||
|
can_create_task = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
||||||
|
|
||||||
return render_template('attract/shots/for_project.html',
|
return render_template('attract/shots/for_project.html',
|
||||||
shots=shots,
|
shots=shots,
|
||||||
@@ -48,7 +49,8 @@ def for_project(project, attract_props, task_id=None, shot_id=None):
|
|||||||
open_shot_id=shot_id,
|
open_shot_id=shot_id,
|
||||||
project=project,
|
project=project,
|
||||||
attract_props=attract_props,
|
attract_props=attract_props,
|
||||||
stats=stats)
|
stats=stats,
|
||||||
|
can_create_task=can_create_task)
|
||||||
|
|
||||||
|
|
||||||
@perproject_blueprint.route('/<shot_id>')
|
@perproject_blueprint.route('/<shot_id>')
|
||||||
@@ -58,13 +60,14 @@ def view_shot(project, attract_props, shot_id):
|
|||||||
return for_project(project, attract_props, shot_id=shot_id)
|
return for_project(project, attract_props, shot_id=shot_id)
|
||||||
|
|
||||||
shot, node_type = routes_common.view_node(project, shot_id, node_type_shot['name'])
|
shot, node_type = routes_common.view_node(project, shot_id, node_type_shot['name'])
|
||||||
|
can_edit = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
||||||
|
|
||||||
return render_template('attract/shots/view_shot_embed.html',
|
return render_template('attract/shots/view_shot_embed.html',
|
||||||
shot=shot,
|
shot=shot,
|
||||||
project=project,
|
project=project,
|
||||||
shot_node_type=node_type,
|
shot_node_type=node_type,
|
||||||
attract_props=attract_props,
|
attract_props=attract_props,
|
||||||
can_edit='PUT' in shot.allowed_methods)
|
can_edit=can_edit and 'PUT' in shot.allowed_methods)
|
||||||
|
|
||||||
|
|
||||||
@perproject_blueprint.route('/<shot_id>', methods=['POST'])
|
@perproject_blueprint.route('/<shot_id>', methods=['POST'])
|
||||||
@@ -73,6 +76,9 @@ def save(project, shot_id):
|
|||||||
log.info('Saving shot %s', shot_id)
|
log.info('Saving shot %s', shot_id)
|
||||||
log.debug('Form data: %s', request.form)
|
log.debug('Form data: %s', request.form)
|
||||||
|
|
||||||
|
if not current_attract.auth.current_user_may(current_attract.auth.Actions.USE):
|
||||||
|
raise wz_exceptions.Forbidden()
|
||||||
|
|
||||||
shot_dict = request.form.to_dict()
|
shot_dict = request.form.to_dict()
|
||||||
current_attract.shot_manager.edit_shot(shot_id, **shot_dict)
|
current_attract.shot_manager.edit_shot(shot_id, **shot_dict)
|
||||||
|
|
||||||
@@ -86,6 +92,9 @@ def save(project, shot_id):
|
|||||||
@perproject_blueprint.route('/create', methods=['POST', 'GET'])
|
@perproject_blueprint.route('/create', methods=['POST', 'GET'])
|
||||||
@attract_project_view()
|
@attract_project_view()
|
||||||
def create_shot(project):
|
def create_shot(project):
|
||||||
|
if not current_attract.auth.current_user_may(current_attract.auth.Actions.USE):
|
||||||
|
raise wz_exceptions.Forbidden()
|
||||||
|
|
||||||
shot = current_attract.shot_manager.create_shot(project)
|
shot = current_attract.shot_manager.create_shot(project)
|
||||||
|
|
||||||
resp = flask.make_response()
|
resp = flask.make_response()
|
||||||
|
@@ -36,6 +36,9 @@ def index():
|
|||||||
|
|
||||||
@blueprint.route('/<task_id>', methods=['DELETE'])
|
@blueprint.route('/<task_id>', methods=['DELETE'])
|
||||||
def delete(task_id):
|
def delete(task_id):
|
||||||
|
if not current_attract.auth.current_user_may(current_attract.auth.Actions.USE):
|
||||||
|
raise wz_exceptions.Forbidden()
|
||||||
|
|
||||||
log.info('Deleting task %s', task_id)
|
log.info('Deleting task %s', task_id)
|
||||||
|
|
||||||
etag = request.form['etag']
|
etag = request.form['etag']
|
||||||
@@ -48,10 +51,13 @@ def delete(task_id):
|
|||||||
@attract_project_view()
|
@attract_project_view()
|
||||||
def for_project(project, task_id=None):
|
def for_project(project, task_id=None):
|
||||||
tasks = current_attract.task_manager.tasks_for_project(project['_id'])
|
tasks = current_attract.task_manager.tasks_for_project(project['_id'])
|
||||||
|
can_create_task = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
||||||
|
|
||||||
return render_template('attract/tasks/for_project.html',
|
return render_template('attract/tasks/for_project.html',
|
||||||
tasks=tasks['_items'],
|
tasks=tasks['_items'],
|
||||||
open_task_id=task_id,
|
open_task_id=task_id,
|
||||||
project=project)
|
project=project,
|
||||||
|
can_create_task=can_create_task)
|
||||||
|
|
||||||
|
|
||||||
@perproject_blueprint.route('/<task_id>')
|
@perproject_blueprint.route('/<task_id>')
|
||||||
@@ -76,7 +82,9 @@ def view_task(project, attract_props, task_id):
|
|||||||
task.properties.due_date = parser.parse('%s' % task.properties.due_date)
|
task.properties.due_date = parser.parse('%s' % task.properties.due_date)
|
||||||
|
|
||||||
# Fetch project users so that we can assign them tasks
|
# Fetch project users so that we can assign them tasks
|
||||||
can_edit = 'PUT' in task.allowed_methods
|
auth = current_attract.auth
|
||||||
|
can_edit = 'PUT' in task.allowed_methods and auth.current_user_may(auth.Actions.USE)
|
||||||
|
|
||||||
if can_edit:
|
if can_edit:
|
||||||
users = project.get_users(api=api)
|
users = project.get_users(api=api)
|
||||||
project.users = users['_items']
|
project.users = users['_items']
|
||||||
|
@@ -6,7 +6,9 @@
|
|||||||
.col_header.item-list-header
|
.col_header.item-list-header
|
||||||
a.item-project(href="{{url_for('projects.view', project_url=project.url)}}") {{ project.name }}
|
a.item-project(href="{{url_for('projects.view', project_url=project.url)}}") {{ project.name }}
|
||||||
span.item-extra Assets ({{ assets | count }})
|
span.item-extra Assets ({{ assets | count }})
|
||||||
|
| {% if can_create_asset %}
|
||||||
a#item-add(href="javascript:asset_create('{{ project.url }}');") + Create Asset
|
a#item-add(href="javascript:asset_create('{{ project.url }}');") + Create Asset
|
||||||
|
| {% endif %}
|
||||||
|
|
||||||
.item-list.asset.col-scrollable
|
.item-list.asset.col-scrollable
|
||||||
.table
|
.table
|
||||||
@@ -61,8 +63,7 @@
|
|||||||
span {{ task.properties.status[0] }}
|
span {{ task.properties.status[0] }}
|
||||||
| #}
|
| #}
|
||||||
| {% endfor %}
|
| {% endfor %}
|
||||||
//- Dirty hack, assume a user can create a task for a asset if they can edit the asset.
|
| {% if can_create_task %}
|
||||||
| {% if 'PUT' in asset.allowed_methods %}
|
|
||||||
button.task-add(
|
button.task-add(
|
||||||
title="Add a new '{{ task_type }}' task",
|
title="Add a new '{{ task_type }}' task",
|
||||||
class="task-add-link {% if tasks_for_assets[asset._id][task_type] %}hidden{% endif %}",
|
class="task-add-link {% if tasks_for_assets[asset._id][task_type] %}hidden{% endif %}",
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
form#item_form(onsubmit="return asset_save('{{asset._id}}', '{{ url_for('attract.assets.perproject.save', project_url=project['url'], asset_id=asset._id) }}')")
|
form#item_form(onsubmit="return asset_save('{{asset._id}}', '{{ url_for('attract.assets.perproject.save', project_url=project['url'], asset_id=asset._id) }}')")
|
||||||
input(type='hidden',name='_etag',value='{{ asset._etag }}')
|
input(type='hidden',name='_etag',value='{{ asset._etag }}')
|
||||||
.input-group
|
.input-group
|
||||||
| {% if 'PUT' in asset.allowed_methods %}
|
| {% if can_edit %}
|
||||||
input.item-name(
|
input.item-name(
|
||||||
name="name",
|
name="name",
|
||||||
type="text",
|
type="text",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
title="Copy ID to clipboard")
|
title="Copy ID to clipboard")
|
||||||
| ID
|
| ID
|
||||||
|
|
||||||
| {% if 'PUT' in asset.allowed_methods %}
|
| {% if can_edit %}
|
||||||
.input-group
|
.input-group
|
||||||
textarea#item-description.input-transparent(
|
textarea#item-description.input-transparent(
|
||||||
name="description",
|
name="description",
|
||||||
@@ -108,7 +108,7 @@ script.
|
|||||||
|
|
||||||
$('.js-help').openModalUrl('Help', "{{ url_for('attract.help', project_url=project.url) }}");
|
$('.js-help').openModalUrl('Help', "{{ url_for('attract.help', project_url=project.url) }}");
|
||||||
|
|
||||||
{% if 'PUT' in asset.allowed_methods %}
|
{% if can_edit %}
|
||||||
/* Resize textareas */
|
/* Resize textareas */
|
||||||
var textAreaFields = $('#item-description, #item-notes');
|
var textAreaFields = $('#item-description, #item-notes');
|
||||||
|
|
||||||
|
@@ -60,8 +60,7 @@
|
|||||||
span {{ task.properties.status[0] }}
|
span {{ task.properties.status[0] }}
|
||||||
| #}
|
| #}
|
||||||
| {% endfor %}
|
| {% endfor %}
|
||||||
//- Dirty hack, assume a user can create a task for a shot if they can edit the shot.
|
| {% if can_create_task %}
|
||||||
| {% if 'PUT' in shot.allowed_methods %}
|
|
||||||
button.task-add(
|
button.task-add(
|
||||||
title="Add a new '{{ task_type }}' task",
|
title="Add a new '{{ task_type }}' task",
|
||||||
class="task-add-link {% if tasks_for_shots[shot._id][task_type] %}hidden{% endif %}"
|
class="task-add-link {% if tasks_for_shots[shot._id][task_type] %}hidden{% endif %}"
|
||||||
|
@@ -6,8 +6,7 @@
|
|||||||
.col_header.item-list-header
|
.col_header.item-list-header
|
||||||
a.item-project(href="{{url_for('projects.view', project_url=project.url)}}") {{ project.name }}
|
a.item-project(href="{{url_for('projects.view', project_url=project.url)}}") {{ project.name }}
|
||||||
span.item-extra Tasks ({{ tasks | count }})
|
span.item-extra Tasks ({{ tasks | count }})
|
||||||
//- Dirty hack, assume a user can create a task if they can edit the project.
|
| {% if can_create_task %}
|
||||||
| {% if 'PUT' in project.allowed_methods %}
|
|
||||||
a#item-add(href="javascript:task_create(undefined, 'generic');") + Create Task
|
a#item-add(href="javascript:task_create(undefined, 'generic');") + Create Task
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
.item-list.task.col-list.col-scrollable
|
.item-list.task.col-list.col-scrollable
|
||||||
|
Reference in New Issue
Block a user