diff --git a/Makefile b/Makefile index c1b7d582e1..fd9d87cd2e 100644 --- a/Makefile +++ b/Makefile @@ -298,12 +298,10 @@ requirements_tower_dev: # Install third-party requirements needed for running unittests in jenkins requirements_jenkins: if [ "$(VENV_BASE)" ]; then \ - . $(VENV_BASE)/tower/bin/activate; \ - $(VENV_BASE)/tower/bin/pip install -Ir requirements/requirements_jenkins.txt; \ + . $(VENV_BASE)/tower/bin/activate && pip install -Ir requirements/requirements_jenkins.txt; \ else \ pip install -Ir requirements/requirements_jenkins.txt; \ - fi && \ - $(NPM_BIN) install csslint + fi requirements: requirements_ansible requirements_tower @@ -317,8 +315,8 @@ develop: pip uninstall -y awx; \ $(PYTHON) setup.py develop; \ else \ - sudo pip uninstall -y awx; \ - sudo $(PYTHON) setup.py develop; \ + pip uninstall -y awx; \ + $(PYTHON) setup.py develop; \ fi version_file: @@ -448,7 +446,7 @@ pylint: reports check: flake8 pep8 # pyflakes pylint -TEST_DIRS=awx/main/tests +TEST_DIRS ?= awx/main/tests # Run all API unit tests. test: @if [ "$(VENV_BASE)" ]; then \ diff --git a/adhoc.controller-test.js b/adhoc.controller-test.js deleted file mode 100644 index d49c699994..0000000000 --- a/adhoc.controller-test.js +++ /dev/null @@ -1,199 +0,0 @@ -import '../support/node'; - -import adhocModule from 'inventories/manage/adhoc/main'; -import RestStub from '../support/rest-stub'; - -describe("adhoc.controller", function() { - var $scope, $rootScope, $location, $stateParams, $stateExtender, - CheckPasswords, PromptForPasswords, CreateLaunchDialog, AdhocForm, - GenerateForm, Rest, ProcessErrors, ClearScope, GetBasePath, GetChoices, - KindChange, LookUpInit, CredentialList, Empty, Wait; - - var $controller, ctrl, generateFormCallback, waitCallback, locationCallback, - getBasePath, processErrorsCallback, restCallback, stateExtenderCallback; - - beforeEach("instantiate the adhoc module", function() { - angular.mock.module(adhocModule.name); - }); - - before("create spies", function() { - getBasePath = function(path) { - return '/' + path + '/'; - }; - generateFormCallback = { - inject: angular.noop - }; - waitCallback = sinon.spy(); - locationCallback = { - path: sinon.spy() - }; - processErrorsCallback = sinon.spy(); - restCallback = new RestStub(); - stateExtenderCallback = { - addState: angular.noop - } - }); - - - beforeEach("mock dependencies", angular.mock.module(['$provide', function(_provide_) { - var $provide = _provide_; - - $provide.value('$location', locationCallback); - $provide.value('CheckPasswords', angular.noop); - $provide.value('PromptForPasswords', angular.noop); - $provide.value('CreateLaunchDialog', angular.noop); - $provide.value('AdhocForm', angular.noop); - $provide.value('GenerateForm', generateFormCallback); - $provide.value('Rest', restCallback); - $provide.value('ProcessErrors', processErrorsCallback); - $provide.value('ClearScope', angular.noop); - $provide.value('GetBasePath', getBasePath); - $provide.value('GetChoices', angular.noop); - $provide.value('KindChange', angular.noop); - $provide.value('LookUpInit', angular.noop); - $provide.value('CredentialList', angular.noop); - $provide.value('Empty', angular.noop); - $provide.value('Wait', waitCallback); - $provide.value('$stateExtender', stateExtenderCallback); - $provide.value('$stateParams', angular.noop); - $provide.value('$state', angular.noop); - }])); - - beforeEach("put the controller in scope", inject(function($rootScope, $controller) { - var scope = $rootScope.$new(); - ctrl = $controller('adhocController', {$scope: scope}); - })); - - beforeEach("put $q in scope", window.inject(['$q', function($q) { - restCallback.$q = $q; - }])); - /* - describe("setAvailableUrls", function() { - it('should only have the specified urls ' + - 'available for adhoc commands', function() { - var urls = ctrl.privateFn.setAvailableUrls(); - expect(urls).to.have.keys('adhocUrl', 'inventoryUrl', - 'machineCredentialUrl'); - - var count = 0; - var i; - - for (i in urls) { - if (urls.hasOwnProperty(i)) { - count++; - } - } - expect(count).to.equal(3); - }); - - }); - - describe("setFieldDefaults", function() { - it('should set the select form field defaults' + - 'based on user settings', function() { - var verbosity_options = [ - {label: "0 (Foo)", value: 0, name: "0 (Foo)", - isDefault: false}, - {label: "1 (Bar)", value: 1, name: "1 (Bar)", - isDefault: true}, - ], - forks_field = {}; - - forks_field.default = 3; - - $scope.$apply(function() { - ctrl.privateFn.setFieldDefaults(verbosity_options, - forks_field.default); - }); - - expect($scope.forks).to.equal(forks_field.default); - expect($scope.verbosity.value).to.equal(1); - }); - }); - - describe("setLoadingStartStop", function() { - it('should start the controller working state when the form is ' + - 'loading', function() { - waitCallback.reset(); - ctrl.privateFn.setLoadingStartStop(); - expect(waitCallback).to.have.been.calledWith("start"); - }); - it('should stop the indicator after all REST calls in the form load have ' + - 'completed', function() { - var forks_field = {}, - adhoc_verbosity_options = {}; - forks_field.default = "1"; - $scope.$apply(function() { - $scope.forks_field = forks_field; - $scope.adhoc_verbosity_options = adhoc_verbosity_options; - }); - waitCallback.reset(); - $scope.$emit('adhocFormReady'); - $scope.$emit('adhocFormReady'); - expect(waitCallback).to.have.been.calledWith("stop"); - }); - }); - - describe("instantiateArgumentHelp", function() { - it("should initially provide a canned argument help response", function() { - expect($scope.argsPopOver).to.equal('
These arguments are used ' + - 'with the specified module.
'); - }); - - it("should change the help response when the module changes", function() { - $scope.$apply(function () { - $scope.module_name = {value: 'foo'}; - }); - expect($scope.argsPopOver).to.equal('These arguments are used ' + - 'with the specified module. You can find information about ' + - 'the foo module here.
'); - }); - - it("should change the help response when the module changes again", function() { - $scope.$apply(function () { - $scope.module_name = {value: 'bar'}; - }); - expect($scope.argsPopOver).to.equal('These arguments are used ' + - 'with the specified module. You can find information about ' + - 'the bar module here.
'); - }); - - it("should change the help response back to the canned response " + - "when no module is selected", function() { - $scope.$apply(function () { - $scope.module_name = null; - }); - expect($scope.argsPopOver).to.equal('These arguments are used ' + - 'with the specified module.
'); - }); - }); - - describe("instantiateHostPatterns", function() { - it("should initialize the limit object based on the provided host " + - "pattern", function() { - ctrl.privateFn.instantiateHostPatterns("foo:bar"); - expect($scope.limit).to.equal("foo:bar"); - }); - - it("should set the providedHostPatterns variable to the provided host " + - "pattern so it is accesible on form reset", function() { - ctrl.privateFn.instantiateHostPatterns("foo:bar"); - expect($scope.providedHostPatterns).to.equal("foo:bar"); - }); - - it("should remove the hostPattern from rootScope after it has been " + - "utilized", function() { - $rootScope.hostPatterns = "foo"; - expect($rootScope.hostPatterns).to.exist; - ctrl.privateFn.instantiateHostPatterns("foo"); - expect($rootScope.hostPatterns).to.not.exist; - }); - }); - */ -}); diff --git a/awx/api/generics.py b/awx/api/generics.py index 51598979d8..1a3e5e2910 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -235,6 +235,13 @@ class ListAPIView(generics.ListAPIView, GenericAPIView): def get_queryset(self): return self.request.user.get_queryset(self.model) + def paginate_queryset(self, queryset): + page = super(ListAPIView, self).paginate_queryset(queryset) + # Queries RBAC info & stores into list objects + if hasattr(self, 'capabilities_prefetch') and page is not None: + cache_list_capabilities(page, self.capabilities_prefetch, self.model, self.request.user) + return page + def get_description_context(self): opts = self.model._meta if 'username' in opts.get_all_field_names(): diff --git a/awx/api/permissions.py b/awx/api/permissions.py index 285441421d..ecd725bc6e 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -49,6 +49,9 @@ class ModelAccessPermission(permissions.BasePermission): if not check_user_access(request.user, view.parent_model, 'read', parent_obj): return False + if hasattr(view, 'parent_key'): + if not check_user_access(request.user, view.model, 'add', {view.parent_key: parent_obj.pk}): + return False return True elif getattr(view, 'is_job_start', False): if not obj: @@ -206,6 +209,8 @@ class ProjectUpdatePermission(ModelAccessPermission): class UserPermission(ModelAccessPermission): def check_post_permissions(self, request, view, obj=None): - if request.user.is_superuser: + if not request.data: + return request.user.admin_of_organizations.exists() + elif request.user.is_superuser: return True raise PermissionDenied() diff --git a/awx/api/serializers.py b/awx/api/serializers.py index ee7784afad..fa442192c2 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -37,6 +37,7 @@ from polymorphic import PolymorphicModel # AWX from awx.main.constants import SCHEDULEABLE_PROVIDERS from awx.main.models import * # noqa +from awx.main.access import get_user_capabilities 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, getattrd from awx.main.conf import tower_settings @@ -345,6 +346,19 @@ class BaseSerializer(serializers.ModelSerializer): } if len(roles) > 0: summary_fields['object_roles'] = roles + + # Advance display of RBAC capabilities + if hasattr(self, 'show_capabilities'): + view = self.context.get('view', None) + parent_obj = None + if view and hasattr(view, 'parent_model'): + parent_obj = view.get_parent_object() + if view and view.request and view.request.user: + user_capabilities = get_user_capabilities( + view.request.user, obj, method_list=self.show_capabilities, parent_obj=parent_obj) + if user_capabilities: + summary_fields['user_capabilities'] = user_capabilities + return summary_fields def get_created(self, obj): @@ -553,6 +567,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer): class UnifiedJobSerializer(BaseSerializer): + show_capabilities = ['start', 'delete'] result_stdout = serializers.SerializerMethodField() @@ -697,11 +712,12 @@ class UserSerializer(BaseSerializer): ldap_dn = serializers.CharField(source='profile.ldap_dn', read_only=True) external_account = serializers.SerializerMethodField(help_text='Set if the account is managed by an external service') is_system_auditor = serializers.BooleanField(default=False) + show_capabilities = ['edit', 'delete'] class Meta: model = User fields = ('*', '-name', '-description', '-modified', - '-summary_fields', 'username', 'first_name', 'last_name', + 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_system_auditor', 'password', 'ldap_dn', 'external_account') def to_representation(self, obj): @@ -822,6 +838,7 @@ class UserSerializer(BaseSerializer): class OrganizationSerializer(BaseSerializer): + show_capabilities = ['edit', 'delete'] class Meta: model = Organization @@ -906,6 +923,7 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): status = serializers.ChoiceField(choices=Project.PROJECT_STATUS_CHOICES, read_only=True) last_update_failed = serializers.BooleanField(read_only=True) last_updated = serializers.DateTimeField(read_only=True) + show_capabilities = ['start', 'schedule', 'edit', 'delete'] class Meta: model = Project @@ -1014,6 +1032,7 @@ class BaseSerializerWithVariables(BaseSerializer): class InventorySerializer(BaseSerializerWithVariables): + show_capabilities = ['edit', 'delete', 'adhoc'] class Meta: model = Inventory @@ -1064,12 +1083,14 @@ class InventoryDetailSerializer(InventorySerializer): class InventoryScriptSerializer(InventorySerializer): + show_capabilities = ['copy', 'edit', 'delete'] class Meta: fields = () class HostSerializer(BaseSerializerWithVariables): + show_capabilities = ['edit', 'delete'] class Meta: model = Host @@ -1180,6 +1201,7 @@ class HostSerializer(BaseSerializerWithVariables): class GroupSerializer(BaseSerializerWithVariables): + show_capabilities = ['start', 'copy', 'schedule', 'edit', 'delete'] class Meta: model = Group @@ -1284,6 +1306,7 @@ class GroupVariableDataSerializer(BaseVariableDataSerializer): class CustomInventoryScriptSerializer(BaseSerializer): script = serializers.CharField(trim_whitespace=False) + show_capabilities = ['edit', 'delete'] class Meta: model = CustomInventoryScript @@ -1454,6 +1477,7 @@ class InventoryUpdateCancelSerializer(InventoryUpdateSerializer): class TeamSerializer(BaseSerializer): + show_capabilities = ['edit', 'delete'] class Meta: model = Team @@ -1522,8 +1546,12 @@ class RoleSerializer(BaseSerializer): return ret +class RoleSerializerWithParentAccess(RoleSerializer): + show_capabilities = ['unattach'] + class ResourceAccessListElementSerializer(UserSerializer): + show_capabilities = [] # Clear fields from UserSerializer parent class def to_representation(self, user): ''' @@ -1539,18 +1567,25 @@ class ResourceAccessListElementSerializer(UserSerializer): ret = super(ResourceAccessListElementSerializer, self).to_representation(user) object_id = self.context['view'].object_id obj = self.context['view'].resource_model.objects.get(pk=object_id) + if self.context['view'].request is not None: + requesting_user = self.context['view'].request.user + else: + requesting_user = None if 'summary_fields' not in ret: ret['summary_fields'] = {} def format_role_perm(role): role_dict = { 'id': role.id, 'name': role.name, 'description': role.description} - try: + if role.content_type is not None: role_dict['resource_name'] = role.content_object.name role_dict['resource_type'] = role.content_type.name role_dict['related'] = reverse_gfk(role.content_object) - except: - pass + role_dict['user_capabilities'] = {'unattach': requesting_user.can_access( + Role, 'unattach', role, user, 'members', data={}, skip_sub_obj_read_check=False)} + else: + # Singleton roles should not be managed from this view, as per copy/edit rework spec + role_dict['user_capabilities'] = {'unattach': False} return { 'role': role_dict, 'descendant_roles': get_roles_on_resource(obj, role)} def format_team_role_perm(team_role, permissive_role_ids): @@ -1563,20 +1598,21 @@ class ResourceAccessListElementSerializer(UserSerializer): 'team_id': team_role.object_id, 'team_name': team_role.content_object.name } - try: + if role.content_type is not None: role_dict['resource_name'] = role.content_object.name role_dict['resource_type'] = role.content_type.name role_dict['related'] = reverse_gfk(role.content_object) - except: - pass + role_dict['user_capabilities'] = {'unattach': requesting_user.can_access( + Role, 'unattach', role, team_role, 'parents', data={}, skip_sub_obj_read_check=False)} + else: + # Singleton roles should not be managed from this view, as per copy/edit rework spec + role_dict['user_capabilities'] = {'unattach': False} ret.append({ 'role': role_dict, 'descendant_roles': get_roles_on_resource(obj, team_role)}) return ret team_content_type = ContentType.objects.get_for_model(Team) content_type = ContentType.objects.get_for_model(obj) - - content_type = ContentType.objects.get_for_model(obj) direct_permissive_role_ids = Role.objects.filter(content_type=content_type, object_id=obj.id).values_list('id', flat=True) all_permissive_role_ids = Role.objects.filter(content_type=content_type, object_id=obj.id).values_list('ancestors__id', flat=True) @@ -1621,6 +1657,7 @@ class ResourceAccessListElementSerializer(UserSerializer): class CredentialSerializer(BaseSerializer): + show_capabilities = ['edit', 'delete'] class Meta: model = Credential @@ -1825,6 +1862,7 @@ class JobOptionsSerializer(BaseSerializer): class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): + show_capabilities = ['start', 'schedule', 'copy', 'edit', 'delete'] status = serializers.ChoiceField(choices=JobTemplate.JOB_TEMPLATE_STATUS_CHOICES, read_only=True, required=False) @@ -1860,21 +1898,6 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): d = super(JobTemplateSerializer, self).get_summary_fields(obj) if obj.survey_spec is not None and ('name' in obj.survey_spec and 'description' in obj.survey_spec): d['survey'] = dict(title=obj.survey_spec['name'], description=obj.survey_spec['description']) - request = self.context.get('request', None) - - # Check for conditions that would create a validation error if coppied - validation_errors, resources_needed_to_start = obj.resource_validation_data() - - if request is None or request.user is None: - d['can_copy'] = False - d['can_edit'] = False - elif request.user.is_superuser: - d['can_copy'] = not validation_errors - d['can_edit'] = True - else: - d['can_copy'] = (not validation_errors) and request.user.can_access(JobTemplate, 'add', {"reference_obj": obj}) - d['can_edit'] = request.user.can_access(JobTemplate, 'change', obj, {}) - d['recent_jobs'] = self._recent_jobs(obj) return d @@ -2561,6 +2584,7 @@ class JobLaunchSerializer(BaseSerializer): return attrs class NotificationTemplateSerializer(BaseSerializer): + show_capabilities = ['edit', 'delete'] class Meta: model = NotificationTemplate @@ -2674,6 +2698,7 @@ class LabelSerializer(BaseSerializer): return res class ScheduleSerializer(BaseSerializer): + show_capabilities = ['edit', 'delete'] class Meta: model = Schedule diff --git a/awx/api/views.py b/awx/api/views.py index e6bd86cae4..38bbb1470c 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -854,7 +854,7 @@ class TeamUsersList(BaseUsersList): class TeamRolesList(SubListCreateAttachDetachAPIView): model = Role - serializer_class = RoleSerializer + serializer_class = RoleSerializerWithParentAccess metadata_class = RoleMetadata parent_model = Team relationship='member_role.children' @@ -953,6 +953,7 @@ class ProjectList(ListCreateAPIView): model = Project serializer_class = ProjectSerializer + capabilities_prefetch = ['admin', 'update'] def get_queryset(self): projects_qs = Project.accessible_objects(self.request.user, 'read_role') @@ -1151,6 +1152,7 @@ class UserList(ListCreateAPIView): model = User serializer_class = UserSerializer + capabilities_prefetch = ['admin'] permission_classes = (UserPermission,) def post(self, request, *args, **kwargs): @@ -1191,7 +1193,7 @@ class UserTeamsList(ListAPIView): class UserRolesList(SubListCreateAttachDetachAPIView): model = Role - serializer_class = RoleSerializer + serializer_class = RoleSerializerWithParentAccess metadata_class = RoleMetadata parent_model = User relationship='roles' @@ -1511,6 +1513,7 @@ class InventoryList(ListCreateAPIView): model = Inventory serializer_class = InventorySerializer + capabilities_prefetch = ['admin', 'adhoc'] def get_queryset(self): qs = Inventory.accessible_objects(self.request.user, 'read_role') @@ -1742,6 +1745,7 @@ class GroupList(ListCreateAPIView): model = Group serializer_class = GroupSerializer + capabilities_prefetch = ['inventory.admin', 'inventory.adhoc', 'inventory.update'] ''' Useful when you have a self-refering ManyToManyRelationship. @@ -2210,6 +2214,10 @@ class JobTemplateList(ListCreateAPIView): model = JobTemplate serializer_class = JobTemplateSerializer always_allow_superuser = False + capabilities_prefetch = [ + 'admin', 'execute', + {'copy': ['project.use', 'inventory.use', 'credential.use', 'cloud_credential.use', 'network_credential.use']} + ] def post(self, request, *args, **kwargs): ret = super(JobTemplateList, self).post(request, *args, **kwargs) @@ -3680,6 +3688,7 @@ class NotificationTemplateTest(GenericAPIView): model = NotificationTemplate serializer_class = EmptySerializer new_in_300 = True + is_job_start = True def post(self, request, *args, **kwargs): obj = self.get_object() diff --git a/awx/main/access.py b/awx/main/access.py index 813851d226..fae143a6de 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -116,6 +116,18 @@ def check_user_access(user, model_class, action, *args, **kwargs): return result return False +def get_user_capabilities(user, instance, **kwargs): + ''' + Returns a dictionary of capabilities the user has on the particular + instance. *NOTE* This is not a direct mapping of can_* methods into this + dictionary, it is intended to munge some queries in a way that is + convenient for the user interface to consume and hide or show various + actions in the interface. + ''' + for access_class in access_registry.get(type(instance), []): + return access_class(user).get_user_capabilities(instance, **kwargs) + return None + def check_superuser(func): ''' check_superuser is a decorator that provides a simple short circuit @@ -207,6 +219,78 @@ class BaseAccess(object): elif "features" not in validation_info: raise LicenseForbids("Features not found in active license.") + def get_user_capabilities(self, obj, method_list=[], parent_obj=None): + if obj is None: + return {} + user_capabilities = {} + + # Custom ordering to loop through methods so we can reuse earlier calcs + for display_method in ['edit', 'delete', 'start', 'schedule', 'copy', 'adhoc', 'unattach']: + if display_method not in method_list: + continue + + # Validation consistency checks + if display_method == 'copy' and isinstance(obj, JobTemplate): + validation_errors, resources_needed_to_start = obj.resource_validation_data() + if validation_errors: + user_capabilities[display_method] = False + continue + elif display_method == 'start' and isinstance(obj, Group): + if obj.inventory_source and not obj.inventory_source._can_update(): + user_capabilities[display_method] = False + continue + + # Grab the answer from the cache, if available + if hasattr(obj, 'capabilities_cache') and display_method in obj.capabilities_cache: + user_capabilities[display_method] = obj.capabilities_cache[display_method] + continue + + # Aliases for going form UI language to API language + if display_method == 'edit': + method = 'change' + elif display_method == 'copy': + method = 'add' + elif display_method == 'adhoc': + method = 'run_ad_hoc_commands' + else: + method = display_method + + # Shortcuts in certain cases by deferring to earlier property + if display_method == 'schedule': + user_capabilities['schedule'] = user_capabilities['edit'] + continue + elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob)): + user_capabilities['delete'] = user_capabilities['edit'] + continue + elif display_method == 'copy' and isinstance(obj, (Group, Host)): + user_capabilities['copy'] = user_capabilities['edit'] + continue + + # Preprocessing before the access method is called + data = {} + if method == 'add': + if isinstance(obj, JobTemplate): + data['reference_obj'] = obj + + # Compute permission + access_method = getattr(self, "can_%s" % method) + if method in ['change']: # 3 args + user_capabilities[display_method] = access_method(obj, data) + elif method in ['delete', 'start', 'run_ad_hoc_commands']: # 2 args + user_capabilities[display_method] = access_method(obj) + elif method in ['add']: # 2 args with data + user_capabilities[display_method] = access_method(data) + elif method in ['attach', 'unattach']: # parent/sub-object call + if type(parent_obj) == Team: + relationship = 'parents' + parent_obj = parent_obj.member_role + else: + relationship = 'members' + user_capabilities[display_method] = access_method( + obj, parent_obj, relationship, skip_sub_obj_read_check=True, data=data) + + return user_capabilities + class UserAccess(BaseAccess): ''' @@ -526,6 +610,12 @@ class GroupAccess(BaseAccess): "active_jobs": active_jobs}) return True + def can_start(self, obj): + # Used as another alias to inventory_source start access for user_capabilities + if obj and obj.inventory_source: + return self.user.can_access(InventorySource, 'start', obj.inventory_source) + return False + class InventorySourceAccess(BaseAccess): ''' I can see inventory sources whenever I can see their group or inventory. @@ -594,6 +684,13 @@ class InventoryUpdateAccess(BaseAccess): # Inventory cascade deletes to inventory update, descends from org admin return self.user in obj.inventory_source.inventory.admin_role + def can_start(self, obj): + # For relaunching + if obj and obj.inventory_source: + access = InventorySourceAccess(self.user) + return access.can_start(obj.inventory_source) + return False + @check_superuser def can_delete(self, obj): return self.user in obj.inventory_source.inventory.admin_role @@ -815,6 +912,12 @@ class ProjectUpdateAccess(BaseAccess): # Project updates cascade delete with project, admin role descends from org admin return self.user in obj.project.admin_role + def can_start(self, obj): + # for relaunching + if obj and obj.project: + return self.user in obj.project.update_role + return False + @check_superuser def can_delete(self, obj): return obj and self.user in obj.project.admin_role @@ -855,7 +958,9 @@ class JobTemplateAccess(BaseAccess): Users who are able to create deploy jobs can also run normal and check (dry run) jobs. ''' if not data: # So the browseable API will work - return True + return ( + Project.accessible_objects(self.user, 'use_role').exists() or + Inventory.accessible_objects(self.user, 'use_role').exists()) # if reference_obj is provided, determine if it can be coppied reference_obj = data.pop('reference_obj', None) @@ -1132,6 +1237,10 @@ class SystemJobAccess(BaseAccess): ''' model = SystemJob + def can_start(self, obj): + return False # no relaunching of system jobs + +# TODO: class WorkflowJobTemplateNodeAccess(BaseAccess): ''' I can see/use a WorkflowJobTemplateNode if I have read permission @@ -1762,6 +1871,12 @@ class NotificationTemplateAccess(BaseAccess): def can_delete(self, obj): return self.can_change(obj, None) + @check_superuser + def can_start(self, obj): + if obj.organization is None: + return False + return self.user in obj.organization.admin_role + class NotificationAccess(BaseAccess): ''' I can see/use a notification if I have permission to @@ -1970,8 +2085,13 @@ class RoleAccess(BaseAccess): @check_superuser def can_unattach(self, obj, sub_obj, relationship, data=None, skip_sub_obj_read_check=False): - if not skip_sub_obj_read_check and relationship in ['members', 'member_role.parents']: - if not check_user_access(self.user, sub_obj.__class__, 'read', sub_obj): + if not skip_sub_obj_read_check and relationship in ['members', 'member_role.parents', 'parents']: + # If we are unattaching a team Role, check the Team read access + if relationship == 'parents': + sub_obj_resource = sub_obj.content_object + else: + sub_obj_resource = sub_obj + if not check_user_access(self.user, sub_obj_resource.__class__, 'read', sub_obj_resource): return False if isinstance(obj.content_object, ResourceMixin) and \ diff --git a/awx/main/management/commands/create_preload_data.py b/awx/main/management/commands/create_preload_data.py index a6b1e41f0d..caeba2c0a3 100644 --- a/awx/main/management/commands/create_preload_data.py +++ b/awx/main/management/commands/create_preload_data.py @@ -47,3 +47,4 @@ class Command(BaseCommand): inventory=i, credential=c) print('Default organization added.') + print('Demo Credential, Inventory, and Job Template added.') diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 9356884a19..34adcb73a4 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -340,6 +340,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): errors.append("'%s' value missing" % survey_element['variable']) elif survey_element['type'] in ["textarea", "text", "password"]: if survey_element['variable'] in data: + if type(data[survey_element['variable']]) not in (str, unicode): + errors.append("Value %s for '%s' expected to be a string." % (data[survey_element['variable']], + survey_element['variable'])) + continue if 'min' in survey_element and survey_element['min'] not in ["", None] and len(data[survey_element['variable']]) < int(survey_element['min']): errors.append("'%s' value %s is too small (length is %s must be at least %s)." % (survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min'])) @@ -348,6 +352,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) elif survey_element['type'] == 'integer': if survey_element['variable'] in data: + if type(data[survey_element['variable']]) != int: + errors.append("Value %s for '%s' expected to be an integer." % (data[survey_element['variable']], + survey_element['variable'])) + continue if 'min' in survey_element and survey_element['min'] not in ["", None] and survey_element['variable'] in data and \ data[survey_element['variable']] < int(survey_element['min']): errors.append("'%s' value %s is too small (must be at least %s)." % @@ -356,20 +364,18 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): data[survey_element['variable']] > int(survey_element['max']): errors.append("'%s' value %s is too large (must be no more than %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) - if type(data[survey_element['variable']]) != int: - errors.append("Value %s for '%s' expected to be an integer." % (data[survey_element['variable']], - survey_element['variable'])) elif survey_element['type'] == 'float': if survey_element['variable'] in data: + if type(data[survey_element['variable']]) not in (float, int): + errors.append("Value %s for '%s' expected to be a numeric type." % (data[survey_element['variable']], + survey_element['variable'])) + continue if 'min' in survey_element and survey_element['min'] not in ["", None] and data[survey_element['variable']] < float(survey_element['min']): errors.append("'%s' value %s is too small (must be at least %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['min'])) if 'max' in survey_element and survey_element['max'] not in ["", None] and data[survey_element['variable']] > float(survey_element['max']): errors.append("'%s' value %s is too large (must be no more than %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) - if type(data[survey_element['variable']]) not in (float, int): - errors.append("Value %s for '%s' expected to be a numeric type." % (data[survey_element['variable']], - survey_element['variable'])) elif survey_element['type'] == 'multiselect': if survey_element['variable'] in data: if type(data[survey_element['variable']]) != list: diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 683ef741a5..b27f7cd36b 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -177,7 +177,6 @@ class WorkflowJobNode(WorkflowNodeBase): # TODO: merge artifacts, add ancestor_artifacts to kwargs if extra_vars: data['extra_vars'] = extra_vars - print ' job KV data: ' + str(data) return data class WorkflowJobOptions(BaseModel): diff --git a/awx/main/tasks.py b/awx/main/tasks.py index fe46166afb..097dca517d 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -654,7 +654,8 @@ class BaseTask(Task): if status == 'canceled': raise Exception("Task %s(pk:%s) was canceled (rc=%s)" % (str(self.model.__class__), str(pk), str(rc))) else: - raise Exception("Task %s(pk:%s) encountered an error (rc=%s)" % (str(self.model.__class__), str(pk), str(rc))) + raise Exception("Task %s(pk:%s) encountered an error (rc=%s), please see task stdout for details." % + (str(self.model.__class__), str(pk), str(rc))) if not hasattr(settings, 'CELERY_UNIT_TEST'): self.signal_finished(pk) diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index 88437a0037..68a7e7aecd 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -3,12 +3,11 @@ import mock # AWX from awx.api.serializers import JobTemplateSerializer, JobLaunchSerializer -from awx.main.models.jobs import JobTemplate, Job +from awx.main.models.jobs import Job from awx.main.models.projects import ProjectOptions from awx.main.migrations import _save_password_keys as save_password_keys # Django -from django.test.client import RequestFactory from django.core.urlresolvers import reverse from django.apps import apps @@ -141,131 +140,7 @@ def test_job_template_role_user(post, organization_factory, job_template_factory response = post(url, dict(id=jt_objects.job_template.execute_role.pk), objects.superusers.admin) assert response.status_code == 204 -# Test protection against limited set of validation problems -@pytest.mark.django_db -def test_bad_data_copy_edit(admin_user, project): - """ - If a required resource (inventory here) was deleted, copying not allowed - because doing so would caues a validation error - """ - - jt_res = JobTemplate.objects.create( - job_type='run', - project=project, - inventory=None, ask_inventory_on_launch=False, # not allowed - credential=None, ask_credential_on_launch=True, - name='deploy-job-template' - ) - serializer = JobTemplateSerializer(jt_res) - request = RequestFactory().get('/api/v1/job_templates/12/') - request.user = admin_user - serializer.context['request'] = request - response = serializer.to_representation(jt_res) - assert not response['summary_fields']['can_copy'] - assert response['summary_fields']['can_edit'] - -# Tests for correspondence between view info and actual access - -@pytest.mark.django_db -def test_admin_copy_edit(jt_copy_edit, admin_user): - "Absent a validation error, system admins can do everything" - - # Serializer can_copy/can_edit fields - serializer = JobTemplateSerializer(jt_copy_edit) - request = RequestFactory().get('/api/v1/job_templates/12/') - request.user = admin_user - serializer.context['request'] = request - response = serializer.to_representation(jt_copy_edit) - assert response['summary_fields']['can_copy'] - assert response['summary_fields']['can_edit'] - -@pytest.mark.django_db -def test_org_admin_copy_edit(jt_copy_edit, org_admin): - "Organization admins SHOULD be able to copy a JT firmly in their org" - - # Serializer can_copy/can_edit fields - serializer = JobTemplateSerializer(jt_copy_edit) - request = RequestFactory().get('/api/v1/job_templates/12/') - request.user = org_admin - serializer.context['request'] = request - response = serializer.to_representation(jt_copy_edit) - assert response['summary_fields']['can_copy'] - assert response['summary_fields']['can_edit'] - -@pytest.mark.django_db -def test_org_admin_foreign_cred_no_copy_edit(jt_copy_edit, org_admin, machine_credential): - """ - Organization admins without access to the 3 related resources: - SHOULD NOT be able to copy JT - SHOULD be able to edit that job template, for nonsensitive changes - """ - - # Attach credential to JT that org admin can not use - jt_copy_edit.credential = machine_credential - jt_copy_edit.save() - - # Serializer can_copy/can_edit fields - serializer = JobTemplateSerializer(jt_copy_edit) - request = RequestFactory().get('/api/v1/job_templates/12/') - request.user = org_admin - serializer.context['request'] = request - response = serializer.to_representation(jt_copy_edit) - assert not response['summary_fields']['can_copy'] - assert response['summary_fields']['can_edit'] - -@pytest.mark.django_db -def test_jt_admin_copy_edit(jt_copy_edit, rando): - """ - JT admins wihout access to associated resources SHOULD NOT be able to copy - SHOULD be able to make nonsensitive changes""" - - # random user given JT admin access only - jt_copy_edit.admin_role.members.add(rando) - jt_copy_edit.save() - - # Serializer can_copy/can_edit fields - serializer = JobTemplateSerializer(jt_copy_edit) - request = RequestFactory().get('/api/v1/job_templates/12/') - request.user = rando - serializer.context['request'] = request - response = serializer.to_representation(jt_copy_edit) - assert not response['summary_fields']['can_copy'] - assert response['summary_fields']['can_edit'] - -@pytest.mark.django_db -def test_proj_jt_admin_copy_edit(jt_copy_edit, rando): - "JT admins with access to associated resources SHOULD be able to copy" - - # random user given JT and project admin abilities - jt_copy_edit.admin_role.members.add(rando) - jt_copy_edit.save() - jt_copy_edit.project.admin_role.members.add(rando) - jt_copy_edit.project.save() - - # Serializer can_copy/can_edit fields - serializer = JobTemplateSerializer(jt_copy_edit) - request = RequestFactory().get('/api/v1/job_templates/12/') - request.user = rando - serializer.context['request'] = request - response = serializer.to_representation(jt_copy_edit) - assert response['summary_fields']['can_copy'] - assert response['summary_fields']['can_edit'] - -# Functional tests - create new JT with all returned fields, as the UI does - -@pytest.mark.django_db -@mock.patch.object(ProjectOptions, "playbooks", project_playbooks) -def test_org_admin_copy_edit_functional(jt_copy_edit, org_admin, get, post): - get_response = get(reverse('api:job_template_detail', args=[jt_copy_edit.pk]), user=org_admin) - assert get_response.status_code == 200 - assert get_response.data['summary_fields']['can_copy'] - - post_data = get_response.data - post_data['name'] = '%s @ 12:19:47 pm' % post_data['name'] - post_response = post(reverse('api:job_template_list', args=[]), user=org_admin, data=post_data) - assert post_response.status_code == 201 - assert post_response.data['name'] == 'copy-edit-job-template @ 12:19:47 pm' @pytest.mark.django_db @mock.patch.object(ProjectOptions, "playbooks", project_playbooks) @@ -277,7 +152,6 @@ def test_jt_admin_copy_edit_functional(jt_copy_edit, rando, get, post): get_response = get(reverse('api:job_template_detail', args=[jt_copy_edit.pk]), user=rando) assert get_response.status_code == 200 - assert not get_response.data['summary_fields']['can_copy'] post_data = get_response.data post_data['name'] = '%s @ 12:19:47 pm' % post_data['name'] diff --git a/awx/main/tests/functional/api/test_rbac_displays.py b/awx/main/tests/functional/api/test_rbac_displays.py new file mode 100644 index 0000000000..eb94e01dff --- /dev/null +++ b/awx/main/tests/functional/api/test_rbac_displays.py @@ -0,0 +1,323 @@ +import pytest + +from django.core.urlresolvers import reverse +from django.test.client import RequestFactory + +from awx.main.models.jobs import JobTemplate +from awx.main.models import Role, Group +from awx.main.access import ( + access_registry, + get_user_capabilities +) +from awx.main.utils import cache_list_capabilities +from awx.api.serializers import JobTemplateSerializer + +# This file covers special-cases of displays of user_capabilities +# general functionality should be covered fully by unit tests, see: +# awx/main/tests/unit/api/test_serializers.py :: +# TestJobTemplateSerializerGetSummaryFields.test_copy_edit_standard +# awx/main/tests/unit/test_access.py :: +# test_user_capabilities_method + + +@pytest.mark.django_db +class TestOptionsRBAC: + """ + Several endpoints are relied-upon by the UI to list POST as an + allowed action or not depending on whether the user has permission + to create a resource. + """ + + def test_inventory_group_host_can_add(self, inventory, alice, options): + inventory.admin_role.members.add(alice) + + response = options(reverse('api:inventory_hosts_list', args=[inventory.pk]), alice) + assert 'POST' in response.data['actions'] + response = options(reverse('api:inventory_groups_list', args=[inventory.pk]), alice) + assert 'POST' in response.data['actions'] + + def test_inventory_group_host_can_not_add(self, inventory, bob, options): + inventory.read_role.members.add(bob) + + response = options(reverse('api:inventory_hosts_list', args=[inventory.pk]), bob) + assert 'POST' not in response.data['actions'] + response = options(reverse('api:inventory_groups_list', args=[inventory.pk]), bob) + assert 'POST' not in response.data['actions'] + + def test_user_list_can_add(self, org_member, org_admin, options): + response = options(reverse('api:user_list'), org_admin) + assert 'POST' in response.data['actions'] + + def test_user_list_can_not_add(self, org_member, org_admin, options): + response = options(reverse('api:user_list'), org_member) + assert 'POST' not in response.data['actions'] + + +@pytest.mark.django_db +class TestJobTemplateCopyEdit: + """ + Tests contain scenarios that were raised as issues in the past, + which resulted from failed copy/edit actions even though the buttons + to do these actions were displayed. + """ + + @pytest.fixture + def jt_copy_edit(self, job_template_factory, project): + objects = job_template_factory( + 'copy-edit-job-template', + project=project) + return objects.job_template + + def fake_context(self, user): + request = RequestFactory().get('/api/v1/resource/42/') + request.user = user + + class FakeView(object): + pass + + fake_view = FakeView() + fake_view.request = request + context = {} + context['view'] = fake_view + context['request'] = request + return context + + def test_validation_bad_data_copy_edit(self, admin_user, project): + """ + If a required resource (inventory here) was deleted, copying not allowed + because doing so would caues a validation error + """ + + jt_res = JobTemplate.objects.create( + job_type='run', + project=project, + inventory=None, ask_inventory_on_launch=False, # not allowed + credential=None, ask_credential_on_launch=True, + name='deploy-job-template' + ) + serializer = JobTemplateSerializer(jt_res) + serializer.context = self.fake_context(admin_user) + response = serializer.to_representation(jt_res) + assert not response['summary_fields']['user_capabilities']['copy'] + assert response['summary_fields']['user_capabilities']['edit'] + + def test_sys_admin_copy_edit(self, jt_copy_edit, admin_user): + "Absent a validation error, system admins can do everything" + serializer = JobTemplateSerializer(jt_copy_edit) + serializer.context = self.fake_context(admin_user) + response = serializer.to_representation(jt_copy_edit) + assert response['summary_fields']['user_capabilities']['copy'] + assert response['summary_fields']['user_capabilities']['edit'] + + def test_org_admin_copy_edit(self, jt_copy_edit, org_admin): + "Organization admins SHOULD be able to copy a JT firmly in their org" + serializer = JobTemplateSerializer(jt_copy_edit) + serializer.context = self.fake_context(org_admin) + response = serializer.to_representation(jt_copy_edit) + assert response['summary_fields']['user_capabilities']['copy'] + assert response['summary_fields']['user_capabilities']['edit'] + + def test_org_admin_foreign_cred_no_copy_edit(self, jt_copy_edit, org_admin, machine_credential): + """ + Organization admins without access to the 3 related resources: + SHOULD NOT be able to copy JT + SHOULD be able to edit that job template, for nonsensitive changes + """ + + # Attach credential to JT that org admin can not use + jt_copy_edit.credential = machine_credential + jt_copy_edit.save() + + serializer = JobTemplateSerializer(jt_copy_edit) + serializer.context = self.fake_context(org_admin) + response = serializer.to_representation(jt_copy_edit) + assert not response['summary_fields']['user_capabilities']['copy'] + assert response['summary_fields']['user_capabilities']['edit'] + + def test_jt_admin_copy_edit(self, jt_copy_edit, rando): + """ + JT admins wihout access to associated resources SHOULD NOT be able to copy + SHOULD be able to make nonsensitive changes""" + + # random user given JT admin access only + jt_copy_edit.admin_role.members.add(rando) + jt_copy_edit.save() + + serializer = JobTemplateSerializer(jt_copy_edit) + serializer.context = self.fake_context(rando) + response = serializer.to_representation(jt_copy_edit) + assert not response['summary_fields']['user_capabilities']['copy'] + assert response['summary_fields']['user_capabilities']['edit'] + + def test_proj_jt_admin_copy_edit(self, jt_copy_edit, rando): + "JT admins with access to associated resources SHOULD be able to copy" + + # random user given JT and project admin abilities + jt_copy_edit.admin_role.members.add(rando) + jt_copy_edit.save() + jt_copy_edit.project.admin_role.members.add(rando) + jt_copy_edit.project.save() + + serializer = JobTemplateSerializer(jt_copy_edit) + serializer.context = self.fake_context(rando) + response = serializer.to_representation(jt_copy_edit) + assert response['summary_fields']['user_capabilities']['copy'] + assert response['summary_fields']['user_capabilities']['edit'] + + +@pytest.fixture +def mock_access_method(mocker): + mock_method = mocker.MagicMock() + mock_method.return_value = 'foobar' + mock_method.__name__ = 'bars' # Required for a logging statement + return mock_method + +@pytest.mark.django_db +class TestAccessListCapabilities: + """ + Test that the access_list serializer shows the exact output of the RoleAccess.can_attach + - looks at /api/v1/inventories/N/access_list/ + - test for types: direct, indirect, and team access + """ + + extra_kwargs = dict(skip_sub_obj_read_check=False, data={}) + + def _assert_one_in_list(self, data, sublist='direct_access'): + "Establish that exactly 1 type of access exists so we know the entry is the right one" + assert len(data['results']) == 1 + assert len(data['results'][0]['summary_fields'][sublist]) == 1 + + def test_access_list_direct_access_capability( + self, inventory, rando, get, mocker, mock_access_method): + inventory.admin_role.members.add(rando) + + with mocker.patch.object(access_registry[Role][0], 'can_unattach', mock_access_method): + response = get(reverse('api:inventory_access_list', args=(inventory.id,)), rando) + + mock_access_method.assert_called_once_with(inventory.admin_role, rando, 'members', **self.extra_kwargs) + self._assert_one_in_list(response.data) + direct_access_list = response.data['results'][0]['summary_fields']['direct_access'] + assert direct_access_list[0]['role']['user_capabilities']['unattach'] == 'foobar' + + def test_access_list_indirect_access_capability( + self, inventory, organization, org_admin, get, mocker, mock_access_method): + with mocker.patch.object(access_registry[Role][0], 'can_unattach', mock_access_method): + response = get(reverse('api:inventory_access_list', args=(inventory.id,)), org_admin) + + mock_access_method.assert_called_once_with(organization.admin_role, org_admin, 'members', **self.extra_kwargs) + self._assert_one_in_list(response.data, sublist='indirect_access') + indirect_access_list = response.data['results'][0]['summary_fields']['indirect_access'] + assert indirect_access_list[0]['role']['user_capabilities']['unattach'] == 'foobar' + + def test_access_list_team_direct_access_capability( + self, inventory, team, team_member, get, mocker, mock_access_method): + team.member_role.children.add(inventory.admin_role) + + with mocker.patch.object(access_registry[Role][0], 'can_unattach', mock_access_method): + response = get(reverse('api:inventory_access_list', args=(inventory.id,)), team_member) + + mock_access_method.assert_called_once_with(inventory.admin_role, team.member_role, 'parents', **self.extra_kwargs) + self._assert_one_in_list(response.data) + direct_access_list = response.data['results'][0]['summary_fields']['direct_access'] + assert direct_access_list[0]['role']['user_capabilities']['unattach'] == 'foobar' + + +@pytest.mark.django_db +def test_team_roles_unattach(mocker, team, team_member, inventory, mock_access_method, get): + team.member_role.children.add(inventory.admin_role) + + with mocker.patch.object(access_registry[Role][0], 'can_unattach', mock_access_method): + response = get(reverse('api:team_roles_list', args=(team.id,)), team_member) + + # Did we assess whether team_member can remove team's permission to the inventory? + mock_access_method.assert_called_once_with( + inventory.admin_role, team.member_role, 'parents', skip_sub_obj_read_check=True, data={}) + assert response.data['results'][0]['summary_fields']['user_capabilities']['unattach'] == 'foobar' + +@pytest.mark.django_db +def test_user_roles_unattach(mocker, organization, alice, bob, mock_access_method, get): + # Add to same organization so that alice and bob can see each other + organization.member_role.members.add(alice) + organization.member_role.members.add(bob) + + with mocker.patch.object(access_registry[Role][0], 'can_unattach', mock_access_method): + response = get(reverse('api:user_roles_list', args=(alice.id,)), bob) + + # Did we assess whether bob can remove alice's permission to the inventory? + mock_access_method.assert_called_once_with( + organization.member_role, alice, 'members', skip_sub_obj_read_check=True, data={}) + assert response.data['results'][0]['summary_fields']['user_capabilities']['unattach'] == 'foobar' + +@pytest.mark.django_db +def test_team_roles_unattach_functional(team, team_member, inventory, get): + team.member_role.children.add(inventory.admin_role) + response = get(reverse('api:team_roles_list', args=(team.id,)), team_member) + # Team member should be able to remove access to inventory, becauase + # the inventory admin_role grants that ability + assert response.data['results'][0]['summary_fields']['user_capabilities']['unattach'] + +@pytest.mark.django_db +def test_user_roles_unattach_functional(organization, alice, bob, get): + organization.member_role.members.add(alice) + organization.member_role.members.add(bob) + response = get(reverse('api:user_roles_list', args=(alice.id,)), bob) + # Org members can not revoke the membership of other members + assert not response.data['results'][0]['summary_fields']['user_capabilities']['unattach'] + + +@pytest.mark.django_db +def test_prefetch_jt_capabilities(job_template, rando): + job_template.execute_role.members.add(rando) + qs = JobTemplate.objects.all() + cache_list_capabilities(qs, ['admin', 'execute'], JobTemplate, rando) + assert qs[0].capabilities_cache == {'edit': False, 'start': True} + +@pytest.mark.django_db +def test_prefetch_group_capabilities(group, rando): + group.inventory.adhoc_role.members.add(rando) + qs = Group.objects.all() + cache_list_capabilities(qs, ['inventory.admin', 'inventory.adhoc'], Group, rando) + assert qs[0].capabilities_cache == {'edit': False, 'adhoc': True} + +@pytest.mark.django_db +def test_prefetch_jt_copy_capability(job_template, project, inventory, machine_credential, rando): + job_template.project = project + job_template.inventory = inventory + job_template.credential = machine_credential + job_template.save() + + qs = JobTemplate.objects.all() + cache_list_capabilities(qs, [{'copy': [ + 'project.use', 'inventory.use', 'credential.use', + 'cloud_credential.use', 'network_credential.use' + ]}], JobTemplate, rando) + assert qs[0].capabilities_cache == {'copy': False} + + project.use_role.members.add(rando) + inventory.use_role.members.add(rando) + machine_credential.use_role.members.add(rando) + + cache_list_capabilities(qs, [{'copy': [ + 'project.use', 'inventory.use', 'credential.use', + 'cloud_credential.use', 'network_credential.use' + ]}], JobTemplate, rando) + assert qs[0].capabilities_cache == {'copy': True} + +@pytest.mark.django_db +def test_group_update_capabilities_possible(group, inventory_source, admin_user): + group.inventory_source = inventory_source + group.save() + + capabilities = get_user_capabilities(admin_user, group, method_list=['start']) + assert capabilities['start'] + +@pytest.mark.django_db +def test_group_update_capabilities_impossible(group, inventory_source, admin_user): + inventory_source.source = "" + inventory_source.save() + group.inventory_source = inventory_source + group.save() + + capabilities = get_user_capabilities(admin_user, group, method_list=['start']) + assert not capabilities['start'] + diff --git a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py index dc0c672a70..ba47b09e7f 100644 --- a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py @@ -6,9 +6,13 @@ import mock from awx.api.serializers import ( JobTemplateSerializer, ) +from awx.api.views import JobTemplateDetail from awx.main.models import ( + Role, + User, Job, ) +from rest_framework.test import APIRequestFactory #DRF from rest_framework import serializers @@ -77,21 +81,33 @@ class TestJobTemplateSerializerGetSummaryFields(): summary = get_summary_fields_mock_and_run(JobTemplateSerializer, job_template) assert 'survey' not in summary - @pytest.mark.skip(reason="RBAC needs to land") - def test_can_copy_true(self, mocker, job_template): - pass + def test_copy_edit_standard(self, mocker, job_template_factory): + """Verify that the exact output of the access.py methods + are put into the serializer user_capabilities""" - @pytest.mark.skip(reason="RBAC needs to land") - def test_can_copy_false(self, mocker, job_template): - pass + jt_obj = job_template_factory('testJT', project='proj1', persisted=False).job_template + jt_obj.id = 5 + jt_obj.admin_role = Role(id=9, role_field='admin_role') + jt_obj.execute_role = Role(id=8, role_field='execute_role') + jt_obj.read_role = Role(id=7, role_field='execute_role') + user = User(username="auser") + serializer = JobTemplateSerializer(job_template) + serializer.show_capabilities = ['copy', 'edit'] + serializer._summary_field_labels = lambda self: [] + serializer._recent_jobs = lambda self: [] + request = APIRequestFactory().get('/api/v1/job_templates/42/') + request.user = user + view = JobTemplateDetail() + view.request = request + serializer.context['view'] = view - @pytest.mark.skip(reason="RBAC needs to land") - def test_can_edit_true(self, mocker, job_template): - pass + with mocker.patch("awx.main.models.rbac.Role.get_description", return_value='Can eat pie'): + with mocker.patch("awx.main.access.JobTemplateAccess.can_change", return_value='foobar'): + with mocker.patch("awx.main.access.JobTemplateAccess.can_add", return_value='foo'): + response = serializer.get_summary_fields(jt_obj) - @pytest.mark.skip(reason="RBAC needs to land") - def test_can_edit_false(self, mocker, job_template): - pass + assert response['user_capabilities']['copy'] == 'foo' + assert response['user_capabilities']['edit'] == 'foobar' class TestJobTemplateSerializerValidation(object): diff --git a/awx/main/tests/unit/models/test_job_template_unit.py b/awx/main/tests/unit/models/test_job_template_unit.py index a156d1e920..088b4d3d7c 100644 --- a/awx/main/tests/unit/models/test_job_template_unit.py +++ b/awx/main/tests/unit/models/test_job_template_unit.py @@ -50,3 +50,32 @@ def test_job_template_survey_password_redaction(job_template_with_survey_passwor """Tests the JobTemplate model's funciton to redact passwords from extra_vars - used when creating a new job""" assert job_template_with_survey_passwords_unit.survey_password_variables() == ['secret_key', 'SSN'] + +def test_job_template_survey_variable_validation(job_template_factory): + objects = job_template_factory( + 'survey_variable_validation', + organization='org1', + inventory='inventory1', + credential='cred1', + persisted=False, + ) + obj = objects.job_template + obj.survey_spec = { + "description": "", + "spec": [ + { + "required": True, + "min": 0, + "default": "5", + "max": 1024, + "question_description": "", + "choices": "", + "variable": "a", + "question_name": "Whosyourdaddy", + "type": "text" + } + ], + "name": "" + } + obj.survey_enabled = True + assert obj.survey_variable_validation({"a": 5}) == ["Value 5 for 'a' expected to be a string."] diff --git a/awx/main/tests/unit/test_access.py b/awx/main/tests/unit/test_access.py index b400a09596..650ed19864 100644 --- a/awx/main/tests/unit/test_access.py +++ b/awx/main/tests/unit/test_access.py @@ -134,3 +134,28 @@ class TestWorkflowAccessMethods: with mock.patch('awx.main.access.get_object_or_400', mock_get_object): assert access.can_add({'organization': 1}) + +@pytest.mark.django_db +def test_user_capabilities_method(): + """Unit test to verify that the user_capabilities method will defer + to the appropriate sub-class methods of the access classes. + Note that normal output is True/False, but a string is returned + in these tests to establish uniqueness. + """ + + class FooAccess(BaseAccess): + def can_change(self, obj, data): + return 'bar' + + def can_add(self, data): + return 'foobar' + + user = User(username='auser') + foo_access = FooAccess(user) + foo = object() + foo_capabilities = foo_access.get_user_capabilities(foo, ['edit', 'copy']) + assert foo_capabilities == { + 'edit': 'bar', + 'copy': 'foobar' + } + diff --git a/awx/main/tests/unit/test_network_credential.py b/awx/main/tests/unit/test_network_credential.py index 8ef5c4cb5e..6517c14a89 100644 --- a/awx/main/tests/unit/test_network_credential.py +++ b/awx/main/tests/unit/test_network_credential.py @@ -74,6 +74,7 @@ def test_net_cred_ssh_agent(mocker, get_ssh_version): mocker.patch.object(run_job, 'should_use_proot', return_value=False) mocker.patch.object(run_job, 'run_pexpect', return_value=('successful', 0)) mocker.patch.object(run_job, 'open_fifo_write', return_value=None) + mocker.patch.object(run_job, 'post_run_hook', return_value=None) run_job.run(mock_job.id) assert run_job.update_model.call_count == 3 diff --git a/awx/main/utils.py b/awx/main/utils.py index 270d62e50f..0603e05997 100644 --- a/awx/main/utils.py +++ b/awx/main/utils.py @@ -33,7 +33,7 @@ logger = logging.getLogger('awx.main.utils') __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', 'memoize', 'get_ansible_version', 'get_ssh_version', 'get_awx_version', 'update_scm_url', - 'get_type_for_model', 'get_model_for_type', 'to_python_boolean', + 'get_type_for_model', 'get_model_for_type', 'cache_list_capabilities', 'to_python_boolean', 'ignore_inventory_computed_fields', 'ignore_inventory_group_removal', '_inventory_updates', 'get_pk_from_dict', 'getattrd', 'NoDefaultProvided', 'get_current_apps', 'set_current_apps'] @@ -409,6 +409,72 @@ def get_model_for_type(type): return ct_model +def cache_list_capabilities(page, prefetch_list, model, user): + ''' + Given a `page` list of objects, the specified roles for the specified user + are save on each object in the list, using 1 query for each role type + + Examples: + capabilities_prefetch = ['admin', 'execute'] + --> prefetch the admin (edit) and execute (start) permissions for + items in list for current user + capabilities_prefetch = ['inventory.admin'] + --> prefetch the related inventory FK permissions for current user, + and put it into the object's cache + capabilities_prefetch = [{'copy': ['inventory.admin', 'project.admin']}] + --> prefetch logical combination of admin permission to inventory AND + project, put into cache dictionary as "copy" + ''' + from django.db.models import Q + page_ids = [obj.id for obj in page] + for obj in page: + obj.capabilities_cache = {} + + for prefetch_entry in prefetch_list: + + display_method = None + if type(prefetch_entry) is dict: + display_method = prefetch_entry.keys()[0] + paths = prefetch_entry[display_method] + else: + paths = prefetch_entry + + if type(paths) is not list: + paths = [paths] + + # Build the query for accessible_objects according the user & role(s) + qs_obj = None + for role_path in paths: + if '.' in role_path: + res_path = '__'.join(role_path.split('.')[:-1]) + role_type = role_path.split('.')[-1] + if qs_obj is None: + qs_obj = model.objects + parent_model = model._meta.get_field(res_path).related_model + kwargs = {'%s__in' % res_path: parent_model.accessible_objects(user, '%s_role' % role_type)} + qs_obj = qs_obj.filter(Q(**kwargs) | Q(**{'%s__isnull' % res_path: True})) + else: + role_type = role_path + qs_obj = model.accessible_objects(user, '%s_role' % role_type) + + if display_method is None: + # Role name translation to UI names for methods + display_method = role_type + if role_type == 'admin': + display_method = 'edit' + elif role_type in ['execute', 'update']: + display_method = 'start' + + # Union that query with the list of items on page + ids_with_role = set(qs_obj.filter(pk__in=page_ids).values_list('pk', flat=True)) + + # Save data item-by-item + for obj in page: + obj.capabilities_cache[display_method] = False + if obj.pk in ids_with_role: + obj.capabilities_cache[display_method] = True + + def get_system_task_capacity(): ''' Measure system memory and use it as a baseline for determining the system's capacity diff --git a/awx/ui/.npmrc b/awx/ui/.npmrc new file mode 100644 index 0000000000..d883e4fa13 --- /dev/null +++ b/awx/ui/.npmrc @@ -0,0 +1 @@ +progress=false diff --git a/awx/ui/client/src/access/roleList.partial.html b/awx/ui/client/src/access/roleList.partial.html index 1478c9dc37..365a20f061 100644 --- a/awx/ui/client/src/access/roleList.partial.html +++ b/awx/ui/client/src/access/roleList.partial.html @@ -1,13 +1,13 @@If no organization is given, the credential can only be used by the user that creates the credential. Organization admins and system administrators can assign an organization so that roles for the credential can be assigned to users and teams in that organization.
", dataTitle: 'Organization ', dataPlacement: 'bottom', - dataContainer: "body" + dataContainer: "body", + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, kind: { label: 'Type', @@ -83,7 +86,8 @@ export default dataTitle: 'Type', dataPlacement: 'right', dataContainer: "body", - hasSubForm: true + hasSubForm: true, + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, access_key: { label: 'Access Key', @@ -96,12 +100,13 @@ export default autocomplete: false, apiField: 'username', subForm: 'credentialSubForm', + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, secret_key: { label: 'Secret Key', type: 'sensitive', ngShow: "kind.value == 'aws'", - ngDisabled: "secret_key_ask", + ngDisabled: "secret_key_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)", awRequiredWhen: { reqExpression: "aws_required", init: false @@ -123,7 +128,8 @@ export default dataTitle: 'STS Token', dataPlacement: 'right', dataContainer: "body", - subForm: 'credentialSubForm' + subForm: 'credentialSubForm', + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, "host": { labelBind: 'hostLabel', @@ -139,7 +145,8 @@ export default reqExpression: 'host_required', init: false }, - subForm: 'credentialSubForm' + subForm: 'credentialSubForm', + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, "subscription": { label: "Subscription ID", @@ -156,7 +163,8 @@ export default dataTitle: 'Subscription ID', dataPlacement: 'right', dataContainer: "body", - subForm: 'credentialSubForm' + subForm: 'credentialSubForm', + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, "username": { labelBind: 'usernameLabel', @@ -168,7 +176,8 @@ export default init: false }, autocomplete: false, - subForm: "credentialSubForm" + subForm: "credentialSubForm", + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, "email_address": { labelBind: 'usernameLabel', @@ -183,7 +192,8 @@ export default dataTitle: 'Email', dataPlacement: 'right', dataContainer: "body", - subForm: 'credentialSubForm' + subForm: 'credentialSubForm', + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, "api_key": { label: 'API Key', @@ -196,7 +206,8 @@ export default autocomplete: false, hasShowInputButton: true, clear: false, - subForm: 'credentialSubForm' + subForm: 'credentialSubForm', + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, "password": { labelBind: 'passwordLabel', @@ -209,13 +220,14 @@ export default reqExpression: "password_required", init: false }, - subForm: "credentialSubForm" + subForm: "credentialSubForm", + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, "ssh_password": { label: 'Password', type: 'sensitive', ngShow: "kind.value == 'ssh'", - ngDisabled: "ssh_password_ask", + ngDisabled: "ssh_password_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)", addRequired: false, editRequired: false, subCheckbox: { @@ -247,7 +259,8 @@ export default dataTitle: 'Private Key', dataPlacement: 'right', dataContainer: "body", - subForm: "credentialSubForm" + subForm: "credentialSubForm", + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' }, "ssh_key_unlock": { label: 'Private Key Passphrase', @@ -255,7 +268,7 @@ export default ngShow: "kind.value == 'ssh' || kind.value == 'scm'", addRequired: false, editRequired: false, - ngDisabled: "keyEntered === false || ssh_key_unlock_ask", + ngDisabled: "keyEntered === false || ssh_key_unlock_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)", subCheckbox: { variable: 'ssh_key_unlock_ask', ngShow: "kind.value == 'ssh'", @@ -278,7 +291,8 @@ export default "sudo | su | pbrun | pfexec | runas sudo)",
dataPlacement: 'right',
dataContainer: "body",
- subForm: 'credentialSubForm'
+ subForm: 'credentialSubForm',
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
"become_username": {
labelBind: 'becomeUsernameLabel',
@@ -287,13 +301,14 @@ export default
addRequired: false,
editRequired: false,
autocomplete: false,
- subForm: 'credentialSubForm'
+ subForm: 'credentialSubForm',
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
"become_password": {
labelBind: 'becomePasswordLabel',
type: 'sensitive',
ngShow: "(kind.value == 'ssh' && (become_method && become_method.value)) ",
- ngDisabled: "become_password_ask",
+ ngDisabled: "become_password_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)",
addRequired: false,
editRequired: false,
subCheckbox: {
@@ -309,7 +324,8 @@ export default
type: 'text',
label: 'Client ID',
subForm: 'credentialSubForm',
- ngShow: "kind.value === 'azure_rm'"
+ ngShow: "kind.value === 'azure_rm'",
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
secret:{
type: 'sensitive',
@@ -317,20 +333,23 @@ export default
autocomplete: false,
label: 'Client Secret',
subForm: 'credentialSubForm',
- ngShow: "kind.value === 'azure_rm'"
+ ngShow: "kind.value === 'azure_rm'",
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
tenant: {
type: 'text',
label: 'Tenant ID',
subForm: 'credentialSubForm',
- ngShow: "kind.value === 'azure_rm'"
+ ngShow: "kind.value === 'azure_rm'",
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
authorize: {
label: 'Authorize',
type: 'checkbox',
ngChange: "toggleCallback('host_config_key')",
subForm: 'credentialSubForm',
- ngShow: "kind.value === 'net'"
+ ngShow: "kind.value === 'net'",
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
authorize_password: {
label: 'Authorize Password',
@@ -339,6 +358,7 @@ export default
autocomplete: false,
subForm: 'credentialSubForm',
ngShow: "authorize && authorize !== 'false'",
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
"project": {
labelBind: 'projectLabel',
@@ -355,7 +375,8 @@ export default
reqExpression: 'project_required',
init: false
},
- subForm: 'credentialSubForm'
+ subForm: 'credentialSubForm',
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
"domain": {
labelBind: 'domainLabel',
@@ -371,13 +392,14 @@ export default
dataContainer: "body",
addRequired: false,
editRequired: false,
- subForm: 'credentialSubForm'
+ subForm: 'credentialSubForm',
+ ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
"vault_password": {
label: "Vault Password",
type: 'sensitive',
ngShow: "kind.value == 'ssh'",
- ngDisabled: "vault_password_ask",
+ ngDisabled: "vault_password_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)",
addRequired: false,
editRequired: false,
subCheckbox: {
@@ -394,11 +416,17 @@ export default
buttons: {
cancel: {
ngClick: 'formCancel()',
+ ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
+ },
+ close: {
+ ngClick: 'formCancel()',
+ ngShow: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
},
save: {
label: 'Save',
ngClick: 'formSave()', //$scope.function to call on click, optional
- ngDisabled: true //Disable when $pristine or $invalid, optional
+ ngDisabled: true,
+ ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)' //Disable when $pristine or $invalid, optional
}
},
@@ -421,7 +449,8 @@ export default
label: 'Add',
awToolTip: 'Add a permission',
actionClass: 'btn List-buttonSubmit',
- buttonContent: '+ ADD'
+ buttonContent: '+ ADD',
+ ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
}
},
diff --git a/awx/ui/client/src/forms/Groups.js b/awx/ui/client/src/forms/Groups.js
index e3327d442e..32723ff5f2 100644
--- a/awx/ui/client/src/forms/Groups.js
+++ b/awx/ui/client/src/forms/Groups.js
@@ -26,14 +26,16 @@ export default
type: 'text',
addRequired: true,
editRequired: true,
- tab: 'properties'
+ tab: 'properties',
+ ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)'
},
description: {
label: 'Description',
type: 'text',
addRequired: false,
editRequired: false,
- tab: 'properties'
+ tab: 'properties',
+ ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)'
},
variables: {
label: 'Variables',
@@ -65,7 +67,8 @@ export default
ngChange: 'sourceChange(source)',
addRequired: false,
editRequired: false,
- ngModel: 'source'
+ ngModel: 'source',
+ ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)'
},
credential: {
label: 'Cloud Credential',
@@ -77,7 +80,8 @@ export default
awRequiredWhen: {
reqExpression: "cloudCredentialRequired",
init: "false"
- }
+ },
+ ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)'
},
source_regions: {
label: 'Regions',
@@ -92,7 +96,8 @@ export default
awPopOver: "Click on the regions field to see a list of regions for your cloud provider. You can select multiple regions, " + "or choose All to include all regions. Tower will only be updated with Hosts associated with the selected regions." + "
", - dataContainer: 'body' + dataContainer: 'body', + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' }, instance_filters: { label: 'Instance Filters', @@ -112,7 +117,8 @@ export default "tag:Name=test*\n" + "
View the Describe Instances documentation " + "for a complete list of supported filters.
", - dataContainer: 'body' + dataContainer: 'body', + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' }, group_by: { label: 'Only Group By', @@ -137,7 +143,8 @@ export default "If blank, all groups above are created except Instance ID.
", - dataContainer: 'body' + dataContainer: 'body', + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' }, inventory_script: { label : "Custom Inventory Script", @@ -149,6 +156,7 @@ export default addRequired: true, editRequired: true, ngRequired: "source && source.value === 'custom'", + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)', }, custom_variables: { id: 'custom_variables', @@ -269,7 +277,8 @@ export default dataTitle: 'Overwrite', dataContainer: 'body', dataPlacement: 'right', - labelClass: 'checkbox-options' + labelClass: 'checkbox-options', + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' }, { name: 'overwrite_vars', label: 'Overwrite Variables', @@ -283,7 +292,8 @@ export default dataTitle: 'Overwrite Variables', dataContainer: 'body', dataPlacement: 'right', - labelClass: 'checkbox-options' + labelClass: 'checkbox-options', + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' }, { name: 'update_on_launch', label: 'Update on Launch', @@ -296,7 +306,8 @@ export default dataTitle: 'Update on Launch', dataContainer: 'body', dataPlacement: 'right', - labelClass: 'checkbox-options' + labelClass: 'checkbox-options', + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' }] }, update_cache_timeout: { @@ -321,11 +332,17 @@ export default buttons: { cancel: { - ngClick: 'formCancel()' + ngClick: 'formCancel()', + ngShow: '(group_obj.summary_fields.user_capabilities.edit || canAdd)' + }, + close: { + ngClick: 'formCancel()', + ngShow: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' }, save: { ngClick: 'formSave()', - ngDisabled: true + ngDisabled: true, + ngShow: '(group_obj.summary_fields.user_capabilities.edit || canAdd)' } }, diff --git a/awx/ui/client/src/forms/Hosts.js b/awx/ui/client/src/forms/Hosts.js index 0da34d3e2e..a1c0e69537 100644 --- a/awx/ui/client/src/forms/Hosts.js +++ b/awx/ui/client/src/forms/Hosts.js @@ -46,13 +46,15 @@ export default "", dataTitle: 'Host Name', dataPlacement: 'right', - dataContainer: 'body' + dataContainer: 'body', + ngDisabled: '!(host.summary_fields.user_capabilities.edit || canAdd)' }, description: { label: 'Description', type: 'text', addRequired: false, - editRequired: false + editRequired: false, + ngDisabled: '!(host.summary_fields.user_capabilities.edit || canAdd)' }, variables: { label: 'Variables', @@ -83,10 +85,16 @@ export default buttons: { cancel: { ngClick: 'formCancel()', + ngShow: '(host.summary_fields.user_capabilities.edit || canAdd)' + }, + close: { + ngClick: 'formCancel()', + ngShow: '!(host.summary_fields.user_capabilities.edit || canAdd)' }, save: { ngClick: 'formSave()', - ngDisabled: true + ngDisabled: true, + ngShow: '(host.summary_fields.user_capabilities.edit || canAdd)' } }, diff --git a/awx/ui/client/src/forms/Inventories.js b/awx/ui/client/src/forms/Inventories.js index 6467bf28d3..72852079c3 100644 --- a/awx/ui/client/src/forms/Inventories.js +++ b/awx/ui/client/src/forms/Inventories.js @@ -26,14 +26,16 @@ export default type: 'text', addRequired: true, editRequired: true, - capitalize: false + capitalize: false, + ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' }, inventory_description: { realName: 'description', label: 'Description', type: 'text', addRequired: false, - editRequired: false + editRequired: false, + ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' }, organization: { label: 'Organization', @@ -44,7 +46,8 @@ export default awRequiredWhen: { reqExpression: "organizationrequired", init: "true" - } + }, + ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' }, variables: { label: 'Variables', @@ -63,17 +66,24 @@ export default 'View YAML examples at docs.ansible.com
', dataTitle: 'Inventory Variables', dataPlacement: 'right', - dataContainer: 'body' + dataContainer: 'body', + ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working } }, buttons: { cancel: { - ngClick: 'formCancel()' + ngClick: 'formCancel()', + ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' + }, + close: { + ngClick: 'formCancel()', + ngHide: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' }, save: { ngClick: 'formSave()', - ngDisabled: true + ngDisabled: true, + ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' } }, @@ -94,7 +104,8 @@ export default label: 'Add', awToolTip: 'Add a permission', actionClass: 'btn List-buttonSubmit', - buttonContent: '+ ADD' + buttonContent: '+ ADD', + ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' } }, diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js index 5d56907c88..3ec35b2288 100644 --- a/awx/ui/client/src/forms/JobTemplates.js +++ b/awx/ui/client/src/forms/JobTemplates.js @@ -27,14 +27,16 @@ export default type: 'text', addRequired: true, editRequired: true, - column: 1 + column: 1, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, description: { label: 'Description', type: 'text', addRequired: false, editRequired: false, - column: 1 + column: 1, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, job_type: { label: 'Job Type', @@ -56,7 +58,8 @@ export default variable: 'ask_job_type_on_launch', ngShow: "!job_type.value || job_type.value !== 'scan'", text: 'Prompt on launch' - } + }, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, inventory: { label: 'Inventory', @@ -78,7 +81,8 @@ export default variable: 'ask_inventory_on_launch', ngShow: "!job_type.value || job_type.value !== 'scan'", text: 'Prompt on launch' - } + }, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, project: { label: 'Project', @@ -100,12 +104,13 @@ export default dataTitle: 'Project', dataPlacement: 'right', dataContainer: "body", + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, playbook: { label: 'Playbook', type:'select', ngOptions: 'book for book in playbook_options track by book', - ngDisabled: "job_type.value === 'scan' && project_name === 'Default'", + ngDisabled: "(job_type.value === 'scan' && project_name === 'Default') || !(job_template_obj.summary_fields.user_capabilities.edit || canAdd)", id: 'playbook-select', awRequiredWhen: { reqExpression: "playbookrequired", @@ -138,7 +143,8 @@ export default subCheckbox: { variable: 'ask_credential_on_launch', text: 'Prompt on launch' - } + }, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, cloud_credential: { label: 'Cloud Credential', @@ -153,7 +159,8 @@ export default "running playbook, allowing provisioning into the cloud without manually passing parameters to the included modules.", dataTitle: 'Cloud Credential', dataPlacement: 'right', - dataContainer: "body" + dataContainer: "body", + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, network_credential: { label: 'Network Credential', @@ -167,7 +174,8 @@ export default awPopOver: "Network credentials are used by Ansible networking modules to connect to and manage networking devices.
", dataTitle: 'Network Credential', dataPlacement: 'right', - dataContainer: "body" + dataContainer: "body", + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, forks: { label: 'Forks', @@ -186,7 +194,8 @@ export default ' target=\"_blank\">ansible configuration file.', dataTitle: 'Forks', dataPlacement: 'right', - dataContainer: "body" + dataContainer: "body", + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working }, limit: { label: 'Limit', @@ -203,7 +212,8 @@ export default subCheckbox: { variable: 'ask_limit_on_launch', text: 'Prompt on launch' - } + }, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, verbosity: { label: 'Verbosity', @@ -216,7 +226,8 @@ export default awPopOver: "Control the level of output ansible will produce as the playbook executes.
", dataTitle: 'Verbosity', dataPlacement: 'right', - dataContainer: "body" + dataContainer: "body", + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, job_tags: { label: 'Job Tags', @@ -235,7 +246,8 @@ export default subCheckbox: { variable: 'ask_tags_on_launch', text: 'Prompt on launch' - } + }, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, skip_tags: { label: 'Skip Tags', @@ -254,7 +266,8 @@ export default subCheckbox: { variable: 'ask_skip_tags_on_launch', text: 'Prompt on launch' - } + }, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, checkbox_group: { label: 'Options', @@ -270,7 +283,8 @@ export default dataPlacement: 'right', dataTitle: 'Become Privilege Escalation', dataContainer: "body", - labelClass: 'stack-inline' + labelClass: 'stack-inline', + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, { name: 'allow_callbacks', label: 'Allow Provisioning Callbacks', @@ -284,7 +298,8 @@ export default dataPlacement: 'right', dataTitle: 'Allow Provisioning Callbacks', dataContainer: "body", - labelClass: 'stack-inline' + labelClass: 'stack-inline', + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }] }, callback_url: { @@ -299,7 +314,8 @@ export default awPopOverWatch: "callback_help", dataPlacement: 'top', dataTitle: 'Provisioning Callback URL', - dataContainer: "body" + dataContainer: "body", + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, host_config_key: { label: 'Host Config Key', @@ -312,7 +328,8 @@ export default awPopOverWatch: "callback_help", dataPlacement: 'right', dataTitle: "Host Config Key", - dataContainer: "body" + dataContainer: "body", + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, labels: { label: 'Labels', @@ -325,7 +342,8 @@ export default dataTitle: 'Labels', dataPlacement: 'right', awPopOver: "Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs in the Tower display.
", - dataContainer: 'body' + dataContainer: 'body', + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' }, variables: { label: 'Extra Variables', @@ -348,14 +366,15 @@ export default subCheckbox: { variable: 'ask_variables_on_launch', text: 'Prompt on launch' - } + }, + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working } }, buttons: { //for now always generates