Skip to content

Commit

Permalink
Migrate JT project.organization to direct org field
Browse files Browse the repository at this point in the history
Add migration logic along with general utility
  for recomputation of parents caching

Add JT.organization field in UI

Adjust organization counts to directly count JTs
  • Loading branch information
AlanCoding committed Jul 17, 2019
1 parent 41b0367 commit 4dc7502
Show file tree
Hide file tree
Showing 23 changed files with 348 additions and 208 deletions.
2 changes: 1 addition & 1 deletion awx/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2704,7 +2704,7 @@ class Meta:
fields = ('*', 'job_type', 'inventory', 'project', 'playbook',
'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags',
'force_handlers', 'skip_tags', 'start_at_task', 'timeout',
'use_fact_cache',)
'use_fact_cache', 'organization',)

def get_related(self, obj):
res = super(JobOptionsSerializer, self).get_related(obj)
Expand Down
37 changes: 6 additions & 31 deletions awx/api/views/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@
import dateutil
import logging

from django.db.models import (
Count,
F,
)
from django.db.models import Count
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils.timezone import now
Expand Down Expand Up @@ -175,28 +172,18 @@ def get_serializer_context(self, *args, **kwargs):

inv_qs = Inventory.accessible_objects(self.request.user, 'read_role')
project_qs = Project.accessible_objects(self.request.user, 'read_role')
jt_qs = JobTemplate.accessible_objects(self.request.user, 'read_role')

# Produce counts of Foreign Key relationships
db_results['inventories'] = inv_qs\
.values('organization').annotate(Count('organization')).order_by('organization')
db_results['inventories'] = inv_qs.values('organization').annotate(Count('organization')).order_by('organization')

db_results['teams'] = Team.accessible_objects(
self.request.user, 'read_role').values('organization').annotate(
Count('organization')).order_by('organization')

JT_project_reference = 'project__organization'
JT_inventory_reference = 'inventory__organization'
db_results['job_templates_project'] = JobTemplate.accessible_objects(
self.request.user, 'read_role').exclude(
project__organization=F(JT_inventory_reference)).values(JT_project_reference).annotate(
Count(JT_project_reference)).order_by(JT_project_reference)

db_results['job_templates_inventory'] = JobTemplate.accessible_objects(
self.request.user, 'read_role').values(JT_inventory_reference).annotate(
Count(JT_inventory_reference)).order_by(JT_inventory_reference)
db_results['job_templates'] = jt_qs.values('organization').annotate(Count('organization')).order_by('organization')

db_results['projects'] = project_qs\
.values('organization').annotate(Count('organization')).order_by('organization')
db_results['projects'] = project_qs.values('organization').annotate(Count('organization')).order_by('organization')

# Other members and admins of organization are always viewable
db_results['users'] = org_qs.annotate(
Expand All @@ -212,11 +199,7 @@ def get_serializer_context(self, *args, **kwargs):
'admins': 0, 'projects': 0}

for res, count_qs in db_results.items():
if res == 'job_templates_project':
org_reference = JT_project_reference
elif res == 'job_templates_inventory':
org_reference = JT_inventory_reference
elif res == 'users':
if res == 'users':
org_reference = 'id'
else:
org_reference = 'organization'
Expand All @@ -229,14 +212,6 @@ def get_serializer_context(self, *args, **kwargs):
continue
count_context[org_id][res] = entry['%s__count' % org_reference]

# Combine the counts for job templates by project and inventory
for org in org_id_list:
org_id = org['id']
count_context[org_id]['job_templates'] = 0
for related_path in ['job_templates_project', 'job_templates_inventory']:
if related_path in count_context[org_id]:
count_context[org_id]['job_templates'] += count_context[org_id].pop(related_path)

full_context['related_field_counts'] = count_context

return full_context
Expand Down
2 changes: 1 addition & 1 deletion awx/api/views/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def get_serializer_context(self, *args, **kwargs):
org_counts['projects'] = Project.accessible_objects(**access_kwargs).filter(
organization__id=org_id).count()
org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter(
project__organization__id=org_id).count()
organization__id=org_id).count()

full_context['related_field_counts'] = {}
full_context['related_field_counts'][org_id] = org_counts
Expand Down
118 changes: 47 additions & 71 deletions awx/main/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -1564,9 +1564,9 @@ def can_delete(self, obj):
@check_superuser
def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False):
if relationship == "instance_groups":
if not obj.project.organization:
if not obj.organization:
return False
return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.project.organization.admin_role
return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.organization.admin_role
if relationship == 'credentials' and isinstance(sub_obj, Credential):
return self.user in obj.admin_role and self.user in sub_obj.use_role
return super(JobTemplateAccess, self).can_attach(
Expand Down Expand Up @@ -1617,42 +1617,19 @@ def filtered_queryset(self):

return qs.filter(
Q(job_template__in=JobTemplate.accessible_objects(self.user, 'read_role')) |
Q(inventory__organization__in=org_access_qs) |
Q(project__organization__in=org_access_qs)).distinct()

def related_orgs(self, obj):
orgs = []
if obj.inventory and obj.inventory.organization:
orgs.append(obj.inventory.organization)
if obj.project and obj.project.organization and obj.project.organization not in orgs:
orgs.append(obj.project.organization)
return orgs

def org_access(self, obj, role_types=['admin_role']):
orgs = self.related_orgs(obj)
for org in orgs:
for role_type in role_types:
role = getattr(org, role_type)
if self.user in role:
return True
return False
Q(organization__in=org_access_qs)).distinct()

def can_add(self, data, validate_license=True):
if validate_license:
self.check_license()

if not data: # So the browseable API will work
return True
return self.user.is_superuser
raise NotImplementedError('Direct job creation not possible in v2 API')

def can_change(self, obj, data):
return (obj.status == 'new' and
self.can_read(obj) and
self.can_add(data, validate_license=False))
raise NotImplementedError('Direct job editing not supported in v2 API')

@check_superuser
def can_delete(self, obj):
return self.org_access(obj)
if not obj.organization:
return False
return self.user in obj.organization.admin_role

def can_start(self, obj, validate_license=True):
if validate_license:
Expand All @@ -1672,45 +1649,38 @@ def can_start(self, obj, validate_license=True):
except JobLaunchConfig.DoesNotExist:
config = None

# Check if JT execute access (and related prompts) is sufficient
if obj.job_template is not None:
if config is None:
prompts_access = False
elif not config.has_user_prompts(obj.job_template):
prompts_access = True
elif obj.created_by_id != self.user.pk and vars_are_encrypted(config.extra_data):
prompts_access = False
if self.save_messages:
self.messages['detail'] = _('Job was launched with secret prompts provided by another user.')
else:
prompts_access = (
JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}) and
not config.has_unprompted(obj.job_template)
)
jt_access = self.user in obj.job_template.execute_role
if prompts_access and jt_access:
return True
elif not jt_access:
return False
# Check permissions to prompts (if any exist)
if config is None:
if self.save_messages:
# only possible with legacy data
self.messages['detail'] = _('Job was launched with unknown prompted fields.')
return False
elif obj.job_template and not config.has_user_prompts(obj.job_template):
pass # further prompts-related checks not needed
elif obj.created_by_id != self.user.pk and vars_are_encrypted(config.extra_data):
if self.save_messages:
self.messages['detail'] = _('Job was launched with secret prompts provided by another user.')
return False
elif obj.job_template and config.has_unprompted(obj.job_template):
if self.save_messages:
self.messages['detail'] = _('Job template no longer accepts the prompts provided for this job.')
return False
elif not JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}):
if self.save_messages:
self.messages['detail'] = _('Job was launched with prompted fields which you do not have access to.')
return False

org_access = bool(obj.inventory) and self.user in obj.inventory.organization.inventory_admin_role
project_access = obj.project is None or self.user in obj.project.admin_role
credential_access = all([self.user in cred.use_role for cred in obj.credentials.all()])
# Check according to the standard permissions model
if obj.job_template and self.user in obj.job_template.execute_role:
return True
elif obj.organization and self.user in obj.organization.execute_role:
# Respect organization ownership of orphaned jobs
return True
elif not (obj.job_template or obj.organization):
if self.save_messages:
self.messages['detail'] = _('Job has been orphaned from its job template and organization.')

# job can be relaunched if user could make an equivalent JT
ret = org_access and credential_access and project_access
if not ret and self.save_messages and not self.messages:
if not obj.job_template:
pretext = _('Job has been orphaned from its job template.')
elif config is None:
pretext = _('Job was launched with unknown prompted fields.')
else:
pretext = _('Job was launched with prompted fields.')
if credential_access:
self.messages['detail'] = '{} {}'.format(pretext, _(' Organization level permissions required.'))
else:
self.messages['detail'] = '{} {}'.format(pretext, _(' You do not have permission to related resources.'))
return ret
return False

def get_method_capability(self, method, obj, parent_obj):
if method == 'start':
Expand All @@ -1723,10 +1693,16 @@ def get_method_capability(self, method, obj, parent_obj):
def can_cancel(self, obj):
if not obj.can_cancel:
return False
# Delete access allows org admins to stop running jobs
if self.user == obj.created_by or self.can_delete(obj):
# Users may always cancel their own jobs
if self.user == obj.created_by:
return True
# Users with direct admin to JT may cancel jobs started by anyone
if obj.job_template and self.user in obj.job_template.admin_role:
return True
return obj.job_template is not None and self.user in obj.job_template.admin_role
# If orphaned, allow org JT admins to stop running jobs
if not obj.job_template and obj.organization and self.user in obj.organization.job_template_admin_role:
return True
return False


class SystemJobTemplateAccess(BaseAccess):
Expand Down
26 changes: 20 additions & 6 deletions awx/main/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@

__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField',
'SmartFilterField', 'OrderedManyToManyField',
'update_role_parentage_for_instance', 'is_implicit_parent']
'update_role_parentage_for_instance',
'is_implicit_parent']


# Provide a (better) custom error message for enum jsonschema validation
Expand Down Expand Up @@ -140,8 +141,9 @@ def resolve_role_field(obj, field):
return []

if len(field_components) == 1:
role_cls = str(utils.get_current_apps().get_model('main', 'Role'))
if not str(type(obj)) == role_cls:
# use extremely generous duck typing to accomidate all possible forms
# of the model that may be used during various migrations
if obj._meta.model_name != 'role' or obj._meta.app_label != 'main':
raise Exception(smart_text('{} refers to a {}, not a Role'.format(field, type(obj))))
ret.append(obj.id)
else:
Expand All @@ -164,7 +166,7 @@ def is_implicit_parent(parent_role, child_role):
# The only singleton implicit parent is the system admin being
# a parent of the system auditor role
return bool(
child_role.singleton_name == ROLE_SINGLETON_SYSTEM_AUDITOR and
child_role.singleton_name == ROLE_SINGLETON_SYSTEM_AUDITOR and
parent_role.singleton_name == ROLE_SINGLETON_SYSTEM_ADMINISTRATOR
)
# Get the list of implicit parents that were defined at the class level.
Expand Down Expand Up @@ -197,18 +199,30 @@ def update_role_parentage_for_instance(instance):
updates the parents listing for all the roles
of a given instance if they have changed
'''
changed_ct = 0
for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'):
changed = False
cur_role = getattr(instance, implicit_role_field.name)
original_parents = set(json.loads(cur_role.implicit_parents))
new_parents = implicit_role_field._resolve_parent_roles(instance)
cur_role.parents.remove(*list(original_parents - new_parents))
cur_role.parents.add(*list(new_parents - original_parents))
removals = original_parents - new_parents
if removals:
changed = True
cur_role.parents.remove(*list(removals))
additions = new_parents - original_parents
if additions:
changed = True
cur_role.parents.add(*list(additions))
new_parents_list = list(new_parents)
new_parents_list.sort()
new_parents_json = json.dumps(new_parents_list)
if cur_role.implicit_parents != new_parents_json:
changed = True
cur_role.implicit_parents = new_parents_json
cur_role.save()
if changed:
changed_ct += 1
return changed_ct


class ImplicitRoleDescriptor(ForwardManyToOneDescriptor):
Expand Down
52 changes: 52 additions & 0 deletions awx/main/migrations/0082_v360_job_template_organization_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-07-10 20:26
from __future__ import unicode_literals

import awx.main.fields
from django.db import migrations, models
import django.db.models.deletion

from awx.main.migrations._rbac import rebuild_role_parentage, migrate_jt_organization


class Migration(migrations.Migration):

dependencies = [
('main', '0081_v360_notify_on_start'),
]

operations = [
# backwards parents and ancestors caching
migrations.RunPython(migrations.RunPython.noop, rebuild_role_parentage),
# add new organization field for JT
migrations.AddField(
model_name='job',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobs', to='main.Organization'),
),
migrations.AddField(
model_name='jobtemplate',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobtemplates', to='main.Organization'),
),
# Modifications to JT role parentage
migrations.AlterField(
model_name='jobtemplate',
name='admin_role',
field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.job_template_admin_role'], related_name='+', to='main.Role'),
),
migrations.AlterField(
model_name='jobtemplate',
name='execute_role',
field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['admin_role', 'organization.execute_role'], related_name='+', to='main.Role'),
),
migrations.AlterField(
model_name='jobtemplate',
name='read_role',
field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.auditor_role', 'execute_role', 'admin_role'], related_name='+', to='main.Role'),
),
migrations.RunPython(migrate_jt_organization, migrations.RunPython.noop),
# Re-compute the role parents and ancestors caching
# this may be a no-op because field post_save hooks from migrate_jt_organization
migrations.RunPython(rebuild_role_parentage, migrations.RunPython.noop),
]
Loading

0 comments on commit 4dc7502

Please sign in to comment.