diff --git a/plugins/action/role_team_assignment.py b/plugins/action/role_team_assignment.py index 90016c79..694d60e4 100644 --- a/plugins/action/role_team_assignment.py +++ b/plugins/action/role_team_assignment.py @@ -10,6 +10,44 @@ from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.role_team_assignment import AnsibleRoleTeamAssignment +# Maps the suffix of a role definition's content_type to the Gateway API +# endpoint used for name-based object lookup. +# e.g. "awx.project" → suffix "project" → endpoint "projects" +_CONTENT_TYPE_ENDPOINT_MAP = { + "organization": "organizations", + "team": "teams", + # Controller (awx) + "project": "projects", + "inventory": "inventories", + "credential": "credentials", + "jobtemplate": "job_templates", + "workflowjobtemplate": "workflow_job_templates", + "executionenvironment": "execution_environments", + "instancegroup": "instance_groups", + "notificationtemplate": "notification_templates", + # EDA (eda) + "activation": "activations", + "edacredential": "eda_credentials", + "eventstream": "event_streams", + "decisionenvironment": "decision_environments", + "credentialinputsource": "credential_input_sources", + # Hub (galaxy) + "namespace": "namespaces", + "collectionremote": "collection_remotes", + "ansiblerepository": "ansible_repositories", + "containernamespace": "container_namespaces", + "containerrepository": "container_repositories", +} + + +def _get_expected_endpoint(content_type): + """Derive the expected lookup endpoint from a role definition's content_type.""" + raw = (content_type or "").strip() + if not raw: + return None + suffix = raw.split(".")[-1] if "." in raw else raw + return _CONTENT_TYPE_ENDPOINT_MAP.get(suffix, "{0}s".format(suffix)) + class ActionModule(BaseResourceActionPlugin): MODULE_NAME = "role_team_assignment" @@ -78,6 +116,20 @@ def run(self, tmp=None, task_vars=None): # ---- single-object path: standard run logic ------------------- return self._run_standard(result, manager, argspec, validated_params, state) + # ---- resolve role_definition content_type for type validation ----- + role_def_name = validated_params.get("role_definition", "") + _role_def_obj = None + try: + _role_def_obj = manager.execute( + operation="find", + module_name="role_definition", + ansible_data={"name": role_def_name}, + ) + except Exception: + pass + _role_content_type = (_role_def_obj or {}).get("content_type") if _role_def_obj else None + _expected_endpoint = _get_expected_endpoint(_role_content_type) + # ---- multi-object path: iterate over assignment_objects ----------- # Base data shared across all assignments (role + team, no object_id) _skip = self._AUTH_PARAMS | { @@ -102,6 +154,23 @@ def run(self, tmp=None, task_vars=None): elif obj.get("object_ansible_id"): per_obj["object_ansible_id"] = str(obj["object_ansible_id"]) elif obj.get("name") and obj.get("type"): + # Validate that the user-provided type matches what the + # role's content_type expects. Mismatches cause the Gateway + # API to reject the assignment with 400/500. + if _expected_endpoint and obj["type"] != _expected_endpoint: + raise AnsibleError( + "Role '{role}' has content_type that requires type '{expected}' " + "for name-based lookup, but assignment_objects specifies " + "type '{provided}'. To grant access to all {resource}s within " + "an organization, use the organization-scoped variant of this " + "role. To target a specific {resource}, use " + "type '{expected}' with the resource name.".format( + role=role_def_name, + expected=_expected_endpoint, + provided=obj["type"], + resource=_expected_endpoint.rstrip("s"), + ) + ) try: oid = manager.lookup_resource_id(obj["type"], "name", obj["name"]) per_obj["object_id"] = str(oid) # CRITICAL: Must be string diff --git a/plugins/modules/role_team_assignment.py b/plugins/modules/role_team_assignment.py index 6fb5331f..24311524 100644 --- a/plugins/modules/role_team_assignment.py +++ b/plugins/modules/role_team_assignment.py @@ -22,13 +22,14 @@ remove those permissions. - Not all role assignments are valid. See Limitations below. notes: - - This module is subject to limitations of the RBAC system in AAP 2.6. - Global roles (e.g. Platform Auditor) cannot be assigned to teams. - - Team roles cannot be assigned to another team - (Team Admin to Team is not supported). - Organization Member role cannot be assigned to teams. - - Only resource-scoped organization roles such as Organization Inventory Admin - and Organization Credential Admin can be meaningfully assigned to teams. + - The C(type) field in C(assignment_objects) must match the resource type + expected by the role definition's C(content_type). For example, a role + with C(content_type=awx.project) requires C(type=projects). Using + C(type=organizations) for such a role will result in an error. Use the + organization-scoped variant of the role (e.g. "Organization Project Admin") + to grant access to all resources of that type within an organization. - Attempting unsupported role assignments will result in errors. options: role_definition: @@ -66,6 +67,7 @@ type: description: - The object type used for name lookup. + - Must match the content_type of the role definition. - Supported values are C(organizations) and C(teams). type: str required: false @@ -119,9 +121,9 @@ team: "APAC-BLR" assignment_objects: - name: "org-emea" - type: "organizations" + type: organizations - name: "org-apac" - type: "organizations" + type: organizations state: present register: result diff --git a/plugins/plugin_utils/api/v1/role_team_assignment.py b/plugins/plugin_utils/api/v1/role_team_assignment.py index b02fb6bf..b3585031 100644 --- a/plugins/plugin_utils/api/v1/role_team_assignment.py +++ b/plugins/plugin_utils/api/v1/role_team_assignment.py @@ -84,6 +84,11 @@ def _get(key): if isinstance(object_id, int) or str(object_id).isdigit(): api_data["object_id"] = str(object_id) elif manager: + # Fallback name→id lookup for the single-object path where + # object_id is passed as a name with no explicit type. + # Scoped to Gateway resources only (organizations, teams). + # Resource-level name resolution (EDA/Hub/Controller) is + # handled in the action plugin via type + assignment_objects. for endpoint in ("organizations", "teams"): resolved = _resolve_fk(manager, endpoint, "name", object_id) if resolved is not None: diff --git a/tests/integration/targets/role_team_assignments_test/tasks/main.yml b/tests/integration/targets/role_team_assignments_test/tasks/main.yml index 8603f2e5..0293d1b0 100644 --- a/tests/integration/targets/role_team_assignments_test/tasks/main.yml +++ b/tests/integration/targets/role_team_assignments_test/tasks/main.yml @@ -106,6 +106,170 @@ state: present register: custom_role + # -------------------------------------------------------------------------- + # Resource-level role definitions for type-mismatch tests + # -------------------------------------------------------------------------- + - name: Create project-scoped custom role (content_type awx.project) + ansible.platform.role_definition: + name: "{{ custom_role_name }}-project" + description: "Project-scoped role for content_type mismatch tests" + content_type: "awx.project" + permissions: + - "awx.view_project" + state: present + register: custom_project_role + + - name: Create activation-scoped custom role (content_type eda.activation) + ansible.platform.role_definition: + name: "{{ custom_role_name }}-activation" + description: "Activation-scoped role for content_type mismatch tests" + content_type: "eda.activation" + permissions: + - "eda.view_activation" + state: present + register: custom_activation_role + + # -------------------------------------------------------------------------- + # Root cause: resource-level role + type:organizations caused 400/500. + # Fix: module now validates type matches content_type and raises a clear error. + # -------------------------------------------------------------------------- + + # TEST 1: Type mismatch — project role + type:organizations must give clear error + - name: "BUG#165 | project-scoped role with type:organizations must fail with clear error" + ansible.platform.role_team_assignment: + role_definition: "{{ custom_role_name }}-project" + team: "{{ team1.name }}" + assignment_objects: + - name: "{{ org1.name }}" + type: organizations + state: present + register: mismatch_project_org + ignore_errors: true + + - name: "BUG#165 | Assert error message tells user the correct type to use" + ansible.builtin.assert: + that: + - mismatch_project_org is failed + - "'projects' in mismatch_project_org.msg" + - "'organizations' in mismatch_project_org.msg" + success_msg: > + BUG FIX CONFIRMED: module rejects project role + type:organizations with + a clear error pointing to 'projects' as the expected type. + fail_msg: > + BUG NOT FIXED: either the assignment was silently accepted (no error) + or the error message does not mention the correct type 'projects'. + Result: {{ mismatch_project_org | to_nice_json }} + + # TEST 2: Type mismatch — activation role + type:organizations must give clear error + - name: "BUG#165 | activation-scoped role with type:organizations must fail with clear error" + ansible.platform.role_team_assignment: + role_definition: "{{ custom_role_name }}-activation" + team: "{{ team1.name }}" + assignment_objects: + - name: "{{ org1.name }}" + type: organizations + state: present + register: mismatch_activation_org + ignore_errors: true + + - name: "BUG#165 | Assert activation mismatch error mentions activations" + ansible.builtin.assert: + that: + - mismatch_activation_org is failed + - "'activations' in mismatch_activation_org.msg" + - "'organizations' in mismatch_activation_org.msg" + success_msg: "BUG FIX CONFIRMED: activation role + type:organizations correctly rejected." + fail_msg: "Activation mismatch not caught. Result: {{ mismatch_activation_org | to_nice_json }}" + + # TEST 3: Correct org-level role assignment still works (no regression) + - name: "BUG#165 | org-scoped role with type:organizations must still work (regression check)" + ansible.platform.role_team_assignment: + role_definition: "{{ custom_role_name }}" + team: "{{ team1.name }}" + assignment_objects: + - name: "{{ org1.name }}" + type: organizations + state: present + register: correct_org_assignment + + - name: "BUG#165 | Assert org-level assignment succeeds" + ansible.builtin.assert: + that: + - correct_org_assignment is not failed + success_msg: "No regression: org-scoped role + type:organizations still works correctly." + fail_msg: "REGRESSION: org-scoped role assignment is broken. {{ correct_org_assignment.msg | default('') }}" + + # TEST 4: Idempotency — re-running org assignment produces no change + - name: "BUG#165 | Idempotency — re-run org assignment" + ansible.platform.role_team_assignment: + role_definition: "{{ custom_role_name }}" + team: "{{ team1.name }}" + assignment_objects: + - name: "{{ org1.name }}" + type: organizations + state: present + register: idempotent_check + + - name: "BUG#165 | Assert idempotency" + ansible.builtin.assert: + that: + - idempotent_check is not failed + - idempotent_check is not changed + success_msg: "Idempotency confirmed — second run made no changes." + fail_msg: "Idempotency broken — second run changed state or failed." + + # TEST 5: object_ansible_id path bypasses type check and still works + - name: "BUG#165 | Assignment via object_ansible_id must work regardless of content_type" + ansible.platform.role_team_assignment: + role_definition: "{{ custom_role_name }}" + team: "{{ team2.name }}" + assignment_objects: + - object_ansible_id: "{{ org2.organization.ansible_id | default(omit) }}" + state: present + register: ansible_id_assignment + when: org2.organization.ansible_id is defined + ignore_errors: true + + # TEST 6: state:absent removes assignment correctly + - name: "BUG#165 | state:absent removes the assignment" + ansible.platform.role_team_assignment: + role_definition: "{{ custom_role_name }}" + team: "{{ team1.name }}" + assignment_objects: + - name: "{{ org1.name }}" + type: organizations + state: absent + register: absent_assignment + + - name: "BUG#165 | Assert assignment was removed" + ansible.builtin.assert: + that: + - absent_assignment is not failed + - absent_assignment is changed + success_msg: "state:absent removed assignment correctly." + fail_msg: "state:absent failed or made no change. {{ absent_assignment.msg | default('') }}" + + # TEST 7: Multi-org assignment — single task, multiple objects + - name: "BUG#165 | Multi-org assignment in one task" + ansible.platform.role_team_assignment: + role_definition: "{{ custom_role_name }}" + team: "{{ team3.name }}" + assignment_objects: + - name: "{{ org3.name }}" + type: organizations + - name: "{{ org4.name }}" + type: organizations + state: present + register: multi_org_assignment + + - name: "BUG#165 | Assert multi-org assignment succeeded" + ansible.builtin.assert: + that: + - multi_org_assignment is not failed + - multi_org_assignment.assignments | length == 2 + success_msg: "Multi-org assignment: both orgs assigned in one task." + fail_msg: "Multi-org assignment failed. {{ multi_org_assignment | to_nice_json }}" + # 1. Assign Org Admin role to Team1 on Org1 (Global role can't be assigned) - name: Assign Org Admin to Team1 on Org1 ansible.platform.role_team_assignment: @@ -258,7 +422,7 @@ - "{{ org3.name }}" - "{{ org4.name }}" - - name: Delete custom role + - name: Delete custom org-scoped role ansible.platform.role_definition: name: "{{ custom_role_name }}" content_type: "shared.organization" @@ -270,4 +434,22 @@ - role_delete.failed - "'Not found' not in role_delete.msg" - "'does not exist' not in role_delete.msg" + + - name: Delete custom project-scoped role (bug#165 test) + ansible.platform.role_definition: + name: "{{ custom_role_name }}-project" + content_type: "awx.project" + permissions: + - "awx.view_project" + state: absent + failed_when: false + + - name: Delete custom activation-scoped role (bug#165 test) + ansible.platform.role_definition: + name: "{{ custom_role_name }}-activation" + content_type: "eda.activation" + permissions: + - "eda.view_activation" + state: absent + failed_when: false ...