diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 72c1121c39..ca454a0b1e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1229,7 +1229,7 @@ class OrganizationSerializer(BaseSerializer): class Meta: model = Organization - fields = ('*', 'custom_virtualenv',) + fields = ('*', 'max_hosts', 'custom_virtualenv',) def get_related(self, obj): res = super(OrganizationSerializer, self).get_related(obj) @@ -1265,6 +1265,20 @@ class OrganizationSerializer(BaseSerializer): summary_dict['related_field_counts'] = counts_dict[obj.id] return summary_dict + def validate(self, attrs): + obj = self.instance + view = self.context['view'] + + obj_limit = getattr(obj, 'max_hosts', None) + api_limit = attrs.get('max_hosts') + + if not view.request.user.is_superuser: + if api_limit is not None and api_limit != obj_limit: + # Only allow superusers to edit the max_hosts field + raise serializers.ValidationError(_('Cannot change max_hosts.')) + + return super(OrganizationSerializer, self).validate(attrs) + class ProjectOptionsSerializer(BaseSerializer): @@ -2202,8 +2216,8 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri class Meta: model = InventoryUpdate - fields = ('*', 'inventory', 'inventory_source', 'license_error', 'source_project_update', - 'custom_virtualenv', '-controller_node',) + fields = ('*', 'inventory', 'inventory_source', 'license_error', 'org_host_limit_error', + 'source_project_update', 'custom_virtualenv', '-controller_node',) def get_related(self, obj): res = super(InventoryUpdateSerializer, self).get_related(obj) diff --git a/awx/main/access.py b/awx/main/access.py index 93150adef8..18ea0a1516 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -330,6 +330,32 @@ class BaseAccess(object): elif "features" not in validation_info: raise LicenseForbids(_("Features not found in active license.")) + def check_org_host_limit(self, data, add_host_name=None): + validation_info = get_licenser().validate() + if validation_info.get('license_type', 'UNLICENSED') == 'open': + return + + inventory = get_object_from_data('inventory', Inventory, data) + if inventory is None: # In this case a missing inventory error is launched + return # further down the line, so just ignore it. + + org = inventory.organization + if org is None or org.max_hosts == 0: + return + + active_count = Host.objects.org_active_count(org.id) + if active_count > org.max_hosts: + raise PermissionDenied( + _("Organization host limit of %s has been exceeded, %s hosts active.") % + (org.max_hosts, active_count)) + + if add_host_name: + host_exists = Host.objects.filter(inventory__organization=org.id, name=add_host_name).exists() + if not host_exists and active_count == org.max_hosts: + raise PermissionDenied( + _("Organization host limit of %s would be exceeded, %s hosts active.") % + (org.max_hosts, active_count)) + def get_user_capabilities(self, obj, method_list=[], parent_obj=None, capabilities_cache={}): if obj is None: return {} @@ -360,7 +386,7 @@ class BaseAccess(object): user_capabilities[display_method] = self.user.is_superuser continue elif display_method == 'copy' and isinstance(obj, Project) and obj.scm_type == '': - # Connot copy manual project without errors + # Cannot copy manual project without errors user_capabilities[display_method] = False continue elif display_method in ['start', 'schedule'] and isinstance(obj, Group): # TODO: remove in 3.3 @@ -628,7 +654,7 @@ class OAuth2ApplicationAccess(BaseAccess): return self.model.objects.filter(organization__in=org_access_qs) def can_change(self, obj, data): - return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj, + return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj, role_field='admin_role', mandatory=True) def can_delete(self, obj): @@ -636,7 +662,7 @@ class OAuth2ApplicationAccess(BaseAccess): def can_add(self, data): if self.user.is_superuser: - return True + return True if not data: return Organization.accessible_objects(self.user, 'admin_role').exists() return self.check_related('organization', Organization, data, role_field='admin_role', mandatory=True) @@ -650,29 +676,29 @@ class OAuth2TokenAccess(BaseAccess): - I am the user of the token. I can create an OAuth2 app token when: - I have the read permission of the related application. - I can read, change or delete a personal token when: + I can read, change or delete a personal token when: - I am the user of the token - I am the superuser I can create an OAuth2 Personal Access Token when: - - I am a user. But I can only create a PAT for myself. + - I am a user. But I can only create a PAT for myself. ''' model = OAuth2AccessToken - + select_related = ('user', 'application') - - def filtered_queryset(self): + + def filtered_queryset(self): org_access_qs = Organization.objects.filter( Q(admin_role__members=self.user) | Q(auditor_role__members=self.user)) return self.model.objects.filter(application__organization__in=org_access_qs) | self.model.objects.filter(user__id=self.user.pk) - + def can_delete(self, obj): if (self.user.is_superuser) | (obj.user == self.user): return True elif not obj.application: return False return self.user in obj.application.organization.admin_role - + def can_change(self, obj, data): return self.can_delete(obj) @@ -840,6 +866,10 @@ class HostAccess(BaseAccess): # Check to see if we have enough licenses self.check_license(add_host_name=data.get('name', None)) + + # Check the per-org limit + self.check_org_host_limit(data, add_host_name=data.get('name', None)) + return True def can_change(self, obj, data): @@ -852,6 +882,10 @@ class HostAccess(BaseAccess): if data and 'name' in data: self.check_license(add_host_name=data['name']) + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}, + add_host_name=data['name']) + # 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 @@ -1346,7 +1380,7 @@ class JobTemplateAccess(BaseAccess): return self.user in project.use_role else: return False - + @check_superuser def can_copy_related(self, obj): ''' @@ -1362,6 +1396,10 @@ class JobTemplateAccess(BaseAccess): # Check license. if validate_license: self.check_license() + + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}) + if obj.survey_enabled: self.check_license(feature='surveys') if Instance.objects.active_count() > 1: @@ -1520,6 +1558,9 @@ class JobAccess(BaseAccess): if validate_license: self.check_license() + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}) + # A super user can relaunch a job if self.user.is_superuser: return True @@ -1886,6 +1927,10 @@ class WorkflowJobTemplateAccess(BaseAccess): if validate_license: # check basic license, node count self.check_license() + + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}) + # if surveys are added to WFJTs, check license here if obj.survey_enabled: self.check_license(feature='surveys') @@ -1957,6 +2002,9 @@ class WorkflowJobAccess(BaseAccess): if validate_license: self.check_license() + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}) + if self.user.is_superuser: return True @@ -2033,6 +2081,9 @@ class AdHocCommandAccess(BaseAccess): if validate_license: self.check_license() + # Check the per-org limit + self.check_org_host_limit(data) + # If a credential is provided, the user should have use access to it. if not self.check_related('credential', Credential, data, role_field='use_role'): return False @@ -2442,7 +2493,7 @@ class ActivityStreamAccess(BaseAccess): model = ActivityStream prefetch_related = ('organization', 'user', 'inventory', 'host', 'group', 'inventory_update', 'credential', 'credential_type', 'team', - 'ad_hoc_command', 'o_auth2_application', 'o_auth2_access_token', + 'ad_hoc_command', 'o_auth2_application', 'o_auth2_access_token', 'notification_template', 'notification', 'label', 'role', 'actor', 'schedule', 'custom_inventory_script', 'unified_job_template', 'workflow_job_template_node',) diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 3224edf5dd..af4c2d88d7 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -904,10 +904,27 @@ class Command(BaseCommand): logger.error(LICENSE_MESSAGE % d) raise CommandError('License count exceeded!') + def check_org_host_limit(self): + license_info = get_licenser().validate() + if license_info.get('license_type', 'UNLICENSED') == 'open': + return + + org = self.inventory.organization + if org is None or org.max_hosts == 0: + return + + active_count = Host.objects.org_active_count(org.id) + if active_count > org.max_hosts: + raise CommandError('Host limit for organization exceeded!') + def mark_license_failure(self, save=True): self.inventory_update.license_error = True self.inventory_update.save(update_fields=['license_error']) + def mark_org_limits_failure(self, save=True): + self.inventory_update.org_host_limit_error = True + self.inventory_update.save(update_fields=['org_host_limit_error']) + def handle(self, *args, **options): self.verbosity = int(options.get('verbosity', 1)) self.set_logging_level() @@ -961,6 +978,13 @@ class Command(BaseCommand): self.mark_license_failure(save=True) raise e + try: + # Check the per-org host limits + self.check_org_host_limit() + except CommandError as e: + self.mark_org_limits_failure(save=True) + raise e + status, tb, exc = 'error', '', None try: if settings.SQL_DEBUG: @@ -1032,9 +1056,17 @@ class Command(BaseCommand): # If the license is not valid, a CommandError will be thrown, # and inventory update will be marked as invalid. # with transaction.atomic() will roll back the changes. + license_fail = True self.check_license() + + # Check the per-org host limits + license_fail = False + self.check_org_host_limit() except CommandError as e: - self.mark_license_failure() + if license_fail: + self.mark_license_failure() + else: + self.mark_org_limits_failure() raise e if settings.SQL_DEBUG: @@ -1062,7 +1094,6 @@ class Command(BaseCommand): else: tb = traceback.format_exc() exc = e - transaction.rollback() if self.invoked_from_dispatcher is False: with ignore_inventory_computed_fields(): @@ -1073,7 +1104,8 @@ class Command(BaseCommand): self.inventory_source.status = status self.inventory_source.save(update_fields=['status']) - if exc and isinstance(exc, CommandError): - sys.exit(1) - elif exc: + if exc: + logger.error(str(exc)) + if isinstance(exc, CommandError): + sys.exit(1) raise exc diff --git a/awx/main/managers.py b/awx/main/managers.py index 341a7e806a..d554792699 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -29,6 +29,34 @@ class HostManager(models.Manager): """ return self.order_by().exclude(inventory_sources__source='tower').values('name').distinct().count() + def org_active_count(self, org_id): + """Return count of active, unique hosts used by an organization. + Construction of query involves: + - remove any ordering specified in model's Meta + - Exclude hosts sourced from another Tower + - Consider only hosts where the canonical inventory is owned by the organization + - Restrict the query to only return the name column + - Only consider results that are unique + - Return the count of this query + """ + return self.order_by().exclude( + inventory_sources__source='tower' + ).filter(inventory__organization=org_id).values('name').distinct().count() + + def active_counts_by_org(self): + """Return the counts of active, unique hosts for each organization. + Construction of query involves: + - remove any ordering specified in model's Meta + - Exclude hosts sourced from another Tower + - Consider only hosts where the canonical inventory is owned by each organization + - Restrict the query to only count distinct names + - Return the counts + """ + return self.order_by().exclude( + inventory_sources__source='tower' + ).values('inventory__organization').annotate( + inventory__organization__count=models.Count('name', distinct=True)) + def get_queryset(self): """When the parent instance of the host query set has a `kind=smart` and a `host_filter` set. Use the `host_filter` to generate the queryset for the hosts. diff --git a/awx/main/migrations/0063_v350_org_host_limits.py b/awx/main/migrations/0063_v350_org_host_limits.py new file mode 100644 index 0000000000..bc410e757a --- /dev/null +++ b/awx/main/migrations/0063_v350_org_host_limits.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2019-02-15 20:03 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0062_v350_new_playbook_stats'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryupdate', + name='org_host_limit_error', + field=models.BooleanField(default=False, editable=False), + ), + migrations.AddField( + model_name='organization', + name='max_hosts', + field=models.PositiveIntegerField(blank=True, default=0, help_text='Maximum number of hosts allowed to be managed by this organization.'), + ), + ] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index b098d2e4a9..df04d5b1ad 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1653,6 +1653,10 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, default=False, editable=False, ) + org_host_limit_error = models.BooleanField( + default=False, + editable=False, + ) source_project_update = models.ForeignKey( 'ProjectUpdate', related_name='scm_inventory_updates', diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 8379cfa0cd..ac7b41bda9 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -10,6 +10,7 @@ from django.db.models import Q from django.contrib.auth.models import User from django.contrib.sessions.models import Session from django.utils.timezone import now as tz_now +from django.utils.translation import ugettext_lazy as _ # AWX @@ -42,6 +43,12 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi 'InstanceGroup', blank=True, ) + max_hosts = models.PositiveIntegerField( + blank=True, + default=0, + help_text=_('Maximum number of hosts allowed to be managed by this organization.'), + ) + admin_role = ImplicitRoleField( parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ) diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index 23ed3a4f7d..d261f3e146 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -326,6 +326,24 @@ def test_create_inventory_host(post, inventory, alice, role_field, expected_stat post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}), data, alice, expect=expected_status_code) +@pytest.mark.parametrize("hosts,expected_status_code", [ + (1, 201), + (2, 201), + (3, 201), +]) +@pytest.mark.django_db +def test_create_inventory_host_with_limits(post, admin_user, inventory, hosts, expected_status_code): + # The per-Organization host limits functionality should be a no-op on AWX. + inventory.organization.max_hosts = 2 + inventory.organization.save() + for i in range(hosts): + inventory.hosts.create(name="Existing host %i" % i) + + data = {'name': 'New name', 'description': 'Hello world'} + post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}), + data, admin_user, expect=expected_status_code) + + @pytest.mark.parametrize("role_field,expected_status_code", [ (None, 403), ('admin_role', 201), @@ -356,6 +374,18 @@ def test_edit_inventory_host(put, host, alice, role_field, expected_status_code) put(reverse('api:host_detail', kwargs={'pk': host.id}), data, alice, expect=expected_status_code) +@pytest.mark.django_db +def test_edit_inventory_host_with_limits(put, host, admin_user): + # The per-Organization host limits functionality should be a no-op on AWX. + inventory = host.inventory + inventory.organization.max_hosts = 1 + inventory.organization.save() + inventory.hosts.create(name='Alternate host') + + data = {'name': 'New name', 'description': 'Hello world'} + put(reverse('api:host_detail', kwargs={'pk': host.id}), data, admin_user, expect=200) + + @pytest.mark.parametrize("role_field,expected_status_code", [ (None, 403), ('admin_role', 204), diff --git a/awx/main/tests/functional/api/test_organizations.py b/awx/main/tests/functional/api/test_organizations.py index c1fa99e810..18aeb269ac 100644 --- a/awx/main/tests/functional/api/test_organizations.py +++ b/awx/main/tests/functional/api/test_organizations.py @@ -199,6 +199,30 @@ def test_update_organization(get, put, organization, alice, bob): put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=bob, expect=403) +@pytest.mark.django_db +def test_update_organization_max_hosts(get, put, organization, admin, alice, bob): + # Admin users can get and update max_hosts + data = get(reverse('api:organization_detail', kwargs={'pk': organization.id}), user=admin, expect=200).data + assert organization.max_hosts == 0 + data['max_hosts'] = 3 + put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=admin, expect=200) + organization.refresh_from_db() + assert organization.max_hosts == 3 + + # Organization admins can get the data and can update other fields, but not max_hosts + organization.admin_role.members.add(alice) + data = get(reverse('api:organization_detail', kwargs={'pk': organization.id}), user=alice, expect=200).data + data['max_hosts'] = 5 + put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=alice, expect=400) + organization.refresh_from_db() + assert organization.max_hosts == 3 + + # Ordinary users shouldn't be able to update either. + put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=bob, expect=403) + organization.refresh_from_db() + assert organization.max_hosts == 3 + + @pytest.mark.django_db @mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True) def test_delete_organization(delete, organization, admin): diff --git a/awx/main/tests/functional/test_rbac_job_start.py b/awx/main/tests/functional/test_rbac_job_start.py index 6748b3df5d..d205d992d3 100644 --- a/awx/main/tests/functional/test_rbac_job_start.py +++ b/awx/main/tests/functional/test_rbac_job_start.py @@ -21,6 +21,19 @@ def test_admin_executing_permissions(deploy_jobtemplate, inventory, machine_cred assert admin_user.can_access(Credential, 'use', machine_credential) +@pytest.mark.django_db +@pytest.mark.job_permissions +def test_admin_executing_permissions_with_limits(deploy_jobtemplate, inventory, user): + admin_user = user('admin-user', True) + + inventory.organization.max_hosts = 1 + inventory.organization.save() + inventory.hosts.create(name="Existing host 1") + inventory.hosts.create(name="Existing host 2") + + assert admin_user.can_access(JobTemplate, 'start', deploy_jobtemplate) + + @pytest.mark.django_db @pytest.mark.job_permissions def test_job_template_start_access(deploy_jobtemplate, user): diff --git a/awx/main/tests/functional/test_rbac_workflow.py b/awx/main/tests/functional/test_rbac_workflow.py index 7a2fede786..04926385c0 100644 --- a/awx/main/tests/functional/test_rbac_workflow.py +++ b/awx/main/tests/functional/test_rbac_workflow.py @@ -140,6 +140,16 @@ class TestWorkflowJobAccess: JobLaunchConfig.objects.create(job=workflow_job) assert WorkflowJobAccess(rando).can_start(workflow_job) + def test_can_start_with_limits(self, workflow_job, inventory, admin_user): + inventory.organization.max_hosts = 1 + inventory.organization.save() + inventory.hosts.create(name="Existing host 1") + inventory.hosts.create(name="Existing host 2") + workflow_job.inventory = inventory + workflow_job.save() + + assert WorkflowJobAccess(admin_user).can_start(workflow_job) + def test_cannot_relaunch_friends_job(self, wfjt, rando, alice): workflow_job = wfjt.workflow_jobs.create(name='foo', created_by=alice) JobLaunchConfig.objects.create( diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 696cfb8adf..67f3bee4a0 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -236,6 +236,18 @@ function getLicenseErrorDetails () { return { label, value }; } +function getHostLimitErrorDetails () { + if (!resource.model.has('org_host_limit_error')) { + return null; + } + + const label = strings.get('labels.HOST_LIMIT_ERROR'); + const tooltip = strings.get('tooltips.HOST_LIMIT'); + const value = resource.model.get('org_host_limit_error'); + + return { tooltip, label, value }; +} + function getLaunchedByDetails () { const createdBy = resource.model.get('summary_fields.created_by'); const jobTemplate = resource.model.get('summary_fields.job_template'); @@ -804,6 +816,7 @@ function JobDetailsController ( vm.overwrite = getOverwriteDetails(); vm.overwriteVars = getOverwriteVarsDetails(); vm.licenseError = getLicenseErrorDetails(); + vm.hostLimitError = getHostLimitErrorDetails(); // Relaunch and Delete Components vm.job = angular.copy(_.get(resource.model, 'model.GET', {})); diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 0e7cc3620a..b2faddea97 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -81,6 +81,26 @@ + +
" + i18n._("The maximum number of hosts allowed to be managed by this organization. Value defaults to 0 which means no limit. Refer to the Ansible documentation for more details.") + "
", + ngDisabled: '!current_user.is_superuser', + ngShow: 'BRAND_NAME === "Tower"' } },