diff --git a/awx/api/views.py b/awx/api/views.py index e9742c56e9..e6bd86cae4 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2631,10 +2631,6 @@ class WorkflowJobTemplateNodeList(ListCreateAPIView): serializer_class = WorkflowJobTemplateNodeListSerializer new_in_310 = True - def update_raw_data(self, data): - for fd in ['job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags']: - data[fd] = None - return super(WorkflowJobTemplateNodeList, self).update_raw_data(data) class WorkflowJobTemplateNodeDetail(RetrieveUpdateDestroyAPIView): diff --git a/awx/main/access.py b/awx/main/access.py index d86be2fadc..813851d226 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1132,10 +1132,26 @@ class SystemJobAccess(BaseAccess): ''' model = SystemJob -# TODO: class WorkflowJobTemplateNodeAccess(BaseAccess): ''' - I can see/use a WorkflowJobTemplateNode if I have permission to associated Workflow Job Template + I can see/use a WorkflowJobTemplateNode if I have read permission + to associated Workflow Job Template + + In order to add a node, I need: + - admin access to parent WFJT + - execute access to the unified job template being used + - access to any credential or inventory provided as the prompted fields + + In order to do anything to a node, I need admin access to its WFJT + + In order to edit fields on a node, I need: + - execute access to the unified job template of the node + - access to BOTH credential and inventory post-change, if present + + In order to delete a node, I only need the admin access its WFJT + + In order to manage connections (edges) between nodes I do not need anything + beyond the standard admin access to its WFJT ''' model = WorkflowJobTemplateNode @@ -1148,26 +1164,78 @@ class WorkflowJobTemplateNodeAccess(BaseAccess): self.user, 'read_role')) return qs - @check_superuser - def can_read(self, obj): + def can_use_prompted_resources(self, data): + cred_pk = data.get('credential', None) + inv_pk = data.get('inventory', None) + if cred_pk: + credential = get_object_or_400(Credential, pk=cred_pk) + if self.user not in credential.use_role: + return False + if inv_pk: + inventory = get_object_or_400(Inventory, pk=inv_pk) + if self.user not in inventory.use_role: + return False return True @check_superuser def can_add(self, data): if not data: # So the browseable API will work return True - + wfjt_pk = data.get('workflow_job_template', None) + if wfjt_pk: + wfjt = get_object_or_400(WorkflowJobTemplate, pk=wfjt_pk) + if self.user not in wfjt.admin_role: + return False + else: + return False + if not self.can_use_prompted_resources(data): + return False return True - @check_superuser + def wfjt_admin(self, obj): + if not obj.workflow_job_template: + return self.user.is_superuser + else: + return self.user in obj.workflow_job_template.admin_role + + def ujt_execute(self, obj): + if not obj.unified_job_template: + return self.wfjt_admin(obj) + else: + return self.user in obj.unified_job_template.execute_role and self.wfjt_admin(obj) + def can_change(self, obj, data): - if self.can_add(data) is False: + if not data: + return True + + if not self.ujt_execute(obj): + # should not be able to edit the prompts if lacking access to UJT return False + if 'credential' in data or 'inventory' in data: + new_data = data + if 'credential' not in data: + new_data['credential'] = self.credential + if 'inventory' not in data: + new_data['inventory'] = self.inventory + return self.can_use_prompted_resources(new_data) return True def can_delete(self, obj): - return self.can_change(obj, None) + return self.wfjt_admin(obj) + + def check_same_WFJT(self, obj, sub_obj): + if type(obj) != self.model or type(sub_obj) != self.model: + raise Exception('Attaching workflow nodes only allowed for other nodes') + if obj.workflow_job_template != sub_obj.workflow_job_template: + return False + return True + + def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): + return self.wfjt_admin(obj) and self.check_same_WFJT(obj, sub_obj) + + def can_unattach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): + return self.wfjt_admin(obj) and self.check_same_WFJT(obj, sub_obj) class WorkflowJobNodeAccess(BaseAccess): ''' @@ -1199,7 +1267,7 @@ class WorkflowJobNodeAccess(BaseAccess): def can_delete(self, obj): return False -# TODO: +# TODO: revisit for survey logic, notification attachments? class WorkflowJobTemplateAccess(BaseAccess): ''' I can only see/manage Workflow Job Templates if I'm a super user @@ -1293,7 +1361,7 @@ class WorkflowJobTemplateAccess(BaseAccess): if ('organization' not in data or (org_pk is None and obj.organization is None) or (obj.organization and obj.organization.pk == org_pk)): - # The simple case + # No organization changes return self.user in obj.admin_role # If it already has an organization set, must be admin of the org to change it @@ -1314,11 +1382,13 @@ class WorkflowJobTemplateAccess(BaseAccess): return True - class WorkflowJobAccess(BaseAccess): ''' I can only see Workflow Jobs if I can see the associated workflow job template that it was created from. + I can delete them if I am admin of their workflow job template + I can cancel one if I can delete it + I can also cancel it if I started it ''' model = WorkflowJob @@ -1345,6 +1415,10 @@ class WorkflowJobAccess(BaseAccess): return self.user.is_superuser return self.user in obj.workflow_job_template.admin_role + def can_cancel(self, obj): + if not obj.can_cancel: + return False + return self.can_delete(obj) or self.user == obj.created_by class AdHocCommandAccess(BaseAccess): ''' diff --git a/awx/main/management/commands/run_task_system.py b/awx/main/management/commands/run_task_system.py index 855491f08c..07b3ab3df5 100644 --- a/awx/main/management/commands/run_task_system.py +++ b/awx/main/management/commands/run_task_system.py @@ -260,9 +260,7 @@ def do_spawn_workflow_jobs(): dag = WorkflowDAG(workflow_job) spawn_nodes = dag.bfs_nodes_to_run() for spawn_node in spawn_nodes: - # TODO: Inject job template template params as kwargs. - # Make sure to take into account extra_vars merge logic - kv = {} + kv = spawn_node.get_job_kwargs() job = spawn_node.unified_job_template.create_unified_job(**kv) spawn_node.job = job spawn_node.save() diff --git a/awx/main/migrations/0033_v310_add_workflows.py b/awx/main/migrations/0033_v310_add_workflows.py index 1ca0462edf..3d2e5afc77 100644 --- a/awx/main/migrations/0033_v310_add_workflows.py +++ b/awx/main/migrations/0033_v310_add_workflows.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django.db import migrations, models +import jsonfield.fields import awx.main.models.notifications import django.db.models.deletion import awx.main.models.workflow @@ -101,4 +102,70 @@ class Migration(migrations.Migration): name='workflow_job_template_node', field=models.ManyToManyField(to='main.WorkflowJobTemplateNode', blank=True), ), + # RBAC, prompting changes + migrations.AddField( + model_name='workflowjobnode', + name='char_prompts', + field=jsonfield.fields.JSONField(default={}, blank=True), + ), + migrations.AddField( + model_name='workflowjobnode', + name='credential', + field=models.ForeignKey(related_name='workflowjobnodes', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to='main.Credential', null=True), + ), + migrations.AddField( + model_name='workflowjobnode', + name='inventory', + field=models.ForeignKey(related_name='workflowjobnodes', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to='main.Inventory', null=True), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='execute_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='organization', + field=models.ForeignKey(related_name='workflows', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='main.Organization', null=True), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='read_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_auditor', b'organization.auditor_role', b'execute_role', b'admin_role'], to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='workflowjobtemplatenode', + name='char_prompts', + field=jsonfield.fields.JSONField(default={}, blank=True), + ), + migrations.AddField( + model_name='workflowjobtemplatenode', + name='credential', + field=models.ForeignKey(related_name='workflowjobtemplatenodes', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to='main.Credential', null=True), + ), + migrations.AddField( + model_name='workflowjobtemplatenode', + name='inventory', + field=models.ForeignKey(related_name='workflowjobtemplatenodes', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to='main.Inventory', null=True), + ), + migrations.AlterField( + model_name='workflowjobnode', + name='unified_job_template', + field=models.ForeignKey(related_name='workflowjobnodes', on_delete=django.db.models.deletion.SET_NULL, default=None, to='main.UnifiedJobTemplate', null=True), + ), + migrations.AlterField( + model_name='workflowjobnode', + name='workflow_job', + field=models.ForeignKey(related_name='workflow_job_nodes', default=None, blank=True, to='main.WorkflowJob', null=True), + ), + migrations.AlterField( + model_name='workflowjobtemplate', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_administrator', b'organization.admin_role'], to='main.Role', null=b'True'), + ), + migrations.AlterField( + model_name='workflowjobtemplatenode', + name='unified_job_template', + field=models.ForeignKey(related_name='workflowjobtemplatenodes', on_delete=django.db.models.deletion.SET_NULL, default=None, to='main.UnifiedJobTemplate', null=True), + ), ] diff --git a/awx/main/migrations/0036_v310_workflow_rbac_prompts.py b/awx/main/migrations/0036_v310_workflow_rbac_prompts.py deleted file mode 100644 index d126ac9c1d..0000000000 --- a/awx/main/migrations/0036_v310_workflow_rbac_prompts.py +++ /dev/null @@ -1,82 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -import jsonfield.fields -import django.db.models.deletion -import awx.main.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0035_v310_jobevent_uuid'), - ] - - operations = [ - migrations.AddField( - model_name='workflowjobnode', - name='char_prompts', - field=jsonfield.fields.JSONField(default={}, blank=True), - ), - migrations.AddField( - model_name='workflowjobnode', - name='credential', - field=models.ForeignKey(related_name='workflowjobnodes', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to='main.Credential', null=True), - ), - migrations.AddField( - model_name='workflowjobnode', - name='inventory', - field=models.ForeignKey(related_name='workflowjobnodes', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to='main.Inventory', null=True), - ), - migrations.AddField( - model_name='workflowjobtemplate', - name='execute_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role'], to='main.Role', null=b'True'), - ), - migrations.AddField( - model_name='workflowjobtemplate', - name='organization', - field=models.ForeignKey(related_name='workflows', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='main.Organization', null=True), - ), - migrations.AddField( - model_name='workflowjobtemplate', - name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_auditor', b'organization.auditor_role', b'execute_role', b'admin_role'], to='main.Role', null=b'True'), - ), - migrations.AddField( - model_name='workflowjobtemplatenode', - name='char_prompts', - field=jsonfield.fields.JSONField(default={}, blank=True), - ), - migrations.AddField( - model_name='workflowjobtemplatenode', - name='credential', - field=models.ForeignKey(related_name='workflowjobtemplatenodes', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to='main.Credential', null=True), - ), - migrations.AddField( - model_name='workflowjobtemplatenode', - name='inventory', - field=models.ForeignKey(related_name='workflowjobtemplatenodes', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to='main.Inventory', null=True), - ), - migrations.AlterField( - model_name='workflowjobnode', - name='unified_job_template', - field=models.ForeignKey(related_name='workflowjobnodes', on_delete=django.db.models.deletion.SET_NULL, default=None, to='main.UnifiedJobTemplate', null=True), - ), - migrations.AlterField( - model_name='workflowjobnode', - name='workflow_job', - field=models.ForeignKey(related_name='workflow_job_nodes', default=None, blank=True, to='main.WorkflowJob', null=True), - ), - migrations.AlterField( - model_name='workflowjobtemplate', - name='admin_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_administrator', b'organization.admin_role'], to='main.Role', null=b'True'), - ), - migrations.AlterField( - model_name='workflowjobtemplatenode', - name='unified_job_template', - field=models.ForeignKey(related_name='workflowjobtemplatenodes', on_delete=django.db.models.deletion.SET_NULL, default=None, to='main.UnifiedJobTemplate', null=True), - ), - ] diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 2831244b2a..683ef741a5 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -22,6 +22,9 @@ from awx.main.models.rbac import ( from awx.main.fields import ImplicitRoleField from awx.main.models.mixins import ResourceMixin +import yaml +import json + __all__ = ['WorkflowJobTemplate', 'WorkflowJob', 'WorkflowJobOptions', 'WorkflowJobNode', 'WorkflowJobTemplateNode',] CHAR_PROMPTS_LIST = ['job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags'] @@ -150,6 +153,33 @@ class WorkflowJobNode(WorkflowNodeBase): def get_absolute_url(self): return reverse('api:workflow_job_node_detail', args=(self.pk,)) + def get_job_kwargs(self): + data = {} + # rejecting/accepting prompting variables done with the node copy + if self.inventory: + data['inventory'] = self.inventory + if self.credential: + data['credential'] = self.credential + if self.char_prompts: + data.update(self.char_prompts) + # process extra_vars + extra_vars = {} + if self.workflow_job and self.workflow_job.extra_vars: + try: + WJ_json_extra_vars = json.loads( + (self.workflow_job.extra_vars or '').strip() or '{}') + except ValueError: + try: + WJ_json_extra_vars = yaml.safe_load(self.workflow_job.extra_vars) + except yaml.YAMLError: + WJ_json_extra_vars = {} + extra_vars.update(WJ_json_extra_vars) + # 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): class Meta: abstract = True @@ -164,9 +194,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, ResourceMixin) class Meta: app_label = 'main' - # admin_role = ImplicitRoleField( - # parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, - # ) organization = models.ForeignKey( 'Organization', blank=True, diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index e52b627076..ac6c93348d 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -167,11 +167,11 @@ def mk_workflow_job(status='new', workflow_job_template=None, extra_vars={}, job.save() return job -def mk_workflow_job_template(name, extra_vars='', spec=None, persisted=True): +def mk_workflow_job_template(name, extra_vars='', spec=None, organization=None, persisted=True): if extra_vars: extra_vars = json.dumps(extra_vars) - wfjt = WorkflowJobTemplate(name=name, extra_vars=extra_vars) + wfjt = WorkflowJobTemplate(name=name, extra_vars=extra_vars, organization=organization) wfjt.survey_spec = spec if wfjt.survey_spec is not None: diff --git a/awx/main/tests/factories/tower.py b/awx/main/tests/factories/tower.py index 5c99c14828..5a23a01577 100644 --- a/awx/main/tests/factories/tower.py +++ b/awx/main/tests/factories/tower.py @@ -360,16 +360,20 @@ def generate_workflow_job_template_nodes(workflow_job_template, new_node = WorkflowJobTemplateNode(workflow_job_template=workflow_job_template, unified_job_template=node['unified_job_template'], id=i) + if persisted: + new_node.save() new_nodes.append(new_node) node_types = ['success_nodes', 'failure_nodes', 'always_nodes'] for node_type in node_types: for i, new_node in enumerate(new_nodes): + if node_type not in workflow_job_template_nodes[i]: + continue for related_index in workflow_job_template_nodes[i][node_type]: getattr(new_node, node_type).add(new_nodes[related_index]) # TODO: Implement survey and jobs -def create_workflow_job_template(name, persisted=True, **kwargs): +def create_workflow_job_template(name, organization=None, persisted=True, **kwargs): Objects = generate_objects(["workflow_job_template", "workflow_job_template_nodes", "survey",], kwargs) @@ -382,7 +386,8 @@ def create_workflow_job_template(name, persisted=True, **kwargs): if 'survey' in kwargs: spec = create_survey_spec(kwargs['survey']) - wfjt = mk_workflow_job_template(name, + wfjt = mk_workflow_job_template(name, + organization=organization, spec=spec, extra_vars=extra_vars, persisted=persisted) diff --git a/awx/main/tests/functional/test_rbac_workflow.py b/awx/main/tests/functional/test_rbac_workflow.py new file mode 100644 index 0000000000..663af2639a --- /dev/null +++ b/awx/main/tests/functional/test_rbac_workflow.py @@ -0,0 +1,73 @@ +import pytest + +from awx.main.access import ( + WorkflowJobTemplateAccess, + WorkflowJobTemplateNodeAccess, + WorkflowJobAccess, + # WorkflowJobNodeAccess +) + +@pytest.fixture +def wfjt(workflow_job_template_factory, organization): + objects = workflow_job_template_factory('test_workflow', organization=organization, persisted=True) + return objects.workflow_job_template + +@pytest.fixture +def wfjt_with_nodes(workflow_job_template_factory, organization, job_template): + objects = workflow_job_template_factory( + 'test_workflow', organization=organization, workflow_job_template_nodes=[{'unified_job_template': job_template}], persisted=True) + return objects.workflow_job_template + +@pytest.fixture +def wfjt_node(wfjt_with_nodes): + return wfjt_with_nodes.workflow_job_template_nodes.all()[0] + +@pytest.fixture +def workflow_job(wfjt): + return wfjt.jobs.create(name='test_workflow') + + +@pytest.mark.django_db +class TestWorkflowJobTemplateAccess: + + def test_random_user_no_edit(self, wfjt, rando): + access = WorkflowJobTemplateAccess(rando) + assert not access.can_change(wfjt, {'name': 'new name'}) + + def test_org_admin_edit(self, wfjt, org_admin): + access = WorkflowJobTemplateAccess(org_admin) + assert access.can_change(wfjt, {'name': 'new name'}) + + def test_org_admin_role_inheritance(self, wfjt, org_admin): + assert org_admin in wfjt.admin_role + assert org_admin in wfjt.execute_role + assert org_admin in wfjt.read_role + + def test_jt_blocks_copy(self, wfjt_with_nodes, org_admin): + """I want to copy a workflow JT in my organization, but someone + included a job template that I don't have access to, so I can + not copy the WFJT as-is""" + access = WorkflowJobTemplateAccess(org_admin) + assert not access.can_add({'reference_obj': wfjt_with_nodes}) + +@pytest.mark.django_db +class TestWorkflowJobTemplateNodeAccess: + + def test_jt_access_to_edit(self, wfjt_node, org_admin): + access = WorkflowJobTemplateNodeAccess(org_admin) + assert not access.can_change(wfjt_node, {'job_type': 'scan'}) + +@pytest.mark.django_db +class TestWorkflowJobAccess: + + def test_wfjt_admin_delete(self, wfjt, workflow_job, rando): + wfjt.admin_role.members.add(rando) + access = WorkflowJobAccess(rando) + assert access.can_delete(workflow_job) + + def test_cancel_your_own_job(self, wfjt, workflow_job, rando): + wfjt.execute_role.members.add(rando) + workflow_job.created_by = rando + workflow_job.save() + access = WorkflowJobAccess(rando) + assert access.can_cancel(workflow_job) diff --git a/awx/main/tests/unit/test_access.py b/awx/main/tests/unit/test_access.py index 9e28b00480..b400a09596 100644 --- a/awx/main/tests/unit/test_access.py +++ b/awx/main/tests/unit/test_access.py @@ -118,11 +118,19 @@ class TestWorkflowAccessMethods: objects = workflow_job_template_factory('test_workflow', persisted=False) return objects.workflow_job_template - class MockQuerySet(object): - pass - def test_workflow_can_add(self, workflow, user_unit): - # user_unit.admin_of_organizations = self.MockQuerySet() - access = WorkflowJobTemplateAccess(user_unit) - assert access.can_add({'organization': 1}) + organization = Organization(name='test-org') + workflow.organization = organization + organization.admin_role = Role() + + def mock_get_object(Class, **kwargs): + if Class == Organization: + return organization + else: + raise Exception('Item requested has not been mocked') + + access = WorkflowJobTemplateAccess(user_unit) + with mock.patch('awx.main.models.rbac.Role.__contains__', return_value=True): + with mock.patch('awx.main.access.get_object_or_400', mock_get_object): + assert access.can_add({'organization': 1})