diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 7f52f64c02..74aab14c85 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -84,6 +84,7 @@ from awx.main.utils.common import ( create_partition, ScheduleWorkflowManager, ScheduleTaskManager, + getattr_dne, ) from awx.conf.license import get_license from awx.main.utils.handlers import SpecialInventoryHandler @@ -92,9 +93,76 @@ from awx.main.utils.update_model import update_model # Django flags from flags.state import flag_enabled +# Workload Identity +from ansible_base.lib.workload_identity.controller import AutomationControllerJobScope + logger = logging.getLogger('awx.main.tasks.jobs') +def populate_claims_for_workload(unified_job) -> dict: + """ + Extract JWT claims from a Controller workload for the aap_controller_automation_job scope. + """ + + # Related objects in the UnifiedJob model, applies to all job types + organization = getattr_dne(unified_job, 'organization') + ujt = getattr_dne(unified_job, 'unified_job_template') + instance_group = getattr_dne(unified_job, 'instance_group') + + claims = { + AutomationControllerJobScope.CLAIM_JOB_ID: unified_job.id, + AutomationControllerJobScope.CLAIM_JOB_NAME: unified_job.name, + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: unified_job.launch_type, + } + + # Related objects in the UnifiedJob model, applies to all job types + # null cases are omitted because of OIDC + if organization := getattr_dne(unified_job, 'organization'): + claims[AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME] = organization.name + claims[AutomationControllerJobScope.CLAIM_ORGANIZATION_ID] = organization.id + + if ujt := getattr_dne(unified_job, 'unified_job_template'): + claims[AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_NAME] = ujt.name + claims[AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_ID] = ujt.id + + if instance_group := getattr_dne(unified_job, 'instance_group'): + claims[AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME] = instance_group.name + claims[AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID] = instance_group.id + + # Related objects on concrete models, may not be valid for type of unified_job + if inventory := getattr_dne(unified_job, 'inventory', None): + claims[AutomationControllerJobScope.CLAIM_INVENTORY_NAME] = inventory.name + claims[AutomationControllerJobScope.CLAIM_INVENTORY_ID] = inventory.id + + if execution_environment := getattr_dne(unified_job, 'execution_environment', None): + claims[AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME] = execution_environment.name + claims[AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID] = execution_environment.id + + if project := getattr_dne(unified_job, 'project', None): + claims[AutomationControllerJobScope.CLAIM_PROJECT_NAME] = project.name + claims[AutomationControllerJobScope.CLAIM_PROJECT_ID] = project.id + + if jt := getattr_dne(unified_job, 'job_template', None): + claims[AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_NAME] = jt.name + claims[AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_ID] = jt.id + + # Only valid for job templates + if hasattr(unified_job, 'playbook'): + claims[AutomationControllerJobScope.CLAIM_PLAYBOOK_NAME] = unified_job.playbook + + # Not valid for inventory updates and system jobs + if hasattr(unified_job, 'job_type'): + claims[AutomationControllerJobScope.CLAIM_JOB_TYPE] = unified_job.job_type + + launched_by: dict = unified_job.launched_by + if 'name' in launched_by: + claims[AutomationControllerJobScope.CLAIM_LAUNCHED_BY_NAME] = launched_by['name'] + if 'id' in launched_by: + claims[AutomationControllerJobScope.CLAIM_LAUNCHED_BY_ID] = launched_by['id'] + + return claims + + def with_path_cleanup(f): @functools.wraps(f) def _wrapped(self, *args, **kwargs): diff --git a/awx/main/tests/unit/tasks/test_jobs.py b/awx/main/tests/unit/tasks/test_jobs.py index 2ff0138de4..6995776b0d 100644 --- a/awx/main/tests/unit/tasks/test_jobs.py +++ b/awx/main/tests/unit/tasks/test_jobs.py @@ -18,8 +18,17 @@ from awx.main.models import ( Job, Organization, Project, + JobTemplate, + UnifiedJobTemplate, + InstanceGroup, + ExecutionEnvironment, + ProjectUpdate, + InventoryUpdate, + InventorySource, + AdHocCommand, ) from awx.main.tasks import jobs +from ansible_base.lib.workload_identity.controller import AutomationControllerJobScope @pytest.fixture @@ -188,3 +197,233 @@ def test_invalid_host_facts(mock_facts_settings, bulk_update_sorted_by_id, priva with pytest.raises(pytest.fail.Exception): if failures: pytest.fail(f" {len(failures)} facts cleared failures : {','.join(failures)}") + + +@pytest.mark.parametrize( + "job_attrs,expected_claims", + [ + ( + { + 'id': 100, + 'name': 'Test Job', + 'job_type': 'run', + 'launch_type': 'manual', + 'playbook': 'site.yml', + 'organization': Organization(id=1, name='Test Org'), + 'inventory': Inventory(id=2, name='Test Inventory'), + 'project': Project(id=3, name='Test Project'), + 'execution_environment': ExecutionEnvironment(id=4, name='Test EE'), + 'job_template': JobTemplate(id=5, name='Test Job Template'), + 'unified_job_template': UnifiedJobTemplate(pk=6, id=6, name='Test Unified Job Template'), + 'instance_group': InstanceGroup(id=7, name='Test Instance Group'), + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 100, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Test Job', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + AutomationControllerJobScope.CLAIM_PLAYBOOK_NAME: 'site.yml', + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: 'Test Org', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_INVENTORY_NAME: 'Test Inventory', + AutomationControllerJobScope.CLAIM_INVENTORY_ID: 2, + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME: 'Test EE', + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID: 4, + AutomationControllerJobScope.CLAIM_PROJECT_NAME: 'Test Project', + AutomationControllerJobScope.CLAIM_PROJECT_ID: 3, + AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_NAME: 'Test Job Template', + AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_ID: 5, + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_NAME: 'Test Unified Job Template', + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_ID: 6, + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME: 'Test Instance Group', + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID: 7, + }, + ), + ( + {'id': 100, 'name': 'Test', 'job_type': 'run', 'launch_type': 'manual', 'organization': Organization(id=1, name='')}, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 100, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Test', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: '', + AutomationControllerJobScope.CLAIM_PLAYBOOK_NAME: '', + }, + ), + ], +) +def test_populate_claims_for_workload(job_attrs, expected_claims): + job = Job() + + for attr, value in job_attrs.items(): + setattr(job, attr, value) + + claims = jobs.populate_claims_for_workload(job) + assert claims == expected_claims + + +@pytest.mark.parametrize( + "workload_attrs,expected_claims", + [ + ( + { + 'id': 200, + 'name': 'Git Sync', + 'job_type': 'check', + 'launch_type': 'sync', + 'organization': Organization(id=1, name='Test Org'), + 'project': Project(pk=3, id=3, name='Test Project'), + 'unified_job_template': Project(pk=3, id=3, name='Test Project'), + 'execution_environment': ExecutionEnvironment(id=4, name='Test EE'), + 'instance_group': InstanceGroup(id=7, name='Test Instance Group'), + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 200, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Git Sync', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'check', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'sync', + AutomationControllerJobScope.CLAIM_LAUNCHED_BY_NAME: 'Test Project', + AutomationControllerJobScope.CLAIM_LAUNCHED_BY_ID: 3, + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: 'Test Org', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_PROJECT_NAME: 'Test Project', + AutomationControllerJobScope.CLAIM_PROJECT_ID: 3, + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_NAME: 'Test Project', + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_ID: 3, + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME: 'Test EE', + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID: 4, + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME: 'Test Instance Group', + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID: 7, + }, + ), + ( + { + 'id': 201, + 'name': 'Minimal Project Update', + 'job_type': 'run', + 'launch_type': 'manual', + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 201, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Minimal Project Update', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + }, + ), + ], +) +def test_populate_claims_for_project_update(workload_attrs, expected_claims): + project_update = ProjectUpdate() + for attr, value in workload_attrs.items(): + setattr(project_update, attr, value) + + claims = jobs.populate_claims_for_workload(project_update) + assert claims == expected_claims + + +@pytest.mark.parametrize( + "workload_attrs,expected_claims", + [ + ( + { + 'id': 300, + 'name': 'AWS Sync', + 'launch_type': 'scheduled', + 'organization': Organization(id=1, name='Test Org'), + 'inventory': Inventory(id=2, name='AWS Inventory'), + 'unified_job_template': InventorySource(pk=8, id=8, name='AWS Source'), + 'execution_environment': ExecutionEnvironment(id=4, name='Test EE'), + 'instance_group': InstanceGroup(id=7, name='Test Instance Group'), + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 300, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'AWS Sync', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'scheduled', + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: 'Test Org', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_INVENTORY_NAME: 'AWS Inventory', + AutomationControllerJobScope.CLAIM_INVENTORY_ID: 2, + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_NAME: 'AWS Source', + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_ID: 8, + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME: 'Test EE', + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID: 4, + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME: 'Test Instance Group', + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID: 7, + }, + ), + ( + { + 'id': 301, + 'name': 'Minimal Inventory Update', + 'launch_type': 'manual', + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 301, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Minimal Inventory Update', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + }, + ), + ], +) +def test_populate_claims_for_inventory_update(workload_attrs, expected_claims): + inventory_update = InventoryUpdate() + for attr, value in workload_attrs.items(): + setattr(inventory_update, attr, value) + + claims = jobs.populate_claims_for_workload(inventory_update) + assert claims == expected_claims + + +@pytest.mark.parametrize( + "workload_attrs,expected_claims", + [ + ( + { + 'id': 400, + 'name': 'Ping All Hosts', + 'job_type': 'run', + 'launch_type': 'manual', + 'organization': Organization(id=1, name='Test Org'), + 'inventory': Inventory(id=2, name='Test Inventory'), + 'execution_environment': ExecutionEnvironment(id=4, name='Test EE'), + 'instance_group': InstanceGroup(id=7, name='Test Instance Group'), + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 400, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Ping All Hosts', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: 'Test Org', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_INVENTORY_NAME: 'Test Inventory', + AutomationControllerJobScope.CLAIM_INVENTORY_ID: 2, + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME: 'Test EE', + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID: 4, + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME: 'Test Instance Group', + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID: 7, + }, + ), + ( + { + 'id': 401, + 'name': 'Minimal Ad Hoc', + 'job_type': 'run', + 'launch_type': 'manual', + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 401, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Minimal Ad Hoc', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + }, + ), + ], +) +def test_populate_claims_for_adhoc_command(workload_attrs, expected_claims): + adhoc_command = AdHocCommand() + for attr, value in workload_attrs.items(): + setattr(adhoc_command, attr, value) + + claims = jobs.populate_claims_for_workload(adhoc_command) + assert claims == expected_claims diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 4365887be2..a02daef166 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -1000,9 +1000,15 @@ def getattrd(obj, name, default=NoDefaultProvided): raise -def getattr_dne(obj, name, notfound=ObjectDoesNotExist): +empty = object() + + +def getattr_dne(obj, name, default=empty, notfound=ObjectDoesNotExist): try: - return getattr(obj, name) + if default is empty: + return getattr(obj, name) + else: + return getattr(obj, name, default) except notfound: return None