Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ use_dev_supervisor.txt
.idea/*
*.unison.tmp
*.#
/awx/ui/.ui-built
/ascender/ui/.ui-built
/_build/
/_build_kube_dev/
/Dockerfile
Expand Down
3 changes: 0 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ LDAP ?= false
VAULT ?= false
# If set to true docker-compose will also start a hashicorp vault instance with TLS enabled
VAULT_TLS ?= false
# If set to true docker-compose will also start a tacacs+ instance
TACACS ?= false

EDITABLE_DEPENDENCIES ?= false

Expand Down Expand Up @@ -519,7 +517,6 @@ docker-compose-sources: .git/hooks/pre-commit
-e enable_ldap=$(LDAP) \
-e enable_vault=$(VAULT) \
-e vault_tls=$(VAULT_TLS) \
-e enable_tacacs=$(TACACS) \
-e install_editable_dependencies=$(EDITABLE_DEPENDENCIES) \
$(EXTRA_SOURCES_ANSIBLE_OPTS)

Expand Down
2 changes: 1 addition & 1 deletion ascender/api/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
label=_('Allow External Users to Create OAuth2 Tokens'),
help_text=_(
'For security reasons, users from external auth providers (LDAP, SAML, '
'SSO, Radius, and others) are not allowed to create OAuth2 tokens. '
'SSO, and others) are not allowed to create OAuth2 tokens. '
'To change this behavior, enable this setting. Existing tokens will '
'not be deleted when this setting is toggled off.'
),
Expand Down
70 changes: 68 additions & 2 deletions ascender/api/serializers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from rest_framework import validators
from polymorphic.models import PolymorphicModel
from ascender.dab.lib.utils.models import get_type_for_model
from ascender.main.access import get_user_capabilities
from ascender.main.access import UnifiedJobAccess, get_user_capabilities
from ascender.main.models import (
AdHocCommand,
InventorySource,
Expand Down Expand Up @@ -458,13 +458,36 @@ def _obj_capability_dict(self, obj):
self.context['capability_map'] = prefetch_page_capabilities(model, qs, prefetch_list, view.request.user)
if obj.id in self.context['capability_map']:
capabilities_cache = self.context['capability_map'][obj.id]
if self.parent:
capabilities_cache = {**capabilities_cache, **self.get_page_capabilities(obj)}
return get_user_capabilities(
view.request.user, obj, method_list=self.show_capabilities, parent_obj=parent_obj, capabilities_cache=capabilities_cache
)
else:
# Contextual information to produce user_capabilities doesn't exist
return {}

def get_page_capabilities(self, obj):
"""
Capabilities answered for a whole page of a list view at once.

capabilities_prefetch covers what can be said as "the user holds this
role on the row or on a related row". A serializer whose capabilities
need more than that overrides this to answer them for the page in a
batch, on the first row, and hands each row its share. Only called
when the serializer is a row of a list, so self.parent.instance is the
page. The answers are used exactly as the capabilities_prefetch ones:
get_user_capabilities takes them in place of its per row check.

Args:
obj: The row being serialized.

Returns:
dict: Display method to bool, for the capabilities answered in
bulk for this row; empty when the per row path should decide.
"""
return {}

def get_created(self, obj):
if obj is None:
return None
Expand Down Expand Up @@ -713,8 +736,15 @@ def get_summary_fields(self, obj):
return summary_fields


# Context key under which a page of unified jobs keeps the cancel capability of
# its rows, computed once for the page by UnifiedJobSerializer.
CANCEL_ANSWERS = 'unified_job_cancel_answers'


class UnifiedJobSerializer(BaseSerializer):
show_capabilities = ['start', 'delete']
# cancel is computed with the job's status as well as the user's role, so
# the interface can offer Cancel only where the api would accept it.
show_capabilities = ['start', 'delete', 'cancel']
event_processing_finished = serializers.BooleanField(
help_text=_('Indicates whether all of the events generated by this unified job have been saved to the database.'), read_only=True
)
Expand Down Expand Up @@ -802,6 +832,38 @@ def get_summary_fields(self, obj):

return summary_fields

def get_page_capabilities(self, obj):
"""
The cancel capability of this row, answered for the whole page at once.

For anyone but a superuser, cancel on a running or pending job reads
the job's template, project or inventory and asks a role question, a
few queries for every such row. The first row asks
UnifiedJobAccess.get_cancel_capabilities for the whole page and leaves
the answers in the context, shared by the polymorphic row serializers
of /api/v2/unified_jobs/ as much as by the rows of /api/v2/jobs/. A row
it did not answer is checked on its own, as it would be without this.

Args:
obj: The unified job being serialized.

Returns:
dict: {'cancel': bool} when the page answered this row, else empty.
"""
if 'cancel' not in self.show_capabilities:
return {}
answers = self.context.get(CANCEL_ANSWERS)
if answers is None:
request = self.context.get('request', None)
page = getattr(self.parent, 'instance', None)
if request is None or page is None:
return {}
answers = UnifiedJobAccess(request.user).get_cancel_capabilities(list(page))
self.context[CANCEL_ANSWERS] = answers
if obj.pk in answers:
return {'cancel': answers[obj.pk]}
return {}

def get_sub_serializer(self, obj):
from ascender.api.serializers.ad_hoc_command import AdHocCommandSerializer
from ascender.api.serializers.inventory import InventoryUpdateSerializer
Expand Down Expand Up @@ -925,6 +987,10 @@ def to_representation(self, obj):
serializer_class = self.get_sub_serializer(obj)
if serializer_class:
serializer = serializer_class(instance=obj, context=self.context)
# The row serializer needs the page for the capabilities it answers
# in bulk (see get_page_capabilities), as UnifiedJobSerializer does.
if self.parent:
serializer.parent = self.parent
ret = serializer.to_representation(obj)
else:
ret = super(UnifiedJobListSerializer, self).to_representation(obj)
Expand Down
8 changes: 8 additions & 0 deletions ascender/api/serializers/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ def get_summary_fields(self, obj):


class LabelSerializer(BaseSerializer):
# edit and delete are both an admin of the label's organization, which the
# interface reads to offer the actions. delete does not say whether the
# label is in use: that is a state the DELETE answers with 409, and working
# it out here would cost a query for every label on a page. The admin check
# is prefetched for the page, and delete copies edit.
show_capabilities = ['edit', 'delete']
capabilities_prefetch = [{'edit': 'organization.admin'}]

class Meta:
model = Label
fields = ('*', '-description', 'organization')
Expand Down
184 changes: 182 additions & 2 deletions ascender/api/serializers/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
"""

from datetime import timedelta
from django.db.models import prefetch_related_objects
from django.db.models.manager import BaseManager
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from ascender.main.access import WorkflowApprovalAccess, WorkflowJobAccess, get_user_capabilities
from ascender.main.models import (
Inventory,
JobTemplate,
Label,
UnifiedJob,
WorkflowApproval,
WorkflowApprovalTemplate,
WorkflowApprovalVote,
Expand Down Expand Up @@ -223,20 +227,142 @@ class Meta:
fields = []


# ----------------------------------------------------------------------------
# Workflow approvals
# ----------------------------------------------------------------------------

# Context keys under which a page of approvals carries its permission answers,
# computed once for the page by WorkflowApprovalPageSerializer.
APPROVE_OR_DENY_ANSWERS = 'workflow_approval_approve_or_deny_answers'
CANCEL_WORKFLOW_ANSWERS = 'workflow_approval_cancel_workflow_answers'


def waiting_workflow_job(approval):
"""
The workflow job an approval waits in, if it waits in one.

Only an approval a workflow spawned waits in a workflow job, and it reaches
that job through the workflow job node that ran it. Either link can be
missing, a node that no longer exists or one with no workflow job, and both
read as the approval waiting in nothing, which is what the two callers want.

Args:
approval: The workflow approval to look from.

Returns:
WorkflowJob or None: The workflow job, or None when the approval was
not spawned by a workflow or the link to its job no longer exists.
"""
if not approval.spawned_by_workflow:
return None
try:
return approval.unified_job_node.workflow_job
except UnifiedJob.unified_job_node.RelatedObjectDoesNotExist:
return None


class WorkflowApprovalPageSerializer(serializers.ListSerializer):
"""
Serializes a page of approvals with its permission checks done once.

can_approve_or_deny and can_cancel_workflow each ask role questions about
every approval, several queries a row for anyone but a superuser. Before
the rows are serialized this asks the access classes for the whole page at
once and leaves the answers in the serializer context, where the row
methods pick them up. A row the access classes did not answer is checked
on its own, as it would be without this.

BaseSerializer already prefetches user_capabilities for a page through
capabilities_prefetch, but that mechanism can only express "the user holds
this role on the row or a related row", and neither answer here fits it:
approving reads votes and quorum, and canceling asks about another object.
"""

# What every row of a page reads besides its own columns: its template for
# the related links and summary fields, and the node, workflow job and
# workflow template behind can_approve_or_deny and can_cancel_workflow.
PAGE_PREFETCH = ('workflow_approval_template', 'unified_job_node__workflow_job__unified_job_template')

@staticmethod
def prefetch_page(approvals):
"""
Load what the rows of a page read, a few queries for the whole page.

This lives here rather than in WorkflowApprovalAccess.prefetch_related
because that queryset also serves the detail, approve and deny views,
which read one approval and would pay for every prefetch up front.

An approval's unified_job_template and workflow_approval_template are
two foreign keys to the same row, the second one typed, and the base
manager of UnifiedJobTemplate is polymorphic, so reading the first
yields the same WorkflowApprovalTemplate the second holds. It is read
once, through workflow_approval_template, and handed to the other.

Args:
approvals: The approvals of the page, as a list. They are changed
in place: the related objects land in their caches.
"""
prefetch_related_objects(approvals, *WorkflowApprovalPageSerializer.PAGE_PREFETCH)
unified_job_template = WorkflowApproval.unified_job_template.field
for approval in approvals:
if unified_job_template.is_cached(approval) or approval.unified_job_template_id is None:
continue
template = approval.workflow_approval_template
if template is not None and template.pk == approval.unified_job_template_id:
unified_job_template.set_cached_value(approval, template)

def to_representation(self, data):
"""
Answer the page's permission checks, then serialize its rows.

Args:
data: The approvals of the page, as a list, queryset or manager.

Returns:
list: One serialized approval per row, as ListSerializer gives.
"""
iterable = data.all() if isinstance(data, BaseManager) else data
approvals = list(iterable)
self.prefetch_page(approvals)
request = self.context.get('request', None)
if request is not None:
# Step 1: can_approve_or_deny, keyed by approval.
self.context[APPROVE_OR_DENY_ANSWERS] = WorkflowApprovalAccess(request.user).can_approve_or_deny_many(approvals)
# Step 2: the cancel capability of the workflow jobs the approvals
# wait in, keyed by workflow job. Only approvals a workflow spawned
# are asked about, as get_can_cancel_workflow only asks for those.
workflow_jobs = {}
for approval in approvals:
workflow_job = waiting_workflow_job(approval)
if workflow_job is not None:
workflow_jobs[workflow_job.pk] = workflow_job
self.context[CANCEL_WORKFLOW_ANSWERS] = WorkflowJobAccess(request.user).get_cancel_capabilities(workflow_jobs.values())
return super().to_representation(approvals)


class WorkflowApprovalSerializer(UnifiedJobSerializer):
can_approve_or_deny = serializers.SerializerMethodField()
can_cancel_workflow = serializers.SerializerMethodField()
approval_expiration = serializers.SerializerMethodField()
timed_out = serializers.ReadOnlyField()
approvals_received = serializers.SerializerMethodField()
user_has_voted = serializers.SerializerMethodField()

# An approval is stopped by approving or denying it, or by canceling the
# workflow job it waits in (reported as can_cancel_workflow). It has no
# cancel endpoint of its own, so the inherited 'cancel' capability would
# offer an action no request could carry out.
show_capabilities = ['start', 'delete']

class Meta:
model = WorkflowApproval
list_serializer_class = WorkflowApprovalPageSerializer
fields = (
'*',
'-controller_node',
'-execution_node',
'can_approve_or_deny',
'can_cancel_workflow',
'approval_expiration',
'timed_out',
'context_message',
Expand All @@ -252,10 +378,54 @@ def get_approval_expiration(self, obj):
return obj.created + timedelta(seconds=obj.timeout)

def get_can_approve_or_deny(self, obj):
"""
Whether the user may approve or deny this approval now.

On a page of approvals the access check was answered for the whole
page up front (see WorkflowApprovalPageSerializer); an approval it left
unanswered, or one serialized on its own, is checked here.

Args:
obj: The workflow approval being serialized.

Returns:
bool: True when the approval is pending and the user may decide it.
"""
request = self.context.get('request', None)
allowed = request.user.can_access(WorkflowApproval, 'approve_or_deny', obj)
answers = self.context.get(APPROVE_OR_DENY_ANSWERS) or {}
if obj.pk in answers:
allowed = answers[obj.pk]
else:
allowed = request.user.can_access(WorkflowApproval, 'approve_or_deny', obj)
return allowed is True and obj.status == 'pending'

def get_can_cancel_workflow(self, obj):
"""
Whether the user may cancel the workflow job this approval waits in.

Canceling the workflow is a right on the workflow job, its creator or an
admin of its template, not the approver role, so it is reported apart
from can_approve_or_deny.

Args:
obj: The workflow approval being serialized.

Returns:
bool: True when a cancel request for the workflow job would be accepted.
"""
request = self.context.get('request', None)
if request is None:
return False
workflow_job = waiting_workflow_job(obj)
if workflow_job is None:
return False
# On a page of approvals the capability was answered for every
# workflow job up front; one left unanswered is checked on its own.
answers = self.context.get(CANCEL_WORKFLOW_ANSWERS) or {}
if workflow_job.pk in answers:
return answers[workflow_job.pk]
return bool(get_user_capabilities(request.user, workflow_job, method_list=['cancel']).get('cancel'))

def get_approvals_received(self, obj):
return obj.approvals_received()

Expand Down Expand Up @@ -291,7 +461,17 @@ class WorkflowApprovalActivityStreamSerializer(WorkflowApprovalSerializer):

class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListSerializer):
class Meta:
fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out', '-context_message')
list_serializer_class = WorkflowApprovalPageSerializer
fields = (
'*',
'-controller_node',
'-execution_node',
'can_approve_or_deny',
'can_cancel_workflow',
'approval_expiration',
'timed_out',
'-context_message',
)

def get_field_names(self, declared_fields, info):
field_names = super(WorkflowApprovalListSerializer, self).get_field_names(declared_fields, info)
Expand Down
Loading