diff --git a/Makefile b/Makefile index a85aef6b69..1e967d88d5 100644 --- a/Makefile +++ b/Makefile @@ -249,7 +249,19 @@ rebase: push: git push origin master -virtualenv: +virtualenv: virtualenv_ansible virtualenv_tower + +virtualenv_ansible: + if [ "$(VENV_BASE)" ]; then \ + if [ ! -d "$(VENV_BASE)" ]; then \ + mkdir $(VENV_BASE); \ + fi; \ + if [ ! -d "$(VENV_BASE)/ansible" ]; then \ + virtualenv --system-site-packages $(VENV_BASE)/ansible; \ + fi; \ + fi + +virtualenv_tower: if [ "$(VENV_BASE)" ]; then \ if [ ! -d "$(VENV_BASE)" ]; then \ mkdir $(VENV_BASE); \ @@ -257,12 +269,9 @@ virtualenv: if [ ! -d "$(VENV_BASE)/tower" ]; then \ virtualenv --system-site-packages $(VENV_BASE)/tower; \ fi; \ - if [ ! -d "$(VENV_BASE)/ansible" ]; then \ - virtualenv --system-site-packages $(VENV_BASE)/ansible; \ - fi; \ fi -requirements_ansible: +requirements_ansible: virtualenv_ansible if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/ansible/bin/activate; \ $(VENV_BASE)/ansible/bin/pip install -U pip==8.1.1; \ @@ -273,7 +282,7 @@ requirements_ansible: fi # Install third-party requirements needed for Tower's environment. -requirements_tower: +requirements_tower: virtualenv_tower if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/tower/bin/activate; \ $(VENV_BASE)/tower/bin/pip install -U pip==8.1.1; \ @@ -299,7 +308,7 @@ requirements_jenkins: fi && \ $(NPM_BIN) install csslint jshint -requirements: virtualenv requirements_ansible requirements_tower +requirements: requirements_ansible requirements_tower requirements_dev: requirements requirements_tower_dev @@ -640,7 +649,7 @@ tar-build/$(SETUP_TAR_FILE): @cp -a setup tar-build/$(SETUP_TAR_NAME) @rsync -az docs/licenses tar-build/$(SETUP_TAR_NAME)/ @cd tar-build/$(SETUP_TAR_NAME) && sed -e 's#%NAME%#$(NAME)#;s#%VERSION%#$(VERSION)#;s#%RELEASE%#$(RELEASE)#;' group_vars/all.in > group_vars/all - @cd tar-build && tar -czf $(SETUP_TAR_FILE) --exclude "*/all.in" $(SETUP_TAR_NAME)/ + @cd tar-build && tar -czf $(SETUP_TAR_FILE) --exclude "*/all.in" --exclude "**/test/*" $(SETUP_TAR_NAME)/ @ln -sf $(SETUP_TAR_FILE) tar-build/$(SETUP_TAR_LINK) tar-build/$(SETUP_TAR_CHECKSUM): diff --git a/awx/api/filters.py b/awx/api/filters.py index 367fd0eda5..d023b2aa08 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -131,6 +131,8 @@ class FieldLookupBackend(BaseFilterBackend): value = to_python_boolean(value) elif new_lookup.endswith('__in'): items = [] + if not value: + raise ValueError('cannot provide empty value for __in') for item in value.split(','): items.append(self.value_to_python_for_field(field, item)) value = items @@ -218,7 +220,7 @@ class FieldLookupBackend(BaseFilterBackend): q = Q(**{k:v}) queryset = queryset.filter(q) queryset = queryset.filter(*args) - return queryset.distinct() + return queryset except (FieldError, FieldDoesNotExist, ValueError), e: raise ParseError(e.args[0]) except ValidationError, e: diff --git a/awx/api/generics.py b/awx/api/generics.py index cdd10e497d..946b7264c7 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -25,6 +25,7 @@ from rest_framework import views # AWX from awx.main.models import * # noqa +from awx.main.models import Label from awx.main.utils import * # noqa from awx.api.serializers import ResourceAccessListElementSerializer @@ -35,7 +36,8 @@ __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView', 'RetrieveUpdateDestroyAPIView', 'DestroyAPIView', 'SubDetailAPIView', 'ResourceAccessList', - 'ParentMixin',] + 'ParentMixin', + 'DeleteLastUnattachLabelMixin',] logger = logging.getLogger('awx.api.generics') @@ -399,12 +401,15 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView): else: return Response(status=status.HTTP_204_NO_CONTENT) - def unattach(self, request, *args, **kwargs): + def unattach_validate(self, request): sub_id = request.data.get('id', None) + res = None if not sub_id: data = dict(msg='"id" is required to disassociate') - return Response(data, status=status.HTTP_400_BAD_REQUEST) + res = Response(data, status=status.HTTP_400_BAD_REQUEST) + return (sub_id, res) + def unattach_by_id(self, request, sub_id): parent = self.get_parent_object() parent_key = getattr(self, 'parent_key', None) relationship = getattrd(parent, self.relationship) @@ -421,6 +426,12 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView): return Response(status=status.HTTP_204_NO_CONTENT) + def unattach(self, request, *args, **kwargs): + (sub_id, res) = self.unattach_validate(request) + if res: + return res + return self.unattach_by_id(request, sub_id) + def post(self, request, *args, **kwargs): if not isinstance(request.data, dict): return Response('invalid type for post data', @@ -430,6 +441,21 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView): else: return self.attach(request, *args, **kwargs) +class DeleteLastUnattachLabelMixin(object): + def unattach(self, request, *args, **kwargs): + (sub_id, res) = super(DeleteLastUnattachLabelMixin, self).unattach_validate(request, *args, **kwargs) + if res: + return res + + res = super(DeleteLastUnattachLabelMixin, self).unattach_by_id(request, sub_id) + + label = Label.objects.get(id=sub_id) + + if label.is_detached(): + label.delete() + + return res + class SubDetailAPIView(generics.RetrieveAPIView, GenericAPIView, ParentMixin): pass @@ -474,7 +500,7 @@ class ResourceAccessList(ListAPIView): def get_queryset(self): self.object_id = self.kwargs['pk'] resource_model = getattr(self, 'resource_model') - obj = resource_model.objects.get(pk=self.object_id) + obj = get_object_or_404(resource_model, pk=self.object_id) content_type = ContentType.objects.get_for_model(obj) roles = set(Role.objects.filter(content_type=content_type, object_id=obj.id)) @@ -483,4 +509,3 @@ class ResourceAccessList(ListAPIView): for r in roles: ancestors.update(set(r.ancestors.all())) return User.objects.filter(roles__in=list(ancestors)) - diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 07f33002aa..572b4246ad 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -38,7 +38,7 @@ from polymorphic import PolymorphicModel from awx.main.constants import SCHEDULEABLE_PROVIDERS from awx.main.models import * # noqa from awx.main.fields import ImplicitRoleField -from awx.main.utils import get_type_for_model, get_model_for_type, build_url, timestamp_apiformat, camelcase_to_underscore +from awx.main.utils import get_type_for_model, get_model_for_type, build_url, timestamp_apiformat, camelcase_to_underscore, getattrd from awx.main.redact import REPLACE_STR from awx.main.conf import tower_settings @@ -78,7 +78,6 @@ SUMMARIZABLE_FK_FIELDS = { 'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'), 'cloud_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'), 'network_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'net'), - 'permission': DEFAULT_SUMMARY_FIELDS, 'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',), 'job_template': DEFAULT_SUMMARY_FIELDS, 'schedule': DEFAULT_SUMMARY_FIELDS + ('next_run',), @@ -90,6 +89,7 @@ SUMMARIZABLE_FK_FIELDS = { 'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'inventory_source': ('source', 'last_updated', 'status'), 'source_script': ('name', 'description'), + 'role': ('id', 'role_field') } @@ -340,16 +340,18 @@ class BaseSerializer(serializers.ModelSerializer): return None elif isinstance(obj, User): return obj.date_joined - else: + elif hasattr(obj, 'created'): return obj.created + return None def get_modified(self, obj): if obj is None: return None elif isinstance(obj, User): return obj.last_login # Not actually exposed for User. - else: + elif hasattr(obj, 'modified'): return obj.modified + return None def build_standard_field(self, field_name, model_field): # DRF 3.3 serializers.py::build_standard_field() -> utils/field_mapping.py::get_field_kwargs() short circuits @@ -699,7 +701,7 @@ class UserSerializer(BaseSerializer): def validate_password(self, value): if not self.instance and value in (None, ''): - raise serializers.ValidationError('Password required for new User') + raise serializers.ValidationError('Password required for new User.') return value def _update_password(self, obj, new_password): @@ -763,7 +765,7 @@ class UserSerializer(BaseSerializer): ldap_managed_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys()) if field_name in ldap_managed_fields: if value != getattr(self.instance, field_name): - raise serializers.ValidationError('Unable to change %s on user managed by LDAP' % field_name) + raise serializers.ValidationError('Unable to change %s on user managed by LDAP.' % field_name) return value def validate_username(self, value): @@ -796,6 +798,7 @@ class OrganizationSerializer(BaseSerializer): users = reverse('api:organization_users_list', args=(obj.pk,)), admins = reverse('api:organization_admins_list', args=(obj.pk,)), teams = reverse('api:organization_teams_list', args=(obj.pk,)), + credentials = reverse('api:organization_credential_list', args=(obj.pk,)), activity_stream = reverse('api:organization_activity_stream_list', args=(obj.pk,)), notifiers = reverse('api:organization_notifiers_list', args=(obj.pk,)), notifiers_any = reverse('api:organization_notifiers_any_list', args=(obj.pk,)), @@ -961,7 +964,7 @@ class BaseSerializerWithVariables(BaseSerializer): try: yaml.safe_load(value) except yaml.YAMLError: - raise serializers.ValidationError('Must be valid JSON or YAML') + raise serializers.ValidationError('Must be valid JSON or YAML.') return value @@ -1113,7 +1116,7 @@ class HostSerializer(BaseSerializerWithVariables): vars_dict['ansible_ssh_port'] = port attrs['variables'] = yaml.dump(vars_dict) except (yaml.YAMLError, TypeError): - raise serializers.ValidationError('Must be valid JSON or YAML') + raise serializers.ValidationError('Must be valid JSON or YAML.') return super(HostSerializer, self).validate(attrs) @@ -1170,7 +1173,7 @@ class GroupSerializer(BaseSerializerWithVariables): def validate_name(self, value): if value in ('all', '_meta'): - raise serializers.ValidationError('Invalid group name') + raise serializers.ValidationError('Invalid group name.') return value def to_representation(self, obj): @@ -1303,10 +1306,10 @@ class InventorySourceOptionsSerializer(BaseSerializer): else: try: if source_script.organization != self.instance.inventory.organization: - errors['source_script'] = 'source_script does not belong to the same organization as the inventory' + errors['source_script'] = 'source_script does not belong to the same organization as the inventory.' except Exception: # TODO: Log - errors['source_script'] = 'source_script doesn\'t exist' + errors['source_script'] = 'source_script doesn\'t exist.' if errors: raise serializers.ValidationError(errors) @@ -1441,8 +1444,26 @@ class RoleSerializer(BaseSerializer): class Meta: model = Role - fields = ('*', 'description', 'name') - read_only_fields = ('description', 'name') + read_only_fields = ('id', 'role_field', 'description', 'name') + + def to_representation(self, obj): + ret = super(RoleSerializer, self).to_representation(obj) + + def spacify_type_name(cls): + return re.sub(r'([a-z])([A-Z])', '\g<1> \g<2>', cls.__name__) + + if obj.object_id: + content_object = obj.content_object + if hasattr(content_object, 'username'): + ret['summary_fields']['resource_name'] = obj.content_object.username + if hasattr(content_object, 'name'): + ret['summary_fields']['resource_name'] = obj.content_object.name + ret['summary_fields']['resource_type'] = obj.content_type.name + ret['summary_fields']['resource_type_display_name'] = spacify_type_name(obj.content_type.model_class()) + + ret.pop('created') + ret.pop('modified') + return ret def get_related(self, obj): ret = super(RoleSerializer, self).get_related(obj) @@ -1524,6 +1545,15 @@ class ResourceAccessListElementSerializer(UserSerializer): .filter(content_type=team_content_type, members=user, children__in=direct_permissive_role_ids) + if content_type == team_content_type: + # When looking at the access list for a team, exclude the entries + # for that team. This exists primarily so we don't list the read role + # as a direct role when a user is a member or admin of a team + direct_team_roles = direct_team_roles.exclude( + children__content_type=team_content_type, + children__object_id=obj.id + ) + indirect_team_roles = Role.objects \ .filter(content_type=team_content_type, @@ -1553,6 +1583,18 @@ class ResourceAccessListElementSerializer(UserSerializer): class CredentialSerializer(BaseSerializer): # FIXME: may want to make some fields filtered based on user accessing + user = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), required=False, default=None, write_only=True, + help_text='Write-only field used to add user to owner role. If provided, ' + 'do not give either team or organization. Only valid for creation.') + team = serializers.PrimaryKeyRelatedField( + queryset=Team.objects.all(), required=False, default=None, write_only=True, + help_text='Write-only field used to add team to owner role. If provided, ' + 'do not give either user or organization. Only valid for creation.') + organization = serializers.PrimaryKeyRelatedField( + queryset=Organization.objects.all(), required=False, default=None, write_only=True, + help_text='Write-only field used to add organization to owner role. If provided, ' + 'do not give either team or team. Only valid for creation.') class Meta: model = Credential @@ -1561,7 +1603,14 @@ class CredentialSerializer(BaseSerializer): 'ssh_key_data', 'ssh_key_unlock', 'become_method', 'become_username', 'become_password', 'vault_password', 'subscription', 'tenant', 'secret', 'client', - 'authorize', 'authorize_password') + 'authorize', 'authorize_password', + 'user', 'team', 'organization') + + def create(self, validated_data): + # Remove the user, team, and organization processed in view + for field in ['user', 'team', 'organization']: + validated_data.pop(field, None) + return super(CredentialSerializer, self).create(validated_data) def build_standard_field(self, field_name, model_field): field_class, field_kwargs = super(CredentialSerializer, self).build_standard_field(field_name, model_field) @@ -1657,9 +1706,9 @@ class JobOptionsSerializer(BaseSerializer): if not project and job_type != PERM_INVENTORY_SCAN: raise serializers.ValidationError({'project': 'This field is required.'}) if project and playbook and force_text(playbook) not in project.playbooks: - raise serializers.ValidationError({'playbook': 'Playbook not found for project'}) + raise serializers.ValidationError({'playbook': 'Playbook not found for project.'}) if project and not playbook: - raise serializers.ValidationError({'playbook': 'Must select playbook for project'}) + raise serializers.ValidationError({'playbook': 'Must select playbook for project.'}) return super(JobOptionsSerializer, self).validate(attrs) @@ -1720,7 +1769,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): survey_enabled = attrs.get('survey_enabled', self.instance and self.instance.survey_enabled or False) job_type = attrs.get('job_type', self.instance and self.instance.job_type or None) if survey_enabled and job_type == PERM_INVENTORY_SCAN: - raise serializers.ValidationError({'survey_enabled': 'Survey Enabled can not be used with scan jobs'}) + raise serializers.ValidationError({'survey_enabled': 'Survey Enabled can not be used with scan jobs.'}) return super(JobTemplateSerializer, self).validate(attrs) @@ -1733,12 +1782,13 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): ask_tags_on_launch = serializers.ReadOnlyField() ask_job_type_on_launch = serializers.ReadOnlyField() ask_inventory_on_launch = serializers.ReadOnlyField() + ask_credential_on_launch = serializers.ReadOnlyField() class Meta: model = Job fields = ('*', 'job_template', 'passwords_needed_to_start', 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_job_type_on_launch', - 'ask_inventory_on_launch') + 'ask_inventory_on_launch', 'ask_credential_on_launch') def get_related(self, obj): res = super(JobSerializer, self).get_related(obj) @@ -1865,9 +1915,9 @@ class JobRelaunchSerializer(JobSerializer): if not obj.credential: raise serializers.ValidationError(dict(credential=["Credential not found or deleted."])) if obj.job_type != PERM_INVENTORY_SCAN and obj.project is None: - raise serializers.ValidationError(dict(errors=["Job Template Project is missing or undefined"])) + raise serializers.ValidationError(dict(errors=["Job Template Project is missing or undefined."])) if obj.inventory is None: - raise serializers.ValidationError(dict(errors=["Job Template Inventory is missing or undefined"])) + raise serializers.ValidationError(dict(errors=["Job Template Inventory is missing or undefined."])) attrs = super(JobRelaunchSerializer, self).validate(attrs) return attrs @@ -1876,7 +1926,7 @@ class AdHocCommandSerializer(UnifiedJobSerializer): class Meta: model = AdHocCommand fields = ('*', 'job_type', 'inventory', 'limit', 'credential', - 'module_name', 'module_args', 'forks', 'verbosity', + 'module_name', 'module_args', 'forks', 'verbosity', 'extra_vars', 'become_enabled', '-unified_job_template', '-description') extra_kwargs = { 'name': { @@ -2104,6 +2154,8 @@ class JobLaunchSerializer(BaseSerializer): inventory_needed_to_start = serializers.SerializerMethodField() survey_enabled = serializers.SerializerMethodField() extra_vars = VerbatimField(required=False, write_only=True) + job_template_data = serializers.SerializerMethodField() + defaults = serializers.SerializerMethodField() class Meta: model = JobTemplate @@ -2113,7 +2165,8 @@ class JobLaunchSerializer(BaseSerializer): 'ask_job_type_on_launch', 'ask_limit_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled', 'variables_needed_to_start', - 'credential_needed_to_start', 'inventory_needed_to_start',) + 'credential_needed_to_start', 'inventory_needed_to_start', + 'job_template_data', 'defaults') read_only_fields = ('ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch') @@ -2137,6 +2190,21 @@ class JobLaunchSerializer(BaseSerializer): return obj.survey_enabled and 'spec' in obj.survey_spec return False + def get_defaults(self, obj): + ask_for_vars_dict = obj._ask_for_vars_dict() + defaults_dict = {} + for field in ask_for_vars_dict: + if field in ('inventory', 'credential'): + defaults_dict[field] = dict( + name=getattrd(obj, '%s.name' % field, None), + id=getattrd(obj, '%s.pk' % field, None)) + else: + defaults_dict[field] = getattr(obj, field) + return defaults_dict + + def get_job_template_data(self, obj): + return dict(name=obj.name, id=obj.id, description=obj.description) + def validate(self, attrs): errors = {} obj = self.context.get('obj') @@ -2166,8 +2234,9 @@ class JobLaunchSerializer(BaseSerializer): except (ValueError, TypeError): try: extra_vars = yaml.safe_load(extra_vars) - except (yaml.YAMLError, TypeError, AttributeError): - errors['extra_vars'] = 'Must be valid JSON or YAML' + assert isinstance(extra_vars, dict) + except (yaml.YAMLError, TypeError, AttributeError, AssertionError): + errors['extra_vars'] = 'Must be a valid JSON or YAML dictionary' if not isinstance(extra_vars, dict): extra_vars = {} @@ -2178,9 +2247,9 @@ class JobLaunchSerializer(BaseSerializer): errors['variables_needed_to_start'] = validation_errors if obj.job_type != PERM_INVENTORY_SCAN and (obj.project is None): - errors['project'] = 'Job Template Project is missing or undefined' + errors['project'] = 'Job Template Project is missing or undefined.' if (obj.inventory is None) and not attrs.get('inventory', None): - errors['inventory'] = 'Job Template Inventory is missing or undefined' + errors['inventory'] = 'Job Template Inventory is missing or undefined.' if errors: raise serializers.ValidationError(errors) @@ -2318,7 +2387,7 @@ class ScheduleSerializer(BaseSerializer): def validate_unified_job_template(self, value): if type(value) == InventorySource and value.source not in SCHEDULEABLE_PROVIDERS: - raise serializers.ValidationError('Inventory Source must be a cloud resource') + raise serializers.ValidationError('Inventory Source must be a cloud resource.') return value # We reject rrules if: @@ -2510,7 +2579,7 @@ class TowerSettingsSerializer(BaseSerializer): def validate(self, attrs): manifest = settings.TOWER_SETTINGS_MANIFEST if attrs['key'] not in manifest: - raise serializers.ValidationError(dict(key=["Key {0} is not a valid settings key".format(attrs['key'])])) + raise serializers.ValidationError(dict(key=["Key {0} is not a valid settings key.".format(attrs['key'])])) if attrs['value_type'] == 'json': attrs['value'] = json.dumps(attrs['value']) @@ -2544,7 +2613,7 @@ class AuthTokenSerializer(serializers.Serializer): else: raise serializers.ValidationError('Unable to login with provided credentials.') else: - raise serializers.ValidationError('Must include "username" and "password"') + raise serializers.ValidationError('Must include "username" and "password".') class FactVersionSerializer(BaseFactSerializer): diff --git a/awx/api/templates/eula.md b/awx/api/templates/eula.md index 02a884eef2..f07453c04d 100644 --- a/awx/api/templates/eula.md +++ b/awx/api/templates/eula.md @@ -1,20 +1,3 @@ TOWER SOFTWARE END USER LICENSE AGREEMENT -Unless otherwise agreed to, and executed in a definitive agreement, between -Ansible, Inc. (“Ansible”) and the individual or entity (“Customer”) signing or -electronically accepting these terms of use for the Tower Software (“EULA”), -all Tower Software, including any and all versions released or made available -by Ansible, shall be subject to the Ansible Software Subscription and Services -Agreement found at www.ansible.com/subscription-agreement (“Agreement”). -Ansible is not responsible for any additional obligations, conditions or -warranties agreed to between Customer and an authorized distributor, or -reseller, of the Tower Software. BY DOWNLOADING AND USING THE TOWER SOFTWARE, -OR BY CLICKING ON THE “YES” BUTTON OR OTHER BUTTON OR MECHANISM DESIGNED TO -ACKNOWLEDGE CONSENT TO THE TERMS OF AN ELECTRONIC COPY OF THIS EULA, THE -CUSTOMER HEREBY ACKNOWLEDGES THAT CUSTOMER HAS READ, UNDERSTOOD, AND AGREES TO -BE BOUND BY THE TERMS OF THIS EULA AND AGREEMENT, INCLUDING ALL TERMS -INCORPORATED HEREIN BY REFERENCE, AND THAT THIS EULA AND AGREEMENT IS -EQUIVALENT TO ANY WRITTEN NEGOTIATED AGREEMENT BETWEEN CUSTOMER AND ANSIBLE. -THIS EULA AND AGREEMENT IS ENFORCEABLE AGAINST ANY PERSON OR ENTITY THAT USES -OR AVAILS ITSELF OF THE TOWER SOFTWARE OR ANY PERSON OR ENTITY THAT USES THE OR -AVAILS ITSELF OF THE TOWER SOFTWARE ON ANOTHER PERSON’S OR ENTITY’S BEHALF. +Unless otherwise agreed to, and executed in a definitive agreement, between Ansible, Inc. (“Ansible”) and the individual or entity (“Customer”) signing or electronically accepting these terms of use for the Tower Software (“EULA”), all Tower Software, including any and all versions released or made available by Ansible, shall be subject to the Ansible Software Subscription and Services Agreement found at www.ansible.com/subscription-agreement (“Agreement”). Ansible is not responsible for any additional obligations, conditions or warranties agreed to between Customer and an authorized distributor, or reseller, of the Tower Software. BY DOWNLOADING AND USING THE TOWER SOFTWARE, OR BY CLICKING ON THE “YES” BUTTON OR OTHER BUTTON OR MECHANISM DESIGNED TO ACKNOWLEDGE CONSENT TO THE TERMS OF AN ELECTRONIC COPY OF THIS EULA, THE CUSTOMER HEREBY ACKNOWLEDGES THAT CUSTOMER HAS READ, UNDERSTOOD, AND AGREES TO BE BOUND BY THE TERMS OF THIS EULA AND AGREEMENT, INCLUDING ALL TERMS INCORPORATED HEREIN BY REFERENCE, AND THAT THIS EULA AND AGREEMENT IS EQUIVALENT TO ANY WRITTEN NEGOTIATED AGREEMENT BETWEEN CUSTOMER AND ANSIBLE. THIS EULA AND AGREEMENT IS ENFORCEABLE AGAINST ANY PERSON OR ENTITY THAT USES OR AVAILS ITSELF OF THE TOWER SOFTWARE OR ANY PERSON OR ENTITY THAT USES THE OR AVAILS ITSELF OF THE TOWER SOFTWARE ON ANOTHER PERSON’S OR ENTITY’S BEHALF. diff --git a/awx/api/views.py b/awx/api/views.py index 1e72d18bee..aa476f18c6 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -30,6 +30,8 @@ from django.views.decorators.csrf import csrf_exempt from django.template.loader import render_to_string from django.core.servers.basehttp import FileWrapper from django.http import HttpResponse +from django.contrib.contenttypes.models import ContentType + # Django REST Framework from rest_framework.exceptions import PermissionDenied, ParseError @@ -616,9 +618,14 @@ class OrganizationList(ListCreateAPIView): JT_reference = 'project__organization' db_results['job_templates'] = JobTemplate.accessible_objects( - self.request.user, 'read_role').values(JT_reference).annotate( + self.request.user, 'read_role').exclude(job_type='scan').values(JT_reference).annotate( Count(JT_reference)).order_by(JT_reference) + JT_scan_reference = 'inventory__organization' + db_results['job_templates_scan'] = JobTemplate.accessible_objects( + self.request.user, 'read_role').filter(job_type='scan').values(JT_scan_reference).annotate( + Count(JT_scan_reference)).order_by(JT_scan_reference) + db_results['projects'] = project_qs\ .values('organization').annotate(Count('organization')).order_by('organization') @@ -638,6 +645,8 @@ class OrganizationList(ListCreateAPIView): for res in db_results: if res == 'job_templates': org_reference = JT_reference + elif res == 'job_templates_scan': + org_reference = JT_scan_reference elif res == 'users': org_reference = 'id' else: @@ -651,6 +660,12 @@ class OrganizationList(ListCreateAPIView): continue count_context[org_id][res] = entry['%s__count' % org_reference] + # Combine the counts for job templates with scan job templates + for org in org_id_list: + org_id = org['id'] + if 'job_templates_scan' in count_context[org_id]: + count_context[org_id]['job_templates'] += count_context[org_id].pop('job_templates_scan') + full_context['related_field_counts'] = count_context return full_context @@ -684,8 +699,10 @@ class OrganizationDetail(RetrieveUpdateDestroyAPIView): organization__id=org_id).count() 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() + org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).exclude( + job_type='scan').filter(project__organization__id=org_id).count() + org_counts['job_templates'] += JobTemplate.accessible_objects(**access_kwargs).filter( + job_type='scan').filter(inventory__organization__id=org_id).count() full_context['related_field_counts'] = {} full_context['related_field_counts'][org_id] = org_counts @@ -814,10 +831,11 @@ class TeamRolesList(SubListCreateAttachDetachAPIView): relationship='member_role.children' def get_queryset(self): - team = Team.objects.get(pk=self.kwargs['pk']) - return team.member_role.children.filter(id__in=Role.visible_roles(self.request.user)) + team = get_object_or_404(Team, pk=self.kwargs['pk']) + if not self.request.user.can_access(Team, 'read', team): + raise PermissionDenied() + return Role.filter_visible_roles(self.request.user, team.member_role.children.all()) - # XXX: Need to enforce permissions def post(self, request, *args, **kwargs): # Forbid implicit role creation here sub_id = request.data.get('id', None) @@ -1081,8 +1099,12 @@ class UserRolesList(SubListCreateAttachDetachAPIView): permission_classes = (IsAuthenticated,) def get_queryset(self): - #u = User.objects.get(pk=self.kwargs['pk']) - return Role.visible_roles(self.request.user).filter(members__in=[int(self.kwargs['pk']), ]) + u = get_object_or_404(User, pk=self.kwargs['pk']) + if not self.request.user.can_access(User, 'read', u): + raise PermissionDenied() + content_type = ContentType.objects.get_for_model(User) + return Role.filter_visible_roles(self.request.user, u.roles.all()) \ + .exclude(content_type=content_type, object_id=u.id) def post(self, request, *args, **kwargs): # Forbid implicit role creation here @@ -1090,6 +1112,10 @@ class UserRolesList(SubListCreateAttachDetachAPIView): if not sub_id: data = dict(msg='Role "id" field is missing') return Response(data, status=status.HTTP_400_BAD_REQUEST) + + if sub_id == self.request.user.admin_role.pk: + raise PermissionDenied('You may not remove your own admin_role') + return super(UserRolesList, self).post(request, *args, **kwargs) def check_parent_access(self, parent=None): @@ -1205,6 +1231,10 @@ class CredentialList(ListCreateAPIView): serializer_class = CredentialSerializer def post(self, request, *args, **kwargs): + for field in [x for x in ['user', 'team', 'organization'] if x in request.data and request.data[x] in ('', None)]: + request.data.pop(field) + kwargs.pop(field, None) + if not any([x in request.data for x in ['user', 'team', 'organization']]): return Response({'detail': 'Missing user, team, or organization'}, status=status.HTTP_400_BAD_REQUEST) @@ -1213,15 +1243,15 @@ class CredentialList(ListCreateAPIView): if 'user' in request.data: user = User.objects.get(pk=request.data['user']) - obj = user + can_add_params = {'user': user.id} if 'team' in request.data: team = Team.objects.get(pk=request.data['team']) - obj = team + can_add_params = {'team': team.id} if 'organization' in request.data: organization = Organization.objects.get(pk=request.data['organization']) - obj = organization + can_add_params = {'organization': organization.id} - if self.request.user not in obj.admin_role: + if not self.request.user.can_access(Credential, 'add', can_add_params): raise PermissionDenied() ret = super(CredentialList, self).post(request, *args, **kwargs) @@ -1251,8 +1281,7 @@ class UserCredentialsList(CredentialList): return user_creds & visible_creds def post(self, request, *args, **kwargs): - user = User.objects.get(pk=self.kwargs['pk']) - request.data['user'] = user.id + request.data['user'] = self.kwargs['pk'] # The following post takes care of ensuring the current user can add a cred to this user return super(UserCredentialsList, self).post(request, args, kwargs) @@ -1271,8 +1300,7 @@ class TeamCredentialsList(CredentialList): return team_creds & visible_creds def post(self, request, *args, **kwargs): - team = Team.objects.get(pk=self.kwargs['pk']) - request.data['team'] = team.id + request.data['team'] = self.kwargs['pk'] # The following post takes care of ensuring the current user can add a cred to this user return super(TeamCredentialsList, self).post(request, args, kwargs) @@ -1479,7 +1507,7 @@ class HostAllGroupsList(SubListAPIView): def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) - qs = self.request.user.get_queryset(self.model) + qs = self.request.user.get_queryset(self.model).distinct() sublist_qs = parent.all_groups.distinct() return qs & sublist_qs @@ -2263,7 +2291,7 @@ class JobTemplateNotifiersSuccessList(SubListCreateAttachDetachAPIView): parent_model = JobTemplate relationship = 'notifiers_success' -class JobTemplateLabelList(SubListCreateAttachDetachAPIView): +class JobTemplateLabelList(SubListCreateAttachDetachAPIView, DeleteLastUnattachLabelMixin): model = Label serializer_class = LabelSerializer @@ -2454,7 +2482,7 @@ class SystemJobTemplateList(ListAPIView): def get(self, request, *args, **kwargs): if not request.user.is_superuser: - return Response(status=status.HTTP_404_NOT_FOUND) + raise PermissionDenied("Superuser privileges needed") return super(SystemJobTemplateList, self).get(request, *args, **kwargs) class SystemJobTemplateDetail(RetrieveAPIView): @@ -3184,7 +3212,7 @@ class SystemJobList(ListCreateAPIView): def get(self, request, *args, **kwargs): if not request.user.is_superuser: - return Response(status=status.HTTP_404_NOT_FOUND) + raise PermissionDenied("Superuser privileges needed") return super(SystemJobList, self).get(request, *args, **kwargs) @@ -3573,7 +3601,7 @@ class RoleChildrenList(SubListAPIView): # XXX: This should be the intersection between the roles of the user # and the roles that the requesting user has access to see role = Role.objects.get(pk=self.kwargs['pk']) - return role.children + return role.children.all() diff --git a/awx/main/access.py b/awx/main/access.py index 7b28b89ae8..2130997130 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -110,6 +110,18 @@ def check_user_access(user, model_class, action, *args, **kwargs): return result return False +def check_superuser(func): + ''' + check_superuser is a decorator that provides a simple short circuit + for access checks. If the User object is a superuser, return True, otherwise + execute the logic of the can_access method. + ''' + def wrapper(self, *args, **kwargs): + if self.user.is_superuser: + return True + return func(self, *args, **kwargs) + return wrapper + class BaseAccess(object): ''' Base class for checking user access to a given model. Subclasses should @@ -242,10 +254,8 @@ class UserAccess(BaseAccess): # that a user should be able to edit for themselves. return bool(self.user == obj or self.can_admin(obj, data)) + @check_superuser def can_admin(self, obj, data): - # Admin implies changing all user fields. - if self.user.is_superuser: - return True return Organization.objects.filter(member_role__members=obj, admin_role__members=self.user).exists() def can_delete(self, obj): @@ -277,9 +287,8 @@ class OrganizationAccess(BaseAccess): qs = self.model.accessible_objects(self.user, 'read_role') return qs.select_related('created_by', 'modified_by').all() + @check_superuser def can_change(self, obj, data): - if self.user.is_superuser: - return True return self.user in obj.admin_role def can_delete(self, obj): @@ -312,27 +321,25 @@ class InventoryAccess(BaseAccess): qs = self.model.accessible_objects(self.user, 'read_role') return qs.select_related('created_by', 'modified_by', 'organization').all() + @check_superuser def can_read(self, obj): - if self.user.is_superuser: - return True return self.user in obj.read_role + @check_superuser def can_use(self, obj): - if self.user.is_superuser: - return True return self.user in obj.use_role + @check_superuser def can_add(self, data): # If no data is specified, just checking for generic add permission? if not data: return Organization.accessible_objects(self.user, 'admin_role').exists() - if self.user.is_superuser: - return True org_pk = get_pk_from_dict(data, 'organization') org = get_object_or_400(Organization, pk=org_pk) return self.user in org.admin_role + @check_superuser def can_change(self, obj, data): # Verify that the user has access to the new organization if moving an # inventory to a new organization. @@ -342,8 +349,9 @@ class InventoryAccess(BaseAccess): if self.user not in org.admin_role: return False # Otherwise, just check for write permission. - return self.user in obj.admin_role + return self.user in obj.update_role + @check_superuser def can_admin(self, obj, data): # Verify that the user has access to the new organization if moving an # inventory to a new organization. @@ -371,12 +379,16 @@ class HostAccess(BaseAccess): def get_queryset(self): inv_qs = Inventory.accessible_objects(self.user, 'read_role') - group_qs = Group.accessible_objects(self.user, 'read_role') - qs = (self.model.objects.filter(inventory=inv_qs) | self.model.objects.filter(groups=group_qs)).distinct() - #qs = qs.select_related('created_by', 'modified_by', 'inventory', - # 'last_job__job_template', - # 'last_job_host_summary__job') - #return qs.prefetch_related('groups').all() + group_qs = Group.accessible_objects(self.user, 'read_role').exclude(inventory__in=inv_qs) + if group_qs.count(): + qs = self.model.objects.filter(Q(inventory__in=inv_qs) | Q(groups__in=group_qs)) + else: + qs = self.model.objects.filter(inventory__in=inv_qs) + + qs = qs.select_related('created_by', 'modified_by', 'inventory', + 'last_job__job_template', + 'last_job_host_summary__job') + qs =qs.prefetch_related('groups').all() return qs def can_read(self, obj): @@ -389,7 +401,7 @@ class HostAccess(BaseAccess): # Checks for admin or change permission on inventory. inventory_pk = get_pk_from_dict(data, 'inventory') inventory = get_object_or_400(Inventory, pk=inventory_pk) - if self.user not in inventory.admin_role: + if self.user not in inventory.update_role: return False # Check to see if we have enough licenses @@ -403,7 +415,7 @@ class HostAccess(BaseAccess): raise PermissionDenied('Unable to change inventory on a host') # Checks for admin or change permission on inventory, controls whether # the user can edit variable data. - return obj and self.user in obj.inventory.admin_role + return obj and self.user in obj.inventory.update_role def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): @@ -440,7 +452,7 @@ class GroupAccess(BaseAccess): # Checks for admin or change permission on inventory. inventory_pk = get_pk_from_dict(data, 'inventory') inventory = get_object_or_400(Inventory, pk=inventory_pk) - return self.user in inventory.admin_role + return self.user in inventory.update_role def can_change(self, obj, data): # Prevent moving a group to a different inventory. @@ -449,7 +461,7 @@ class GroupAccess(BaseAccess): raise PermissionDenied('Unable to change inventory on a group') # Checks for admin or change permission on inventory, controls whether # the user can attach subgroups or edit variable data. - return obj and self.user in obj.inventory.admin_role + return obj and self.user in obj.inventory.update_role def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): @@ -483,7 +495,7 @@ class InventorySourceAccess(BaseAccess): def get_queryset(self): qs = self.model.objects.all() qs = qs.select_related('created_by', 'modified_by', 'group', 'inventory') - inventory_ids = set(self.user.get_queryset(Inventory).values_list('id', flat=True)) + inventory_ids = self.user.get_queryset(Inventory) return qs.filter(Q(inventory_id__in=inventory_ids) | Q(group__inventory_id__in=inventory_ids)) @@ -502,7 +514,7 @@ class InventorySourceAccess(BaseAccess): def can_change(self, obj, data): # Checks for admin or change permission on group. if obj and obj.group: - return self.user in obj.group.admin_role + return self.user in obj.group.update_role # Can't change inventory sources attached to only the inventory, since # these are created automatically from the management command. else: @@ -555,21 +567,36 @@ class CredentialAccess(BaseAccess): qs = self.model.accessible_objects(self.user, 'read_role') return qs.select_related('created_by', 'modified_by').all() + @check_superuser def can_read(self, obj): return self.user in obj.read_role def can_add(self, data): - # Access enforced in our view where we have context enough to make a decision - return True - - def can_use(self, obj): if self.user.is_superuser: return True + user_pk = get_pk_from_dict(data, 'user') + if user_pk: + user_obj = get_object_or_400(User, pk=user_pk) + return check_user_access(self.user, User, 'change', user_obj, None) + team_pk = get_pk_from_dict(data, 'team') + if team_pk: + team_obj = get_object_or_400(Team, pk=team_pk) + return check_user_access(self.user, Team, 'change', team_obj, None) + organization_pk = get_pk_from_dict(data, 'organization') + if organization_pk: + organization_obj = get_object_or_400(Organization, pk=organization_pk) + return check_user_access(self.user, Organization, 'change', organization_obj, None) + return False + + + @check_superuser + def can_use(self, obj): return self.user in obj.use_role + @check_superuser def can_change(self, obj, data): - if self.user.is_superuser: - return True + if not self.can_add(data): + return False return self.user in obj.owner_role def can_delete(self, obj): @@ -596,14 +623,12 @@ class TeamAccess(BaseAccess): qs = self.model.accessible_objects(self.user, 'read_role') return qs.select_related('created_by', 'modified_by', 'organization').all() + @check_superuser def can_add(self, data): - if self.user.is_superuser: + org_pk = get_pk_from_dict(data, 'organization') + org = get_object_or_400(Organization, pk=org_pk) + if self.user in org.admin_role: return True - else: - org_pk = get_pk_from_dict(data, 'organization') - org = get_object_or_400(Organization, pk=org_pk) - if self.user in org.admin_role: - return True return False def can_change(self, obj, data): @@ -611,6 +636,8 @@ class TeamAccess(BaseAccess): org_pk = get_pk_from_dict(data, 'organization') if obj and org_pk and obj.organization.pk != org_pk: raise PermissionDenied('Unable to change organization on a team') + if self.user.is_superuser: + return True return self.user in obj.admin_role def can_delete(self, obj): @@ -640,15 +667,13 @@ class ProjectAccess(BaseAccess): qs = self.model.accessible_objects(self.user, 'read_role') return qs.select_related('modified_by', 'credential', 'current_job', 'last_job').all() + @check_superuser def can_add(self, data): - if self.user.is_superuser: - return True qs = Organization.accessible_objects(self.user, 'admin_role') return qs.exists() + @check_superuser def can_change(self, obj, data): - if self.user.is_superuser: - return True return self.user in obj.admin_role def can_delete(self, obj): @@ -674,9 +699,11 @@ class ProjectUpdateAccess(BaseAccess): project_ids = set(self.user.get_queryset(Project).values_list('id', flat=True)) return qs.filter(project_id__in=project_ids) + @check_superuser def can_cancel(self, obj): return self.can_change(obj, {}) and obj.can_cancel + @check_superuser def can_delete(self, obj): return obj and self.user in obj.project.admin_role @@ -704,8 +731,7 @@ class JobTemplateAccess(BaseAccess): 'credential', 'cloud_credential', 'next_schedule').all() def can_read(self, obj): - # you can only see the job templates that you have permission to launch. - return self.can_start(obj, validate_license=False) + return self.user in obj.read_role def can_add(self, data): ''' @@ -847,10 +873,8 @@ class JobAccess(BaseAccess): def can_change(self, obj, data): return obj.status == 'new' and self.can_read(obj) and self.can_add(data) + @check_superuser def can_delete(self, obj): - # Allow org admins and superusers to delete jobs - if self.user.is_superuser: - return True return self.user in obj.inventory.admin_role def can_start(self, obj): @@ -866,11 +890,12 @@ class JobAccess(BaseAccess): return self.user in obj.job_template.execute_role inventory_access = self.user in obj.inventory.use_role + credential_access = self.user in obj.credential.use_role org_access = self.user in obj.inventory.organization.admin_role project_access = obj.project is None or self.user in obj.project.admin_role - return inventory_access and (org_access or project_access) + return inventory_access and credential_access and (org_access or project_access) def can_cancel(self, obj): return self.can_read(obj) and obj.can_cancel @@ -1146,18 +1171,16 @@ class ScheduleAccess(BaseAccess): UnifiedJobTemplate.objects.filter(Q(inventorysource__in=inventory_source_qs)) return qs.filter(unified_job_template__in=unified_qs) + @check_superuser def can_read(self, obj): - if self.user.is_superuser: - return True if obj and obj.unified_job_template: job_class = obj.unified_job_template return self.user.can_access(type(job_class), 'read', obj.unified_job_template) else: return False + @check_superuser def can_add(self, data): - if self.user.is_superuser: - return True pk = get_pk_from_dict(data, 'unified_job_template') obj = get_object_or_400(UnifiedJobTemplate, pk=pk) if obj: @@ -1165,18 +1188,16 @@ class ScheduleAccess(BaseAccess): else: return False + @check_superuser def can_change(self, obj, data): - if self.user.is_superuser: - return True if obj and obj.unified_job_template: job_class = obj.unified_job_template return self.user.can_access(type(job_class), 'change', job_class, None) else: return False + @check_superuser def can_delete(self, obj): - if self.user.is_superuser: - return True if obj and obj.unified_job_template: job_class = obj.unified_job_template return self.user.can_access(type(job_class), 'change', job_class, None) @@ -1195,25 +1216,22 @@ class NotifierAccess(BaseAccess): return qs return self.model.objects.filter(organization__in=Organization.accessible_objects(self.user, 'admin_role').all()) + @check_superuser def can_read(self, obj): - if self.user.is_superuser: - return True if obj.organization is not None: return self.user in obj.organization.admin_role return False + @check_superuser def can_add(self, data): - if self.user.is_superuser: - return True if not data: return Organization.accessible_objects(self.user, 'admin_role').exists() org_pk = get_pk_from_dict(data, 'organization') org = get_object_or_400(Organization, pk=org_pk) return self.user in org.admin_role + @check_superuser def can_change(self, obj, data): - if self.user.is_superuser: - return True org_pk = get_pk_from_dict(data, 'organization') if obj and org_pk and obj.organization.pk != org_pk: org = get_object_or_400(Organization, pk=org_pk) @@ -1260,15 +1278,12 @@ class LabelAccess(BaseAccess): organization__in=Organization.accessible_objects(self.user, 'read_role') ) + @check_superuser def can_read(self, obj): - if self.user.is_superuser: - return True return self.user in obj.organization.read_role + @check_superuser def can_add(self, data): - if self.user.is_superuser: - return True - if not data or '_method' in data: # So the browseable API will work? return True @@ -1276,10 +1291,8 @@ class LabelAccess(BaseAccess): org = get_object_or_400(Organization, pk=org_pk) return self.user in org.read_role + @check_superuser def can_change(self, obj, data): - if self.user.is_superuser: - return True - if self.can_add(data) is False: return False @@ -1376,26 +1389,10 @@ class CustomInventoryScriptAccess(BaseAccess): return self.model.objects.distinct().all() return self.model.accessible_objects(self.user, 'read_role').all() + @check_superuser def can_read(self, obj): - if self.user.is_superuser: - return True return self.user in obj.read_role - def can_add(self, data): - if self.user.is_superuser: - return True - return False - - def can_change(self, obj, data): - if self.user.is_superuser: - return True - return False - - def can_delete(self, obj): - if self.user.is_superuser: - return True - return False - class TowerSettingsAccess(BaseAccess): ''' @@ -1409,17 +1406,6 @@ class TowerSettingsAccess(BaseAccess): model = TowerSettings - def get_queryset(self): - if self.user.is_superuser: - return self.model.objects.all() - return self.model.objects.none() - - def can_change(self, obj, data): - return self.user.is_superuser - - def can_delete(self, obj): - return self.user.is_superuser - class RoleAccess(BaseAccess): ''' @@ -1432,14 +1418,6 @@ class RoleAccess(BaseAccess): model = Role - def get_queryset(self): - if self.user.is_superuser: - return self.model.objects.all() - return Role.objects.none() - - def can_change(self, obj, data): - return self.user.is_superuser - def can_read(self, obj): if not obj: return False @@ -1463,9 +1441,8 @@ class RoleAccess(BaseAccess): skip_sub_obj_read_check=False): return self.can_unattach(obj, sub_obj, relationship) + @check_superuser def can_unattach(self, obj, sub_obj, relationship): - if self.user.is_superuser: - return True if obj.object_id and \ isinstance(obj.content_object, ResourceMixin) and \ self.user in obj.content_object.admin_role: diff --git a/awx/main/fields.py b/awx/main/fields.py index e116299bcb..b77452ca5b 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -18,12 +18,12 @@ from django.db.models.fields.related import ( ReverseManyRelatedObjectsDescriptor, ) from django.utils.encoding import smart_text -from django.utils.timezone import now # AWX -from awx.main.models.rbac import batch_role_ancestor_rebuilding +from awx.main.models.rbac import batch_role_ancestor_rebuilding, Role from awx.main.utils import get_current_apps + __all__ = ['AutoOneToOneField', 'ImplicitRoleField'] @@ -92,9 +92,7 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): class ImplicitRoleField(models.ForeignKey): """Implicitly creates a role entry for a resource""" - def __init__(self, role_name=None, role_description=None, parent_role=None, *args, **kwargs): - self.role_name = role_name - self.role_description = role_description if role_description else "" + def __init__(self, parent_role=None, *args, **kwargs): self.parent_role = parent_role kwargs.setdefault('to', 'Role') @@ -104,8 +102,6 @@ class ImplicitRoleField(models.ForeignKey): def deconstruct(self): name, path, args, kwargs = super(ImplicitRoleField, self).deconstruct() - kwargs['role_name'] = self.role_name - kwargs['role_description'] = self.role_description kwargs['parent_role'] = self.parent_role return name, path, args, kwargs @@ -190,11 +186,7 @@ class ImplicitRoleField(models.ForeignKey): if cur_role is None: missing_roles.append( Role_( - created=now(), - modified=now(), role_field=implicit_role_field.name, - name=implicit_role_field.role_name, - description=implicit_role_field.role_description, content_type_id=ct_id, object_id=instance.id ) @@ -208,7 +200,7 @@ class ImplicitRoleField(models.ForeignKey): updates[role.role_field] = role.id role_ids.append(role.id) type(instance).objects.filter(pk=instance.pk).update(**updates) - Role_._simultaneous_ancestry_rebuild(role_ids) + Role.rebuild_role_ancestor_list(role_ids, []) # Update parentage if necessary for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'): @@ -247,12 +239,7 @@ class ImplicitRoleField(models.ForeignKey): if qs.count() >= 1: role = qs[0] else: - role = Role_.objects.create(created=now(), - modified=now(), - role_field=path, - singleton_name=singleton_name, - name=singleton_name, - description=singleton_name) + role = Role_.objects.create(singleton_name=singleton_name, role_field=singleton_name) parents = [role.id] else: parents = resolve_role_field(instance, path) @@ -269,4 +256,4 @@ class ImplicitRoleField(models.ForeignKey): Role_ = get_current_apps().get_model('main', 'Role') child_ids = [x for x in Role_.parents.through.objects.filter(to_role_id__in=role_ids).distinct().values_list('from_role_id', flat=True)] Role_.objects.filter(id__in=role_ids).delete() - Role_._simultaneous_ancestry_rebuild(child_ids) + Role.rebuild_role_ancestor_list([], child_ids) diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 9f350246bb..d5b2bcdcd3 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -468,9 +468,7 @@ def load_inventory_source(source, all_group=None, group_filter_re=None, ''' # Sanity check: We sanitize these module names for our API but Ansible proper doesn't follow # good naming conventions - if source == 'azure': - source = 'windows_azure' - + source = source.replace('azure.py', 'windows_azure.py') logger.debug('Analyzing type of source: %s', source) original_all_group = all_group if not os.path.exists(source): diff --git a/awx/main/migrations/0008_v300_rbac_changes.py b/awx/main/migrations/0008_v300_rbac_changes.py index c22e89cde9..dd112700cf 100644 --- a/awx/main/migrations/0008_v300_rbac_changes.py +++ b/awx/main/migrations/0008_v300_rbac_changes.py @@ -2,21 +2,21 @@ from __future__ import unicode_literals from django.db import migrations, models -import django.db.models.deletion from django.conf import settings -import taggit.managers import awx.main.fields class Migration(migrations.Migration): dependencies = [ - ('taggit', '0002_auto_20150616_2121'), ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('main', '0007_v300_active_flag_removal'), ] operations = [ + # + # Patch up existing + # migrations.RenameField( 'Organization', 'admins', @@ -47,300 +47,6 @@ class Migration(migrations.Migration): name='deprecated_projects', field=models.ManyToManyField(related_name='deprecated_teams', to='main.Project', blank=True), ), - - migrations.CreateModel( - name='RoleAncestorEntry', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('role_field', models.TextField()), - ('content_type_id', models.PositiveIntegerField(null=False)), - ('object_id', models.PositiveIntegerField(null=False)), - ], - options={ - 'db_table': 'main_rbac_role_ancestors', - 'verbose_name_plural': 'role_ancestors', - }, - ), - migrations.CreateModel( - name='Role', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created', models.DateTimeField(default=None, editable=False)), - ('modified', models.DateTimeField(default=None, editable=False)), - ('description', models.TextField(default=b'', blank=True)), - ('name', models.CharField(max_length=512)), - ('singleton_name', models.TextField(default=None, unique=True, null=True, db_index=True)), - ('object_id', models.PositiveIntegerField(default=None, null=True)), - ('ancestors', models.ManyToManyField(related_name='descendents', through='main.RoleAncestorEntry', to='main.Role')), - ('content_type', models.ForeignKey(default=None, to='contenttypes.ContentType', null=True)), - ('created_by', models.ForeignKey(related_name="{u'class': 'role', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('members', models.ManyToManyField(related_name='roles', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(related_name="{u'class': 'role', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('parents', models.ManyToManyField(related_name='children', to='main.Role')), - ('implicit_parents', models.TextField(null=False, default=b'[]')), - ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), - ], - options={ - 'db_table': 'main_rbac_roles', - 'verbose_name_plural': 'roles', - }, - ), - migrations.AddField( - model_name='roleancestorentry', - name='ancestor', - field=models.ForeignKey(related_name='+', to='main.Role'), - ), - migrations.AddField( - model_name='roleancestorentry', - name='descendent', - field=models.ForeignKey(related_name='+', to='main.Role'), - ), - migrations.AlterIndexTogether( - name='roleancestorentry', - index_together=set([('ancestor', 'content_type_id', 'object_id'), ('ancestor', 'content_type_id', 'role_field')]), - ), - - migrations.AddField( - model_name='credential', - name='auditor_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Auditor of the credential', parent_role=[b'singleton:System Auditor'], to='main.Role', role_name=b'Credential Auditor', null=b'True'), - ), - migrations.AddField( - model_name='credential', - name='owner_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Owner of the credential', parent_role=[b'singleton:System Administrator'], to='main.Role', role_name=b'Credential Owner', null=b'True'), - ), - migrations.AddField( - model_name='credential', - name='use_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May use this credential, but not read sensitive portions or modify it', parent_role=None, to='main.Role', role_name=b'Credential User', null=b'True'), - ), - migrations.AddField( - model_name='custominventoryscript', - name='admin_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May manage this inventory', parent_role=b'organization.admin_role', to='main.Role', role_name=b'CustomInventory Administrator', null=b'True'), - ), - migrations.AddField( - model_name='custominventoryscript', - name='auditor_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May view but not modify this inventory', parent_role=b'organization.auditor_role', to='main.Role', role_name=b'CustomInventory Auditor', null=b'True'), - ), - migrations.AddField( - model_name='custominventoryscript', - name='member_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May view but not modify this inventory', parent_role=b'organization.member_role', to='main.Role', role_name=b'CustomInventory Member', null=b'True'), - ), - migrations.AddField( - model_name='group', - name='admin_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'', parent_role=[b'inventory.admin_role', b'parents.admin_role'], to='main.Role', role_name=b'Inventory Group Administrator', null=b'True'), - ), - migrations.AddField( - model_name='group', - name='auditor_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'', parent_role=[b'inventory.auditor_role', b'parents.auditor_role'], to='main.Role', role_name=b'Inventory Group Auditor', null=b'True'), - ), - migrations.AddField( - model_name='group', - name='execute_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'', parent_role=[b'inventory.execute_role', b'parents.executor_role'], to='main.Role', role_name=b'Inventory Group Executor', null=b'True'), - ), - migrations.AddField( - model_name='group', - name='update_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'', parent_role=[b'inventory.update_role', b'parents.updater_role'], to='main.Role', role_name=b'Inventory Group Updater', null=b'True'), - ), - migrations.AddField( - model_name='inventory', - name='admin_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May manage this inventory', parent_role=b'organization.admin_role', to='main.Role', role_name=b'Inventory Administrator', null=b'True'), - ), - migrations.AddField( - model_name='inventory', - name='auditor_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May view but not modify this inventory', parent_role=b'organization.auditor_role', to='main.Role', role_name=b'Inventory Auditor', null=b'True'), - ), - migrations.AddField( - model_name='inventory', - name='execute_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May execute jobs against this inventory', parent_role=None, to='main.Role', role_name=b'Inventory Executor', null=b'True'), - ), - migrations.AddField( - model_name='inventory', - name='update_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May update the inventory', parent_role=None, to='main.Role', role_name=b'Inventory Updater', null=b'True'), - ), - migrations.AddField( - model_name='inventory', - name='use_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May use this inventory, but not read sensitive portions or modify it', parent_role=None, to='main.Role', role_name=b'Inventory User', null=b'True'), - ), - migrations.AddField( - model_name='jobtemplate', - name='admin_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Full access to all settings', parent_role=[(b'project.admin_role', b'inventory.admin_role')], to='main.Role', role_name=b'Job Template Administrator', null=b'True'), - ), - migrations.AddField( - model_name='jobtemplate', - name='auditor_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Read-only access to all settings', parent_role=[(b'project.auditor_role', b'inventory.auditor_role')], to='main.Role', role_name=b'Job Template Auditor', null=b'True'), - ), - migrations.AddField( - model_name='jobtemplate', - name='execute_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May run the job template', parent_role=None, to='main.Role', role_name=b'Job Template Runner', null=b'True'), - ), - migrations.AddField( - model_name='organization', - name='admin_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May manage all aspects of this organization', parent_role=b'singleton:System Administrator', to='main.Role', role_name=b'Organization Administrator', null=b'True'), - ), - migrations.AddField( - model_name='organization', - name='auditor_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May read all settings associated with this organization', parent_role=b'singleton:System Auditor', to='main.Role', role_name=b'Organization Auditor', null=b'True'), - ), - migrations.AddField( - model_name='organization', - name='member_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'A member of this organization', parent_role=b'admin_role', to='main.Role', role_name=b'Organization Member', null=b'True'), - ), - migrations.AddField( - model_name='project', - name='admin_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May manage this project', parent_role=[b'organization.admin_role', b'singleton:System Administrator'], to='main.Role', role_name=b'Project Administrator', null=b'True'), - ), - migrations.AddField( - model_name='project', - name='auditor_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May read all settings associated with this project', parent_role=[b'organization.auditor_role', b'singleton:System Auditor'], to='main.Role', role_name=b'Project Auditor', null=b'True'), - ), - migrations.AddField( - model_name='project', - name='member_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Implies membership within this project', parent_role=None, to='main.Role', role_name=b'Project Member', null=b'True'), - ), - migrations.AddField( - model_name='project', - name='scm_update_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May update this project from the source control management system', parent_role=b'admin_role', to='main.Role', role_name=b'Project Updater', null=b'True'), - ), - migrations.AddField( - model_name='team', - name='admin_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May manage this team', parent_role=b'organization.admin_role', to='main.Role', role_name=b'Team Administrator', null=b'True'), - ), - migrations.AddField( - model_name='team', - name='auditor_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May read all settings associated with this team', parent_role=b'organization.auditor_role', to='main.Role', role_name=b'Team Auditor', null=b'True'), - ), - migrations.AddField( - model_name='team', - name='member_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'A member of this team', to='main.Role', role_name=b'Team Member', null=b'True'), - ), - - migrations.AddField( - model_name='credential', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May read this credential', parent_role=[b'use_role', b'auditor_role', b'owner_role'], to='main.Role', role_name=b'Credential REad', null=b'True'), - ), - migrations.AddField( - model_name='custominventoryscript', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May view but not modify this inventory', parent_role=[b'auditor_role', b'member_role', b'admin_role'], to='main.Role', role_name=b'CustomInventory Read', null=b'True'), - ), - migrations.AddField( - model_name='group', - name='adhoc_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May execute ad hoc commands against this inventory', parent_role=[b'inventory.adhoc_role', b'parents.adhoc_role', b'admin_role'], to='main.Role', role_name=b'Inventory Ad Hoc', null=b'True'), - ), - migrations.AddField( - model_name='group', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'', parent_role=[b'execute_role', b'update_role', b'auditor_role', b'admin_role'], to='main.Role', role_name=b'Inventory Group Executor', null=b'True'), - ), - migrations.AddField( - model_name='inventory', - name='adhoc_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May execute ad hoc commands against this inventory', parent_role=[b'admin_role'], to='main.Role', role_name=b'Inventory Ad Hoc', null=b'True'), - ), - migrations.AddField( - model_name='inventory', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May view this inventory', parent_role=[b'auditor_role', b'execute_role', b'update_role', b'use_role', b'admin_role'], to='main.Role', role_name=b'Read', null=b'True'), - ), - migrations.AddField( - model_name='jobtemplate', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May run the job template', parent_role=[b'execute_role', b'auditor_role', b'admin_role'], to='main.Role', role_name=b'Job Template Runner', null=b'True'), - ), - migrations.AddField( - model_name='organization', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Read an organization', parent_role=[b'member_role', b'auditor_role'], to='main.Role', role_name=b'Organization Read Access', null=b'True'), - ), - migrations.AddField( - model_name='project', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Read access to this project', parent_role=[b'member_role', b'auditor_role', b'scm_update_role'], to='main.Role', role_name=b'Project Read Access', null=b'True'), - ), - migrations.AddField( - model_name='role', - name='role_field', - field=models.TextField(default=b''), - ), - migrations.AddField( - model_name='team', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Can view this team', parent_role=[b'admin_role', b'auditor_role', b'member_role'], to='main.Role', role_name=b'Read', null=b'True'), - ), - migrations.AlterField( - model_name='credential', - name='use_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May use this credential, but not read sensitive portions or modify it', parent_role=[b'owner_role'], to='main.Role', role_name=b'Credential User', null=b'True'), - ), - migrations.AlterField( - model_name='group', - name='execute_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'', parent_role=[b'inventory.execute_role', b'parents.execute_role', b'adhoc_role'], to='main.Role', role_name=b'Inventory Group Executor', null=b'True'), - ), - migrations.AlterField( - model_name='group', - name='update_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'', parent_role=[b'inventory.update_role', b'parents.update_role', b'admin_role'], to='main.Role', role_name=b'Inventory Group Updater', null=b'True'), - ), - migrations.AlterField( - model_name='inventory', - name='execute_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May execute jobs against this inventory', parent_role=b'adhoc_role', to='main.Role', role_name=b'Inventory Executor', null=b'True'), - ), - migrations.AlterField( - model_name='inventory', - name='update_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May update the inventory', parent_role=[b'admin_role'], to='main.Role', role_name=b'Inventory Updater', null=b'True'), - ), - migrations.AlterField( - model_name='inventory', - name='use_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May use this inventory, but not read sensitive portions or modify it', parent_role=[b'admin_role'], to='main.Role', role_name=b'Inventory User', null=b'True'), - ), - migrations.AlterField( - model_name='jobtemplate', - name='execute_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'May run the job template', parent_role=[b'admin_role'], to='main.Role', role_name=b'Job Template Runner', null=b'True'), - ), - migrations.AlterField( - model_name='project', - name='member_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', role_description=b'Implies membership within this project', parent_role=b'admin_role', to='main.Role', role_name=b'Project Member', null=b'True'), - ), - - - - - migrations.RenameField( model_name='organization', old_name='projects', @@ -380,4 +86,245 @@ class Migration(migrations.Migration): name='credential', unique_together=set([]), ), + + + # + # New RBAC models and fields + # + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('role_field', models.TextField()), + ('singleton_name', models.TextField(default=None, unique=True, null=True, db_index=True)), + ('members', models.ManyToManyField(related_name='roles', to=settings.AUTH_USER_MODEL)), + ('parents', models.ManyToManyField(related_name='children', to='main.Role')), + ('implicit_parents', models.TextField(default=b'[]')), + ('content_type', models.ForeignKey(default=None, to='contenttypes.ContentType', null=True)), + ('object_id', models.PositiveIntegerField(default=None, null=True)), + + ], + options={ + 'db_table': 'main_rbac_roles', + 'verbose_name_plural': 'roles', + }, + ), + migrations.CreateModel( + name='RoleAncestorEntry', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('role_field', models.TextField()), + ('content_type_id', models.PositiveIntegerField()), + ('object_id', models.PositiveIntegerField()), + ('ancestor', models.ForeignKey(related_name='+', to='main.Role')), + ('descendent', models.ForeignKey(related_name='+', to='main.Role')), + ], + options={ + 'db_table': 'main_rbac_role_ancestors', + 'verbose_name_plural': 'role_ancestors', + }, + ), + migrations.AddField( + model_name='role', + name='ancestors', + field=models.ManyToManyField(related_name='descendents', through='main.RoleAncestorEntry', to='main.Role'), + ), + migrations.AlterIndexTogether( + name='role', + index_together=set([('content_type', 'object_id')]), + ), + migrations.AlterIndexTogether( + name='roleancestorentry', + index_together=set([('ancestor', 'content_type_id', 'object_id'), ('ancestor', 'content_type_id', 'role_field'), ('ancestor', 'descendent')]), + ), + migrations.AddField( + model_name='credential', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_auditor'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='credential', + name='owner_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_administrator'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='credential', + name='use_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'owner_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='credential', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'use_role', b'auditor_role', b'owner_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='custominventoryscript', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'organization.admin_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='custominventoryscript', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'organization.auditor_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='custominventoryscript', + name='member_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'organization.member_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='custominventoryscript', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'auditor_role', b'member_role', b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'inventory.admin_role', b'parents.admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='adhoc_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'inventory.adhoc_role', b'parents.adhoc_role', b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'inventory.auditor_role', b'parents.auditor_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='execute_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'inventory.execute_role', b'parents.execute_role', b'adhoc_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='update_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'inventory.update_role', b'parents.update_role', b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'execute_role', b'update_role', b'auditor_role', b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'organization.admin_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='adhoc_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'organization.auditor_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='execute_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'adhoc_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='update_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='use_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'auditor_role', b'execute_role', b'update_role', b'use_role', b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='jobtemplate', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[(b'project.admin_role', b'inventory.admin_role')], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='jobtemplate', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[(b'project.auditor_role', b'inventory.auditor_role')], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='jobtemplate', + name='execute_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='jobtemplate', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'execute_role', b'auditor_role', b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='organization', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'singleton:system_administrator', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='organization', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'singleton:system_auditor', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='organization', + name='member_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'admin_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='organization', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'member_role', b'auditor_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'organization.admin_role', b'singleton:system_administrator'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'organization.auditor_role', b'singleton:system_auditor'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='member_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'admin_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='scm_update_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'admin_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'member_role', b'auditor_role', b'scm_update_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='team', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'organization.admin_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='team', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=b'organization.auditor_role', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='team', + name='member_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=None, to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='team', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role', b'auditor_role', b'member_role'], to='main.Role', null=b'True'), + ), ] diff --git a/awx/main/migrations/0009_v300_rbac_migrations.py b/awx/main/migrations/0009_v300_rbac_migrations.py index 9c7f7d8dd7..cf5d5bbe89 100644 --- a/awx/main/migrations/0009_v300_rbac_migrations.py +++ b/awx/main/migrations/0009_v300_rbac_migrations.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from awx.main.migrations import _rbac as rbac +from awx.main.migrations import _migration_utils as migration_utils from django.db import migrations @@ -12,11 +13,14 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(rbac.init_rbac_migration), + migrations.RunPython(migration_utils.set_current_apps_for_migrations), migrations.RunPython(rbac.migrate_users), + migrations.RunPython(rbac.create_roles), migrations.RunPython(rbac.migrate_organization), migrations.RunPython(rbac.migrate_team), migrations.RunPython(rbac.migrate_inventory), migrations.RunPython(rbac.migrate_projects), migrations.RunPython(rbac.migrate_credential), + migrations.RunPython(rbac.migrate_job_templates), + migrations.RunPython(rbac.rebuild_role_hierarchy), ] diff --git a/awx/main/migrations/0017_v300_prompting_migrations.py b/awx/main/migrations/0017_v300_prompting_migrations.py index f08d760a8c..c5a1df0eb9 100644 --- a/awx/main/migrations/0017_v300_prompting_migrations.py +++ b/awx/main/migrations/0017_v300_prompting_migrations.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from awx.main.migrations import _ask_for_variables as ask_for_variables +from awx.main.migrations import _migration_utils as migration_utils from django.db import migrations @@ -12,5 +13,6 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython(migration_utils.set_current_apps_for_migrations), migrations.RunPython(ask_for_variables.migrate_credential), ] diff --git a/awx/main/migrations/0020_v300_labels_changes.py b/awx/main/migrations/0020_v300_labels_changes.py new file mode 100644 index 0000000000..5ae949340c --- /dev/null +++ b/awx/main/migrations/0020_v300_labels_changes.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0019_v300_new_azure_credential'), + ] + + operations = [ + migrations.RemoveField( + model_name='job', + name='labels', + ), + migrations.RemoveField( + model_name='jobtemplate', + name='labels', + ), + migrations.AddField( + model_name='unifiedjob', + name='labels', + field=models.ManyToManyField(related_name='unifiedjob_labels', to='main.Label', blank=True), + ), + migrations.AddField( + model_name='unifiedjobtemplate', + name='labels', + field=models.ManyToManyField(related_name='unifiedjobtemplate_labels', to='main.Label', blank=True), + ), + ] diff --git a/awx/main/migrations/0021_v300_activity_stream.py b/awx/main/migrations/0021_v300_activity_stream.py new file mode 100644 index 0000000000..900fd4b07d --- /dev/null +++ b/awx/main/migrations/0021_v300_activity_stream.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0020_v300_labels_changes'), + ] + + operations = [ + migrations.AddField( + model_name='activitystream', + name='role', + field=models.ManyToManyField(to='main.Role', blank=True), + ), + ] diff --git a/awx/main/migrations/0022_v300_adhoc_extravars.py b/awx/main/migrations/0022_v300_adhoc_extravars.py new file mode 100644 index 0000000000..42939bad1e --- /dev/null +++ b/awx/main/migrations/0022_v300_adhoc_extravars.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0021_v300_activity_stream'), + ] + + operations = [ + migrations.AddField( + model_name='adhoccommand', + name='extra_vars', + field=models.TextField(default=b'', blank=True), + ), + migrations.AlterField( + model_name='credential', + name='kind', + field=models.CharField(default=b'ssh', max_length=32, choices=[(b'ssh', 'Machine'), (b'net', 'Network'), (b'scm', 'Source Control'), (b'aws', 'Amazon Web Services'), (b'rax', 'Rackspace'), (b'vmware', 'VMware vCenter'), (b'foreman', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'gce', 'Google Compute Engine'), (b'azure', 'Microsoft Azure Classic (deprecated)'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'openstack', 'OpenStack')]), + ), + migrations.AlterField( + model_name='inventorysource', + name='source', + field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'Local File, Directory or Script'), (b'rax', 'Rackspace Cloud Servers'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure', 'Microsoft Azure Classic (deprecated)'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'foreman', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'custom', 'Custom Script')]), + ), + migrations.AlterField( + model_name='inventoryupdate', + name='source', + field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'Local File, Directory or Script'), (b'rax', 'Rackspace Cloud Servers'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure', 'Microsoft Azure Classic (deprecated)'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'foreman', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'custom', 'Custom Script')]), + ), + ] diff --git a/awx/main/migrations/_migration_utils.py b/awx/main/migrations/_migration_utils.py new file mode 100644 index 0000000000..232310ca50 --- /dev/null +++ b/awx/main/migrations/_migration_utils.py @@ -0,0 +1,11 @@ +from awx.main.utils import set_current_apps + + +def set_current_apps_for_migrations(apps, schema_editor): + ''' + This is necessary for migrations which do explicit saves on any model that + has an ImplicitRoleFIeld (which generally means anything that has + some RBAC bindings associated with it). This sets the current 'apps' that + the ImplicitRoleFIeld should be using when creating new roles. + ''' + set_current_apps(apps) diff --git a/awx/main/migrations/_old_access.py b/awx/main/migrations/_old_access.py index 15b0d4f391..88021bc937 100644 --- a/awx/main/migrations/_old_access.py +++ b/awx/main/migrations/_old_access.py @@ -206,9 +206,9 @@ class UserAccess(BaseAccess): return qs return qs.filter( Q(pk=self.user.pk) | - Q(organizations__in=self.user.deprecated_admin_of_organizations) | - Q(organizations__in=self.user.deprecated_organizations) | - Q(deprecated_teams__in=self.user.deprecated_teams) + Q(deprecated_organizations__in=self.user.deprecated_admin_of_organizations.all()) | + Q(deprecated_organizations__in=self.user.deprecated_organizations.all()) | + Q(deprecated_teams__in=self.user.deprecated_teams.all()) ).distinct() def can_add(self, data): @@ -563,18 +563,18 @@ class CredentialAccess(BaseAccess): # If the user is a superuser, and therefore can see everything, this # is also sufficient, and we are done. qs = self.model.objects.distinct() - qs = qs.select_related('created_by', 'modified_by', 'user', 'team') + qs = qs.select_related('created_by', 'modified_by') if self.user.is_superuser: return qs # Get the list of organizations for which the user is an admin orgs_as_admin_ids = set(self.user.deprecated_admin_of_organizations.values_list('id', flat=True)) return qs.filter( - Q(user=self.user) | - Q(user__deprecated_organizations__id__in=orgs_as_admin_ids) | - Q(user__deprecated_admin_of_organizations__id__in=orgs_as_admin_ids) | - Q(team__organization__id__in=orgs_as_admin_ids) | - Q(team__deprecated_users__in=[self.user]) + Q(deprecated_user=self.user) | + Q(deprecated_user__deprecated_organizations__id__in=orgs_as_admin_ids) | + Q(deprecated_user__deprecated_admin_of_organizations__id__in=orgs_as_admin_ids) | + Q(deprecated_team__organization__id__in=orgs_as_admin_ids) | + Q(deprecated_team__deprecated_users__in=[self.user]) ) def can_add(self, data): @@ -597,22 +597,22 @@ class CredentialAccess(BaseAccess): return False if self.user == obj.created_by: return True - if obj.user: - if self.user == obj.user: + if obj.deprecated_user: + if self.user == obj.deprecated_user: return True - if obj.user.deprecated_organizations.filter(deprecated_admins__in=[self.user]).exists(): + if obj.deprecated_user.deprecated_organizations.filter(deprecated_admins__in=[self.user]).exists(): return True - if obj.user.deprecated_admin_of_organizations.filter(deprecated_admins__in=[self.user]).exists(): + if obj.deprecated_user.deprecated_admin_of_organizations.filter(deprecated_admins__in=[self.user]).exists(): return True - if obj.team: - if self.user in obj.team.organization.deprecated_admins.all(): + if obj.deprecated_team: + if self.user in obj.deprecated_team.organization.deprecated_admins.all(): return True return False def can_delete(self, obj): # Unassociated credentials may be marked deleted by anyone, though we # shouldn't ever end up with those. - if obj.user is None and obj.team is None: + if obj.deprecated_user is None and obj.deprecated_team is None: return True return self.can_change(obj, None) diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 0d7aa3ecb7..6f1ef0d4ad 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -1,11 +1,12 @@ import logging +from time import time from django.utils.encoding import smart_text from django.db.models import Q -from django.utils.timezone import now from collections import defaultdict -from awx.main.utils import getattrd, set_current_apps +from awx.main.utils import getattrd +from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding import _old_access as old_access logger = logging.getLogger(__name__) @@ -27,8 +28,35 @@ def log_migration(wrapped): return wrapper @log_migration -def init_rbac_migration(apps, schema_editor): - set_current_apps(apps) +def create_roles(apps, schema_editor): + ''' + Implicit role creation happens in our post_save hook for all of our + resources. Here we iterate through all of our resource types and call + .save() to ensure all that happens for every object in the system before we + get busy with the actual migration work. + + This gets run after migrate_users, which does role creation for users a + little differently. + ''' + + models = [ + apps.get_model('main', m) for m in [ + 'Organization', + 'Team', + 'Inventory', + 'Group', + 'Project', + 'Credential', + 'CustomInventoryScript', + 'JobTemplate', + ] + ] + + with batch_role_ancestor_rebuilding(): + for model in models: + for obj in model.objects.iterator(): + obj.save() + @log_migration def migrate_users(apps, schema_editor): @@ -44,9 +72,7 @@ def migrate_users(apps, schema_editor): logger.info(smart_text(u"found existing role for user: {}".format(user.username))) except Role.DoesNotExist: role = Role.objects.create( - created=now(), - modified=now(), - singleton_name = smart_text(u'{}-admin_role'.format(user.username)), + role_field='admin_role', content_type = user_content_type, object_id = user.id ) @@ -54,14 +80,12 @@ def migrate_users(apps, schema_editor): logger.info(smart_text(u"migrating to new role for user: {}".format(user.username))) if user.is_superuser: - if Role.objects.filter(singleton_name='System Administrator').exists(): - sa_role = Role.objects.get(singleton_name='System Administrator') + if Role.objects.filter(singleton_name='system_administrator').exists(): + sa_role = Role.objects.get(singleton_name='system_administrator') else: sa_role = Role.objects.create( - created=now(), - modified=now(), - singleton_name='System Administrator', - name='System Administrator' + singleton_name='system_administrator', + role_field='system_administrator' ) sa_role.members.add(user) @@ -71,19 +95,17 @@ def migrate_users(apps, schema_editor): def migrate_organization(apps, schema_editor): Organization = apps.get_model('main', "Organization") for org in Organization.objects.iterator(): - org.save() # force creates missing roles for admin in org.deprecated_admins.all(): org.admin_role.members.add(admin) logger.info(smart_text(u"added admin: {}, {}".format(org.name, admin.username))) for user in org.deprecated_users.all(): - org.auditor_role.members.add(user) - logger.info(smart_text(u"added auditor: {}, {}".format(org.name, user.username))) + org.member_role.members.add(user) + logger.info(smart_text(u"added member: {}, {}".format(org.name, user.username))) @log_migration def migrate_team(apps, schema_editor): Team = apps.get_model('main', 'Team') for t in Team.objects.iterator(): - t.save() for user in t.deprecated_users.all(): t.member_role.members.add(user) logger.info(smart_text(u"team: {}, added user: {}".format(t.name, user.username))) @@ -103,8 +125,6 @@ def attrfunc(attr_path): def _update_credential_parents(org, cred): org.admin_role.children.add(cred.owner_role) - org.member_role.children.add(cred.use_role) - cred.deprecated_user, cred.deprecated_team = None, None cred.save() def _discover_credentials(instances, cred, orgfunc): @@ -122,7 +142,12 @@ def _discover_credentials(instances, cred, orgfunc): ''' orgs = defaultdict(list) for inst in instances: - orgs[orgfunc(inst)].append(inst) + try: + orgs[orgfunc(inst)].append(inst) + except AttributeError: + # JobTemplate.inventory can be NULL sometimes, eg when an inventory + # has been deleted. This protects against that. + pass if len(orgs) == 1: _update_credential_parents(orgfunc(instances[0]), cred) @@ -136,7 +161,6 @@ def _discover_credentials(instances, cred, orgfunc): cred.save() # Unlink the old information from the new credential - cred.deprecated_user, cred.deprecated_team = None, None cred.owner_role, cred.use_role = None, None cred.save() @@ -150,43 +174,32 @@ def migrate_credential(apps, schema_editor): Credential = apps.get_model('main', "Credential") JobTemplate = apps.get_model('main', 'JobTemplate') Project = apps.get_model('main', 'Project') - Role = apps.get_model('main', 'Role') - User = apps.get_model('auth', 'User') InventorySource = apps.get_model('main', 'InventorySource') - ContentType = apps.get_model('contenttypes', "ContentType") - user_content_type = ContentType.objects.get_for_model(User) for cred in Credential.objects.iterator(): - cred.save() - results = (JobTemplate.objects.filter(Q(credential=cred) | Q(cloud_credential=cred)).all() or - InventorySource.objects.filter(credential=cred).all()) - if results: + results = [x for x in JobTemplate.objects.filter(Q(credential=cred) | Q(cloud_credential=cred)).all()] + \ + [x for x in InventorySource.objects.filter(credential=cred).all()] + if cred.deprecated_team is not None and results: if len(results) == 1: _update_credential_parents(results[0].inventory.organization, cred) else: _discover_credentials(results, cred, attrfunc('inventory.organization')) logger.info(smart_text(u"added Credential(name={}, kind={}, host={}) at organization level".format(cred.name, cred.kind, cred.host))) - continue projs = Project.objects.filter(credential=cred).all() - if projs: + if cred.deprecated_team is not None and projs: if len(projs) == 1: _update_credential_parents(projs[0].organization, cred) else: _discover_credentials(projs, cred, attrfunc('organization')) logger.info(smart_text(u"added Credential(name={}, kind={}, host={}) at organization level".format(cred.name, cred.kind, cred.host))) - continue if cred.deprecated_team is not None: - cred.deprecated_team.admin_role.children.add(cred.owner_role) - cred.deprecated_team.member_role.children.add(cred.use_role) - cred.deprecated_user, cred.deprecated_team = None, None + cred.deprecated_team.member_role.children.add(cred.owner_role) cred.save() logger.info(smart_text(u"added Credential(name={}, kind={}, host={}) at user level".format(cred.name, cred.kind, cred.host))) elif cred.deprecated_user is not None: - user_admin_role = Role.objects.get(content_type=user_content_type, object_id=cred.deprecated_user.id) - user_admin_role.children.add(cred.owner_role) - cred.deprecated_user, cred.deprecated_team = None, None + cred.owner_role.members.add(cred.deprecated_user) cred.save() logger.info(smart_text(u"added Credential(name={}, kind={}, host={}) at user level".format(cred.name, cred.kind, cred.host, ))) else: @@ -205,14 +218,13 @@ def migrate_inventory(apps, schema_editor): return inventory.auditor_role elif perm.permission_type == 'write': return inventory.update_role - elif perm.permission_type == 'check' or perm.permission_type == 'run': + elif perm.permission_type == 'check' or perm.permission_type == 'run' or perm.permission_type == 'create': # These permission types are handled differntly in RBAC now, nothing to migrate. return False else: return None for inventory in Inventory.objects.iterator(): - inventory.save() for perm in Permission.objects.filter(inventory=inventory): role = None execrole = None @@ -260,7 +272,6 @@ def migrate_projects(apps, schema_editor): # Migrate projects to single organizations, duplicating as necessary for project in Project.objects.iterator(): - project.save() original_project_name = project.name project_orgs = project.deprecated_organizations.distinct().all() @@ -373,7 +384,6 @@ def migrate_job_templates(apps, schema_editor): Permission = apps.get_model('main', 'Permission') for jt in JobTemplate.objects.iterator(): - jt.save() permission = Permission.objects.filter( inventory=jt.inventory, project=jt.project, @@ -390,7 +400,7 @@ def migrate_job_templates(apps, schema_editor): jt.execute_role.members.add(user) logger.info(smart_text(u'adding User({}) access to JobTemplate({})'.format(user.username, jt.name))) - if user in jt.execute_role: + if jt.execute_role.ancestors.filter(members=user).exists(): # aka "user in jt.execute_role" # If the job template is already accessible by the user, because they # are a sytem, organization, or project admin, then don't add an explicit # role entry for them @@ -399,3 +409,22 @@ def migrate_job_templates(apps, schema_editor): if old_access.check_user_access(user, jt.__class__, 'start', jt, False): jt.execute_role.members.add(user) logger.info(smart_text(u'adding User({}) access to JobTemplate({})'.format(user.username, jt.name))) + +@log_migration +def rebuild_role_hierarchy(apps, schema_editor): + logger.info('Computing role roots..') + start = time() + roots = Role.objects \ + .all() \ + .exclude(pk__in=Role.parents.through.objects.all() + .values_list('from_role_id', flat=True).distinct()) \ + .values_list('id', flat=True) + stop = time() + logger.info('Found %d roots in %f seconds, rebuilding ancestry map' % (len(roots), stop - start)) + start = time() + Role.rebuild_role_ancestor_list(roots, []) + stop = time() + logger.info('Rebuild completed in %f seconds' % (stop - start)) + logger.info('Done.') + + diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index cffdf83809..ae07acd79c 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -56,6 +56,7 @@ class ActivityStream(models.Model): notifier = models.ManyToManyField("Notifier", blank=True) notification = models.ManyToManyField("Notification", blank=True) label = models.ManyToManyField("Label", blank=True) + role = models.ManyToManyField("Role", blank=True) def get_absolute_url(self): return reverse('api:activity_stream_detail', args=(self.pk,)) diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index 6a780da38a..8694f56537 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -84,11 +84,17 @@ class AdHocCommand(UnifiedJob): editable=False, through='AdHocCommandEvent', ) + extra_vars = models.TextField( + blank=True, + default='', + ) + + extra_vars_dict = VarsDictProperty('extra_vars', True) def clean_inventory(self): inv = self.inventory if not inv: - raise ValidationError('Inventory is no longer available.') + raise ValidationError('No valid inventory.') return inv def clean_credential(self): diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index d47153285d..11b2a31e87 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -204,27 +204,19 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): help_text=_('Tenant identifier for this credential'), ) owner_role = ImplicitRoleField( - role_name='Credential Owner', - role_description='Owner of the credential', parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ], ) auditor_role = ImplicitRoleField( - role_name='Credential Auditor', - role_description='Auditor of the credential', parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, ], ) use_role = ImplicitRoleField( - role_name='Credential User', - role_description='May use this credential, but not read sensitive portions or modify it', parent_role=['owner_role'] ) read_role = ImplicitRoleField( - role_name='Credential REad', - role_description='May read this credential', parent_role=[ 'use_role', 'auditor_role', 'owner_role' ], diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index d192825ec7..0eaf35c471 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -97,39 +97,25 @@ class Inventory(CommonModel, ResourceMixin): help_text=_('Number of external inventory sources in this inventory with failures.'), ) admin_role = ImplicitRoleField( - role_name='Inventory Administrator', - role_description='May manage this inventory', parent_role='organization.admin_role', ) auditor_role = ImplicitRoleField( - role_name='Inventory Auditor', - role_description='May view but not modify this inventory', parent_role='organization.auditor_role', ) update_role = ImplicitRoleField( - role_name='Inventory Updater', - role_description='May update the inventory', parent_role=['admin_role'], ) use_role = ImplicitRoleField( - role_name='Inventory User', - role_description='May use this inventory, but not read sensitive portions or modify it', parent_role=['admin_role'], ) adhoc_role = ImplicitRoleField( - role_name='Inventory Ad Hoc', - role_description='May execute ad hoc commands against this inventory', parent_role=['admin_role'], ) execute_role = ImplicitRoleField( - role_name='Inventory Executor', - role_description='May execute jobs against this inventory', parent_role='adhoc_role', ) read_role = ImplicitRoleField( - role_name='Read', parent_role=['auditor_role', 'execute_role', 'update_role', 'use_role', 'admin_role'], - role_description='May view this inventory', ) def get_absolute_url(self): @@ -335,7 +321,7 @@ class Inventory(CommonModel, ResourceMixin): return self.groups.exclude(parents__pk__in=group_pks).distinct() -class Host(CommonModelNameNotUnique, ResourceMixin): +class Host(CommonModelNameNotUnique): ''' A managed node ''' @@ -531,28 +517,21 @@ class Group(CommonModelNameNotUnique, ResourceMixin): help_text=_('Inventory source(s) that created or modified this group.'), ) admin_role = ImplicitRoleField( - role_name='Inventory Group Administrator', parent_role=['inventory.admin_role', 'parents.admin_role'], ) auditor_role = ImplicitRoleField( - role_name='Inventory Group Auditor', parent_role=['inventory.auditor_role', 'parents.auditor_role'], ) update_role = ImplicitRoleField( - role_name='Inventory Group Updater', parent_role=['inventory.update_role', 'parents.update_role', 'admin_role'], ) adhoc_role = ImplicitRoleField( - role_name='Inventory Ad Hoc', parent_role=['inventory.adhoc_role', 'parents.adhoc_role', 'admin_role'], - role_description='May execute ad hoc commands against this inventory', ) execute_role = ImplicitRoleField( - role_name='Inventory Group Executor', parent_role=['inventory.execute_role', 'parents.execute_role', 'adhoc_role'], ) read_role = ImplicitRoleField( - role_name='Inventory Group Executor', parent_role=['execute_role', 'update_role', 'auditor_role', 'admin_role'], ) @@ -1321,25 +1300,15 @@ class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin): ) admin_role = ImplicitRoleField( - role_name='CustomInventory Administrator', - role_description='May manage this inventory', parent_role='organization.admin_role', ) - member_role = ImplicitRoleField( - role_name='CustomInventory Member', - role_description='May view but not modify this inventory', parent_role='organization.member_role', ) - auditor_role = ImplicitRoleField( - role_name='CustomInventory Auditor', - role_description='May view but not modify this inventory', parent_role='organization.auditor_role', ) read_role = ImplicitRoleField( - role_name='CustomInventory Read', - role_description='May view but not modify this inventory', parent_role=['auditor_role', 'member_role', 'admin_role'], ) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index c48007e24a..2d5c176604 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -135,11 +135,6 @@ class JobOptions(BaseModel): become_enabled = models.BooleanField( default=False, ) - labels = models.ManyToManyField( - "Label", - blank=True, - related_name='%(class)s_labels' - ) extra_vars_dict = VarsDictProperty('extra_vars', True) @@ -226,23 +221,15 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): default={}, ) admin_role = ImplicitRoleField( - role_name='Job Template Administrator', - role_description='Full access to all settings', parent_role=[('project.admin_role', 'inventory.admin_role')] ) auditor_role = ImplicitRoleField( - role_name='Job Template Auditor', - role_description='Read-only access to all settings', parent_role=[('project.auditor_role', 'inventory.auditor_role')] ) execute_role = ImplicitRoleField( - role_name='Job Template Runner', - role_description='May run the job template', parent_role=['admin_role'], ) read_role = ImplicitRoleField( - role_name='Job Template Runner', - role_description='May run the job template', parent_role=['execute_role', 'auditor_role', 'admin_role'], ) @@ -523,6 +510,36 @@ class Job(UnifiedJob, JobOptions): return self.job_template.ask_variables_on_launch return False + @property + def ask_limit_on_launch(self): + if self.job_template is not None: + return self.job_template.ask_limit_on_launch + return False + + @property + def ask_tags_on_launch(self): + if self.job_template is not None: + return self.job_template.ask_tags_on_launch + return False + + @property + def ask_job_type_on_launch(self): + if self.job_template is not None: + return self.job_template.ask_job_type_on_launch + return False + + @property + def ask_inventory_on_launch(self): + if self.job_template is not None: + return self.job_template.ask_inventory_on_launch + return False + + @property + def ask_credential_on_launch(self): + if self.job_template is not None: + return self.job_template.ask_credential_on_launch + return False + def get_passwords_needed_to_start(self): return self.passwords_needed_to_start diff --git a/awx/main/models/label.py b/awx/main/models/label.py index 8458e4cb23..af9a2241b7 100644 --- a/awx/main/models/label.py +++ b/awx/main/models/label.py @@ -8,6 +8,7 @@ from django.utils.translation import ugettext_lazy as _ # AWX from awx.main.models.base import CommonModelNameNotUnique +from awx.main.models.unified_jobs import UnifiedJobTemplate, UnifiedJob __all__ = ('Label', ) @@ -39,3 +40,19 @@ class Label(CommonModelNameNotUnique): jobtemplate_labels__isnull=True ) + def is_detached(self): + return bool( + Label.objects.filter( + id=self.id, + unifiedjob_labels__isnull=True, + unifiedjobtemplate_labels__isnull=True + ).count()) + + def is_candidate_for_detach(self): + c1 = UnifiedJob.objects.filter(labels__in=[self.id]).count() + c2 = UnifiedJobTemplate.objects.filter(labels__in=[self.id]).count() + if (c1 + c2 - 1) == 0: + return True + else: + return False + diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 571f9117ab..3d8a06f446 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -53,23 +53,15 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin): related_name='deprecated_organizations', ) admin_role = ImplicitRoleField( - role_name='Organization Administrator', - role_description='May manage all aspects of this organization', parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ) auditor_role = ImplicitRoleField( - role_name='Organization Auditor', - role_description='May read all settings associated with this organization', parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, ) member_role = ImplicitRoleField( - role_name='Organization Member', - role_description='A member of this organization', parent_role='admin_role', ) read_role = ImplicitRoleField( - role_name='Organization Read Access', - role_description='Read an organization', parent_role=['member_role', 'auditor_role'], ) @@ -110,22 +102,13 @@ class Team(CommonModelNameNotUnique, ResourceMixin): related_name='deprecated_teams', ) admin_role = ImplicitRoleField( - role_name='Team Administrator', - role_description='May manage this team', parent_role='organization.admin_role', ) auditor_role = ImplicitRoleField( - role_name='Team Auditor', - role_description='May read all settings associated with this team', parent_role='organization.auditor_role', ) - member_role = ImplicitRoleField( - role_name='Team Member', - role_description='A member of this team', - ) + member_role = ImplicitRoleField() read_role = ImplicitRoleField( - role_name='Read', - role_description='Can view this team', parent_role=['admin_role', 'auditor_role', 'member_role'], ) diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 12357fca2e..41145821f4 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -221,34 +221,24 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): blank=True, ) admin_role = ImplicitRoleField( - role_name='Project Administrator', - role_description='May manage this project', parent_role=[ 'organization.admin_role', 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ], ) auditor_role = ImplicitRoleField( - role_name='Project Auditor', - role_description='May read all settings associated with this project', parent_role=[ 'organization.auditor_role', 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, ], ) member_role = ImplicitRoleField( - role_name='Project Member', - role_description='Implies membership within this project', parent_role='admin_role', ) scm_update_role = ImplicitRoleField( - role_name='Project Updater', - role_description='May update this project from the source control management system', parent_role='admin_role', ) read_role = ImplicitRoleField( - role_name='Project Read Access', - role_description='Read access to this project', parent_role=['member_role', 'auditor_role', 'scm_update_role'], ) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index f757dc580e..3dd88d5227 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -8,7 +8,6 @@ import contextlib # Django from django.db import models, transaction, connection -from django.db.models import Q from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ from django.contrib.contenttypes.models import ContentType @@ -29,8 +28,39 @@ __all__ = [ logger = logging.getLogger('awx.main.models.rbac') -ROLE_SINGLETON_SYSTEM_ADMINISTRATOR='System Administrator' -ROLE_SINGLETON_SYSTEM_AUDITOR='System Auditor' +ROLE_SINGLETON_SYSTEM_ADMINISTRATOR='system_administrator' +ROLE_SINGLETON_SYSTEM_AUDITOR='system_auditor' + +role_names = { + 'system_administrator' : 'System Administrator', + 'system_auditor' : 'System Auditor', + 'adhoc_role' : 'Ad Hoc', + 'admin_role' : 'Admin', + 'auditor_role' : 'Auditor', + 'execute_role' : 'Execute', + 'member_role' : 'Member', + 'owner_role' : 'Owner', + 'read_role' : 'Read', + 'scm_update_role' : 'SCM Update', + 'update_role' : 'Update', + 'use_role' : 'Use', +} + +role_descriptions = { + 'system_administrator' : '[TODO] System Administrator', + 'system_auditor' : '[TODO] System Auditor', + 'adhoc_role' : '[TODO] Ad Hoc', + 'admin_role' : '[TODO] Admin', + 'auditor_role' : '[TODO] Auditor', + 'execute_role' : '[TODO] Execute', + 'member_role' : '[TODO] Member', + 'owner_role' : '[TODO] Owner', + 'read_role' : '[TODO] Read', + 'scm_update_role' : '[TODO] SCM Update', + 'update_role' : '[TODO] Update', + 'use_role' : '[TODO] Use', +} + tls = threading.local() # thread local storage @@ -51,23 +81,22 @@ def batch_role_ancestor_rebuilding(allow_nesting=False): try: setattr(tls, 'batch_role_rebuilding', True) if not batch_role_rebuilding: - setattr(tls, 'roles_needing_rebuilding', set()) + setattr(tls, 'additions', set()) + setattr(tls, 'removals', set()) yield finally: setattr(tls, 'batch_role_rebuilding', batch_role_rebuilding) if not batch_role_rebuilding: - rebuild_set = getattr(tls, 'roles_needing_rebuilding') + additions = getattr(tls, 'additions') + removals = getattr(tls, 'removals') with transaction.atomic(): - Role._simultaneous_ancestry_rebuild(list(rebuild_set)) - - #for role in Role.objects.filter(id__in=list(rebuild_set)).all(): - # # TODO: We can reduce this to one rebuild call with our new upcoming rebuild method.. do this - # role.rebuild_role_ancestor_list() - delattr(tls, 'roles_needing_rebuilding') + Role.rebuild_role_ancestor_list(list(additions), list(removals)) + delattr(tls, 'additions') + delattr(tls, 'removals') -class Role(CommonModelNameNotUnique): +class Role(models.Model): ''' Role model ''' @@ -76,9 +105,12 @@ class Role(CommonModelNameNotUnique): app_label = 'main' verbose_name_plural = _('roles') db_table = 'main_rbac_roles' + index_together = [ + ("content_type", "object_id") + ] + role_field = models.TextField(null=False) singleton_name = models.TextField(null=True, default=None, db_index=True, unique=True) - role_field = models.TextField(null=False, default='') parents = models.ManyToManyField('Role', related_name='children') implicit_parents = models.TextField(null=False, default='[]') ancestors = models.ManyToManyField( @@ -94,7 +126,7 @@ class Role(CommonModelNameNotUnique): def save(self, *args, **kwargs): super(Role, self).save(*args, **kwargs) - self.rebuild_role_ancestor_list() + self.rebuild_role_ancestor_list([self.id], []) def get_absolute_url(self): return reverse('api:role_detail', args=(self.pk,)) @@ -112,20 +144,36 @@ class Role(CommonModelNameNotUnique): object_id=accessor.id) return self.ancestors.filter(pk__in=roles).exists() - def rebuild_role_ancestor_list(self): + @property + def name(self): + global role_names + return role_names[self.role_field] + + @property + def description(self): + global role_descriptions + return role_descriptions[self.role_field] + + @staticmethod + def rebuild_role_ancestor_list(additions, removals): ''' Updates our `ancestors` map to accurately reflect all of the ancestors for a role You should never need to call this. Signal handlers should be calling this method when the role hierachy changes automatically. - - Note that this method relies on any parents' ancestor list being correct. ''' - Role._simultaneous_ancestry_rebuild([self.id]) - - - @staticmethod - def _simultaneous_ancestry_rebuild(role_ids_to_rebuild): + # The ancestry table + # ================================================= + # + # The role ancestors table denormalizes the parental relations + # between all roles in the system. If you have role A which is a + # parent of B which is a parent of C, then the ancestors table will + # contain a row noting that B is a descendent of A, and two rows for + # denoting that C is a descendent of both A and B. In addition to + # storing entries for each descendent relationship, we also store an + # entry that states that C is a 'descendent' of itself, C. This makes + # usage of this table simple in our queries as it enables us to do + # straight joins where we would have to do unions otherwise. # # The simple version of what this function is doing # ================================================= @@ -163,37 +211,18 @@ class Role(CommonModelNameNotUnique): # # SQL Breakdown # ============= - # The Role ancestors has three columns, (id, from_role_id, to_role_id) - # - # id: Unqiue row ID - # from_role_id: Descendent role ID - # to_role_id: Ancestor role ID - # - # *NOTE* In addition to mapping roles to parents, there also - # always exists must exist an entry where - # - # from_role_id == role_id == to_role_id - # - # this makes our joins simple when we go to derive permissions or - # accessible objects. - # - # # We operate under the assumption that our parent's ancestor list is # correct, thus we can always compute what our ancestor list should # be by taking the union of our parent's ancestor lists and adding - # our self reference entry from_role_id == role_id == to_role_id + # our self reference entry where ancestor_id = descendent_id # - # The inner query for the two SQL statements compute this union, - # the union of the parent's ancestors and the self referncing entry, - # for all roles in the current set of roles to rebuild. + # The DELETE query deletes all entries in the ancestor table that + # should no longer be there (as determined by the NOT EXISTS query, + # which checks to see if the ancestor is still an ancestor of one + # or more of our parents) # - # The DELETE query uses this to select all entries on disk for the - # roles we're dealing with, and removes the entries that are not in - # this list. - # - # The INSERT query uses this to select all entries in the list that - # are not in the database yet, and inserts all of the missing - # records. + # The INSERT query computes the list of what our ancestor maps should + # be, and inserts any missing entries. # # Once complete, we select all of the children for the roles we are # working with, this list becomes the new role list we are working @@ -205,18 +234,17 @@ class Role(CommonModelNameNotUnique): # # - if len(role_ids_to_rebuild) == 0: + if len(additions) == 0 and len(removals) == 0: return global tls batch_role_rebuilding = getattr(tls, 'batch_role_rebuilding', False) if batch_role_rebuilding: - roles_needing_rebuilding = getattr(tls, 'roles_needing_rebuilding') - roles_needing_rebuilding.update(set(role_ids_to_rebuild)) + getattr(tls, 'additions').update(set(additions)) + getattr(tls, 'removals').update(set(removals)) return - cursor = connection.cursor() loop_ct = 0 @@ -226,94 +254,143 @@ class Role(CommonModelNameNotUnique): 'roles_table': Role._meta.db_table, } + # SQLlite has a 1M sql statement limit.. since the django sqllite + # driver isn't letting us pass in the ids through the preferred + # parameter binding system, this function exists to obey this. + # est max 12 bytes per number, used up to 2 times in a query, + # minus 4k of padding for the other parts of the query, leads us + # to the magic number of 41496, or 40000 for a nice round number def split_ids_for_sqlite(role_ids): - for i in xrange(0, len(role_ids), 999): - yield role_ids[i:i + 999] - - while role_ids_to_rebuild: - if loop_ct > 1000: - raise Exception('Ancestry role rebuilding error: infinite loop detected') - loop_ct += 1 - - delete_ct = 0 - for ids in split_ids_for_sqlite(role_ids_to_rebuild): - sql_params['ids'] = ','.join(str(x) for x in ids) - cursor.execute(''' - DELETE FROM %(ancestors_table)s - WHERE descendent_id IN (%(ids)s) - AND - id NOT IN ( - SELECT %(ancestors_table)s.id FROM ( - SELECT parents.from_role_id from_id, ancestors.ancestor_id to_id - FROM %(parents_table)s as parents - LEFT JOIN %(ancestors_table)s as ancestors - ON (parents.to_role_id = ancestors.descendent_id) - WHERE parents.from_role_id IN (%(ids)s) AND ancestors.ancestor_id IS NOT NULL - - UNION - - SELECT id from_id, id to_id from %(roles_table)s WHERE id IN (%(ids)s) - ) new_ancestry_list - LEFT JOIN %(ancestors_table)s ON (new_ancestry_list.from_id = %(ancestors_table)s.descendent_id - AND new_ancestry_list.to_id = %(ancestors_table)s.ancestor_id) - WHERE %(ancestors_table)s.id IS NOT NULL - ) - ''' % sql_params) - delete_ct += cursor.rowcount - - insert_ct = 0 - for ids in split_ids_for_sqlite(role_ids_to_rebuild): - sql_params['ids'] = ','.join(str(x) for x in ids) - cursor.execute(''' - INSERT INTO %(ancestors_table)s (descendent_id, ancestor_id, role_field, content_type_id, object_id) - SELECT from_id, to_id, new_ancestry_list.role_field, new_ancestry_list.content_type_id, new_ancestry_list.object_id FROM ( - SELECT parents.from_role_id from_id, - ancestors.ancestor_id to_id, - roles.role_field, - COALESCE(roles.content_type_id, 0) content_type_id, - COALESCE(roles.object_id, 0) object_id - FROM %(parents_table)s as parents - INNER JOIN %(roles_table)s as roles ON (parents.from_role_id = roles.id) - LEFT OUTER JOIN %(ancestors_table)s as ancestors - ON (parents.to_role_id = ancestors.descendent_id) - WHERE parents.from_role_id IN (%(ids)s) AND ancestors.ancestor_id IS NOT NULL - - UNION - - SELECT id from_id, - id to_id, - role_field, - COALESCE(content_type_id, 0) content_type_id, - COALESCE(object_id, 0) object_id - from %(roles_table)s WHERE id IN (%(ids)s) - ) new_ancestry_list - LEFT JOIN %(ancestors_table)s ON (new_ancestry_list.from_id = %(ancestors_table)s.descendent_id - AND new_ancestry_list.to_id = %(ancestors_table)s.ancestor_id) - WHERE %(ancestors_table)s.id IS NULL - ''' % sql_params) - insert_ct += cursor.rowcount - - if insert_ct == 0 and delete_ct == 0: - break - - new_role_ids_to_rebuild = set() - for ids in split_ids_for_sqlite(role_ids_to_rebuild): - sql_params['ids'] = ','.join(str(x) for x in ids) - new_role_ids_to_rebuild.update(set(Role.objects.distinct() - .filter(id__in=ids, children__id__isnull=False) - .values_list('children__id', flat=True))) - role_ids_to_rebuild = list(new_role_ids_to_rebuild) + for i in xrange(0, len(role_ids), 40000): + yield role_ids[i:i + 40000] + with transaction.atomic(): + while len(additions) > 0 or len(removals) > 0: + if loop_ct > 100: + raise Exception('Role ancestry rebuilding error: infinite loop detected') + loop_ct += 1 + + delete_ct = 0 + if len(removals) > 0: + for ids in split_ids_for_sqlite(removals): + sql_params['ids'] = ','.join(str(x) for x in ids) + cursor.execute(''' + DELETE FROM %(ancestors_table)s + WHERE descendent_id IN (%(ids)s) + AND descendent_id != ancestor_id + AND NOT EXISTS ( + SELECT 1 + FROM %(parents_table)s as parents + INNER JOIN %(ancestors_table)s as inner_ancestors + ON (parents.to_role_id = inner_ancestors.descendent_id) + WHERE parents.from_role_id = %(ancestors_table)s.descendent_id + AND %(ancestors_table)s.ancestor_id = inner_ancestors.ancestor_id + ) + ''' % sql_params) + + delete_ct += cursor.rowcount + + insert_ct = 0 + if len(additions) > 0: + for ids in split_ids_for_sqlite(additions): + sql_params['ids'] = ','.join(str(x) for x in ids) + cursor.execute(''' + INSERT INTO %(ancestors_table)s (descendent_id, ancestor_id, role_field, content_type_id, object_id) + SELECT from_id, to_id, new_ancestry_list.role_field, new_ancestry_list.content_type_id, new_ancestry_list.object_id FROM ( + SELECT roles.id from_id, + ancestors.ancestor_id to_id, + roles.role_field, + COALESCE(roles.content_type_id, 0) content_type_id, + COALESCE(roles.object_id, 0) object_id + FROM %(roles_table)s as roles + INNER JOIN %(parents_table)s as parents + ON (parents.from_role_id = roles.id) + INNER JOIN %(ancestors_table)s as ancestors + ON (parents.to_role_id = ancestors.descendent_id) + WHERE roles.id IN (%(ids)s) + + UNION + + SELECT id from_id, + id to_id, + role_field, + COALESCE(content_type_id, 0) content_type_id, + COALESCE(object_id, 0) object_id + from %(roles_table)s WHERE id IN (%(ids)s) + ) new_ancestry_list + WHERE NOT EXISTS ( + SELECT 1 FROM %(ancestors_table)s + WHERE %(ancestors_table)s.descendent_id = new_ancestry_list.from_id + AND %(ancestors_table)s.ancestor_id = new_ancestry_list.to_id + ) + + ''' % sql_params) + insert_ct += cursor.rowcount + + if insert_ct == 0 and delete_ct == 0: + break + + new_additions = set() + for ids in split_ids_for_sqlite(additions): + sql_params['ids'] = ','.join(str(x) for x in ids) + # get all children for the roles we're operating on + cursor.execute('SELECT DISTINCT from_role_id FROM %(parents_table)s WHERE to_role_id IN (%(ids)s)' % sql_params) + new_additions.update([row[0] for row in cursor.fetchall()]) + additions = list(new_additions) + + new_removals = set() + for ids in split_ids_for_sqlite(removals): + sql_params['ids'] = ','.join(str(x) for x in ids) + # get all children for the roles we're operating on + cursor.execute('SELECT DISTINCT from_role_id FROM %(parents_table)s WHERE to_role_id IN (%(ids)s)' % sql_params) + new_removals.update([row[0] for row in cursor.fetchall()]) + removals = list(new_removals) @staticmethod def visible_roles(user): - return Role.objects.filter(Q(descendents__in=user.roles.filter()) | Q(ancestors__in=user.roles.filter())) + sql_params = { + 'ancestors_table': Role.ancestors.through._meta.db_table, + 'parents_table': Role.parents.through._meta.db_table, + 'roles_table': Role._meta.db_table, + 'ids': ','.join(str(x) for x in user.roles.values_list('id', flat=True)) + } + + qs = Role.objects.extra( + where = [''' + %(roles_table)s.id IN ( + SELECT descendent_id FROM %(ancestors_table)s WHERE ancestor_id IN (%(ids)s) + UNION + SELECT ancestor_id FROM %(ancestors_table)s WHERE descendent_id IN (%(ids)s) + ) + ''' % sql_params] + ) + return qs + + @staticmethod + def filter_visible_roles(user, roles_qs): + sql_params = { + 'ancestors_table': Role.ancestors.through._meta.db_table, + 'parents_table': Role.parents.through._meta.db_table, + 'roles_table': Role._meta.db_table, + 'ids': ','.join(str(x) for x in user.roles.all().values_list('id', flat=True)) + } + + qs = roles_qs.extra( + where = [''' + EXISTS ( + SELECT 1 FROM + %(ancestors_table)s + WHERE (descendent_id = %(roles_table)s.id AND ancestor_id IN (%(ids)s)) + OR (ancestor_id = %(roles_table)s.id AND descendent_id IN (%(ids)s)) + ) ''' % sql_params] + ) + return qs @staticmethod def singleton(name): - role, _ = Role.objects.get_or_create(singleton_name=name, name=name) + role, _ = Role.objects.get_or_create(singleton_name=name, role_field=name) return role def is_ancestor_of(self, role): @@ -328,6 +405,7 @@ class RoleAncestorEntry(models.Model): index_together = [ ("ancestor", "content_type_id", "object_id"), # used by get_roles_on_resource ("ancestor", "content_type_id", "role_field"), # used by accessible_objects + ("ancestor", "descendent"), # used by rebuild_role_ancestor_list in the NOT EXISTS clauses. ] descendent = models.ForeignKey(Role, null=False, on_delete=models.CASCADE, related_name='+') @@ -359,5 +437,5 @@ def get_roles_on_resource(resource, accessor): ancestor__in=roles, content_type_id=ContentType.objects.get_for_model(resource).id, object_id=resource.id - ).values_list('role_field', flat=True) + ).values_list('role_field', flat=True).distinct() ] diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 002d04f573..d4920a1357 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -141,6 +141,11 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio default='ok', editable=False, ) + labels = models.ManyToManyField( + "Label", + blank=True, + related_name='%(class)s_labels' + ) def get_absolute_url(self): real_instance = self.get_real_instance() @@ -476,6 +481,12 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique default='', editable=False, ) + labels = models.ManyToManyField( + "Label", + blank=True, + related_name='%(class)s_labels' + ) + def get_absolute_url(self): real_instance = self.get_real_instance() diff --git a/awx/main/notifications/slack_backend.py b/awx/main/notifications/slack_backend.py index 00f23ed60c..ffc52bc44b 100644 --- a/awx/main/notifications/slack_backend.py +++ b/awx/main/notifications/slack_backend.py @@ -43,6 +43,8 @@ class SlackBackend(TowerBaseEmailBackend): for m in messages: try: for r in m.recipients(): + if r.startswith('#'): + r = r[1:] self.connection.rtm_send_message(r, m.subject) sent_messages += 1 except Exception as e: diff --git a/awx/main/registrar.py b/awx/main/registrar.py index c78bf22f7c..de6673fc5a 100644 --- a/awx/main/registrar.py +++ b/awx/main/registrar.py @@ -3,7 +3,7 @@ import logging -from django.db.models.signals import pre_save, post_save, post_delete, m2m_changed +from django.db.models.signals import pre_save, post_save, pre_delete, m2m_changed logger = logging.getLogger('awx.main.registrar') @@ -17,12 +17,12 @@ class ActivityStreamRegistrar(object): if not getattr(tower_settings, 'ACTIVITY_STREAM_ENABLED', True): return from awx.main.signals import activity_stream_create, activity_stream_update, activity_stream_delete, activity_stream_associate - + if model not in self.models: self.models.append(model) post_save.connect(activity_stream_create, sender=model, dispatch_uid=str(self.__class__) + str(model) + "_create") pre_save.connect(activity_stream_update, sender=model, dispatch_uid=str(self.__class__) + str(model) + "_update") - post_delete.connect(activity_stream_delete, sender=model, dispatch_uid=str(self.__class__) + str(model) + "_delete") + pre_delete.connect(activity_stream_delete, sender=model, dispatch_uid=str(self.__class__) + str(model) + "_delete") for m2mfield in model._meta.many_to_many: try: @@ -36,7 +36,7 @@ class ActivityStreamRegistrar(object): if model in self.models: post_save.disconnect(dispatch_uid=str(self.__class__) + str(model) + "_create") pre_save.disconnect(dispatch_uid=str(self.__class__) + str(model) + "_update") - post_delete.disconnect(dispatch_uid=str(self.__class__) + str(model) + "_delete") + pre_delete.disconnect(dispatch_uid=str(self.__class__) + str(model) + "_delete") self.models.pop(model) diff --git a/awx/main/signals.py b/awx/main/signals.py index e4893d34c2..799a70f372 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -108,12 +108,17 @@ def emit_update_inventory_on_created_or_deleted(sender, **kwargs): def rebuild_role_ancestor_list(reverse, model, instance, pk_set, action, **kwargs): 'When a role parent is added or removed, update our role hierarchy list' - if action in ['post_add', 'post_remove', 'post_clear']: + if action == 'post_add': if reverse: - for id in pk_set: - model.objects.get(id=id).rebuild_role_ancestor_list() + model.rebuild_role_ancestor_list(list(pk_set), []) else: - instance.rebuild_role_ancestor_list() + model.rebuild_role_ancestor_list([instance.id], []) + + if action in ['post_remove', 'post_clear']: + if reverse: + model.rebuild_role_ancestor_list([], list(pk_set)) + else: + model.rebuild_role_ancestor_list([], [instance.id]) def sync_superuser_status_to_rbac(instance, **kwargs): 'When the is_superuser flag is changed on a user, reflect that in the membership of the System Admnistrator role' @@ -127,11 +132,10 @@ def create_user_role(instance, **kwargs): Role.objects.get( content_type=ContentType.objects.get_for_model(instance), object_id=instance.id, - name = 'User Admin' + role_field='admin_role' ) except Role.DoesNotExist: role = Role.objects.create( - name = 'User Admin', role_field='admin_role', content_object = instance, ) @@ -152,6 +156,24 @@ def org_admin_edit_members(instance, action, model, reverse, pk_set, **kwargs): if action == 'pre_remove': instance.content_object.admin_role.children.remove(user.admin_role) +def rbac_activity_stream(instance, sender, **kwargs): + user_type = ContentType.objects.get_for_model(User) + # Only if we are associating/disassociating + if kwargs['action'] in ['pre_add', 'pre_remove']: + # Only if this isn't for the User.admin_role + if hasattr(instance, 'content_type'): + if instance.content_type in [None, user_type]: + return + role = instance + instance = instance.content_object + else: + role = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']).first() + activity_stream_associate(sender, instance, role=role, **kwargs) + +def cleanup_detached_labels_on_deleted_parent(sender, instance, **kwargs): + for l in instance.labels.all(): + if l.is_candidate_for_detach(): + l.delete() post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -169,9 +191,11 @@ post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) m2m_changed.connect(org_admin_edit_members, Role.members.through) +m2m_changed.connect(rbac_activity_stream, Role.members.through) post_save.connect(sync_superuser_status_to_rbac, sender=User) post_save.connect(create_user_role, sender=User) - +pre_delete.connect(cleanup_detached_labels_on_deleted_parent, sender=UnifiedJob) +pre_delete.connect(cleanup_detached_labels_on_deleted_parent, sender=UnifiedJobTemplate) # Migrate hosts, groups to parent group(s) whenever a group is deleted @@ -331,14 +355,10 @@ def activity_stream_update(sender, instance, **kwargs): def activity_stream_delete(sender, instance, **kwargs): if not activity_stream_enabled: return - try: - old = sender.objects.get(id=instance.id) - except sender.DoesNotExist: - return # Skip recording any inventory source directly associated with a group. if isinstance(instance, InventorySource) and instance.group: return - changes = model_instance_diff(old, instance) + changes = model_to_dict(instance) object1 = camelcase_to_underscore(instance.__class__.__name__) activity_entry = ActivityStream( operation='delete', @@ -349,7 +369,7 @@ def activity_stream_delete(sender, instance, **kwargs): def activity_stream_associate(sender, instance, **kwargs): if not activity_stream_enabled: return - if 'pre_add' in kwargs['action'] or 'pre_remove' in kwargs['action']: + if kwargs['action'] in ['pre_add', 'pre_remove']: if kwargs['action'] == 'pre_add': action = 'associate' elif kwargs['action'] == 'pre_remove': @@ -378,6 +398,23 @@ def activity_stream_associate(sender, instance, **kwargs): getattr(activity_entry, object1).add(obj1) getattr(activity_entry, object2).add(obj2_actual) + # Record the role for RBAC changes + if 'role' in kwargs: + role = kwargs['role'] + if role.content_object is not None: + obj_rel = '.'.join([role.content_object.__module__, + role.content_object.__class__.__name__, + role.role_field]) + + # If the m2m is from the User side we need to + # set the content_object of the Role for our entry. + if type(instance) == User and role.content_object is not None: + getattr(activity_entry, role.content_type.name).add(role.content_object) + + activity_entry.role.add(role) + activity_entry.object_relationship_type = obj_rel + activity_entry.save() + @receiver(current_user_getter) def get_current_user_from_drf_request(sender, **kwargs): diff --git a/awx/main/tasks.py b/awx/main/tasks.py index db47f3eaec..a8ad070705 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -208,7 +208,7 @@ def handle_work_success(self, result, task_actual): notification_body = instance.notification_data() notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name, task_actual['id'], - instance_name, + smart_text(instance_name), notification_body['url']) notification_body['friendly_name'] = friendly_name send_notifications.delay([n.generate_notification(notification_subject, notification_body).id @@ -246,8 +246,8 @@ def handle_work_error(self, task_id, subtasks=None): instance_name = instance.module_name notifiers = [] friendly_name = "AdHoc Command" - elif task_actual['type'] == 'system_job': - instance = SystemJob.objects.get(id=task_actual['id']) + elif each_task['type'] == 'system_job': + instance = SystemJob.objects.get(id=each_task['id']) instance_name = instance.system_job_template.name notifiers = instance.system_job_template.notifiers friendly_name = "System Job" @@ -270,7 +270,7 @@ def handle_work_error(self, task_id, subtasks=None): notification_body = first_task.notification_data() notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name, first_task_id, - first_task_name, + smart_text(first_task_name), notification_body['url']) notification_body['friendly_name'] = first_task_friendly_name send_notifications.delay([n.generate_notification(notification_subject, notification_body).id @@ -558,7 +558,7 @@ class BaseTask(Task): instance = self.update_model(instance.pk) if instance.cancel_flag: try: - if tower_settings.AWX_PROOT_ENABLED: + if tower_settings.AWX_PROOT_ENABLED and self.should_use_proot(instance): # NOTE: Refactor this once we get a newer psutil across the board if not psutil: os.kill(child.pid, signal.SIGKILL) @@ -1615,6 +1615,9 @@ class RunAdHocCommand(BaseTask): if ad_hoc_command.verbosity: args.append('-%s' % ('v' * min(5, ad_hoc_command.verbosity))) + if ad_hoc_command.extra_vars_dict: + args.extend(['-e', json.dumps(ad_hoc_command.extra_vars_dict)]) + args.extend(['-m', ad_hoc_command.module_name]) args.extend(['-a', ad_hoc_command.module_args]) diff --git a/awx/main/tests/functional/api/test_activity_streams.py b/awx/main/tests/functional/api/test_activity_streams.py index 5cb74222f6..4658470177 100644 --- a/awx/main/tests/functional/api/test_activity_streams.py +++ b/awx/main/tests/functional/api/test_activity_streams.py @@ -59,3 +59,29 @@ def test_middleware_actor_added(monkeypatch, post, get, user): assert response.status_code == 200 assert response.data['summary_fields']['actor']['username'] == 'admin-poster' + +@pytest.mark.skipif(not getattr(settings, 'ACTIVITY_STREAM_ENABLED', True), reason="Activity stream not enabled") +@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) +@pytest.mark.django_db +def test_rbac_stream_resource_roles(mocker, organization, user): + member = user('test', False) + organization.admin_role.members.add(member) + + activity_stream = ActivityStream.objects.filter(organization__pk=organization.pk, operation='associate').first() + assert activity_stream.user.first() == member + assert activity_stream.organization.first() == organization + assert activity_stream.role.first() == organization.admin_role + assert activity_stream.object_relationship_type == 'awx.main.models.organization.Organization.admin_role' + +@pytest.mark.skipif(not getattr(settings, 'ACTIVITY_STREAM_ENABLED', True), reason="Activity stream not enabled") +@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) +@pytest.mark.django_db +def test_rbac_stream_user_roles(mocker, organization, user): + member = user('test', False) + member.roles.add(organization.admin_role) + + activity_stream = ActivityStream.objects.filter(organization__pk=organization.pk, operation='associate').first() + assert activity_stream.user.first() == member + assert activity_stream.organization.first() == organization + assert activity_stream.role.first() == organization.admin_role + assert activity_stream.object_relationship_type == 'awx.main.models.organization.Organization.admin_role' diff --git a/awx/main/tests/functional/api/test_credential.py b/awx/main/tests/functional/api/test_credential.py index 24b653b863..fffab6f1a0 100644 --- a/awx/main/tests/functional/api/test_credential.py +++ b/awx/main/tests/functional/api/test_credential.py @@ -24,6 +24,7 @@ def test_create_user_credential_via_credentials_list(post, get, alice): @pytest.mark.django_db def test_create_user_credential_via_user_credentials_list(post, get, alice): response = post(reverse('api:user_credentials_list', args=(alice.pk,)), { + 'user': alice.pk, 'name': 'Some name', 'username': 'someusername', }, alice) @@ -45,6 +46,7 @@ def test_create_user_credential_via_credentials_list_xfail(post, alice, bob): @pytest.mark.django_db def test_create_user_credential_via_user_credentials_list_xfail(post, alice, bob): response = post(reverse('api:user_credentials_list', args=(bob.pk,)), { + 'user': bob.pk, 'name': 'Some name', 'username': 'someusername' }, alice) @@ -71,6 +73,7 @@ def test_create_team_credential(post, get, team, org_admin, team_member): @pytest.mark.django_db def test_create_team_credential_via_team_credentials_list(post, get, team, org_admin, team_member): response = post(reverse('api:team_credentials_list', args=(team.pk,)), { + 'team': team.pk, 'name': 'Some name', 'username': 'someusername', }, org_admin) diff --git a/awx/main/tests/functional/api/test_job_runtime_params.py b/awx/main/tests/functional/api/test_job_runtime_params.py index c6b5521966..6f76fdf1b6 100644 --- a/awx/main/tests/functional/api/test_job_runtime_params.py +++ b/awx/main/tests/functional/api/test_job_runtime_params.py @@ -22,6 +22,10 @@ def runtime_data(organization): credential=cred_obj.pk, ) +@pytest.fixture +def job_with_links(machine_credential, inventory): + return Job.objects.create(name='existing-job', credential=machine_credential, inventory=inventory) + @pytest.fixture def job_template_prompts(project, inventory, machine_credential): def rf(on_off): @@ -155,7 +159,7 @@ def test_job_reject_invalid_prompted_extra_vars(runtime_data, job_template_promp dict(extra_vars='{"unbalanced brackets":'), user('admin', True)) assert response.status_code == 400 - assert response.data['extra_vars'] == ['Must be valid JSON or YAML'] + assert response.data['extra_vars'] == ['Must be a valid JSON or YAML dictionary'] @pytest.mark.django_db @pytest.mark.job_runtime_vars @@ -167,51 +171,72 @@ def test_job_launch_fails_without_inventory(deploy_jobtemplate, post, user): args=[deploy_jobtemplate.pk]), {}, user('admin', True)) assert response.status_code == 400 - assert response.data['inventory'] == ['Job Template Inventory is missing or undefined'] + assert response.data['inventory'] == ['Job Template Inventory is missing or undefined.'] @pytest.mark.django_db @pytest.mark.job_runtime_vars -def test_job_launch_fails_without_inventory_access(job_template_prompts, runtime_data, machine_credential, post, user, mocker): +def test_job_launch_fails_without_inventory_access(job_template_prompts, runtime_data, post, user): job_template = job_template_prompts(True) common_user = user('test-user', False) job_template.execute_role.members.add(common_user) - # Assure that the base job template can be launched to begin with - mock_job = mocker.MagicMock(spec=Job, id=968, **runtime_data) - with mocker.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.create_unified_job', return_value=mock_job): - with mocker.patch('awx.api.serializers.JobSerializer.to_representation'): - response = post(reverse('api:job_template_launch', - args=[job_template.pk]), {}, common_user) - - assert response.status_code == 201 - # Assure that giving an inventory without access to the inventory blocks the launch - new_inv = job_template.project.organization.inventories.create(name="user-can-not-use") + runtime_inventory = Inventory.objects.get(pk=runtime_data['inventory']) response = post(reverse('api:job_template_launch', args=[job_template.pk]), - dict(inventory=new_inv.pk), common_user) + dict(inventory=runtime_inventory.pk), common_user) assert response.status_code == 403 assert response.data['detail'] == u'You do not have permission to perform this action.' @pytest.mark.django_db @pytest.mark.job_runtime_vars -def test_job_relaunch_copy_vars(runtime_data, job_template_prompts, project, post, mocker): +def test_job_launch_fails_without_credential_access(job_template_prompts, runtime_data, post, user): job_template = job_template_prompts(True) + common_user = user('test-user', False) + job_template.execute_role.members.add(common_user) - # Create a job with the given data that will be relaunched - job_create_kwargs = runtime_data - inv_obj = Inventory.objects.get(pk=job_create_kwargs.pop('inventory')) - cred_obj = Credential.objects.get(pk=job_create_kwargs.pop('credential')) - original_job = Job.objects.create(inventory=inv_obj, credential=cred_obj, job_template=job_template, **job_create_kwargs) - with mocker.patch('awx.main.models.unified_jobs.UnifiedJobTemplate._get_unified_job_field_names', return_value=runtime_data.keys()): - second_job = original_job.copy() + # Assure that giving a credential without access blocks the launch + runtime_credential = Credential.objects.get(pk=runtime_data['credential']) + response = post(reverse('api:job_template_launch', args=[job_template.pk]), + dict(credential=runtime_credential.pk), common_user) + + assert response.status_code == 403 + assert response.data['detail'] == u'You do not have permission to perform this action.' + +@pytest.mark.django_db +@pytest.mark.job_runtime_vars +def test_job_relaunch_copy_vars(job_with_links, machine_credential, inventory, + deploy_jobtemplate, post, mocker): + job_with_links.job_template = deploy_jobtemplate + job_with_links.limit = "my_server" + with mocker.patch('awx.main.models.unified_jobs.UnifiedJobTemplate._get_unified_job_field_names', + return_value=['inventory', 'credential', 'limit']): + second_job = job_with_links.copy() # Check that job data matches the original variables - assert 'job_launch_var' in yaml.load(second_job.extra_vars) - assert original_job.limit == second_job.limit - assert original_job.job_type == second_job.job_type - assert original_job.inventory.pk == second_job.inventory.pk - assert original_job.job_tags == second_job.job_tags + assert second_job.credential == job_with_links.credential + assert second_job.inventory == job_with_links.inventory + assert second_job.limit == 'my_server' + +@pytest.mark.django_db +@pytest.mark.job_runtime_vars +def test_job_relaunch_resource_access(job_with_links, user): + inventory_user = user('user1', False) + credential_user = user('user2', False) + both_user = user('user3', False) + + # Confirm that a user with inventory & credential access can launch + job_with_links.credential.use_role.members.add(both_user) + job_with_links.inventory.use_role.members.add(both_user) + assert both_user.can_access(Job, 'start', job_with_links) + + # Confirm that a user with credential access alone can not launch + job_with_links.credential.use_role.members.add(credential_user) + assert not credential_user.can_access(Job, 'start', job_with_links) + + # Confirm that a user with inventory access alone can not launch + job_with_links.inventory.use_role.members.add(inventory_user) + assert not inventory_user.can_access(Job, 'start', job_with_links) @pytest.mark.django_db def test_job_launch_JT_with_validation(machine_credential, deploy_jobtemplate): diff --git a/awx/main/tests/functional/api/test_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py index bc6df133d7..0e57488fab 100644 --- a/awx/main/tests/functional/api/test_organization_counts.py +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -150,6 +150,26 @@ def test_two_organizations(resourced_organization, organizations, user, get): assert counts[org_id_full] == COUNTS_PRIMES assert counts[org_id_zero] == COUNTS_ZEROS +@pytest.mark.django_db +def test_scan_JT_counted(resourced_organization, user, get): + admin_user = user('admin', True) + # Add a scan job template to the org + resourced_organization.projects.all()[0].jobtemplates.create( + job_type='scan', inventory=resourced_organization.inventories.all()[0], + name='scan-job-template') + counts_dict = COUNTS_PRIMES + counts_dict['job_templates'] += 1 + + # Test list view + list_response = get(reverse('api:organization_list', args=[]), admin_user) + assert list_response.status_code == 200 + assert list_response.data['results'][0]['summary_fields']['related_field_counts'] == counts_dict + + # Test detail view + detail_response = get(reverse('api:organization_detail', args=[resourced_organization.pk]), admin_user) + assert detail_response.status_code == 200 + assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict + @pytest.mark.django_db def test_JT_associated_with_project(organizations, project, user, get): # Check that adding a project to an organization gets the project's JT diff --git a/awx/main/tests/functional/api/test_resource_access_lists.py b/awx/main/tests/functional/api/test_resource_access_lists.py index 75e55fd8ca..9d8d95c98a 100644 --- a/awx/main/tests/functional/api/test_resource_access_lists.py +++ b/awx/main/tests/functional/api/test_resource_access_lists.py @@ -1,6 +1,7 @@ import pytest from django.core.urlresolvers import reverse +from awx.main.models import Role @pytest.mark.django_db def test_indirect_access_list(get, organization, project, team_factory, user, admin): @@ -53,5 +54,5 @@ def test_indirect_access_list(get, organization, project, team_factory, user, ad assert org_admin_team_member_entry['team_name'] == org_admin_team.name admin_entry = admin_res['summary_fields']['indirect_access'][0]['role'] - assert admin_entry['name'] == 'System Administrator' + assert admin_entry['name'] == Role.singleton('system_administrator').name diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 4b481895d6..33c24f3cc6 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -36,7 +36,6 @@ from awx.main.models.organization import ( Team, ) -from awx.main.models.rbac import Role from awx.main.models.notifications import Notifier ''' @@ -193,11 +192,6 @@ def notifier(organization): notification_type="webhook", notification_configuration=dict(url="http://localhost", headers={"Test": "Header"})) - -@pytest.fixture -def role(): - return Role.objects.create(name='role') - @pytest.fixture def admin(user): return user('admin', True) diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index 3e080c8453..2297868aa6 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -10,6 +10,10 @@ def mock_feature_enabled(feature, bypass_database=None): #@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) +@pytest.fixture +def role(): + return Role.objects.create() + # # /roles @@ -85,13 +89,14 @@ def test_get_user_roles_list(get, admin): response = get(url, admin) assert response.status_code == 200 roles = response.data - assert roles['count'] > 0 # 'System Administrator' role if nothing else + assert roles['count'] > 0 # 'system_administrator' role if nothing else @pytest.mark.django_db def test_user_view_other_user_roles(organization, inventory, team, get, alice, bob): 'Users can see roles for other users, but only the roles that that user has access to see as well' organization.member_role.members.add(alice) organization.admin_role.members.add(bob) + organization.member_role.members.add(bob) custom_role = Role.objects.create(name='custom_role-test_user_view_admin_roles_list') organization.member_role.children.add(custom_role) team.member_role.members.add(bob) diff --git a/awx/main/tests/functional/test_rbac_core.py b/awx/main/tests/functional/test_rbac_core.py index 537052afd2..8001cb6d71 100644 --- a/awx/main/tests/functional/test_rbac_core.py +++ b/awx/main/tests/functional/test_rbac_core.py @@ -11,8 +11,8 @@ from awx.main.models import ( @pytest.mark.django_db def test_auto_inheritance_by_children(organization, alice): - A = Role.objects.create(name='A', role_field='') - B = Role.objects.create(name='B', role_field='') + A = Role.objects.create() + B = Role.objects.create() A.members.add(alice) assert alice not in organization.admin_role @@ -38,8 +38,8 @@ def test_auto_inheritance_by_children(organization, alice): @pytest.mark.django_db def test_auto_inheritance_by_parents(organization, alice): - A = Role.objects.create(name='A') - B = Role.objects.create(name='B') + A = Role.objects.create() + B = Role.objects.create() A.members.add(alice) assert alice not in organization.admin_role @@ -58,9 +58,9 @@ def test_auto_inheritance_by_parents(organization, alice): @pytest.mark.django_db def test_accessible_objects(organization, alice, bob): - A = Role.objects.create(name='A') + A = Role.objects.create() A.members.add(alice) - B = Role.objects.create(name='B') + B = Role.objects.create() B.members.add(alice) B.members.add(bob) @@ -118,7 +118,7 @@ def test_auto_field_adjustments(organization, inventory, team, alice): def test_implicit_deletes(alice): 'Ensures implicit resources and roles delete themselves' delorg = Organization.objects.create(name='test-org') - child = Role.objects.create(name='child-role') + child = Role.objects.create() child.parents.add(delorg.admin_role) delorg.admin_role.members.add(alice) @@ -129,14 +129,14 @@ def test_implicit_deletes(alice): assert Role.objects.filter(id=admin_role_id).count() == 1 assert Role.objects.filter(id=auditor_role_id).count() == 1 n_alice_roles = alice.roles.count() - n_system_admin_children = Role.singleton('System Administrator').children.count() + n_system_admin_children = Role.singleton('system_administrator').children.count() delorg.delete() assert Role.objects.filter(id=admin_role_id).count() == 0 assert Role.objects.filter(id=auditor_role_id).count() == 0 assert alice.roles.count() == (n_alice_roles - 1) - assert Role.singleton('System Administrator').children.count() == (n_system_admin_children - 1) + assert Role.singleton('system_administrator').children.count() == (n_system_admin_children - 1) assert child.ancestors.count() == 1 assert child.ancestors.all()[0] == child @@ -152,11 +152,11 @@ def test_content_object(user): def test_hierarchy_rebuilding_multi_path(): 'Tests a subdtle cases around role hierarchy rebuilding when you have multiple paths to the same role of different length' - X = Role.objects.create(name='X') - A = Role.objects.create(name='A') - B = Role.objects.create(name='B') - C = Role.objects.create(name='C') - D = Role.objects.create(name='D') + X = Role.objects.create() + A = Role.objects.create() + B = Role.objects.create() + C = Role.objects.create() + D = Role.objects.create() A.children.add(B) A.children.add(D) diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index e48b964bc8..75bcffecb6 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -27,7 +27,7 @@ def test_credential_use_role(credential, user, permissions): @pytest.mark.django_db def test_credential_migration_team_member(credential, team, user, permissions): u = user('user', False) - team.admin_role.members.add(u) + team.member_role.members.add(u) credential.deprecated_team = team credential.save() @@ -91,7 +91,8 @@ def test_credential_access_admin(user, team, credential): assert access.can_change(credential, {'user': u.pk}) @pytest.mark.django_db -def test_cred_job_template(user, deploy_jobtemplate): +def test_cred_job_template_xfail(user, deploy_jobtemplate): + ' Personal credential migration ' a = user('admin', False) org = deploy_jobtemplate.project.organization org.admin_role.members.add(a) @@ -102,19 +103,17 @@ def test_cred_job_template(user, deploy_jobtemplate): access = CredentialAccess(a) rbac.migrate_credential(apps, None) - assert access.can_change(cred, {'organization': org.pk}) - - org.admin_role.members.remove(a) assert not access.can_change(cred, {'organization': org.pk}) @pytest.mark.django_db -def test_cred_multi_job_template_single_org(user, deploy_jobtemplate): +def test_cred_job_template(user, team, deploy_jobtemplate): + ' Team credential migration => org credential ' a = user('admin', False) org = deploy_jobtemplate.project.organization org.admin_role.members.add(a) cred = deploy_jobtemplate.credential - cred.deprecated_user = user('john', False) + cred.deprecated_team = team cred.save() access = CredentialAccess(a) @@ -125,8 +124,42 @@ def test_cred_multi_job_template_single_org(user, deploy_jobtemplate): assert not access.can_change(cred, {'organization': org.pk}) @pytest.mark.django_db -def test_single_cred_multi_job_template_multi_org(user, organizations, credential): +def test_cred_multi_job_template_single_org_xfail(user, deploy_jobtemplate): + a = user('admin', False) + org = deploy_jobtemplate.project.organization + org.admin_role.members.add(a) + + cred = deploy_jobtemplate.credential + cred.deprecated_user = user('john', False) + cred.save() + + access = CredentialAccess(a) + rbac.migrate_credential(apps, None) + assert not access.can_change(cred, {'organization': org.pk}) + +@pytest.mark.django_db +def test_cred_multi_job_template_single_org(user, team, deploy_jobtemplate): + a = user('admin', False) + org = deploy_jobtemplate.project.organization + org.admin_role.members.add(a) + + cred = deploy_jobtemplate.credential + cred.deprecated_team = team + cred.save() + + access = CredentialAccess(a) + rbac.migrate_credential(apps, None) + assert access.can_change(cred, {'organization': org.pk}) + + org.admin_role.members.remove(a) + assert not access.can_change(cred, {'organization': org.pk}) + +@pytest.mark.django_db +def test_single_cred_multi_job_template_multi_org(user, organizations, credential, team): orgs = organizations(2) + credential.deprecated_team = team + credential.save() + jts = [] for org in orgs: inv = org.inventories.create(name="inv-%d" % org.pk) @@ -169,7 +202,7 @@ def test_cred_inventory_source(user, inventory, credential): assert u not in credential.use_role rbac.migrate_credential(apps, None) - assert u in credential.use_role + assert u not in credential.use_role @pytest.mark.django_db def test_cred_project(user, credential, project): @@ -181,7 +214,7 @@ def test_cred_project(user, credential, project): assert u not in credential.use_role rbac.migrate_credential(apps, None) - assert u in credential.use_role + assert u not in credential.use_role @pytest.mark.django_db def test_cred_no_org(user, credential): diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index 93538f67f0..e439533f07 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -87,7 +87,7 @@ def test_job_template_team_migration_check(deploy_jobtemplate, check_jobtemplate rbac.migrate_projects(apps, None) rbac.migrate_inventory(apps, None) - assert joe in check_jobtemplate.read_role + assert joe not in check_jobtemplate.read_role assert admin in check_jobtemplate.execute_role assert joe not in check_jobtemplate.execute_role @@ -120,12 +120,13 @@ def test_job_template_team_deploy_migration(deploy_jobtemplate, check_jobtemplat rbac.migrate_projects(apps, None) rbac.migrate_inventory(apps, None) - assert joe in deploy_jobtemplate.read_role + assert joe not in deploy_jobtemplate.read_role assert admin in deploy_jobtemplate.execute_role assert joe not in deploy_jobtemplate.execute_role rbac.migrate_job_templates(apps, None) + assert joe in deploy_jobtemplate.read_role assert admin in deploy_jobtemplate.execute_role assert joe in deploy_jobtemplate.execute_role diff --git a/awx/main/tests/functional/test_rbac_project.py b/awx/main/tests/functional/test_rbac_project.py index d2e504645a..a225154d21 100644 --- a/awx/main/tests/functional/test_rbac_project.py +++ b/awx/main/tests/functional/test_rbac_project.py @@ -148,7 +148,7 @@ def test_project_user_project(user_project, project, user): def test_project_accessible_by_sa(user, project): u = user('systemadmin', is_superuser=True) # This gets setup by a signal, but we want to test the migration which will set this up too, so remove it - Role.singleton('System Administrator').members.remove(u) + Role.singleton('system_administrator').members.remove(u) assert u not in project.read_role rbac.migrate_organization(apps, None) diff --git a/awx/main/tests/functional/test_rbac_team.py b/awx/main/tests/functional/test_rbac_team.py index 3961cb837a..d4f03f0cfc 100644 --- a/awx/main/tests/functional/test_rbac_team.py +++ b/awx/main/tests/functional/test_rbac_team.py @@ -3,6 +3,25 @@ import pytest from awx.main.access import TeamAccess from awx.main.models import Project + +@pytest.mark.django_db +def test_team_attach_unattach(team, user): + u = user('member', False) + access = TeamAccess(u) + + team.member_role.members.add(u) + assert not access.can_attach(team, u.admin_role, 'member_role.children', None) + assert not access.can_unattach(team, u.admin_role, 'member_role.children') + + team.admin_role.members.add(u) + assert access.can_attach(team, u.admin_role, 'member_role.children', None) + assert access.can_unattach(team, u.admin_role, 'member_role.children') + + u2 = user('non-member', False) + access = TeamAccess(u2) + assert not access.can_attach(team, u2.admin_role, 'member_role.children', None) + assert not access.can_unattach(team, u2.admin_role, 'member_role.chidlren') + @pytest.mark.django_db def test_team_access_superuser(team, user): team.member_role.members.add(user('member', False)) diff --git a/awx/main/tests/functional/test_rbac_user.py b/awx/main/tests/functional/test_rbac_user.py index 8e620771f5..c5959a2c32 100644 --- a/awx/main/tests/functional/test_rbac_user.py +++ b/awx/main/tests/functional/test_rbac_user.py @@ -13,7 +13,7 @@ def test_user_admin(user_project, project, user): joe = user(username, is_superuser = False) admin = user('admin', is_superuser = True) - sa = Role.singleton('System Administrator') + sa = Role.singleton('system_administrator') # this should happen automatically with our signal assert sa.members.filter(id=admin.id).exists() is True diff --git a/awx/main/tests/unit/api/test_filters.py b/awx/main/tests/unit/api/test_filters.py new file mode 100644 index 0000000000..8f045db877 --- /dev/null +++ b/awx/main/tests/unit/api/test_filters.py @@ -0,0 +1,17 @@ +import pytest + +from awx.api.filters import FieldLookupBackend +from awx.main.models import JobTemplate + +@pytest.mark.parametrize(u"empty_value", [u'', '']) +def test_empty_in(empty_value): + field_lookup = FieldLookupBackend() + with pytest.raises(ValueError) as excinfo: + field_lookup.value_to_python(JobTemplate, 'project__in', empty_value) + assert 'empty value for __in' in str(excinfo.value) + +@pytest.mark.parametrize(u"valid_value", [u'foo', u'foo,']) +def test_valid_in(valid_value): + field_lookup = FieldLookupBackend() + value, new_lookup = field_lookup.value_to_python(JobTemplate, 'project__in', valid_value) + assert 'foo' in value diff --git a/awx/main/tests/unit/api/test_generics.py b/awx/main/tests/unit/api/test_generics.py index 42fe141c49..42d755abcf 100644 --- a/awx/main/tests/unit/api/test_generics.py +++ b/awx/main/tests/unit/api/test_generics.py @@ -1,13 +1,14 @@ # Python import pytest +import mock # DRF from rest_framework import status from rest_framework.response import Response # AWX -from awx.api.generics import ParentMixin, SubListCreateAttachDetachAPIView +from awx.api.generics import ParentMixin, SubListCreateAttachDetachAPIView, DeleteLastUnattachLabelMixin @pytest.fixture def get_object_or_404(mocker): @@ -37,7 +38,7 @@ def parent_relationship_factory(mocker): return (serializer, mock_parent_relationship) return rf -# TODO: Test create and associate failure (i.e. id doesn't exist or record already exists) +# TODO: Test create and associate failure (i.e. id doesn't exist, record already exists, permission denied) # TODO: Mock and check return (Response) class TestSubListCreateAttachDetachAPIView: def test_attach_create_and_associate(self, mocker, get_object_or_400, parent_relationship_factory, mock_response_new): @@ -65,6 +66,97 @@ class TestSubListCreateAttachDetachAPIView: mock_parent_relationship.wife.add.assert_called_with(get_object_or_400.return_value) mock_response_new.assert_called_with(Response, status=status.HTTP_204_NO_CONTENT) + def test_unattach_validate_ok(self, mocker): + mock_request = mocker.MagicMock(data=dict(id=1)) + serializer = SubListCreateAttachDetachAPIView() + + (sub_id, res) = serializer.unattach_validate(mock_request) + + assert sub_id == 1 + assert res is None + + def test_unattach_validate_missing_id(self, mocker): + mock_request = mocker.MagicMock(data=dict()) + serializer = SubListCreateAttachDetachAPIView() + + (sub_id, res) = serializer.unattach_validate(mock_request) + + assert sub_id is None + assert type(res) is Response + + def test_unattach_by_id_ok(self, mocker, parent_relationship_factory, get_object_or_400): + (serializer, mock_parent_relationship) = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') + mock_request = mocker.MagicMock() + mock_sub = mocker.MagicMock(name="object to unattach") + get_object_or_400.return_value = mock_sub + + res = serializer.unattach_by_id(mock_request, 1) + + assert type(res) is Response + assert res.status_code == status.HTTP_204_NO_CONTENT + mock_parent_relationship.wife.remove.assert_called_with(mock_sub) + + def test_unattach_ok(self, mocker): + mock_request = mocker.MagicMock() + mock_sub_id = mocker.MagicMock() + view = SubListCreateAttachDetachAPIView() + view.unattach_validate = mocker.MagicMock() + view.unattach_by_id = mocker.MagicMock() + view.unattach_validate.return_value = (mock_sub_id, None) + + view.unattach(mock_request) + + view.unattach_validate.assert_called_with(mock_request) + view.unattach_by_id.assert_called_with(mock_request, mock_sub_id) + + def test_unattach_invalid(self, mocker): + mock_request = mocker.MagicMock() + mock_res = mocker.MagicMock() + view = SubListCreateAttachDetachAPIView() + view.unattach_validate = mocker.MagicMock() + view.unattach_by_id = mocker.MagicMock() + view.unattach_validate.return_value = (None, mock_res) + + view.unattach(mock_request) + + view.unattach_validate.assert_called_with(mock_request) + view.unattach_by_id.assert_not_called() + +class TestDeleteLastUnattachLabelMixin: + @mock.patch('awx.api.generics.super') + def test_unattach_ok(self, super, mocker): + mock_request = mocker.MagicMock() + mock_sub_id = mocker.MagicMock() + super.return_value = super + super.unattach_validate = mocker.MagicMock(return_value=(mock_sub_id, None)) + super.unattach_by_id = mocker.MagicMock() + mock_label = mocker.patch('awx.api.generics.Label') + mock_label.objects.get.return_value = mock_label + mock_label.is_detached.return_value = True + + view = DeleteLastUnattachLabelMixin() + + view.unattach(mock_request, None, None) + + super.unattach_validate.assert_called_with(mock_request, None, None) + super.unattach_by_id.assert_called_with(mock_request, mock_sub_id) + mock_label.is_detached.assert_called_with() + mock_label.objects.get.assert_called_with(id=mock_sub_id) + mock_label.delete.assert_called_with() + + @mock.patch('awx.api.generics.super') + def test_unattach_fail(self, super, mocker): + mock_request = mocker.MagicMock() + mock_response = mocker.MagicMock() + super.return_value = super + super.unattach_validate = mocker.MagicMock(return_value=(None, mock_response)) + view = DeleteLastUnattachLabelMixin() + + res = view.unattach(mock_request, None, None) + + super.unattach_validate.assert_called_with(mock_request, None, None) + assert mock_response == res + class TestParentMixin: def test_get_parent_object(self, mocker, get_object_or_404): parent_mixin = ParentMixin() diff --git a/awx/main/tests/unit/api/test_views.py b/awx/main/tests/unit/api/test_views.py index a5d63906c5..6b886513bd 100644 --- a/awx/main/tests/unit/api/test_views.py +++ b/awx/main/tests/unit/api/test_views.py @@ -1,8 +1,9 @@ -# Python import pytest -# AWX -from awx.api.views import ApiV1RootView +from awx.api.views import ( + ApiV1RootView, +) + @pytest.fixture def mock_response_new(mocker): @@ -10,6 +11,7 @@ def mock_response_new(mocker): m.return_value = m return m + class TestApiV1RootView: def test_get_endpoints(self, mocker, mock_response_new): endpoints = [ diff --git a/awx/main/tests/unit/models/test_label.py b/awx/main/tests/unit/models/test_label.py index c5c6e50b91..20da73e9ad 100644 --- a/awx/main/tests/unit/models/test_label.py +++ b/awx/main/tests/unit/models/test_label.py @@ -1,4 +1,8 @@ +import pytest + from awx.main.models.label import Label +from awx.main.models.unified_jobs import UnifiedJobTemplate, UnifiedJob + def test_get_orphaned_labels(mocker): mock_query_set = mocker.MagicMock() @@ -8,3 +12,54 @@ def test_get_orphaned_labels(mocker): assert mock_query_set == ret Label.objects.filter.assert_called_with(organization=None, jobtemplate_labels__isnull=True) + +def test_is_detached(mocker): + mock_query_set = mocker.MagicMock() + Label.objects.filter = mocker.MagicMock(return_value=mock_query_set) + mock_query_set.count.return_value = 1 + + label = Label(id=37) + ret = label.is_detached() + + assert ret is True + Label.objects.filter.assert_called_with(id=37, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True) + mock_query_set.count.assert_called_with() + +def test_is_detached_not(mocker): + mock_query_set = mocker.MagicMock() + Label.objects.filter = mocker.MagicMock(return_value=mock_query_set) + mock_query_set.count.return_value = 0 + + label = Label(id=37) + ret = label.is_detached() + + assert ret is False + Label.objects.filter.assert_called_with(id=37, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True) + mock_query_set.count.assert_called_with() + +@pytest.mark.parametrize("jt_count,j_count,expected", [ + (1, 0, True), + (0, 1, True), + (1, 1, False), +]) +def test_is_candidate_for_detach(mocker, jt_count, j_count, expected): + mock_job_qs = mocker.MagicMock() + mock_job_qs.count = mocker.MagicMock(return_value=j_count) + UnifiedJob.objects = mocker.MagicMock() + UnifiedJob.objects.filter = mocker.MagicMock(return_value=mock_job_qs) + + mock_jt_qs = mocker.MagicMock() + mock_jt_qs.count = mocker.MagicMock(return_value=jt_count) + UnifiedJobTemplate.objects = mocker.MagicMock() + UnifiedJobTemplate.objects.filter = mocker.MagicMock(return_value=mock_jt_qs) + + label = Label(id=37) + ret = label.is_candidate_for_detach() + + UnifiedJob.objects.filter.assert_called_with(labels__in=[label.id]) + UnifiedJobTemplate.objects.filter.assert_called_with(labels__in=[label.id]) + mock_job_qs.count.assert_called_with() + mock_jt_qs.count.assert_called_with() + + assert ret is expected + diff --git a/awx/main/tests/unit/test_access.py b/awx/main/tests/unit/test_access.py new file mode 100644 index 0000000000..4885f334cd --- /dev/null +++ b/awx/main/tests/unit/test_access.py @@ -0,0 +1,21 @@ +from django.contrib.auth.models import User +from awx.main.access import ( + BaseAccess, + check_superuser, +) + + +def test_superuser(mocker): + user = mocker.MagicMock(spec=User, id=1, is_superuser=True) + access = BaseAccess(user) + + can_add = check_superuser(BaseAccess.can_add) + assert can_add(access, None) is True + +def test_not_superuser(mocker): + user = mocker.MagicMock(spec=User, id=1, is_superuser=False) + access = BaseAccess(user) + + can_add = check_superuser(BaseAccess.can_add) + assert can_add(access, None) is False + diff --git a/awx/main/tests/unit/test_signals.py b/awx/main/tests/unit/test_signals.py new file mode 100644 index 0000000000..c3830ee525 --- /dev/null +++ b/awx/main/tests/unit/test_signals.py @@ -0,0 +1,17 @@ +from awx.main import signals + +class TestCleanupDetachedLabels: + def test_cleanup_detached_labels_on_deleted_parent(self, mocker): + mock_labels = [mocker.MagicMock(), mocker.MagicMock()] + mock_instance = mocker.MagicMock() + mock_instance.labels.all = mocker.MagicMock() + mock_instance.labels.all.return_value = mock_labels + mock_labels[0].is_candidate_for_detach.return_value = True + mock_labels[1].is_candidate_for_detach.return_value = False + + signals.cleanup_detached_labels_on_deleted_parent(None, mock_instance) + + mock_labels[0].is_candidate_for_detach.assert_called_with() + mock_labels[1].is_candidate_for_detach.assert_called_with() + mock_labels[0].delete.assert_called_with() + mock_labels[1].delete.assert_not_called() diff --git a/awx/plugins/callback/job_event_callback.py b/awx/plugins/callback/job_event_callback.py index 8aa6dfc9e5..a9c5b712ed 100644 --- a/awx/plugins/callback/job_event_callback.py +++ b/awx/plugins/callback/job_event_callback.py @@ -70,7 +70,7 @@ import psutil # pass # statsd = NoStatsClient() -CENSOR_FIELD_WHITELIST=[ +CENSOR_FIELD_WHITELIST = [ 'msg', 'failed', 'changed', @@ -80,7 +80,6 @@ CENSOR_FIELD_WHITELIST=[ 'delta', 'cmd', '_ansible_no_log', - 'cmd', 'rc', 'failed_when_result', 'skipped', @@ -114,6 +113,7 @@ def censor(obj, no_log=False): obj['results'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result" return obj + class TokenAuth(requests.auth.AuthBase): def __init__(self, token): @@ -194,31 +194,7 @@ class BaseCallbackModule(object): self._init_connection() if self.context is None: self._start_connection() - if 'res' in event_data and hasattr(event_data['res'], 'get') \ - and event_data['res'].get('_ansible_no_log', False): - res = event_data['res'] - if 'stdout' in res and res['stdout']: - res['stdout'] = '' - if 'stdout_lines' in res and res['stdout_lines']: - res['stdout_lines'] = [''] - if 'stderr' in res and res['stderr']: - res['stderr'] = '' - if 'stderr_lines' in res and res['stderr_lines']: - res['stderr_lines'] = [''] - if res.get('cmd', None) and re.search(r'\s', res['cmd']): - res['cmd'] = re.sub(r'^(([^\s\\]|\\\s)+).*$', - r'\1 ', - res['cmd']) - if 'invocation' in res \ - and 'module_args' in res['invocation'] \ - and '_raw_params' in res['invocation']['module_args'] \ - and re.search(r'\s', - res['invocation']['module_args']['_raw_params']): - res['invocation']['module_args']['_raw_params'] = \ - re.sub(r'^(([^\s\\]|\\\s)+).*$', - r'\1 ', - res['invocation']['module_args']['_raw_params']) - msg['event_data']['res'] = res + self.socket.send_json(msg) self.socket.recv() return diff --git a/awx/plugins/fact_caching/tower.py b/awx/plugins/fact_caching/tower.py index 84b44a5e6f..3e89ccef36 100755 --- a/awx/plugins/fact_caching/tower.py +++ b/awx/plugins/fact_caching/tower.py @@ -32,7 +32,6 @@ import sys import os import time -from copy import deepcopy from ansible import constants as C try: from ansible.cache.base import BaseCacheModule @@ -50,7 +49,7 @@ class CacheModule(BaseCacheModule): def __init__(self, *args, **kwargs): # Basic in-memory caching for typical runs self._cache = {} - self._cache_prev = {} + self._all_keys = {} # This is the local tower zmq connection self._tower_connection = C.CACHE_PLUGIN_CONNECTION @@ -72,12 +71,10 @@ class CacheModule(BaseCacheModule): def identify_new_module(self, key, value): # Return the first key found that doesn't exist in the # previous set of facts - if key in self._cache_prev: - value_old = self._cache_prev[key] - for k,v in value.iteritems(): - if k not in value_old: - if not k.startswith('ansible_'): - return k + if key in self._all_keys: + for k in value.iterkeys(): + if k not in self._all_keys[key] and not k.startswith('ansible_'): + return k # First time we have seen facts from this host # it's either ansible facts or a module facts (including module_setup) elif len(value) == 1: @@ -110,7 +107,7 @@ class CacheModule(BaseCacheModule): # Assume ansible fact triggered the set if no new module found facts = self.filter_ansible_facts(value) if not module else dict({ module : value[module]}) self._cache[key] = value - self._cache_prev = deepcopy(self._cache) + self._all_keys[key] = value.keys() packet = { 'host': key, 'inventory_id': os.environ['INVENTORY_ID'], diff --git a/awx/plugins/library/scan_services.py b/awx/plugins/library/scan_services.py index 7214c57012..f417a3fdfa 100644 --- a/awx/plugins/library/scan_services.py +++ b/awx/plugins/library/scan_services.py @@ -59,8 +59,8 @@ class ServiceScanService(BaseService): initctl_path = self.module.get_bin_path("initctl") chkconfig_path = self.module.get_bin_path("chkconfig") - # Upstart and sysvinit - if initctl_path is not None and chkconfig_path is None: + # sysvinit + if service_path is not None and chkconfig_path is None: rc, stdout, stderr = self.module.run_command("%s --status-all 2>&1 | grep -E \"\\[ (\\+|\\-) \\]\"" % service_path, use_unsafe_shell=True) for line in stdout.split("\n"): line_data = line.split() @@ -72,6 +72,9 @@ class ServiceScanService(BaseService): else: service_state = "stopped" services.append({"name": service_name, "state": service_state, "source": "sysv"}) + + # Upstart + if initctl_path is not None and chkconfig_path is None: p = re.compile('^\s?(?P.*)\s(?P\w+)\/(?P\w+)(\,\sprocess\s(?P[0-9]+))?\s*$') rc, stdout, stderr = self.module.run_command("%s list" % initctl_path) real_stdout = stdout.replace("\r","") diff --git a/awx/static/api/api.css b/awx/static/api/api.css index 509d604f2f..a1d7a3fb55 100644 --- a/awx/static/api/api.css +++ b/awx/static/api/api.css @@ -47,8 +47,9 @@ body .navbar .navbar-brand:hover { } body .navbar .navbar-brand img { display: inline-block; - max-width: 150px; - max-height: 50px; + width: 93px; + height: 30px; + margin-right:14px; } body .navbar .navbar-brand > span { display: inline-block; diff --git a/awx/templates/rest_framework/api.html b/awx/templates/rest_framework/api.html index 7ae3c811f8..7a0df3670d 100644 --- a/awx/templates/rest_framework/api.html +++ b/awx/templates/rest_framework/api.html @@ -24,7 +24,7 @@ - + {% trans 'REST API' %} @@ -49,10 +49,7 @@ \n"; + this.form.name + '_form.' + fld + ".$error.required\">" + (field.requiredErrorMsg ? field.requiredErrorMsg : "Please select a value.") + "\n"; } if (field.integer) { html += "
Please enter a number.
\n"; @@ -1193,7 +1242,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // Add error messages if ((options.mode === 'add' && field.addRequired) || (options.mode === 'edit' && field.editRequired)) { html += "
Please select at least one value.
\n"; + this.form.name + '_form.' + fld + ".$error.required\">" + (field.requiredErrorMsg ? field.requiredErrorMsg : "Please select at least one value.") + "\n"; } if (field.integer) { html += "
Please select a number.
\n"; @@ -1342,9 +1391,24 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat if(field.awRequiredWhen) { html += field.awRequiredWhen.init ? "data-awrequired-init=\"" + field.awRequiredWhen.init + "\" " : ""; html += field.awRequiredWhen.reqExpression ? "aw-required-when=\"" + field.awRequiredWhen.reqExpression + "\" " : ""; + html += field.awRequiredWhen.alwaysShowAsterisk ? "data-awrequired-always-show-asterisk=true " : ""; } html += " awlookup >\n"; html += "\n"; + + if (field.subCheckbox) { + html += "