adjutant/adjutant/api/v1/tasks.py

616 lines
21 KiB
Python

# Copyright (C) 2015 Catalyst IT Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from rest_framework.response import Response
from adjutant.common.user_store import IdentityManager
from adjutant.api.models import Task
from django.utils import timezone
from adjutant.api import utils
from adjutant.api.v1.views import APIViewWithLogger
from adjutant.api.v1.utils import (
send_stage_email, create_notification, create_token, create_task_hash,
add_task_id_for_roles)
from adjutant.exceptions import SerializerMissingException
from django.conf import settings
class TaskView(APIViewWithLogger):
"""
Base class for api calls that start a Task.
'default_actions' is a required hardcoded field.
The default_actions are considered the primary actions and
will always run first (in the given order). Additional actions
are defined in the settings file and will run in the order supplied,
but after the default_actions.
Default actions can be overridden in the settings file as well if
needed.
"""
default_actions = []
def get(self, request):
"""
The get method will return a json listing the actions this
view will run, and the data fields that those actions require.
"""
class_conf = settings.TASK_SETTINGS.get(
self.task_type, settings.DEFAULT_TASK_SETTINGS)
actions = (
class_conf.get('default_actions', []) or
self.default_actions[:])
actions += class_conf.get('additional_actions', [])
required_fields = []
for action in actions:
action_class, action_serializer = settings.ACTION_CLASSES[action]
for field in action_class.required:
if field not in required_fields:
required_fields.append(field)
return Response({'actions': actions,
'required_fields': required_fields})
def _instantiate_action_serializers(self, request, class_conf):
action_serializer_list = []
action_names = (
class_conf.get('default_actions', []) or
self.default_actions[:])
action_names += class_conf.get('additional_actions', [])
# instantiate all action serializers and check validity
valid = True
for action_name in action_names:
action_class, serializer_class = \
settings.ACTION_CLASSES[action_name]
# instantiate serializer class
if not serializer_class:
raise SerializerMissingException(
"No serializer defined for action %s" % action_name)
serializer = serializer_class(data=request.data)
action_serializer_list.append({
'name': action_name,
'action': action_class,
'serializer': serializer})
if serializer and not serializer.is_valid():
valid = False
if not valid:
errors = {}
for action in action_serializer_list:
if action['serializer']:
errors.update(action['serializer'].errors)
return {'errors': errors}, 400
return action_serializer_list
def _handle_duplicates(self, class_conf, hash_key):
duplicate_tasks = Task.objects.filter(
hash_key=hash_key,
completed=0,
cancelled=0)
if not duplicate_tasks:
return False
duplicate_policy = class_conf.get("duplicate_policy", "")
if duplicate_policy == "cancel":
self.logger.info(
"(%s) - Task is a duplicate - Cancelling old tasks." %
timezone.now())
for task in duplicate_tasks:
task.cancelled = True
task.save()
return False
self.logger.info(
"(%s) - Task is a duplicate - Ignoring new task." %
timezone.now())
return (
{'errors': ['Task is a duplicate of an existing task']},
409)
def process_actions(self, request):
"""
Will ensure the request data contains the required data
based on the action serializer, and if present will create
a Task and the linked actions, attaching notes
based on running of the the pre_approve validation
function on all the actions.
If during the pre_approve step at least one of the actions
sets auto_approve to True, and none of them set it to False
the approval steps will also be run.
"""
class_conf = settings.TASK_SETTINGS.get(
self.task_type, settings.DEFAULT_TASK_SETTINGS)
# Action serializers
action_serializer_list = self._instantiate_action_serializers(
request, class_conf)
if isinstance(action_serializer_list, tuple):
return action_serializer_list
hash_key = create_task_hash(self.task_type, action_serializer_list)
# Handle duplicates
duplicate_error = self._handle_duplicates(class_conf, hash_key)
if duplicate_error:
return duplicate_error
# Instantiate Task
ip_address = request.META['REMOTE_ADDR']
keystone_user = request.keystone_user
try:
task = Task.objects.create(
ip_address=ip_address,
keystone_user=keystone_user,
project_id=keystone_user['project_id'],
task_type=self.task_type,
hash_key=hash_key)
except KeyError:
task = Task.objects.create(
ip_address=ip_address,
keystone_user=keystone_user,
task_type=self.task_type,
hash_key=hash_key)
task.save()
# Instantiate actions with serializers
action_instances = []
for i, action in enumerate(action_serializer_list):
data = action['serializer'].validated_data
# construct the action class
action_instances.append(action['action'](
data=data,
task=task,
order=i
))
# We run pre_approve on the actions once we've setup all of them.
for action_instance in action_instances:
try:
action_instance.pre_approve()
except Exception as e:
import traceback
trace = traceback.format_exc()
self.logger.critical((
"(%s) - Exception escaped! %s\nTrace: \n%s") % (
timezone.now(), e, trace))
notes = {
'errors':
[("Error: '%s' while setting up task. " +
"See task itself for details.") % e]
}
create_notification(task, notes, error=True)
response_dict = {
'errors':
["Error: Something went wrong on the server. " +
"It will be looked into shortly."]
}
return response_dict, 500
# send initial confirmation email:
email_conf = class_conf.get('emails', {}).get('initial', None)
send_stage_email(task, email_conf)
action_models = task.actions
approve_list = [act.get_action().auto_approve for act in action_models]
# TODO(amelia): It would be nice to explicitly test this, however
# currently we don't have the right combinations of
# actions to allow for it.
if False in approve_list:
can_auto_approve = False
elif True in approve_list:
can_auto_approve = True
else:
can_auto_approve = False
if can_auto_approve:
task_name = self.__class__.__name__
self.logger.info("(%s) - AutoApproving %s request."
% (timezone.now(), task_name))
approval_data, status = self.approve(request, task)
# Additional information that would be otherwise expected
approval_data['task'] = task
approval_data['auto_approved'] = True
return approval_data, status
return {'task': task}, 200
def _create_token(self, task):
token = create_token(task)
try:
class_conf = settings.TASK_SETTINGS.get(
self.task_type, settings.DEFAULT_TASK_SETTINGS)
# will throw a key error if the token template has not
# been specified
email_conf = class_conf['emails']['token']
send_stage_email(task, email_conf, token)
return {'notes': ['created token']}, 200
except KeyError as e:
import traceback
trace = traceback.format_exc()
self.logger.critical((
"(%s) - Exception escaped! %s\nTrace: \n%s") % (
timezone.now(), e, trace))
notes = {
'errors':
[("Error: '%s' while sending " +
"token. See task " +
"itself for details.") % e]
}
create_notification(task, notes, error=True)
response_dict = {
'errors':
["Error: Something went wrong on the " +
"server. It will be looked into shortly."]
}
return response_dict, 500
def approve(self, request, task):
"""
Approves the task and runs the post_approve steps.
Will create a token if required, otherwise will run the
submit steps.
"""
# We approve the task before running actions,
# that way if something goes wrong we know if it was approved,
# when it was approved, and who approved it.
task.approved = True
task.approved_on = timezone.now()
task.approved_by = request.keystone_user
task.save()
action_models = task.actions
actions = [act.get_action() for act in action_models]
need_token = False
valid = all([act.valid for act in actions])
if not valid:
return {'errors': ['actions invalid']}, 400
# post_approve all actions
for action in actions:
try:
action.post_approve()
except Exception as e:
import traceback
trace = traceback.format_exc()
self.logger.critical((
"(%s) - Exception escaped! %s\nTrace: \n%s") % (
timezone.now(), e, trace))
notes = {
'errors':
[("Error: '%s' while approving task. " +
"See task itself for details.") % e]
}
create_notification(task, notes, error=True)
response_dict = {
'errors':
["Error: Something went wrong on the server. " +
"It will be looked into shortly."]
}
return response_dict, 500
valid = all([act.valid for act in actions])
if not valid:
return {'errors': ['actions invalid']}, 400
need_token = any([act.need_token for act in actions])
if need_token:
return self._create_token(task)
# submit all actions
for action in actions:
try:
action.submit({})
except Exception as e:
import traceback
trace = traceback.format_exc()
self.logger.critical((
"(%s) - Exception escaped! %s\nTrace: \n%s") % (
timezone.now(), e, trace))
notes = {
'errors':
[("Error: '%s' while submitting " +
"task. See task " +
"itself for details.") % e]
}
create_notification(task, notes, error=True)
response_dict = {
'errors':
["Error: Something went wrong on the " +
"server. It will be looked into shortly."]
}
return response_dict, 500
task.completed = True
task.completed_on = timezone.now()
task.save()
# Sending confirmation email:
class_conf = settings.TASK_SETTINGS.get(
self.task_type, settings.DEFAULT_TASK_SETTINGS)
email_conf = class_conf.get(
'emails', {}).get('completed', None)
send_stage_email(task, email_conf)
return {'notes': ["Task completed successfully."]}, 200
# NOTE(adriant): We should deprecate these TaskViews properly and switch tests
# to work against the openstack ones. One option is making these abstract
# classes, so we retain the code here, but make them useless without extension.
class CreateProject(TaskView):
task_type = "create_project"
default_actions = ["NewProjectWithUserAction", ]
def post(self, request, format=None):
"""
Unauthenticated endpoint bound primarily to NewProjectWithUser.
This process requires approval, so this will validate
incoming data and create a task to be approved
later.
"""
self.logger.info("(%s) - Starting new project task." %
timezone.now())
class_conf = settings.TASK_SETTINGS.get(self.task_type, {})
# we need to set the region the resources will be created in:
request.data['region'] = class_conf.get('default_region')
# parent_id for new project, if null defaults to domain:
request.data['parent_id'] = class_conf.get('default_parent_id')
processed, status = self.process_actions(request)
errors = processed.get('errors', None)
if errors:
self.logger.info("(%s) - Validation errors with task." %
timezone.now())
return Response(errors, status=status)
notes = {
'notes':
['New task for CreateProject.']
}
create_notification(processed['task'], notes)
self.logger.info("(%s) - Task created." % timezone.now())
response_dict = {'notes': ['task created']}
add_task_id_for_roles(request, processed, response_dict, ['admin'])
return Response(response_dict, status=status)
class InviteUser(TaskView):
task_type = "invite_user"
default_actions = ['NewUserAction', ]
@utils.mod_or_admin
def get(self, request):
return super(InviteUser, self).get(request)
@utils.mod_or_admin
def post(self, request, format=None):
"""
Invites a user to the current tenant.
This endpoint requires either Admin access or the
request to come from a project_admin|project_mod.
As such this Task is considered pre-approved.
"""
self.logger.info("(%s) - New AttachUser request." % timezone.now())
# Default project_id to the keystone user's project
if ('project_id' not in request.data or
request.data['project_id'] is None):
request.data['project_id'] = request.keystone_user['project_id']
processed, status = self.process_actions(request)
errors = processed.get('errors', None)
if errors:
self.logger.info("(%s) - Validation errors with task." %
timezone.now())
if isinstance(errors, dict):
return Response(errors, status=status)
return Response({'errors': errors}, status=status)
response_dict = {'notes': processed['notes']}
add_task_id_for_roles(request, processed, response_dict, ['admin'])
return Response(response_dict, status=status)
class ResetPassword(TaskView):
task_type = "reset_password"
default_actions = ['ResetUserPasswordAction', ]
def post(self, request, format=None):
"""
Unauthenticated endpoint bound to the password reset action.
This will submit and approve a password reset request.
---
parameters:
- name: email
required: true
type: string
description: The email of the user to reset
- name: username
required: false
type: string
description: The username of the user, not required if using
USERNAME_IS_PASSWORD
responseMessages:
- code: 400
message: Validation Errors
- code: 200
message: Success. Does not indicate user exists.
"""
self.logger.info("(%s) - New ResetUser request." % timezone.now())
processed, status = self.process_actions(request)
errors = processed.get('errors', None)
if errors:
self.logger.info("(%s) - Validation errors with task." %
timezone.now())
return Response(errors, status=status)
task = processed['task']
self.logger.info("(%s) - AutoApproving Resetuser request."
% timezone.now())
# NOTE(amelia): Not using auto approve due to security implications
# as it will return all errors including whether the user exists
self.approve(request, task)
response_dict = {'notes': [
"If user with email exists, reset token will be issued."]}
add_task_id_for_roles(request, processed, response_dict, ['admin'])
return Response(response_dict, status=200)
class EditUser(TaskView):
task_type = "edit_user"
default_actions = ['EditUserRolesAction', ]
@utils.mod_or_admin
def get(self, request):
class_conf = settings.TASK_SETTINGS.get(
self.task_type, settings.DEFAULT_TASK_SETTINGS)
action_names = (
class_conf.get('default_actions', []) or
self.default_actions[:])
action_names += class_conf.get('additional_actions', [])
role_blacklist = class_conf.get('role_blacklist', [])
required_fields = set()
for action_name in action_names:
action_class, action_serializer = \
settings.ACTION_CLASSES[action_name]
required_fields |= action_class.required
user_list = []
id_manager = IdentityManager()
project_id = request.keystone_user['project_id']
project = id_manager.get_project(project_id)
# todo: move to interface class
for user in id_manager.list_users(project):
skip = False
roles = []
for role in user.roles:
if role.name in role_blacklist:
skip = True
continue
roles.append(role.name)
if skip:
continue
user_list.append({"username": user.username,
"email": user.username,
"roles": roles})
return Response({'actions': action_names,
'required_fields': list(required_fields),
'users': user_list})
@utils.mod_or_admin
def post(self, request, format=None):
"""
This endpoint requires either mod access or the
request to come from a project_admin.
As such this Task is considered pre-approved.
Runs process_actions, then does the approve step and
post_approve validation, and creates a Token if valid.
"""
self.logger.info("(%s) - New EditUser request." % timezone.now())
processed, status = self.process_actions(request)
errors = processed.get('errors', None)
if errors:
self.logger.info("(%s) - Validation errors with task." %
timezone.now())
return Response(errors, status=status)
response_dict = {'notes': processed.get('notes')}
add_task_id_for_roles(request, processed, response_dict, ['admin'])
return Response(response_dict, status=status)
class UpdateEmail(TaskView):
task_type = "update_email"
default_actions = ["UpdateUserEmailAction", ]
@utils.authenticated
def post(self, request, format=None):
"""
Endpoint bound to the update email action.
This will submit and approve an update email action.
"""
request.data['user_id'] = request.keystone_user['user_id']
processed, status = self.process_actions(request)
errors = processed.get('errors', None)
if errors:
self.logger.info("(%s) - Validation errors with task." %
timezone.now())
return Response(errors, status=status)
response_dict = {'notes': processed['notes']}
return Response(response_dict, status=status)