616 lines
21 KiB
Python
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)
|