Organization on JT as read-only field

Set JT.organization with value from its project

Remove validation requiring JT.organization

Undo some of the additional org definitions in tests

Revert some tests no longer needed for feature

exclude workflow approvals from unified organization field

revert awxkit changes for providing organization

Roll back additional JT creation permission requirement

Fix up more issues by persisting organization field when project is removed

Restrict project org editing, logging, and testing

Grant removed inventory org admin permissions in migration

Add special validate_unique for job templates
  this deals with enforcing name-organization uniqueness

Add back in special message where config is unknown
  when receiving 403 on job relaunch

Fix logical and performance bugs with data migration

within JT.inventory.organization make-permission-explicit migration

remove nested loops so we do .iterator() on JT queryset

in reverse migration, carefully remove execute role on JT
  held by org admins of inventory organization,
  as well as the execute_role holders

Use current state of Role model in logic, with 1 notable exception
  that is used to filter on ancestors
  the ancestor and descentent relationship in the migration model
    is not reliable
  output of this is saved as an integer list to avoid future
    compatibility errors

make the parents rebuilding logic skip over irrelevant models
  this is the largest performance gain for small resource numbers
This commit is contained in:
AlanCoding
2020-01-21 11:12:08 -05:00
parent daa9282790
commit 7d0b207571
31 changed files with 517 additions and 226 deletions

View File

@@ -72,6 +72,7 @@ from awx.main.utils import (
prefetch_page_capabilities, get_external_account, truncate_stdout, prefetch_page_capabilities, get_external_account, truncate_stdout,
) )
from awx.main.utils.filters import SmartFilter from awx.main.utils.filters import SmartFilter
from awx.main.utils.named_url_graph import reset_counters
from awx.main.redact import UriCleaner, REPLACE_STR from awx.main.redact import UriCleaner, REPLACE_STR
from awx.main.validators import vars_validate_or_raise from awx.main.validators import vars_validate_or_raise
@@ -347,6 +348,7 @@ class BaseSerializer(serializers.ModelSerializer, metaclass=BaseSerializerMetacl
def _generate_named_url(self, url_path, obj, node): def _generate_named_url(self, url_path, obj, node):
url_units = url_path.split('/') url_units = url_path.split('/')
reset_counters()
named_url = node.generate_named_url(obj) named_url = node.generate_named_url(obj)
url_units[4] = named_url url_units[4] = named_url
return '/'.join(url_units) return '/'.join(url_units)
@@ -700,18 +702,6 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
else: else:
return super(UnifiedJobTemplateSerializer, self).to_representation(obj) return super(UnifiedJobTemplateSerializer, self).to_representation(obj)
def validate(self, attrs):
if 'organization' in self.fields:
# Do not allow setting template organization to null
# otherwise be as non-restrictive as possible for PATCH or PUT, even with orphans
# does not correspond with any REST framework field construct
if self.instance is None and attrs.get('organization', None) is None:
raise serializers.ValidationError({'organization': _('Organization required for new object.')})
if self.instance and self.instance.organization_id and attrs.get('organization', 'blank') is None:
raise serializers.ValidationError({'organization': _('Organization can not be set to null.')})
return super(UnifiedJobTemplateSerializer, self).validate(attrs)
class UnifiedJobSerializer(BaseSerializer): class UnifiedJobSerializer(BaseSerializer):
show_capabilities = ['start', 'delete'] show_capabilities = ['start', 'delete']
@@ -2741,6 +2731,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer):
'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags',
'force_handlers', 'skip_tags', 'start_at_task', 'timeout', 'force_handlers', 'skip_tags', 'start_at_task', 'timeout',
'use_fact_cache', 'organization',) 'use_fact_cache', 'organization',)
read_only_fields = ('organization',)
def get_related(self, obj): def get_related(self, obj):
res = super(JobOptionsSerializer, self).get_related(obj) res = super(JobOptionsSerializer, self).get_related(obj)

View File

@@ -1465,10 +1465,6 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
if self.user not in inventory.use_role: if self.user not in inventory.use_role:
return False return False
organization = get_value(Organization, 'organization')
if (not organization) or (self.user not in organization.job_template_admin_role):
return False
project = get_value(Project, 'project') project = get_value(Project, 'project')
# If the user has admin access to the project (as an org admin), should # If the user has admin access to the project (as an org admin), should
# be able to proceed without additional checks. # be able to proceed without additional checks.
@@ -1651,7 +1647,7 @@ class JobAccess(BaseAccess):
except JobLaunchConfig.DoesNotExist: except JobLaunchConfig.DoesNotExist:
config = None config = None
# Standard permissions model (1) # Standard permissions model
if obj.job_template and (self.user not in obj.job_template.execute_role): if obj.job_template and (self.user not in obj.job_template.execute_role):
return False return False
@@ -1666,13 +1662,15 @@ class JobAccess(BaseAccess):
if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}): if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}):
return True return True
# Standard permissions model (2) # Standard permissions model without job template involved
if obj.organization and self.user in obj.organization.execute_role: if obj.organization and self.user in obj.organization.execute_role:
# Respect organization ownership of orphaned jobs
return True return True
elif not (obj.job_template or obj.organization): elif not (obj.job_template or obj.organization):
if self.save_messages: raise PermissionDenied(_('Job has been orphaned from its job template and organization.'))
self.messages['detail'] = _('Job has been orphaned from its job template and organization.') elif obj.job_template and config is not None:
raise PermissionDenied(_('Job was launched with prompted fields you do not have access to.'))
elif obj.job_template and config is None:
raise PermissionDenied(_('Job was launched with unknown prompted fields. Organization admin permissions required.'))
return False return False

View File

@@ -257,7 +257,7 @@ def copy_tables(since, full_path):
unified_job_query = '''COPY (SELECT main_unifiedjob.id, unified_job_query = '''COPY (SELECT main_unifiedjob.id,
main_unifiedjob.polymorphic_ctype_id, main_unifiedjob.polymorphic_ctype_id,
django_content_type.model, django_content_type.model,
main_project.organization_id, main_unifiedjob.organization_id,
main_organization.name as organization_name, main_organization.name as organization_name,
main_unifiedjob.created, main_unifiedjob.created,
main_unifiedjob.name, main_unifiedjob.name,
@@ -275,10 +275,8 @@ def copy_tables(since, full_path):
main_unifiedjob.job_explanation, main_unifiedjob.job_explanation,
main_unifiedjob.instance_group_id main_unifiedjob.instance_group_id
FROM main_unifiedjob FROM main_unifiedjob
JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id
JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id
JOIN main_project ON main_project.unifiedjobtemplate_ptr_id = main_job.project_id JOIN main_organization ON main_organization.id = main_unifiedjob.organization_id
JOIN main_organization ON main_organization.id = main_project.organization_id
WHERE main_unifiedjob.created > {} WHERE main_unifiedjob.created > {}
AND main_unifiedjob.launch_type != 'sync' AND main_unifiedjob.launch_type != 'sync'
ORDER BY main_unifiedjob.id ASC) TO STDOUT WITH CSV HEADER'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'")) ORDER BY main_unifiedjob.id ASC) TO STDOUT WITH CSV HEADER'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'"))

View File

@@ -200,29 +200,27 @@ def update_role_parentage_for_instance(instance):
of a given instance if they have changed of a given instance if they have changed
''' '''
changed_ct = 0 changed_ct = 0
parents_removed = set()
parents_added = set()
for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'): for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'):
changed = False
cur_role = getattr(instance, implicit_role_field.name) cur_role = getattr(instance, implicit_role_field.name)
original_parents = set(json.loads(cur_role.implicit_parents)) original_parents = set(json.loads(cur_role.implicit_parents))
new_parents = implicit_role_field._resolve_parent_roles(instance) new_parents = implicit_role_field._resolve_parent_roles(instance)
removals = original_parents - new_parents removals = original_parents - new_parents
if removals: if removals:
changed = True
cur_role.parents.remove(*list(removals)) cur_role.parents.remove(*list(removals))
parents_removed.add(cur_role.pk)
additions = new_parents - original_parents additions = new_parents - original_parents
if additions: if additions:
changed = True
cur_role.parents.add(*list(additions)) cur_role.parents.add(*list(additions))
parents_added.add(cur_role.pk)
new_parents_list = list(new_parents) new_parents_list = list(new_parents)
new_parents_list.sort() new_parents_list.sort()
new_parents_json = json.dumps(new_parents_list) new_parents_json = json.dumps(new_parents_list)
if cur_role.implicit_parents != new_parents_json: if cur_role.implicit_parents != new_parents_json:
changed = True
cur_role.implicit_parents = new_parents_json cur_role.implicit_parents = new_parents_json
cur_role.save() cur_role.save(update_fields=['implicit_parents'])
if changed: return (parents_added, parents_removed)
changed_ct += 1
return changed_ct
class ImplicitRoleDescriptor(ForwardManyToOneDescriptor): class ImplicitRoleDescriptor(ForwardManyToOneDescriptor):

View File

@@ -5,18 +5,26 @@ import awx.main.fields
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from awx.main.migrations._rbac import rebuild_role_parentage, migrate_ujt_organization, migrate_ujt_organization_backward from awx.main.migrations._rbac import (
rebuild_role_parentage, rebuild_role_hierarchy,
migrate_ujt_organization, migrate_ujt_organization_backward,
restore_inventory_admins, restore_inventory_admins_backward
)
def rebuild_jt_parents(apps, schema_editor):
rebuild_role_parentage(apps, schema_editor, models=('jobtemplate',))
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('main', '0084_v360_token_description'), ('main', '0106_v370_remove_inventory_groups_with_active_failures'),
] ]
operations = [ operations = [
# backwards parents and ancestors caching # backwards parents and ancestors caching
migrations.RunPython(migrations.RunPython.noop, rebuild_role_parentage), migrations.RunPython(migrations.RunPython.noop, rebuild_jt_parents),
# add new organization field for JT and all other unified jobs # add new organization field for JT and all other unified jobs
migrations.AddField( migrations.AddField(
model_name='unifiedjob', model_name='unifiedjob',
@@ -67,6 +75,7 @@ class Migration(migrations.Migration):
field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role'], related_name='+', to='main.Role'), field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role'], related_name='+', to='main.Role'),
), ),
# Re-compute the role parents and ancestors caching # 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_jt_parents, migrations.RunPython.noop),
migrations.RunPython(rebuild_role_parentage, migrations.RunPython.noop), # for all permissions that will be removed, make them explicit
migrations.RunPython(restore_inventory_admins, restore_inventory_admins_backward),
] ]

View File

@@ -1,7 +1,7 @@
import logging import logging
from time import time from time import time
from django.db.models import Subquery, OuterRef from django.db.models import Subquery, OuterRef, F
from awx.main.fields import update_role_parentage_for_instance from awx.main.fields import update_role_parentage_for_instance
from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding
@@ -115,7 +115,7 @@ def _migrate_unified_organization(apps, unified_cls_name, backward=False):
if backward and UNIFIED_ORG_LOOKUPS.get(cls_name, 'not-found') is not None: if backward and UNIFIED_ORG_LOOKUPS.get(cls_name, 'not-found') is not None:
logger.debug('Not reverse migrating {}, existing data should remain valid'.format(cls_name)) logger.debug('Not reverse migrating {}, existing data should remain valid'.format(cls_name))
continue continue
logger.debug('Migrating {} to new organization field'.format(cls_name)) logger.debug('{}Migrating {} to new organization field'.format('Reverse ' if backward else '', cls_name))
sub_qs = implicit_org_subquery(UnifiedClass, cls, backward=backward) sub_qs = implicit_org_subquery(UnifiedClass, cls, backward=backward)
if sub_qs is None: if sub_qs is None:
@@ -129,7 +129,7 @@ def _migrate_unified_organization(apps, unified_cls_name, backward=False):
r = UnifiedClass.objects.order_by().filter(polymorphic_ctype=this_ct).update(tmp_organization=sub_qs) r = UnifiedClass.objects.order_by().filter(polymorphic_ctype=this_ct).update(tmp_organization=sub_qs)
if r: if r:
logger.info('Organization migration on {} affected {} rows.'.format(cls_name, r)) logger.info('Organization migration on {} affected {} rows.'.format(cls_name, r))
logger.info('Unified organization migration completed in %f seconds' % (time() - start)) logger.info('Unified organization migration completed in {:.4f} seconds'.format(time() - start))
def migrate_ujt_organization(apps, schema_editor): def migrate_ujt_organization(apps, schema_editor):
@@ -144,6 +144,74 @@ def migrate_ujt_organization_backward(apps, schema_editor):
_migrate_unified_organization(apps, 'UnifiedJob', backward=True) _migrate_unified_organization(apps, 'UnifiedJob', backward=True)
def _restore_inventory_admins(apps, schema_editor, backward=False):
"""With the JT.organization changes, admins of organizations connected to
job templates via inventory will have their permissions demoted.
This maintains current permissions over the migration by granting the
permissions they used to have explicitly on the JT itself.
"""
start = time()
JobTemplate = apps.get_model('main', 'JobTemplate')
User = apps.get_model('auth', 'User')
changed_ct = 0
jt_qs = JobTemplate.objects.filter(inventory__isnull=False)
jt_qs = jt_qs.exclude(inventory__organization=F('project__organization'))
jt_qs = jt_qs.only('id', 'admin_role_id', 'execute_role_id', 'inventory_id')
for jt in jt_qs.iterator():
org = jt.inventory.organization
for role_name in ('admin_role', 'execute_role'):
role_id = getattr(jt, '{}_id'.format(role_name))
user_qs = User.objects
if not backward:
# In this specific case, the name for the org role and JT roles were the same
org_role_id = getattr(org, '{}_id'.format(role_name))
user_qs = user_qs.filter(roles=org_role_id)
# bizarre migration behavior - ancestors / descendents of
# migration version of Role model is reversed, using current model briefly
ancestor_ids = list(
Role.objects.filter(descendents=role_id).values_list('id', flat=True)
)
# same as Role.__contains__, filter for "user in jt.admin_role"
user_qs = user_qs.exclude(roles__in=ancestor_ids)
else:
# use the database to filter intersection of users without access
# to the JT role and either organization role
user_qs = user_qs.filter(roles__in=[org.admin_role_id, org.execute_role_id])
# in reverse, intersection of users who have both
user_qs = user_qs.filter(roles=role_id)
user_ids = list(user_qs.values_list('id', flat=True))
if not user_ids:
continue
role = getattr(jt, role_name)
logger.debug('{} {} on jt {} for users {} via inventory.organization {}'.format(
'Removing' if backward else 'Setting',
role_name, jt.pk, user_ids, org.pk
))
if not backward:
# in reverse, explit role becomes redundant
role.members.add(*user_ids)
else:
role.members.remove(*user_ids)
changed_ct += len(user_ids)
if changed_ct:
logger.info('{} explicit JT permission for {} users in {:.4f} seconds'.format(
'Removed' if backward else 'Added',
changed_ct, time() - start
))
def restore_inventory_admins(apps, schema_editor):
_restore_inventory_admins(apps, schema_editor)
def restore_inventory_admins_backward(apps, schema_editor):
_restore_inventory_admins(apps, schema_editor, backward=True)
def rebuild_role_hierarchy(apps, schema_editor): def rebuild_role_hierarchy(apps, schema_editor):
''' '''
This should be called in any migration when ownerships are changed. This should be called in any migration when ownerships are changed.
@@ -164,7 +232,7 @@ def rebuild_role_hierarchy(apps, schema_editor):
logger.info('Done.') logger.info('Done.')
def rebuild_role_parentage(apps, schema_editor): def rebuild_role_parentage(apps, schema_editor, models=None):
''' '''
This should be called in any migration when any parent_role entry This should be called in any migration when any parent_role entry
is modified so that the cached parent fields will be updated. Ex: is modified so that the cached parent fields will be updated. Ex:
@@ -177,13 +245,23 @@ def rebuild_role_parentage(apps, schema_editor):
''' '''
start = time() start = time()
seen_models = set() seen_models = set()
updated_ct = 0
model_ct = 0 model_ct = 0
noop_ct = 0 noop_ct = 0
Role = apps.get_model('main', "Role") ContentType = apps.get_model('contenttypes', "ContentType")
for role in Role.objects.iterator(): additions = set()
removals = set()
role_qs = Role.objects
if models:
# update_role_parentage_for_instance is expensive
# if the models have been downselected, ignore those which are not in the list
ct_ids = list(ContentType.objects.filter(
model__in=[name.lower() for name in models]
).values_list('id', flat=True))
role_qs = role_qs.filter(content_type__in=ct_ids)
for role in role_qs.iterator():
if not role.object_id: if not role.object_id:
noop_ct += 1
continue continue
model_tuple = (role.content_type_id, role.object_id) model_tuple = (role.content_type_id, role.object_id)
if model_tuple in seen_models: if model_tuple in seen_models:
@@ -198,19 +276,26 @@ def rebuild_role_parentage(apps, schema_editor):
ct_model = apps.get_model(app, ct.model) ct_model = apps.get_model(app, ct.model)
content_object = ct_model.objects.get(pk=role.object_id) content_object = ct_model.objects.get(pk=role.object_id)
updated = update_role_parentage_for_instance(content_object) parents_added, parents_removed = update_role_parentage_for_instance(content_object)
if updated: additions.update(parents_added)
removals.update(parents_removed)
if parents_added:
model_ct += 1 model_ct += 1
logger.debug('Updated parents of {} roles of {}'.format(updated, content_object)) logger.debug('Added to parents of roles {} of {}'.format(parents_added, content_object))
if parents_removed:
model_ct += 1
logger.debug('Removed from parents of roles {} of {}'.format(parents_removed, content_object))
else: else:
noop_ct += 1 noop_ct += 1
updated_ct += updated
logger.debug('No changes to role parents for {} roles'.format(noop_ct)) logger.debug('No changes to role parents for {} resources'.format(noop_ct))
if updated_ct: logger.debug('Added parents to {} roles'.format(len(additions)))
logger.info('Updated parentage for {} roles of {} resources'.format(updated_ct, model_ct)) logger.debug('Removed parents from {} roles'.format(len(removals)))
if model_ct:
logger.info('Updated implicit parents of {} resources'.format(model_ct))
logger.info('Rebuild parentage completed in %f seconds' % (time() - start)) logger.info('Rebuild parentage completed in %f seconds' % (time() - start))
if updated_ct: # this is ran because the ordinary signals for
rebuild_role_hierarchy(apps, schema_editor) # Role.parents.add and Role.parents.remove not called in migration
Role.rebuild_role_ancestor_list(list(additions), list(removals))

View File

@@ -323,6 +323,41 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
else: else:
return self.job_slice_count return self.job_slice_count
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields', [])
# if project is deleted for some reason, then keep the old organization
# to retain ownership for organization admins
if self.project and self.project.organization_id != self.organization_id:
self.organization_id = self.project.organization_id
if 'organization' not in update_fields and 'organization_id' not in update_fields:
update_fields.append('organization_id')
return super(JobTemplate, self).save(*args, **kwargs)
def validate_unique(self, exclude=None):
"""Custom over-ride for JT specifically
because organization is inferred from project after full_clean is finished
thus the organization field is not yet set when validation happens
"""
errors = []
for ut in JobTemplate.SOFT_UNIQUE_TOGETHER:
kwargs = {'name': self.name}
if self.project:
kwargs['organization'] = self.project.organization_id
else:
kwargs['organization'] = None
qs = JobTemplate.objects.filter(**kwargs)
if self.pk:
qs = qs.exclude(pk=self.pk)
if qs.exists():
errors.append(
'%s with this (%s) combination already exists.' % (
JobTemplate.__name__,
', '.join(set(ut) - {'polymorphic_ctype'})
)
)
if errors:
raise ValidationError(errors)
def create_unified_job(self, **kwargs): def create_unified_job(self, **kwargs):
prevent_slicing = kwargs.pop('_prevent_slicing', False) prevent_slicing = kwargs.pop('_prevent_slicing', False)
slice_ct = self.get_effective_slice_ct(kwargs) slice_ct = self.get_effective_slice_ct(kwargs)

View File

@@ -325,6 +325,13 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
['name', 'description', 'organization'] ['name', 'description', 'organization']
) )
def clean_organization(self):
if self.pk:
old_org_id = getattr(self, '_prior_values_store', {}).get('organization_id', None)
if self.organization_id != old_org_id and self.jobtemplates.exists():
raise ValidationError({'organization': _('Organization cannot be changed when in use by job templates.')})
return self.organization
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
new_instance = not bool(self.pk) new_instance = not bool(self.pk)
pre_save_vals = getattr(self, '_prior_values_store', {}) pre_save_vals = getattr(self, '_prior_values_store', {})

View File

@@ -102,7 +102,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
ordering = ('name',) ordering = ('name',)
# unique_together here is intentionally commented out. Please make sure sub-classes of this model # unique_together here is intentionally commented out. Please make sure sub-classes of this model
# contain at least this uniqueness restriction: SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')] # contain at least this uniqueness restriction: SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')]
#unique_together = [('polymorphic_ctype', 'name')] #unique_together = [('polymorphic_ctype', 'name', 'organization')]
old_pk = models.PositiveIntegerField( old_pk = models.PositiveIntegerField(
null=True, null=True,

View File

@@ -376,7 +376,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
FIELDS_TO_PRESERVE_AT_COPY = [ FIELDS_TO_PRESERVE_AT_COPY = [
'labels', 'instance_groups', 'workflow_job_template_nodes', 'credentials', 'survey_spec' 'labels', 'organization', 'instance_groups', 'workflow_job_template_nodes', 'credentials', 'survey_spec'
] ]
class Meta: class Meta:

View File

@@ -157,17 +157,26 @@ def cleanup_detached_labels_on_deleted_parent(sender, instance, **kwargs):
def save_related_job_templates(sender, instance, **kwargs): def save_related_job_templates(sender, instance, **kwargs):
'''save_related_job_templates loops through all of the '''save_related_job_templates loops through all of the
job templates that use an Inventory or Project that have had their job templates that use an Inventory that have had their
Organization updated. This triggers the rebuilding of the RBAC hierarchy Organization updated. This triggers the rebuilding of the RBAC hierarchy
and ensures the proper access restrictions. and ensures the proper access restrictions.
''' '''
if sender not in (Project, Inventory): if sender is not Inventory:
raise ValueError('This signal callback is only intended for use with Project or Inventory') raise ValueError('This signal callback is only intended for use with Project or Inventory')
update_fields = kwargs.get('update_fields', None)
if ((update_fields and not ('organization' in update_fields or 'organization_id' in update_fields)) or
kwargs.get('created', False)):
return
if instance._prior_values_store.get('organization_id') != instance.organization_id: if instance._prior_values_store.get('organization_id') != instance.organization_id:
jtq = JobTemplate.objects.filter(**{sender.__name__.lower(): instance}) jtq = JobTemplate.objects.filter(**{sender.__name__.lower(): instance})
for jt in jtq: for jt in jtq:
update_role_parentage_for_instance(jt) parents_added, parents_removed = update_role_parentage_for_instance(jt)
if parents_added or parents_removed:
logger.info('Permissions on JT {} changed due to inventory {} organization change from {} to {}.'.format(
jt.pk, instance.pk, instance._prior_values_store.get('organization_id'), instance.organization_id
))
def connect_computed_field_signals(): def connect_computed_field_signals():

View File

@@ -159,8 +159,7 @@ def mk_job_template(name, job_type='run',
extra_vars = json.dumps(extra_vars) extra_vars = json.dumps(extra_vars)
jt = JobTemplate(name=name, job_type=job_type, extra_vars=extra_vars, jt = JobTemplate(name=name, job_type=job_type, extra_vars=extra_vars,
webhook_service=webhook_service, playbook='helloworld.yml', webhook_service=webhook_service, playbook='helloworld.yml')
organization=organization)
jt.inventory = inventory jt.inventory = inventory
if jt.inventory is None: if jt.inventory is None:

View File

@@ -255,7 +255,7 @@ def create_job_template(name, roles=None, persisted=True, webhook_service='', **
jt = mk_job_template(name, project=proj, inventory=inv, credential=cred, jt = mk_job_template(name, project=proj, inventory=inv, credential=cred,
network_credential=net_cred, cloud_credential=cloud_cred, network_credential=net_cred, cloud_credential=cloud_cred,
job_type=job_type, spec=spec, extra_vars=extra_vars, job_type=job_type, spec=spec, extra_vars=extra_vars,
persisted=persisted, webhook_service=webhook_service, organization=org) persisted=persisted, webhook_service=webhook_service)
if 'jobs' in kwargs: if 'jobs' in kwargs:
for i in kwargs['jobs']: for i in kwargs['jobs']:

View File

@@ -53,7 +53,7 @@ def test_job_relaunch_permission_denied_response(
# Job has prompted extra_credential, launch denied w/ message # Job has prompted extra_credential, launch denied w/ message
job.launch_config.credentials.add(net_credential) job.launch_config.credentials.add(net_credential)
r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403) r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403)
assert 'launched with prompted fields which you do not have access to' in r.data['detail'] assert 'launched with prompted fields you do not have access to' in r.data['detail']
@pytest.mark.django_db @pytest.mark.django_db
@@ -73,7 +73,6 @@ def test_job_relaunch_prompts_not_accepted_response(
# Job has prompted extra_credential, launch denied w/ message # Job has prompted extra_credential, launch denied w/ message
job.launch_config.credentials.add(net_credential) job.launch_config.credentials.add(net_credential)
r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403) r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403)
assert 'no longer accepts the prompts provided for this job' in r.data['detail']
@pytest.mark.django_db @pytest.mark.django_db
@@ -220,8 +219,7 @@ def test_block_unprocessed_events(delete, admin_user, mocker):
def test_block_related_unprocessed_events(mocker, organization, project, delete, admin_user): def test_block_related_unprocessed_events(mocker, organization, project, delete, admin_user):
job_template = JobTemplate.objects.create( job_template = JobTemplate.objects.create(
project=project, project=project,
playbook='helloworld.yml', playbook='helloworld.yml'
organization=organization
) )
time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500") time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500")
Job.objects.create( Job.objects.create(
@@ -230,7 +228,7 @@ def test_block_related_unprocessed_events(mocker, organization, project, delete,
finished=time_of_finish, finished=time_of_finish,
job_template=job_template, job_template=job_template,
project=project, project=project,
organization=organization organization=project.organization
) )
view = RelatedJobsPreventDeleteMixin() view = RelatedJobsPreventDeleteMixin()
time_of_request = time_of_finish + relativedelta(seconds=2) time_of_request = time_of_finish + relativedelta(seconds=2)

View File

@@ -6,7 +6,7 @@ import pytest
# AWX # AWX
from awx.api.serializers import JobTemplateSerializer from awx.api.serializers import JobTemplateSerializer
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization, Project
from awx.main.migrations import _save_password_keys as save_password_keys from awx.main.migrations import _save_password_keys as save_password_keys
# Django # Django
@@ -32,50 +32,16 @@ def test_create(post, project, machine_credential, inventory, alice, grant_proje
inventory.use_role.members.add(alice) inventory.use_role.members.add(alice)
project.organization.job_template_admin_role.members.add(alice) project.organization.job_template_admin_role.members.add(alice)
r = post(reverse('api:job_template_list'), {
'name': 'Some name',
'project': project.id,
'inventory': inventory.id,
'playbook': 'helloworld.yml',
'organization': project.organization_id
}, alice)
assert r.status_code == expect
@pytest.mark.django_db
def test_creation_uniqueness_rules(post, project, inventory, admin_user):
orgA = Organization.objects.create(name='orga')
orgB = Organization.objects.create(name='orgb')
create_data = {
'name': 'this_unique_name',
'project': project.pk,
'inventory': inventory.pk,
'playbook': 'helloworld.yml',
'organization': orgA.pk
}
post( post(
url=reverse('api:job_template_list'), url=reverse('api:job_template_list'),
data=create_data, data={
user=admin_user, 'name': 'Some name',
expect=201 'project': project.id,
) 'inventory': inventory.id,
r = post( 'playbook': 'helloworld.yml'
url=reverse('api:job_template_list'), },
data=create_data, user=alice,
user=admin_user, expect=expect
expect=400
)
msg = str(r.data['__all__'][0])
assert "JobTemplate with this (" in msg
assert ") combination already exists" in msg
# can create JT with same name, only if it is in different org
create_data['organization'] = orgB.pk
post(
url=reverse('api:job_template_list'),
data=create_data,
user=admin_user,
expect=201
) )
@@ -162,14 +128,18 @@ def test_create_with_forks_exceeding_maximum_xfail(alice, post, project, invento
project.use_role.members.add(alice) project.use_role.members.add(alice)
inventory.use_role.members.add(alice) inventory.use_role.members.add(alice)
settings.MAX_FORKS = 10 settings.MAX_FORKS = 10
response = post(reverse('api:job_template_list'), { response = post(
'name': 'Some name', url=reverse('api:job_template_list'),
'project': project.id, data={
'inventory': inventory.id, 'name': 'Some name',
'playbook': 'helloworld.yml', 'project': project.id,
'forks': 11, 'inventory': inventory.id,
}, alice) 'playbook': 'helloworld.yml',
assert response.status_code == 400 'forks': 11,
},
user=alice,
expect=400
)
assert 'Maximum number of forks (10) exceeded' in str(response.data) assert 'Maximum number of forks (10) exceeded' in str(response.data)
@@ -549,6 +519,72 @@ def test_job_template_unset_custom_virtualenv(get, patch, organization_factory,
assert resp.data['custom_virtualenv'] is None assert resp.data['custom_virtualenv'] is None
@pytest.mark.django_db
def test_jt_organization_follows_project(post, patch, admin_user):
org1 = Organization.objects.create(name='foo1')
org2 = Organization.objects.create(name='foo2')
project_common = dict(scm_type='git', playbook_files=['helloworld.yml'])
project1 = Project.objects.create(name='proj1', organization=org1, **project_common)
project2 = Project.objects.create(name='proj2', organization=org2, **project_common)
r = post(
url=reverse('api:job_template_list'),
data={
"name": "fooo",
"ask_inventory_on_launch": True,
"project": project1.pk,
"playbook": "helloworld.yml"
},
user=admin_user,
expect=201
)
data = r.data
assert data['organization'] == project1.organization_id
data['project'] = project2.id
jt = JobTemplate.objects.get(pk=data['id'])
r = patch(
url=jt.get_absolute_url(),
data=data,
user=admin_user,
expect=200
)
assert r.data['organization'] == project2.organization_id
@pytest.mark.django_db
def test_jt_organization_field_is_read_only(patch, post, project, admin_user):
org = project.organization
jt = JobTemplate.objects.create(
name='foo_jt',
ask_inventory_on_launch=True,
project=project, playbook='helloworld.yml'
)
org2 = Organization.objects.create(name='foo2')
r = patch(
url=jt.get_absolute_url(),
data={'organization': org2.id},
user=admin_user,
expect=200
)
assert r.data['organization'] == org.id
assert JobTemplate.objects.get(pk=jt.pk).organization == org
# similar test, but on creation
r = post(
url=reverse('api:job_template_list'),
data={
'name': 'foobar',
'project': project.id,
'organization': org2.id,
'ask_inventory_on_launch': True,
'playbook': 'helloworld.yml'
},
user=admin_user,
expect=201
)
assert r.data['organization'] == org.id
assert JobTemplate.objects.get(pk=r.data['id']).organization == org
@pytest.mark.django_db @pytest.mark.django_db
def test_callback_disallowed_null_inventory(project): def test_callback_disallowed_null_inventory(project):
jt = JobTemplate.objects.create( jt = JobTemplate.objects.create(
@@ -563,14 +599,13 @@ def test_callback_disallowed_null_inventory(project):
@pytest.mark.django_db @pytest.mark.django_db
def test_job_template_branch_error(project, inventory, organization, post, admin_user): def test_job_template_branch_error(project, inventory, post, admin_user):
r = post( r = post(
url=reverse('api:job_template_list'), url=reverse('api:job_template_list'),
data={ data={
"name": "fooo", "name": "fooo",
"inventory": inventory.pk, "inventory": inventory.pk,
"project": project.pk, "project": project.pk,
"organization": organization.pk,
"playbook": "helloworld.yml", "playbook": "helloworld.yml",
"scm_branch": "foobar" "scm_branch": "foobar"
}, },
@@ -581,14 +616,13 @@ def test_job_template_branch_error(project, inventory, organization, post, admin
@pytest.mark.django_db @pytest.mark.django_db
def test_job_template_branch_prompt_error(project, inventory, post, organization, admin_user): def test_job_template_branch_prompt_error(project, inventory, post, admin_user):
r = post( r = post(
url=reverse('api:job_template_list'), url=reverse('api:job_template_list'),
data={ data={
"name": "fooo", "name": "fooo",
"inventory": inventory.pk, "inventory": inventory.pk,
"project": project.pk, "project": project.pk,
"organization": organization.pk,
"playbook": "helloworld.yml", "playbook": "helloworld.yml",
"ask_scm_branch_on_launch": True "ask_scm_branch_on_launch": True
}, },

View File

@@ -61,7 +61,7 @@ class TestJobTemplateCopyEdit:
def jt_copy_edit(self, job_template_factory, project): def jt_copy_edit(self, job_template_factory, project):
objects = job_template_factory( objects = job_template_factory(
'copy-edit-job-template', 'copy-edit-job-template',
project=project, organization=project.organization) project=project)
return objects.job_template return objects.job_template
def fake_context(self, user): def fake_context(self, user):
@@ -129,8 +129,9 @@ class TestJobTemplateCopyEdit:
# random user given JT and project admin abilities # random user given JT and project admin abilities
jt_copy_edit.admin_role.members.add(rando) jt_copy_edit.admin_role.members.add(rando)
jt_copy_edit.save()
jt_copy_edit.project.admin_role.members.add(rando) jt_copy_edit.project.admin_role.members.add(rando)
jt_copy_edit.organization.job_template_admin_role.members.add(rando) jt_copy_edit.project.save()
serializer = JobTemplateSerializer(jt_copy_edit, context=self.fake_context(rando)) serializer = JobTemplateSerializer(jt_copy_edit, context=self.fake_context(rando))
response = serializer.to_representation(jt_copy_edit) response = serializer.to_representation(jt_copy_edit)

View File

@@ -39,29 +39,6 @@ class TestUnifiedOrganization:
data['ask_inventory_on_launch'] = True data['ask_inventory_on_launch'] = True
return data return data
def test_organization_required_on_creation(self, model, admin_user, post):
cls = getattr(models, model)
data = self.data_for_model(model)
r = post(
url=reverse('api:{}_list'.format(get_type_for_model(cls))),
data=data,
user=admin_user,
expect=400
)
assert 'organization' in r.data
assert 'required for new object' in r.data['organization'][0]
# Surprising behavior - not providing the key can often give
# different behavior from giving it as null on create
data.pop('organization')
r = post(
url=reverse('api:{}_list'.format(get_type_for_model(cls))),
data=data,
user=admin_user,
expect=400
)
assert 'organization' in r.data
assert 'required' in r.data['organization'][0]
def test_organization_blank_on_edit_of_orphan(self, model, admin_user, patch): def test_organization_blank_on_edit_of_orphan(self, model, admin_user, patch):
cls = getattr(models, model) cls = getattr(models, model)
data = self.data_for_model(model, orm_style=True) data = self.data_for_model(model, orm_style=True)
@@ -107,15 +84,3 @@ class TestUnifiedOrganization:
) )
obj.refresh_from_db() obj.refresh_from_db()
assert obj.name == 'foooooo' assert obj.name == 'foooooo'
def test_organization_cannot_change_to_null(self, model, admin_user, patch, organization):
cls = getattr(models, model)
data = self.data_for_model(model, orm_style=True)
data['organization'] = organization
obj = cls.objects.create(**data)
patch(
url=obj.get_absolute_url(),
data={'organization': None},
user=admin_user,
expect=400
)

View File

@@ -75,26 +75,24 @@ def user():
@pytest.fixture @pytest.fixture
def check_jobtemplate(project, inventory, credential, organization): def check_jobtemplate(project, inventory, credential):
jt = JobTemplate.objects.create( jt = JobTemplate.objects.create(
job_type='check', job_type='check',
project=project, project=project,
inventory=inventory, inventory=inventory,
name='check-job-template', name='check-job-template'
organization=organization
) )
jt.credentials.add(credential) jt.credentials.add(credential)
return jt return jt
@pytest.fixture @pytest.fixture
def deploy_jobtemplate(project, inventory, credential, organization): def deploy_jobtemplate(project, inventory, credential):
jt = JobTemplate.objects.create( jt = JobTemplate.objects.create(
job_type='run', job_type='run',
project=project, project=project,
inventory=inventory, inventory=inventory,
name='deploy-job-template', name='deploy-job-template'
organization=organization
) )
jt.credentials.add(credential) jt.credentials.add(credential)
return jt return jt

View File

@@ -1,6 +1,9 @@
import pytest import pytest
from awx.main.models import JobTemplate, Job, JobHostSummary, WorkflowJob, Inventory from awx.main.models import (
JobTemplate, Job, JobHostSummary,
WorkflowJob, Inventory, Project, Organization
)
@pytest.mark.django_db @pytest.mark.django_db
@@ -79,6 +82,22 @@ def test_job_host_summary_representation(host):
assert 'N/A changed=1 dark=2 failures=3 ignored=4 ok=5 processed=6 rescued=7 skipped=8' == str(jhs) assert 'N/A changed=1 dark=2 failures=3 ignored=4 ok=5 processed=6 rescued=7 skipped=8' == str(jhs)
@pytest.mark.django_db
def test_jt_organization_follows_project():
org1 = Organization.objects.create(name='foo1')
org2 = Organization.objects.create(name='foo2')
project1 = Project.objects.create(name='proj1', organization=org1)
project2 = Project.objects.create(name='proj2', organization=org2)
jt = JobTemplate.objects.create(
name='foo', playbook='helloworld.yml',
project=project1
)
assert jt.organization == org1
jt.project = project2
jt.save()
assert JobTemplate.objects.get(pk=jt.id).organization == org2
@pytest.mark.django_db @pytest.mark.django_db
class TestSlicingModels: class TestSlicingModels:

View File

@@ -13,6 +13,7 @@ from awx.main.models import (
WorkflowApprovalTemplate, Project, WorkflowJob, Schedule, WorkflowApprovalTemplate, Project, WorkflowJob, Schedule,
Credential Credential
) )
from awx.api.versioning import reverse
@pytest.mark.django_db @pytest.mark.django_db
@@ -26,6 +27,29 @@ def test_subclass_types(rando):
]) ])
@pytest.mark.django_db
def test_soft_unique_together(post, project, admin_user):
"""This tests that SOFT_UNIQUE_TOGETHER restrictions are applied correctly.
"""
jt1 = JobTemplate.objects.create(
name='foo_jt',
project=project
)
assert jt1.organization == project.organization
r = post(
url=reverse('api:job_template_list'),
data=dict(
name='foo_jt', # same as first
project=project.id,
ask_inventory_on_launch=True,
playbook='helloworld.yml'
),
user=admin_user,
expect=400
)
assert 'combination already exists' in str(r.data)
@pytest.mark.django_db @pytest.mark.django_db
class TestCreateUnifiedJob: class TestCreateUnifiedJob:
''' '''

View File

@@ -11,11 +11,10 @@ from awx.main.tasks import deep_copy_model_obj
@pytest.mark.django_db @pytest.mark.django_db
def test_job_template_copy(post, get, project, inventory, organization, machine_credential, vault_credential, def test_job_template_copy(post, get, project, inventory, machine_credential, vault_credential,
credential, alice, job_template_with_survey_passwords, admin): credential, alice, job_template_with_survey_passwords, admin):
job_template_with_survey_passwords.project = project job_template_with_survey_passwords.project = project
job_template_with_survey_passwords.inventory = inventory job_template_with_survey_passwords.inventory = inventory
job_template_with_survey_passwords.organization = organization
job_template_with_survey_passwords.save() job_template_with_survey_passwords.save()
job_template_with_survey_passwords.credentials.add(credential) job_template_with_survey_passwords.credentials.add(credential)
job_template_with_survey_passwords.credentials.add(machine_credential) job_template_with_survey_passwords.credentials.add(machine_credential)
@@ -23,7 +22,6 @@ def test_job_template_copy(post, get, project, inventory, organization, machine_
job_template_with_survey_passwords.admin_role.members.add(alice) job_template_with_survey_passwords.admin_role.members.add(alice)
project.admin_role.members.add(alice) project.admin_role.members.add(alice)
inventory.admin_role.members.add(alice) inventory.admin_role.members.add(alice)
organization.job_template_admin_role.members.add(alice)
assert get( assert get(
reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}), reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}),
alice, expect=200 alice, expect=200

View File

@@ -1,7 +1,7 @@
import pytest import pytest
from unittest import mock from unittest import mock
from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate, Organization from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate
from awx.main.models.ha import Instance, InstanceGroup from awx.main.models.ha import Instance, InstanceGroup
from awx.main.tasks import apply_cluster_membership_policies from awx.main.tasks import apply_cluster_membership_policies
from awx.api.versioning import reverse from awx.api.versioning import reverse
@@ -253,7 +253,7 @@ def test_inherited_instance_group_membership(instance_group_factory, default_ins
j.inventory = inventory j.inventory = inventory
ig_org = instance_group_factory("basicA", [default_instance_group.instances.first()]) ig_org = instance_group_factory("basicA", [default_instance_group.instances.first()])
ig_inv = instance_group_factory("basicB", [default_instance_group.instances.first()]) ig_inv = instance_group_factory("basicB", [default_instance_group.instances.first()])
j.organization.instance_groups.add(ig_org) j.project.organization.instance_groups.add(ig_org)
j.inventory.instance_groups.add(ig_inv) j.inventory.instance_groups.add(ig_inv)
assert ig_org in j.preferred_instance_groups assert ig_org in j.preferred_instance_groups
assert ig_inv in j.preferred_instance_groups assert ig_inv in j.preferred_instance_groups
@@ -320,14 +320,13 @@ class TestInstanceGroupOrdering:
assert pu.preferred_instance_groups == [ig_tmp, ig_org] assert pu.preferred_instance_groups == [ig_tmp, ig_org]
def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group): def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group):
org = Organization.objects.create(name='foo') jt = JobTemplate.objects.create(inventory=inventory, project=project)
jt = JobTemplate.objects.create(inventory=inventory, project=project, organization=org) job = jt.create_unified_job()
job = Job.objects.create(inventory=inventory, job_template=jt, project=project, organization=org)
assert job.preferred_instance_groups == [default_instance_group] assert job.preferred_instance_groups == [default_instance_group]
ig_org = instance_group_factory("OrgIstGrp", [default_instance_group.instances.first()]) ig_org = instance_group_factory("OrgIstGrp", [default_instance_group.instances.first()])
ig_inv = instance_group_factory("InvIstGrp", [default_instance_group.instances.first()]) ig_inv = instance_group_factory("InvIstGrp", [default_instance_group.instances.first()])
ig_tmp = instance_group_factory("TmpIstGrp", [default_instance_group.instances.first()]) ig_tmp = instance_group_factory("TmpIstGrp", [default_instance_group.instances.first()])
jt.organization.instance_groups.add(ig_org) project.organization.instance_groups.add(ig_org)
inventory.instance_groups.add(ig_inv) inventory.instance_groups.add(ig_inv)
assert job.preferred_instance_groups == [ig_inv, ig_org] assert job.preferred_instance_groups == [ig_inv, ig_org]
job.job_template.instance_groups.add(ig_tmp) job.job_template.instance_groups.add(ig_tmp)

View File

@@ -1,5 +1,7 @@
import pytest import pytest
from rest_framework.exceptions import PermissionDenied
from awx.main.access import ( from awx.main.access import (
JobAccess, JobAccess,
JobLaunchConfigAccess, JobLaunchConfigAccess,
@@ -171,9 +173,11 @@ class TestJobRelaunchAccess:
machine_credential.use_role.members.add(u) machine_credential.use_role.members.add(u)
access = JobAccess(u) access = JobAccess(u)
assert access.can_start(job_with_links, validate_license=False) == can_start, ( if can_start:
"Inventory access: {}\nCredential access: {}\n Expected access: {}".format(inv_access, cred_access, can_start) assert access.can_start(job_with_links, validate_license=False)
) else:
with pytest.raises(PermissionDenied):
access.can_start(job_with_links, validate_license=False)
def test_job_relaunch_credential_access( def test_job_relaunch_credential_access(
self, inventory, project, credential, net_credential): self, inventory, project, credential, net_credential):
@@ -188,7 +192,8 @@ class TestJobRelaunchAccess:
# Job has prompted net credential, launch denied w/ message # Job has prompted net credential, launch denied w/ message
job = jt.create_unified_job(credentials=[net_credential]) job = jt.create_unified_job(credentials=[net_credential])
assert not jt_user.can_access(Job, 'start', job, validate_license=False) with pytest.raises(PermissionDenied):
jt_user.can_access(Job, 'start', job, validate_license=False)
def test_prompted_credential_relaunch_denied( def test_prompted_credential_relaunch_denied(
self, inventory, project, net_credential, rando): self, inventory, project, net_credential, rando):
@@ -201,7 +206,8 @@ class TestJobRelaunchAccess:
# Job has prompted net credential, rando lacks permission to use it # Job has prompted net credential, rando lacks permission to use it
job = jt.create_unified_job(credentials=[net_credential]) job = jt.create_unified_job(credentials=[net_credential])
assert not rando.can_access(Job, 'start', job, validate_license=False) with pytest.raises(PermissionDenied):
rando.can_access(Job, 'start', job, validate_license=False)
def test_prompted_credential_relaunch_allowed( def test_prompted_credential_relaunch_allowed(
self, inventory, project, net_credential, rando): self, inventory, project, net_credential, rando):

View File

@@ -1,5 +1,7 @@
import pytest import pytest
from rest_framework.exceptions import PermissionDenied
from awx.main.models.inventory import Inventory from awx.main.models.inventory import Inventory
from awx.main.models.credential import Credential from awx.main.models.credential import Credential
from awx.main.models.jobs import JobTemplate, Job from awx.main.models.jobs import JobTemplate, Job
@@ -121,6 +123,7 @@ class TestJobRelaunchAccess:
def test_orphan_relaunch_via_organization(self, job_no_prompts, rando, organization): def test_orphan_relaunch_via_organization(self, job_no_prompts, rando, organization):
"JT for job has been deleted, relevant organization roles will allow management" "JT for job has been deleted, relevant organization roles will allow management"
assert job_no_prompts.organization == organization
organization.execute_role.members.add(rando) organization.execute_role.members.add(rando)
job_no_prompts.job_template.delete() job_no_prompts.job_template.delete()
job_no_prompts.job_template = None # Django should do this for us, but it does not job_no_prompts.job_template = None # Django should do this for us, but it does not
@@ -129,7 +132,9 @@ class TestJobRelaunchAccess:
def test_no_relaunch_without_prompted_fields_access(self, job_with_prompts, rando): def test_no_relaunch_without_prompted_fields_access(self, job_with_prompts, rando):
"Has JT execute_role but no use_role on inventory & credential - deny relaunch" "Has JT execute_role but no use_role on inventory & credential - deny relaunch"
job_with_prompts.job_template.execute_role.members.add(rando) job_with_prompts.job_template.execute_role.members.add(rando)
assert not rando.can_access(Job, 'start', job_with_prompts) with pytest.raises(PermissionDenied) as exc:
rando.can_access(Job, 'start', job_with_prompts)
assert 'Job was launched with prompted fields you do not have access to' in str(exc)
def test_can_relaunch_with_prompted_fields_access(self, job_with_prompts, rando): def test_can_relaunch_with_prompted_fields_access(self, job_with_prompts, rando):
"Has use_role on the prompted inventory & credential - allow relaunch" "Has use_role on the prompted inventory & credential - allow relaunch"
@@ -148,11 +153,15 @@ class TestJobRelaunchAccess:
jt.ask_limit_on_launch = False jt.ask_limit_on_launch = False
jt.save() jt.save()
jt.execute_role.members.add(rando) jt.execute_role.members.add(rando)
assert not rando.can_access(Job, 'start', job_with_prompts) with pytest.raises(PermissionDenied):
rando.can_access(Job, 'start', job_with_prompts)
def test_can_relaunch_if_limit_was_prompt(self, job_with_prompts, rando): def test_can_relaunch_if_limit_was_prompt(self, job_with_prompts, rando):
"Job state differs from JT, but only on prompted fields - allow relaunch" "Job state differs from JT, but only on prompted fields - allow relaunch"
job_with_prompts.job_template.execute_role.members.add(rando) job_with_prompts.job_template.execute_role.members.add(rando)
job_with_prompts.limit = 'webservers' job_with_prompts.limit = 'webservers'
job_with_prompts.save() job_with_prompts.save()
assert not rando.can_access(Job, 'start', job_with_prompts) job_with_prompts.inventory.use_role.members.add(rando)
for cred in job_with_prompts.credentials.all():
cred.use_role.members.add(rando)
assert rando.can_access(Job, 'start', job_with_prompts)

View File

@@ -8,8 +8,7 @@ from awx.main.access import (
ScheduleAccess ScheduleAccess
) )
from awx.main.models.jobs import JobTemplate from awx.main.models.jobs import JobTemplate
from awx.main.models.organization import Organization from awx.main.models import Project, Organization, Inventory, Schedule, User
from awx.main.models.schedules import Schedule
@mock.patch.object(BaseAccess, 'check_license', return_value=None) @mock.patch.object(BaseAccess, 'check_license', return_value=None)
@@ -126,11 +125,11 @@ def test_job_template_extra_credentials_prompts_access(
) )
jt.credentials.add(machine_credential) jt.credentials.add(machine_credential)
jt.execute_role.members.add(rando) jt.execute_role.members.add(rando)
r = post( post(
reverse('api:job_template_launch', kwargs={'pk': jt.id}), reverse('api:job_template_launch', kwargs={'pk': jt.id}),
{'credentials': [machine_credential.pk, vault_credential.pk]}, rando {'credentials': [machine_credential.pk, vault_credential.pk]}, rando,
expect=403
) )
assert r.status_code == 403
@pytest.mark.django_db @pytest.mark.django_db
@@ -188,16 +187,12 @@ def test_job_template_creator_access(project, organization, rando, post):
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.job_permissions @pytest.mark.job_permissions
@pytest.mark.parametrize('lacking', ['project', 'inventory', 'organization']) @pytest.mark.parametrize('lacking', ['project', 'inventory'])
def test_job_template_insufficient_creator_permissions(lacking, project, inventory, organization, rando, post): def test_job_template_insufficient_creator_permissions(lacking, project, inventory, organization, rando, post):
if lacking != 'project': if lacking != 'project':
project.use_role.members.add(rando) project.use_role.members.add(rando)
else: else:
project.read_role.members.add(rando) project.read_role.members.add(rando)
if lacking != 'organization':
organization.job_template_admin_role.members.add(rando)
else:
organization.member_role.members.add(rando)
if lacking != 'inventory': if lacking != 'inventory':
inventory.use_role.members.add(rando) inventory.use_role.members.add(rando)
else: else:
@@ -206,7 +201,6 @@ def test_job_template_insufficient_creator_permissions(lacking, project, invento
name='newly-created-jt', name='newly-created-jt',
inventory=inventory.id, inventory=inventory.id,
project=project.pk, project=project.pk,
organization=organization.id,
playbook='helloworld.yml' playbook='helloworld.yml'
), user=rando, expect=403) ), user=rando, expect=403)
@@ -278,25 +272,104 @@ class TestJobTemplateSchedules:
@pytest.mark.django_db @pytest.mark.django_db
def test_jt_org_ownership_change(user, jt_linked): class TestProjectOrganization:
admin1 = user('admin1') """Tests stories related to management of JT organization via its project
org1 = jt_linked.organization which have some bearing on RBAC integrity
org1.admin_role.members.add(admin1) """
a1_access = JobTemplateAccess(admin1)
assert a1_access.can_read(jt_linked) def test_new_project_org_change(self, project, patch, admin_user):
org2 = Organization.objects.create(name='bar')
patch(
url=project.get_absolute_url(),
data={'organization': org2.id},
user=admin_user,
expect=200
)
assert Project.objects.get(pk=project.id).organization_id == org2.id
def test_jt_org_cannot_change(self, project, post, patch, admin_user):
post(
url=reverse('api:job_template_list'),
data={
'name': 'foo_template',
'project': project.id,
'playbook': 'helloworld.yml',
'ask_inventory_on_launch': True
},
user=admin_user,
expect=201
)
org2 = Organization.objects.create(name='bar')
r = patch(
url=project.get_absolute_url(),
data={'organization': org2.id},
user=admin_user,
expect=400
)
assert 'Organization cannot be changed' in str(r.data)
admin2 = user('admin2') def test_orphan_JT_adoption(self, project, patch, admin_user, org_admin):
org2 = Organization.objects.create(name='mrroboto', description='domo') jt = JobTemplate.objects.create(
org2.admin_role.members.add(admin2) name='bar',
a2_access = JobTemplateAccess(admin2) ask_inventory_on_launch=True,
playbook='helloworld.yml'
)
assert org_admin not in jt.admin_role
patch(
url=jt.get_absolute_url(),
data={'project': project.id},
user=admin_user,
expect=200
)
assert org_admin in jt.admin_role
assert not a2_access.can_read(jt_linked) def test_inventory_read_transfer_direct(self, patch):
orgs = []
invs = []
admins = []
for i in range(2):
org = Organization.objects.create(name='org{}'.format(i))
org_admin = User.objects.create(username='user{}'.format(i))
inv = Inventory.objects.create(
organization=org,
name='inv{}'.format(i)
)
org.auditor_role.members.add(org_admin)
orgs.append(org)
admins.append(org_admin)
invs.append(inv)
jt_linked.organization = org2 jt = JobTemplate.objects.create(name='foo', inventory=invs[0])
jt_linked.save() assert admins[0] in jt.read_role
assert admins[1] not in jt.read_role
assert a2_access.can_read(jt_linked) jt.inventory = invs[1]
assert not a1_access.can_read(jt_linked) jt.save(update_fields=['inventory'])
assert admins[0] not in jt.read_role
assert admins[1] in jt.read_role
def test_inventory_read_transfer_indirect(self, patch):
orgs = []
admins = []
for i in range(2):
org = Organization.objects.create(name='org{}'.format(i))
org_admin = User.objects.create(username='user{}'.format(i))
org.auditor_role.members.add(org_admin)
orgs.append(org)
admins.append(org_admin)
inv = Inventory.objects.create(
organization=orgs[0],
name='inv{}'.format(i)
)
jt = JobTemplate.objects.create(name='foo', inventory=inv)
assert admins[0] in jt.read_role
assert admins[1] not in jt.read_role
inv.organization = orgs[1]
inv.save(update_fields=['organization'])
assert admins[0] not in jt.read_role
assert admins[1] in jt.read_role

View File

@@ -1,11 +1,14 @@
import pytest import pytest
from django.apps import apps
from awx.main.migrations import _rbac as rbac from awx.main.migrations import _rbac as rbac
from awx.main.models import ( from awx.main.models import (
UnifiedJobTemplate, UnifiedJobTemplate,
InventorySource, Inventory, InventorySource, Inventory,
JobTemplate, Project, JobTemplate, Project,
Organization Organization,
User
) )
@@ -62,3 +65,37 @@ def test_implied_organization_subquery_job_template():
assert jt.test_field is None assert jt.test_field is None
else: else:
assert jt.test_field == jt.project.organization_id assert jt.test_field == jt.project.organization_id
@pytest.mark.django_db
def test_give_explicit_inventory_permission():
dual_admin = User.objects.create(username='alice')
inv_admin = User.objects.create(username='bob')
inv_org = Organization.objects.create(name='inv-org')
proj_org = Organization.objects.create(name='proj-org')
inv_org.admin_role.members.add(inv_admin, dual_admin)
proj_org.admin_role.members.add(dual_admin)
proj = Project.objects.create(
name="test-proj",
organization=proj_org
)
inv = Inventory.objects.create(
name='test-inv',
organization=inv_org
)
jt = JobTemplate.objects.create(
name='foo',
project=proj,
inventory=inv
)
assert dual_admin in jt.admin_role
rbac.restore_inventory_admins(apps, None)
assert inv_admin in jt.admin_role.members.all()
assert dual_admin not in jt.admin_role.members.all()
assert dual_admin in jt.admin_role

View File

@@ -6,6 +6,7 @@ from awx.main.models import (
UnifiedJobTemplate, UnifiedJobTemplate,
WorkflowJob, WorkflowJob,
WorkflowJobNode, WorkflowJobNode,
WorkflowApprovalTemplate,
Job, Job,
User, User,
Project, Project,
@@ -70,7 +71,9 @@ def test_organization_copy_to_jobs():
All unified job types should infer their organization from their template organization All unified job types should infer their organization from their template organization
''' '''
for cls in UnifiedJobTemplate.__subclasses__(): for cls in UnifiedJobTemplate.__subclasses__():
assert 'organization' in cls._get_unified_job_field_names() if cls is WorkflowApprovalTemplate:
continue # these do not track organization
assert 'organization' in cls._get_unified_job_field_names(), cls
def test_log_representation(): def test_log_representation():

View File

@@ -315,3 +315,8 @@ def generate_graph(models):
settings.NAMED_URL_GRAPH = largest_graph settings.NAMED_URL_GRAPH = largest_graph
for node in settings.NAMED_URL_GRAPH.values(): for node in settings.NAMED_URL_GRAPH.values():
node.add_bindings() node.add_bindings()
def reset_counters():
for node in settings.NAMED_URL_GRAPH.values():
node.counter = 0

View File

@@ -7,7 +7,7 @@ from awxkit.utils import (
suppress, suppress,
update_payload, update_payload,
PseudoNamespace) PseudoNamespace)
from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate, Organization from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate
from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, HasSurvey, HasCopy, DSAdapter from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, HasSurvey, HasCopy, DSAdapter
from awxkit.api.resources import resources from awxkit.api.resources import resources
import awxkit.exceptions as exc import awxkit.exceptions as exc
@@ -23,7 +23,7 @@ class JobTemplate(
HasSurvey, HasSurvey,
UnifiedJobTemplate): UnifiedJobTemplate):
optional_dependencies = [Organization, Inventory, Credential, Project] optional_dependencies = [Inventory, Credential, Project]
def launch(self, payload={}): def launch(self, payload={}):
"""Launch the job_template using related->launch endpoint.""" """Launch the job_template using related->launch endpoint."""
@@ -129,7 +129,6 @@ class JobTemplate(
playbook='ping.yml', playbook='ping.yml',
credential=Credential, credential=Credential,
inventory=Inventory, inventory=Inventory,
organization=Organization,
project=None, project=None,
**kwargs): **kwargs):
if not project: if not project:
@@ -149,18 +148,12 @@ class JobTemplate(
project = self.ds.project if project else None project = self.ds.project if project else None
inventory = self.ds.inventory if inventory else None inventory = self.ds.inventory if inventory else None
credential = self.ds.credential if credential else None credential = self.ds.credential if credential else None
# if the created project has an organization, and the parameters
# specified no organization, then borrow the one from the project
if hasattr(project.ds, 'organization') and organization is Organization:
self.ds.organization = project.ds.organization
organization = self.ds.organization
payload = self.payload( payload = self.payload(
name=name, name=name,
description=description, description=description,
job_type=job_type, job_type=job_type,
playbook=playbook, playbook=playbook,
organization=organization,
credential=credential, credential=credential,
inventory=inventory, inventory=inventory,
project=project, project=project,
@@ -176,12 +169,11 @@ class JobTemplate(
playbook='ping.yml', playbook='ping.yml',
credential=Credential, credential=Credential,
inventory=Inventory, inventory=Inventory,
organization=Organization,
project=None, project=None,
**kwargs): **kwargs):
payload, credential = self.create_payload(name=name, description=description, job_type=job_type, payload, credential = self.create_payload(name=name, description=description, job_type=job_type,
playbook=playbook, credential=credential, inventory=inventory, playbook=playbook, credential=credential, inventory=inventory,
project=project, organization=organization, **kwargs) project=project, **kwargs)
ret = self.update_identity( ret = self.update_identity(
JobTemplates( JobTemplates(
self.connection).post(payload)) self.connection).post(payload))

View File

@@ -12,7 +12,7 @@ from . import page
class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate): class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate):
dependencies = [Organization] optional_dependencies = [Organization]
def launch(self, payload={}): def launch(self, payload={}):
"""Launch using related->launch endpoint.""" """Launch using related->launch endpoint."""
@@ -71,14 +71,14 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi
return payload return payload
def create_payload(self, name='', description='', organization=Organization, **kwargs): def create_payload(self, name='', description='', organization=None, **kwargs):
self.create_and_update_dependencies(*filter_by_class((organization, Organization))) self.create_and_update_dependencies(*filter_by_class((organization, Organization)))
organization = self.ds.organization if organization else None organization = self.ds.organization if organization else None
payload = self.payload(name=name, description=description, organization=organization, **kwargs) payload = self.payload(name=name, description=description, organization=organization, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store) payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload return payload
def create(self, name='', description='', organization=Organization, **kwargs): def create(self, name='', description='', organization=None, **kwargs):
payload = self.create_payload(name=name, description=description, organization=organization, **kwargs) payload = self.create_payload(name=name, description=description, organization=organization, **kwargs)
return self.update_identity(WorkflowJobTemplates(self.connection).post(payload)) return self.update_identity(WorkflowJobTemplates(self.connection).post(payload))

View File

@@ -63,6 +63,7 @@ class Resources(object):
_inventory_related_root_groups = r'inventories/\d+/root_groups/' _inventory_related_root_groups = r'inventories/\d+/root_groups/'
_inventory_related_script = r'inventories/\d+/script/' _inventory_related_script = r'inventories/\d+/script/'
_inventory_related_update_inventory_sources = r'inventories/\d+/update_inventory_sources/' _inventory_related_update_inventory_sources = r'inventories/\d+/update_inventory_sources/'
_inventory_scan_job_templates = r'inventories/\d+/scan_job_templates/'
_inventory_script = r'inventory_scripts/\d+/' _inventory_script = r'inventory_scripts/\d+/'
_inventory_script_copy = r'inventory_scripts/\d+/copy/' _inventory_script_copy = r'inventory_scripts/\d+/copy/'
_inventory_scripts = 'inventory_scripts/' _inventory_scripts = 'inventory_scripts/'