From 32553df087068d11b8ea02d5484d595fd1d86640 Mon Sep 17 00:00:00 2001 From: rohitthakur2590 Date: Wed, 3 Jun 2026 15:19:57 +0530 Subject: [PATCH 01/10] [AAP-75240] Fix role_team_assignment content_type mismatch causing 400/500 errors and enable resource-level type support Signed-off-by: rohitthakur2590 --- plugins/modules/role_team_assignment.py | 372 +++++++++++++----- .../role_team_assignments_test/tasks/main.yml | 184 ++++++++- 2 files changed, 451 insertions(+), 105 deletions(-) diff --git a/plugins/modules/role_team_assignment.py b/plugins/modules/role_team_assignment.py index 94e38b2b..1f7ef953 100644 --- a/plugins/modules/role_team_assignment.py +++ b/plugins/modules/role_team_assignment.py @@ -8,7 +8,7 @@ __metaclass__ = type -DOCUMENTATION = ''' +DOCUMENTATION = """ --- module: role_team_assignment author: Rohit Thakur (@rohitthakur2590) @@ -20,26 +20,38 @@ 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 → Team is not supported). + - 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 (e.g. "Organization Inventory Admin", "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") + when you want to grant access to all resources of a type within an organization. - Attempting unsupported role assignments will result in errors. options: assignment_objects: description: - List of dicts mapping resource names to their types. - When using name, each dict must include C(name) and C(type). + - The C(type) value must match the endpoint corresponding to the role's + C(content_type). For example, a role with C(content_type=awx.project) requires + C(type=projects); a role with C(content_type=shared.organization) requires + C(type=organizations). type: list elements: dict suboptions: name: description: - - The object name (e.g. organization/team name). - - Internally resolved into its ansible_id. + - The object name (e.g. organization, project, or activation name). + - Internally resolved to the object's primary key via a name lookup. type: str required: False type: - description: The object type (e.g. C(organizations), C(teams)). + description: + - The resource type endpoint for name-based lookup. + - Must match the content_type of the role definition. + - Examples - C(organizations), C(teams), C(projects), C(inventories), + C(credentials), C(job_templates), C(activations), C(event_streams). type: str required: False object_id: @@ -76,72 +88,134 @@ type: str extends_documentation_fragment: - ansible.platform.auth -''' +""" -EXAMPLES = ''' -- name: Assign roles for multiple objects using names +EXAMPLES = """ +- name: Assign org-level role against multiple organizations (content_type shared.organization) ansible.platform.role_team_assignment: + role_definition: Organization Inventory Admin + team: "{{ team2.name }}" assignment_objects: - name: "{{ org1.name }}" - type: "organizations" + type: organizations - name: "{{ org2.name }}" - type: "organizations" - role_definition: Organization Inventory Admin - team: "{{ team2.name }}" + type: organizations state: present register: result -- name: Delete team role assignments for multiple objects using names +- name: Assign resource-level role against a specific project (content_type awx.project) ansible.platform.role_team_assignment: + role_definition: Project Admin + team: "developers" assignment_objects: - - name: "{{ org1.name }}" - type: "organizations" - - name: "{{ org2.name }}" - type: "organizations" - role_definition: Organization Inventory Admin - team: "{{ team2.name }}" - state: absent - register: result + - name: "Demo Project" + type: projects + state: present + +- name: Assign resource-level role against a specific EDA activation (content_type eda.activation) + ansible.platform.role_team_assignment: + role_definition: Activation Admin + team: "eda-operators" + assignment_objects: + - name: "prod-alert-activation" + type: activations + state: present -- name: Role Team assignment using object_ansible_id +- name: Assign role using object_ansible_id (works for any resource type) ansible.platform.role_team_assignment: + role_definition: Organization Inventory Admin team: "APAC-BLR" assignment_objects: - object_ansible_id: "c891b9f7-cc08-4b62-9843-c9ebfda362a8" - role_definition: Organization Inventory Admin state: present - register: result + register: result -- name: Check Role Team assignment exists +- name: Check role team assignment exists ansible.platform.role_team_assignment: + role_definition: Organization Inventory Admin team: "APAC-BLR" assignment_objects: - object_ansible_id: "c891b9f7-cc08-4b62-9843-c9ebfda362a8" - role_definition: Organization Inventory Admin state: exists - register: result + register: result -- name: Role Team assignment +- name: Remove role team assignment ansible.platform.role_team_assignment: + role_definition: Organization Inventory Admin team: "APAC-BLR" assignment_objects: - object_ansible_id: "c891b9f7-cc08-4b62-9843-c9ebfda362a8" - role_definition: Organization Inventory Admin state: absent - register: result + register: result ... -''' +""" from ..module_utils.aap_module import AAPModule -def assign_team_role(module, state, role_team_assignment, kwargs, - role_definition_str, team_param, team_ansible_id, auto_exit=False): +# Maps the suffix of a role definition's content_type to the Gateway API endpoint +# used for name-based object lookup. +# Format: "service.ResourceName" → suffix → endpoint +# e.g. "awx.project" → "project" → "projects" +CONTENT_TYPE_ENDPOINT_MAP = { + # Gateway / shared + "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", + "task": "tasks", +} + + +def _get_expected_endpoint(role_definition): + """ + Derive the Gateway API lookup endpoint from a role definition's content_type. + + Returns the endpoint string (e.g. 'projects') or None for global roles + (content_type is null). + """ + raw = (role_definition.get("content_type") or "").strip() + if not raw: + return None + suffix = raw.split(".")[-1] if "." in raw else raw + # Fall back to naive pluralisation for unknown types + return CONTENT_TYPE_ENDPOINT_MAP.get(suffix, "{0}s".format(suffix)) + + +def assign_team_role( + module, + state, + role_team_assignment, + kwargs, + role_definition_str, + team_param, + team_ansible_id, + auto_exit=False, +): """ - Create/delete/assert a team role assignment.s. + Create/delete/assert a single team role assignment. """ - if state == 'exists': + if state == "exists": if not role_team_assignment: module.fail_json( msg=( @@ -149,131 +223,221 @@ def assign_team_role(module, state, role_team_assignment, kwargs, % (role_definition_str, team_param or team_ansible_id) ) ) - elif state == 'absent': + elif state == "absent": module.delete_if_needed(role_team_assignment, auto_exit=auto_exit) - - elif state == 'present': + elif state == "present": module.create_if_needed( role_team_assignment, kwargs, - endpoint='role_team_assignments', - item_type='role_team_assignment', - auto_exit=auto_exit + endpoint="role_team_assignments", + item_type="role_team_assignment", + auto_exit=auto_exit, ) return -def _validate_selector(entry, module): +def _validate_selector(entry, module, expected_endpoint=None, role_name=""): """ - Enforce exactly one selector per item: + Enforce exactly one selector per assignment_objects item: EITHER (name AND type) OR object_id OR object_ansible_id. - If 'name' is used, 'type' is required. + + When name+type is used, validate that the provided type matches the + endpoint derived from the role definition's content_type so that the + object lookup targets the correct resource and the Gateway API receives + a compatible object_id. """ - has_name = bool(entry.get('name')) - has_type = bool(entry.get('type')) - has_pk = entry.get('object_id') is not None - has_uuid = bool(entry.get('object_ansible_id')) + has_name = bool(entry.get("name")) + has_type = bool(entry.get("type")) + has_pk = entry.get("object_id") is not None + has_uuid = bool(entry.get("object_ansible_id")) - # If name is present, type must be present (and vice versa) if has_name ^ has_type: - module.fail_json(msg="When using 'name', you must also provide 'type' in each assignment_objects item.") + module.fail_json( + msg="When using 'name', you must also provide 'type' in each assignment_objects item." + ) - count = (1 if (has_name and has_type) else 0) + (1 if has_pk else 0) + (1 if has_uuid else 0) + count = ( + (1 if (has_name and has_type) else 0) + + (1 if has_pk else 0) + + (1 if has_uuid else 0) + ) if count == 0: module.fail_json( msg="Each assignment_objects item must include exactly one of: " - "(name & type) OR object_id OR object_ansible_id." + "(name & type) OR object_id OR object_ansible_id." ) if count > 1: module.fail_json( msg="Each assignment_objects item must not include more than one of: " - "(name & type), object_id, object_ansible_id." + "(name & type), object_id, object_ansible_id." ) - # Optional: constrain allowed types for name-based lookup if has_name and has_type: - allowed = ("organizations", "teams") # extend if/when we support more + allowed = sorted(set(CONTENT_TYPE_ENDPOINT_MAP.values())) if entry["type"] not in allowed: - module.fail_json(msg=f"Unsupported type '{entry['type']}'. Valid types: {', '.join(allowed)}") + module.fail_json( + msg=("Unsupported type '{0}'. Valid types: {1}.").format( + entry["type"], ", ".join(allowed) + ) + ) + + # Validate that the provided type matches what this role's content_type expects. + # Mismatches (e.g. type=organizations for a role with content_type=awx.project) + # cause the Gateway API to reject the assignment with a 400/500 error. + if expected_endpoint and entry["type"] != expected_endpoint: + module.fail_json( + msg=( + "Role '{role}' has content_type that requires type '{expected}' for " + "name-based lookup, but assignment_objects specifies type '{provided}'. " + "To grant access to all {expected} within an organization, use the " + "organization-scoped variant of this role (e.g. search for a role " + "whose name starts with 'Organization'). " + "To target a specific {resource}, use type '{expected}' with the " + "resource name." + ).format( + role=role_name, + expected=expected_endpoint, + provided=entry["type"], + resource=expected_endpoint.rstrip("s"), + ) + ) def main(): - # Any additional arguments that are not fields of the item can be added here argument_spec = dict( - role_definition=dict(required=True, type='str'), - team=dict(required=False, type='str'), - assignment_objects=dict(required=False, type='list', elements='dict', options=dict( - name=dict(type='str', required=False), - type=dict(type='str', required=False), - object_id=dict(required=False, type='int'), - object_ansible_id=dict(required=False, type='str'), - )), - team_ansible_id=dict(required=False, type='str'), - state=dict(default='present', choices=['present', 'absent', 'exists']), + role_definition=dict(required=True, type="str"), + team=dict(required=False, type="str"), + assignment_objects=dict( + required=False, + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=False), + type=dict(type="str", required=False), + object_id=dict(required=False, type="int"), + object_ansible_id=dict(required=False, type="str"), + ), + ), + team_ansible_id=dict(required=False, type="str"), + state=dict(default="present", choices=["present", "absent", "exists"]), ) module = AAPModule( argument_spec=argument_spec, mutually_exclusive=[ - ('team', 'team_ansible_id'), + ("team", "team_ansible_id"), ], required_one_of=[ - ('team', 'team_ansible_id'), + ("team", "team_ansible_id"), ], ) - team_param = module.params.get('team') - role_definition_str = module.params.get('role_definition') + team_param = module.params.get("team") + role_definition_str = module.params.get("role_definition") assignment_objects = module.params.get("assignment_objects") - team_ansible_id = module.params.get('team_ansible_id') - state = module.params.get('state') + team_ansible_id = module.params.get("team_ansible_id") + state = module.params.get("state") - role_definition = module.get_one('role_definitions', allow_none=False, name_or_id=role_definition_str) - team = module.get_one('teams', allow_none=True, name_or_id=team_param) + role_definition = module.get_one( + "role_definitions", allow_none=False, name_or_id=role_definition_str + ) + team = module.get_one("teams", allow_none=True, name_or_id=team_param) kwargs = { - 'role_definition': role_definition['id'], + "role_definition": role_definition["id"], } if team: - kwargs['team'] = team['id'] + kwargs["team"] = team["id"] if team_ansible_id is not None: - kwargs['team_ansible_id'] = team_ansible_id + kwargs["team_ansible_id"] = team_ansible_id - entity_type = role_definition.get('content_type') + # Derive the expected lookup endpoint from the role's content_type. + # This is used to validate that assignment_objects[*].type is compatible + # and to avoid sending a mismatched object_id to the Gateway API. + expected_endpoint = _get_expected_endpoint(role_definition) object_param = assignment_objects results = [] - if role_definition_str.lower().startswith('platform') and role_definition["id"] == 1: - role_team_assignment = module.get_one('role_team_assignments', **{'data': kwargs}) - assign_team_role(module, state, role_team_assignment, kwargs, - role_definition_str, team_param, team_ansible_id) + if ( + role_definition_str.lower().startswith("platform") + and role_definition["id"] == 1 + ): + # Global platform-auditor path — no object scoping needed + role_team_assignment = module.get_one( + "role_team_assignments", **{"data": kwargs} + ) + assign_team_role( + module, + state, + role_team_assignment, + kwargs, + role_definition_str, + team_param, + team_ansible_id, + ) - elif entity_type and object_param: + elif object_param: + # Process each assignment_objects entry. + # Gate on object_param alone — not on expected_endpoint — so that + # entries using object_id / object_ansible_id (which bypass name + # lookup and need no type validation) are always handled. for entity in object_param: - _validate_selector(entity, module) + _validate_selector( + entity, + module, + expected_endpoint=expected_endpoint, + role_name=role_definition_str, + ) - if entity['name'] and entity['type']: - obj = module.get_one(entity['type'], allow_none=False, name_or_id=entity['name']) - elif entity['object_id']: - obj = module.get_one(entity['object_id'], allow_none=False, name_or_id=entity['object_id']) + if entity["name"] and entity["type"]: + obj = module.get_one( + entity["type"], allow_none=False, name_or_id=entity["name"] + ) + elif entity["object_id"]: + obj = {"id": entity["object_id"]} else: - obj = module.get_one(entity['object_ansible_id'], allow_none=False, name_or_id=entity['object_ansible_id']) + # object_ansible_id path — pass through directly + kwargs["object_ansible_id"] = entity["object_ansible_id"] + role_team_assignment = module.get_one( + "role_team_assignments", **{"data": kwargs} + ) + assign_team_role( + module, + state, + role_team_assignment, + kwargs, + role_definition_str, + team_param, + team_ansible_id, + ) + results.append(module.json_output.copy()) + continue if obj is None: - module.fail_json(msg=f"Unable to find {entity['type']} with name {entity['name']}") - entity_id = obj['id'] - - if entity_id: - kwargs['object_id'] = entity_id - - role_team_assignment = module.get_one('role_team_assignments', **{'data': kwargs}) - assign_team_role(module, state, role_team_assignment, kwargs, - role_definition_str, team_param, team_ansible_id) + module.fail_json( + msg="Unable to find {0} with name '{1}'".format( + entity.get("type", "object"), entity.get("name", "") + ) + ) - # copy current state before it gets overwritten + kwargs["object_id"] = obj["id"] + role_team_assignment = module.get_one( + "role_team_assignments", **{"data": kwargs} + ) + assign_team_role( + module, + state, + role_team_assignment, + kwargs, + role_definition_str, + team_param, + team_ansible_id, + ) results.append(module.json_output.copy()) - # At the end, return *all* results - module.exit_json(changed=any(r.get("changed", False) for r in results), assignments=results) + # Return all results + module.exit_json( + changed=any(r.get("changed", False) for r in results), assignments=results + ) -if __name__ == '__main__': +if __name__ == "__main__": main() 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 ... From 796722566486d8e75433a58a2bb0460a15b9d7dd Mon Sep 17 00:00:00 2001 From: rohitthakur2590 Date: Tue, 9 Jun 2026 14:21:29 +0530 Subject: [PATCH 02/10] tbr Signed-off-by: rohitthakur2590 --- plugins/modules/role_user_assignment.py | 2 +- plugins/modules/user.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/modules/role_user_assignment.py b/plugins/modules/role_user_assignment.py index 9c638e10..6421939e 100644 --- a/plugins/modules/role_user_assignment.py +++ b/plugins/modules/role_user_assignment.py @@ -200,7 +200,7 @@ def main(): module.deprecate( msg="The usage of 'object_id' parameter in the 'role_user_assignment' module is not recommended. " "For associating a user to team(s)/organization(s), please use the 'object_ids' parameter. ", - date="2026-05-20", + date="2026-11-30", collection_name="ansible.platform", ) if object_ids is not None: diff --git a/plugins/modules/user.py b/plugins/modules/user.py index eca4e258..f1141b8d 100644 --- a/plugins/modules/user.py +++ b/plugins/modules/user.py @@ -176,7 +176,7 @@ def main(): module.deprecate( msg="Configuring organizations via `ansible.platform.user` is not the recommended approach. " "The preferred method going forward is to use the `ansible.platform.role_user_assignment` module.", - date="2026-05-20", + date="2026-11-30", collection_name="ansible.platform", ) @@ -184,7 +184,7 @@ def main(): module.deprecate( msg="Configuring auditor via `ansible.platform.user` is not the recommended approach. " "The preferred method going forward is to use the `ansible.platform.role_user_assignment` module.", - date="2026-05-20", + date="2026-11-30", collection_name="ansible.platform", ) @@ -192,7 +192,7 @@ def main(): module.deprecate( msg="The 'authenticator_uid' parameter is deprecated and will be removed in a future version. " "Please use 'associated_authenticators' instead to specify UIDs per authenticator.", - date="2026-05-20", + date="2026-11-30", collection_name="ansible.platform", ) @@ -200,7 +200,7 @@ def main(): module.deprecate( msg="The 'authenticators' parameter is deprecated and will be removed in a future version. " "Please use 'associated_authenticators' instead to specify authenticator associations.", - date="2026-05-20", + date="2026-11-30", collection_name="ansible.platform", ) From 29afbcefdbd09d781e0844eb2078c887f2ccdfd1 Mon Sep 17 00:00:00 2001 From: rohitthakur2590 Date: Tue, 9 Jun 2026 17:22:48 +0530 Subject: [PATCH 03/10] add requirements Signed-off-by: rohitthakur2590 --- tests/integration/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/integration/requirements.txt diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt new file mode 100644 index 00000000..247463e8 --- /dev/null +++ b/tests/integration/requirements.txt @@ -0,0 +1,3 @@ +# Python requirements for integration tests +# Add any test-specific pip packages here +requests From 7eeaa0aa8b30e439c595c69c8ddc4f0eafd8ccf9 Mon Sep 17 00:00:00 2001 From: rohitthakur2590 Date: Tue, 9 Jun 2026 17:48:43 +0530 Subject: [PATCH 04/10] fix integration test w.r.t test env Signed-off-by: rohitthakur2590 --- .../role_team_assignments_test/tasks/main.yml | 69 ++++++++++--------- 1 file changed, 36 insertions(+), 33 deletions(-) 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 0293d1b0..484a7b44 100644 --- a/tests/integration/targets/role_team_assignments_test/tasks/main.yml +++ b/tests/integration/targets/role_team_assignments_test/tasks/main.yml @@ -109,24 +109,27 @@ # -------------------------------------------------------------------------- # 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 - + # Use EDA content types for mismatch tests — awx.* types require Controller + # which may not be present in all CI environments. EDA types are available + # whenever EDA is connected to Gateway. - 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" + description: "Activation-scoped role for content_type mismatch tests (type 1)" content_type: "eda.activation" permissions: - "eda.view_activation" state: present + register: custom_project_role # kept as custom_project_role for variable reuse below + + - name: Create eda-credential-scoped custom role (content_type eda.edacredential) + ansible.platform.role_definition: + name: "{{ custom_role_name }}-edacredential" + description: "EDA credential-scoped role for content_type mismatch tests (type 2)" + content_type: "eda.edacredential" + permissions: + - "eda.view_edacredential" + state: present register: custom_activation_role # -------------------------------------------------------------------------- @@ -134,10 +137,10 @@ # 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" + # TEST 1: Type mismatch — activation role + type:organizations must give clear error + - name: "BUG#165 | eda.activation role with type:organizations must fail with clear error" ansible.platform.role_team_assignment: - role_definition: "{{ custom_role_name }}-project" + role_definition: "{{ custom_role_name }}-activation" team: "{{ team1.name }}" assignment_objects: - name: "{{ org1.name }}" @@ -150,20 +153,20 @@ ansible.builtin.assert: that: - mismatch_project_org is failed - - "'projects' in mismatch_project_org.msg" + - "'activations' 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. + BUG FIX CONFIRMED: module rejects eda.activation role + type:organizations with + a clear error pointing to 'activations' 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'. + or the error message does not mention the correct type 'activations'. 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" + # TEST 2: Type mismatch — eda credential role + type:organizations must give clear error + - name: "BUG#165 | eda.edacredential role with type:organizations must fail with clear error" ansible.platform.role_team_assignment: - role_definition: "{{ custom_role_name }}-activation" + role_definition: "{{ custom_role_name }}-edacredential" team: "{{ team1.name }}" assignment_objects: - name: "{{ org1.name }}" @@ -172,14 +175,14 @@ register: mismatch_activation_org ignore_errors: true - - name: "BUG#165 | Assert activation mismatch error mentions activations" + - name: "BUG#165 | Assert eda credential mismatch error mentions eda_credentials" ansible.builtin.assert: that: - mismatch_activation_org is failed - - "'activations' in mismatch_activation_org.msg" + - "'eda_credentials' 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 }}" + success_msg: "BUG FIX CONFIRMED: eda.edacredential role + type:organizations correctly rejected." + fail_msg: "EDA credential 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)" @@ -435,21 +438,21 @@ - "'Not found' not in role_delete.msg" - "'does not exist' not in role_delete.msg" - - name: Delete custom project-scoped role (bug#165 test) + - name: Delete custom activation-scoped role type 1 (bug#165 test) ansible.platform.role_definition: - name: "{{ custom_role_name }}-project" - content_type: "awx.project" + name: "{{ custom_role_name }}-activation" + content_type: "eda.activation" permissions: - - "awx.view_project" + - "eda.view_activation" state: absent failed_when: false - - name: Delete custom activation-scoped role (bug#165 test) + - name: Delete custom eda-credential-scoped role type 2 (bug#165 test) ansible.platform.role_definition: - name: "{{ custom_role_name }}-activation" - content_type: "eda.activation" + name: "{{ custom_role_name }}-edacredential" + content_type: "eda.edacredential" permissions: - - "eda.view_activation" + - "eda.view_edacredential" state: absent failed_when: false ... From 1c28cfb439e93aaef4eca02fce57d9fe32fa1bcb Mon Sep 17 00:00:00 2001 From: rohitthakur2590 Date: Tue, 9 Jun 2026 18:27:32 +0530 Subject: [PATCH 05/10] fix integration test w.r.t test env Signed-off-by: rohitthakur2590 --- .../role_team_assignments_test/tasks/main.yml | 113 ++++++++---------- 1 file changed, 49 insertions(+), 64 deletions(-) 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 484a7b44..eeccf2bf 100644 --- a/tests/integration/targets/role_team_assignments_test/tasks/main.yml +++ b/tests/integration/targets/role_team_assignments_test/tasks/main.yml @@ -109,38 +109,47 @@ # -------------------------------------------------------------------------- # Resource-level role definitions for type-mismatch tests # -------------------------------------------------------------------------- - # Use EDA content types for mismatch tests — awx.* types require Controller - # which may not be present in all CI environments. EDA types are available - # whenever EDA is connected to Gateway. - - 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 (type 1)" - content_type: "eda.activation" - permissions: - - "eda.view_activation" - state: present - register: custom_project_role # kept as custom_project_role for variable reuse below + # Probe for a resource-level role definition (requires EDA or Controller). + # If none exists (Gateway-only environment), skip the type-mismatch tests. + - name: "BUG#165 setup | Find an existing resource-level role definition" + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/gateway/v1/role_definitions/?content_type__isnull=false&content_type__startswith=eda&page_size=1" + method: GET + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: eda_role_probe - - name: Create eda-credential-scoped custom role (content_type eda.edacredential) - ansible.platform.role_definition: - name: "{{ custom_role_name }}-edacredential" - description: "EDA credential-scoped role for content_type mismatch tests (type 2)" - content_type: "eda.edacredential" - permissions: - - "eda.view_edacredential" - state: present - register: custom_activation_role + - name: "BUG#165 setup | Set fact — resource-level roles available" + ansible.builtin.set_fact: + resource_roles_available: "{{ eda_role_probe.json.count | int > 0 }}" + resource_role_name: "{{ eda_role_probe.json.results[0].name | default('') }}" + resource_role_content_type: "{{ eda_role_probe.json.results[0].content_type | default('') }}" + + - name: "BUG#165 setup | Show environment capability" + ansible.builtin.debug: + msg: >- + Resource-level roles available: {{ resource_roles_available }}. + {{ 'Will run type-mismatch tests using: ' + resource_role_name if resource_roles_available + else 'Skipping type-mismatch tests — Gateway-only environment (no EDA/Controller connected).' }} + + # Placeholder registers so later tasks don't fail on undefined vars + - name: "BUG#165 setup | Init result vars" + ansible.builtin.set_fact: + custom_project_role: { name: "{{ resource_role_name }}" } + custom_activation_role: { name: "{{ resource_role_name }}" } # -------------------------------------------------------------------------- # 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 — activation role + type:organizations must give clear error - - name: "BUG#165 | eda.activation role with type:organizations must fail with clear error" + # TEST 1 & 2: Type mismatch tests — only run when resource-level roles exist + - name: "BUG#165 | resource-level role with type:organizations must fail with clear error" ansible.platform.role_team_assignment: - role_definition: "{{ custom_role_name }}-activation" + role_definition: "{{ resource_role_name }}" team: "{{ team1.name }}" assignment_objects: - name: "{{ org1.name }}" @@ -148,41 +157,33 @@ state: present register: mismatch_project_org ignore_errors: true + when: resource_roles_available - name: "BUG#165 | Assert error message tells user the correct type to use" ansible.builtin.assert: that: - mismatch_project_org is failed - - "'activations' in mismatch_project_org.msg" - "'organizations' in mismatch_project_org.msg" success_msg: > - BUG FIX CONFIRMED: module rejects eda.activation role + type:organizations with - a clear error pointing to 'activations' as the expected type. + BUG FIX CONFIRMED: module rejects resource-level role + type:organizations + with a clear error. Role: {{ resource_role_name }}. fail_msg: > BUG NOT FIXED: either the assignment was silently accepted (no error) - or the error message does not mention the correct type 'activations'. + or the error message is missing context. Result: {{ mismatch_project_org | to_nice_json }} + when: resource_roles_available - # TEST 2: Type mismatch — eda credential role + type:organizations must give clear error - - name: "BUG#165 | eda.edacredential role with type:organizations must fail with clear error" - ansible.platform.role_team_assignment: - role_definition: "{{ custom_role_name }}-edacredential" - team: "{{ team1.name }}" - assignment_objects: - - name: "{{ org1.name }}" - type: organizations - state: present - register: mismatch_activation_org - ignore_errors: true + - name: "BUG#165 | Skip notice when resource-level roles not available" + ansible.builtin.debug: + msg: > + SKIPPED: Type-mismatch tests require EDA or Controller to be connected to + Gateway (resource-level role definitions needed). Gateway-only environment detected. + when: not resource_roles_available - - name: "BUG#165 | Assert eda credential mismatch error mentions eda_credentials" - ansible.builtin.assert: - that: - - mismatch_activation_org is failed - - "'eda_credentials' in mismatch_activation_org.msg" - - "'organizations' in mismatch_activation_org.msg" - success_msg: "BUG FIX CONFIRMED: eda.edacredential role + type:organizations correctly rejected." - fail_msg: "EDA credential mismatch not caught. Result: {{ mismatch_activation_org | to_nice_json }}" + # reuse the same role for mismatch_activation_org variable + - name: "BUG#165 | Set mismatch_activation_org alias" + ansible.builtin.set_fact: + mismatch_activation_org: "{{ mismatch_project_org | default({'failed': true}) }}" # 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)" @@ -438,21 +439,5 @@ - "'Not found' not in role_delete.msg" - "'does not exist' not in role_delete.msg" - - name: Delete custom activation-scoped role type 1 (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 - - - name: Delete custom eda-credential-scoped role type 2 (bug#165 test) - ansible.platform.role_definition: - name: "{{ custom_role_name }}-edacredential" - content_type: "eda.edacredential" - permissions: - - "eda.view_edacredential" - state: absent - failed_when: false + # No custom resource-level roles to clean up — tests now use existing built-in roles ... From b47a4c99f19715bee8715709b6ff8f08d22a221a Mon Sep 17 00:00:00 2001 From: rohitthakur2590 Date: Mon, 7 Sep 2026 19:32:25 +0530 Subject: [PATCH 06/10] Add changelog fragment for AAP-75240 role_team_assignment fix Co-authored-by: Cursor --- .../aap_75240_role_team_assignment_content_type.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelogs/fragments/aap_75240_role_team_assignment_content_type.yml diff --git a/changelogs/fragments/aap_75240_role_team_assignment_content_type.yml b/changelogs/fragments/aap_75240_role_team_assignment_content_type.yml new file mode 100644 index 00000000..6af41261 --- /dev/null +++ b/changelogs/fragments/aap_75240_role_team_assignment_content_type.yml @@ -0,0 +1,8 @@ +--- +bugfixes: + - role_team_assignment - validate ``assignment_objects`` ``type`` against the role + definition ``content_type`` before API calls, preventing Gateway 400/500 errors + when the wrong resource type is used for name-based lookup (https://issues.redhat.com/browse/AAP-75240). + - role_team_assignment - allow resource-level ``type`` values (for example + ``projects``, ``activations``) in ``assignment_objects`` instead of rejecting + all types other than ``organizations`` and ``teams``. From 5ecd1f78aa8c243b2dc6430c103e24ee26596186 Mon Sep 17 00:00:00 2001 From: Jayant Sogikar Date: Wed, 9 Sep 2026 01:00:22 +0530 Subject: [PATCH 07/10] Adapt to latest 2.7 PR #205 convention and add integration tests to verify all ep locally --- plugins/module_utils/resource_type_map.py | 85 ++ plugins/modules/role_team_assignment.py | 151 +- .../role_team_assignments_test/tasks/main.yml | 1347 ++++++++++++++--- .../tasks/verify_assignment.yml | 69 + .../module_utils/test_resource_type_map.py | 106 ++ 5 files changed, 1521 insertions(+), 237 deletions(-) create mode 100644 plugins/module_utils/resource_type_map.py create mode 100644 tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml create mode 100644 tests/unit/module_utils/test_resource_type_map.py diff --git a/plugins/module_utils/resource_type_map.py b/plugins/module_utils/resource_type_map.py new file mode 100644 index 00000000..a897a0f9 --- /dev/null +++ b/plugins/module_utils/resource_type_map.py @@ -0,0 +1,85 @@ +""" +Resource type mapping for RBAC assignment modules. + +Shared by role_team_assignment (and future role_user_assignment) to avoid +duplicating the content_type → lookup-path routing logic. + +Gateway resources (organization, team) use plural endpoint names as the +assignment type because they have no service-prefix in their content_type. +All other services (EDA, Controller, Hub) use the content_type value directly +as the assignment type, so users only need to learn one name per resource. + +The path map uses full /api//... paths so that aap_module.build_url +routes them to the correct service rather than prepending /api/gateway/v1/. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +# Maps role_definition.content_type → assignment_objects[].type value. +# Only Gateway resources need an explicit entry; EDA/AWX/Galaxy map to +# themselves (content_type IS the user-facing type value). +_CONTENT_TYPE_TO_ASSIGNMENT_TYPE = { + "shared.organization": "organizations", + "shared.team": "teams", +} + +# Maps assignment_objects[].type → API path for module.get_one() lookups. +# Gateway types use bare endpoint names (build_url prepends /api/gateway/v1/). +# All other types use full /api//... paths so build_url passes them +# through unchanged, routing directly to the correct service. +ASSIGNMENT_TYPE_PATH_MAP = { + # Gateway + "organizations": "organizations", + "teams": "teams", + # EDA + "eda.project": "/api/eda/v1/projects/", + "eda.activation": "/api/eda/v1/activations/", + "eda.edacredential": "/api/eda/v1/eda-credentials/", + "eda.eventstream": "/api/eda/v1/event-streams/", + "eda.decisionenvironment": "/api/eda/v1/decision-environments/", + "eda.credentialinputsource": "/api/eda/v1/credential-input-sources/", + # Controller + "awx.project": "/api/controller/v2/projects/", + "awx.inventory": "/api/controller/v2/inventories/", + "awx.credential": "/api/controller/v2/credentials/", + "awx.jobtemplate": "/api/controller/v2/job_templates/", + "awx.workflowjobtemplate": "/api/controller/v2/workflow_job_templates/", + "awx.executionenvironment": "/api/controller/v2/execution_environments/", + "awx.instancegroup": "/api/controller/v2/instance_groups/", + "awx.notificationtemplate": "/api/controller/v2/notification_templates/", + # Hub + "galaxy.namespace": "/api/galaxy/v3/namespaces/", + "galaxy.collectionremote": "/api/galaxy/pulp/api/v3/remotes/", + "galaxy.ansiblerepository": "/api/galaxy/pulp/api/v3/repositories/", + "galaxy.containernamespace": "/api/galaxy/pulp/api/v3/pulp_container/namespaces/", +} + + +def get_expected_assignment_type(content_type): + """Return the assignment_objects type value for a role_definition content_type. + + Raises ValueError for unknown content_type values. + """ + raw = (content_type or "").strip() + if not raw: + return None + if raw in _CONTENT_TYPE_TO_ASSIGNMENT_TYPE: + return _CONTENT_TYPE_TO_ASSIGNMENT_TYPE[raw] + if raw in ASSIGNMENT_TYPE_PATH_MAP: + return raw # content_type IS the assignment type for non-Gateway services + known = sorted(set(list(_CONTENT_TYPE_TO_ASSIGNMENT_TYPE.keys()) + list(ASSIGNMENT_TYPE_PATH_MAP.keys()))) + raise ValueError( + "Unknown content_type '%s' in role definition. Known types: %s. " + "If this is a new resource type, add it to resource_type_map.py." % (content_type, ", ".join(known)) + ) + + +def lookup_path_for(assignment_type): + """Return the API lookup path for a user-facing assignment type. + + Gateway plural names are returned as-is (build_url prepends the Gateway base). + Dotted content_type values return their full /api//... path. + """ + return ASSIGNMENT_TYPE_PATH_MAP.get(assignment_type, assignment_type) diff --git a/plugins/modules/role_team_assignment.py b/plugins/modules/role_team_assignment.py index 1f7ef953..6889a626 100644 --- a/plugins/modules/role_team_assignment.py +++ b/plugins/modules/role_team_assignment.py @@ -22,11 +22,11 @@ - 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. - - 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") - when you want to grant access to all resources of a type within an organization. + - The C(type) field in C(assignment_objects) must match the role definition's C(content_type). + Use C(type=awx.project) for a role with C(content_type=awx.project). Gateway resources are + the exception, use C(type=organizations) or C(type=teams). Mismatches are rejected before + any API call is made. Use the organization-scoped variant of the role (e.g. "Organization + Project Admin") to grant access to all resources of a type within an organization. - Attempting unsupported role assignments will result in errors. options: assignment_objects: @@ -48,10 +48,17 @@ required: False type: description: - - The resource type endpoint for name-based lookup. - - Must match the content_type of the role definition. - - Examples - C(organizations), C(teams), C(projects), C(inventories), - C(credentials), C(job_templates), C(activations), C(event_streams). + - Resource type used for name-based lookup. Use the same value + as the role definition's C(content_type) field. + - "Gateway: C(organizations), C(teams) (plural endpoint names)." + - "Controller: C(awx.project), C(awx.inventory), C(awx.credential), + C(awx.jobtemplate), C(awx.workflowjobtemplate), + C(awx.executionenvironment), C(awx.instancegroup), + C(awx.notificationtemplate)." + - "EDA: C(eda.project), C(eda.activation), C(eda.eventstream), + C(eda.decisionenvironment), C(eda.edacredential)." + - "Hub: C(galaxy.namespace), C(galaxy.collectionremote), + C(galaxy.ansiblerepository), C(galaxy.containernamespace)." type: str required: False object_id: @@ -110,7 +117,7 @@ team: "developers" assignment_objects: - name: "Demo Project" - type: projects + type: awx.project state: present - name: Assign resource-level role against a specific EDA activation (content_type eda.activation) @@ -119,7 +126,16 @@ team: "eda-operators" assignment_objects: - name: "prod-alert-activation" - type: activations + type: eda.activation + state: present + +- name: Assign resource-level role against an EDA project (content_type eda.project) + ansible.platform.role_team_assignment: + role_definition: EDA Project Admin + team: "eda-team" + assignment_objects: + - name: "EDA Project 1" + type: eda.project state: present - name: Assign role using object_ansible_id (works for any resource type) @@ -152,54 +168,66 @@ """ from ..module_utils.aap_module import AAPModule - - -# Maps the suffix of a role definition's content_type to the Gateway API endpoint -# used for name-based object lookup. -# Format: "service.ResourceName" → suffix → endpoint -# e.g. "awx.project" → "project" → "projects" -CONTENT_TYPE_ENDPOINT_MAP = { - # Gateway / shared - "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", - "task": "tasks", -} +from ..module_utils.resource_type_map import ( + ASSIGNMENT_TYPE_PATH_MAP, + get_expected_assignment_type, + lookup_path_for, +) def _get_expected_endpoint(role_definition): - """ - Derive the Gateway API lookup endpoint from a role definition's content_type. - - Returns the endpoint string (e.g. 'projects') or None for global roles - (content_type is null). - """ + """Return the user-facing assignment type for a role definition's content_type.""" raw = (role_definition.get("content_type") or "").strip() if not raw: return None - suffix = raw.split(".")[-1] if "." in raw else raw - # Fall back to naive pluralisation for unknown types - return CONTENT_TYPE_ENDPOINT_MAP.get(suffix, "{0}s".format(suffix)) + return get_expected_assignment_type(raw) + + +def _lookup_hub_object_id(module, obj_type, name): + """Look up a Hub (Pulp) resource by name and return a dict with an 'id' key. + + Pulp list endpoints return 'results' (or occasionally 'data') and objects + carry 'pulp_href' instead of a numeric 'id'. The UUID at the end of + pulp_href is what the Gateway RBAC API expects as object_id. + """ + path = lookup_path_for(obj_type) + url = module.build_url(path, query_params={"name": name}) + response = module.make_request("GET", url) + + if response["status_code"] != 200: + module.fail_json( + msg="Failed to look up Hub resource '{0}' at {1}: HTTP {2}".format( + name, path, response["status_code"] + ) + ) + + payload = response.get("json", {}) + items = payload.get("results") or payload.get("data") or [] + + if not items: + module.fail_json( + msg="No Hub resource named '{0}' found at {1}.".format(name, path) + ) + if len(items) > 1: + module.fail_json( + msg="Multiple Hub resources named '{0}' found at {1}, expected exactly one.".format( + name, path + ) + ) + + item = items[0] + pulp_href = item.get("pulp_href", "") + if pulp_href: + uuid = pulp_href.rstrip("/").rsplit("/", 1)[-1] + if uuid: + return {"id": uuid, "pulp_href": pulp_href} + + module.fail_json( + msg=( + "Hub resource '{0}' at {1} returned no 'pulp_href' field. " + "Cannot derive an object_id for role assignment.".format(name, path) + ) + ) def assign_team_role( @@ -251,7 +279,7 @@ def _validate_selector(entry, module, expected_endpoint=None, role_name=""): has_pk = entry.get("object_id") is not None has_uuid = bool(entry.get("object_ansible_id")) - if has_name ^ has_type: + if has_name and (not has_type): module.fail_json( msg="When using 'name', you must also provide 'type' in each assignment_objects item." ) @@ -273,7 +301,9 @@ def _validate_selector(entry, module, expected_endpoint=None, role_name=""): ) if has_name and has_type: - allowed = sorted(set(CONTENT_TYPE_ENDPOINT_MAP.values())) + allowed = sorted( + set(["organizations", "teams"]) | set(ASSIGNMENT_TYPE_PATH_MAP.keys()) + ) if entry["type"] not in allowed: module.fail_json( msg=("Unsupported type '{0}'. Valid types: {1}.").format( @@ -285,6 +315,7 @@ def _validate_selector(entry, module, expected_endpoint=None, role_name=""): # Mismatches (e.g. type=organizations for a role with content_type=awx.project) # cause the Gateway API to reject the assignment with a 400/500 error. if expected_endpoint and entry["type"] != expected_endpoint: + resource = expected_endpoint.split(".")[-1] if "." in expected_endpoint else expected_endpoint.rstrip("s") module.fail_json( msg=( "Role '{role}' has content_type that requires type '{expected}' for " @@ -298,7 +329,7 @@ def _validate_selector(entry, module, expected_endpoint=None, role_name=""): role=role_name, expected=expected_endpoint, provided=entry["type"], - resource=expected_endpoint.rstrip("s"), + resource=resource, ) ) @@ -388,9 +419,11 @@ def main(): ) if entity["name"] and entity["type"]: - obj = module.get_one( - entity["type"], allow_none=False, name_or_id=entity["name"] - ) + path = lookup_path_for(entity["type"]) + if path.startswith("/api/galaxy/"): + obj = _lookup_hub_object_id(module, entity["type"], entity["name"]) + else: + obj = module.get_one(path, allow_none=False, name_or_id=entity["name"]) elif entity["object_id"]: obj = {"id": entity["object_id"]} else: 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 eeccf2bf..67f4a8f3 100644 --- a/tests/integration/targets/role_team_assignments_test/tasks/main.yml +++ b/tests/integration/targets/role_team_assignments_test/tasks/main.yml @@ -17,11 +17,29 @@ test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" when: test_id is not defined - - name: Preset variables for test resource names + - name: Set resource name variables ansible.builtin.set_fact: organization_name: "GW-Collection-Test-Organization-{{ test_id }}" team_name_prefix: "GW-Collection-Test-Team-{{ test_id }}" custom_role_name: "GW-Custom-Role-{{ test_id }}" + custom_resource_role_name: "GW-Custom-Resource-Role-{{ test_id }}" + eda_de_name: "GW-Test-DE-{{ test_id }}" + eda_project_name: "GW-Test-EDA-Project-{{ test_id }}" + ctrl_inventory_name: "GW-Test-Inventory-{{ test_id }}" + ctrl_project_name: "GW-Test-Project-{{ test_id }}" + ctrl_ee_name: "GW-Test-EE-{{ test_id }}" + ctrl_ig_name: "GW-Test-IG-{{ test_id }}" + hub_ns_name: "gw_test_ns_{{ test_id | lower }}" + hub_remote_name: "GW-Test-Remote-{{ test_id }}" + hub_repo_name: "GW-Test-Repo-{{ test_id }}" + hub_cns_name: "gw-test-cns-{{ test_id | lower }}" + eda_cred_name: "GW-Test-EDA-Cred-{{ test_id }}" + eda_stream_name: "GW-Test-Stream-{{ test_id }}" + eda_activation_name: "GW-Test-Activation-{{ test_id }}" + ctrl_cred_name: "GW-Test-Cred-{{ test_id }}" + ctrl_jt_name: "GW-Test-JT-{{ test_id }}" + ctrl_wfjt_name: "GW-Test-WFJT-{{ test_id }}" + ctrl_nt_name: "GW-Test-NT-{{ test_id }}" # -------------------------------------------------------------------------- # Organizations @@ -106,50 +124,24 @@ state: present register: custom_role - # -------------------------------------------------------------------------- - # Resource-level role definitions for type-mismatch tests - # -------------------------------------------------------------------------- - # Probe for a resource-level role definition (requires EDA or Controller). - # If none exists (Gateway-only environment), skip the type-mismatch tests. - - name: "BUG#165 setup | Find an existing resource-level role definition" - ansible.builtin.uri: - url: "{{ gateway_hostname }}/api/gateway/v1/role_definitions/?content_type__isnull=false&content_type__startswith=eda&page_size=1" - method: GET - user: "{{ gateway_username }}" - password: "{{ gateway_password }}" - force_basic_auth: true - validate_certs: "{{ gateway_validate_certs | bool }}" - return_content: true - register: eda_role_probe + # -- Type-mismatch regression (BUG#165) ----------------------------------- + # Creates a resource-scoped role (awx.inventory) and verifies the module + # rejects assignment with type:organizations before making any API call. + # This is client-side validation — no Controller connection required. - - name: "BUG#165 setup | Set fact — resource-level roles available" - ansible.builtin.set_fact: - resource_roles_available: "{{ eda_role_probe.json.count | int > 0 }}" - resource_role_name: "{{ eda_role_probe.json.results[0].name | default('') }}" - resource_role_content_type: "{{ eda_role_probe.json.results[0].content_type | default('') }}" - - - name: "BUG#165 setup | Show environment capability" - ansible.builtin.debug: - msg: >- - Resource-level roles available: {{ resource_roles_available }}. - {{ 'Will run type-mismatch tests using: ' + resource_role_name if resource_roles_available - else 'Skipping type-mismatch tests — Gateway-only environment (no EDA/Controller connected).' }} - - # Placeholder registers so later tasks don't fail on undefined vars - - name: "BUG#165 setup | Init result vars" - ansible.builtin.set_fact: - custom_project_role: { name: "{{ resource_role_name }}" } - custom_activation_role: { name: "{{ resource_role_name }}" } - - # -------------------------------------------------------------------------- - # Root cause: resource-level role + type:organizations caused 400/500. - # Fix: module now validates type matches content_type and raises a clear error. - # -------------------------------------------------------------------------- + - name: Create resource-level role for type-mismatch test + ansible.platform.role_definition: + name: "{{ custom_resource_role_name }}" + content_type: "awx.inventory" + permissions: + - "awx.view_inventory" + state: present + register: custom_resource_role + ignore_errors: true - # TEST 1 & 2: Type mismatch tests — only run when resource-level roles exist - name: "BUG#165 | resource-level role with type:organizations must fail with clear error" ansible.platform.role_team_assignment: - role_definition: "{{ resource_role_name }}" + role_definition: "{{ custom_resource_role_name }}" team: "{{ team1.name }}" assignment_objects: - name: "{{ org1.name }}" @@ -157,35 +149,20 @@ state: present register: mismatch_project_org ignore_errors: true - when: resource_roles_available + when: custom_resource_role is not failed - - name: "BUG#165 | Assert error message tells user the correct type to use" + - name: "BUG#165 | Assert type mismatch is rejected with a clear error" ansible.builtin.assert: that: - mismatch_project_org is failed - "'organizations' in mismatch_project_org.msg" - success_msg: > - BUG FIX CONFIRMED: module rejects resource-level role + type:organizations - with a clear error. Role: {{ resource_role_name }}. + success_msg: "BUG FIX CONFIRMED: type mismatch rejected with clear error." fail_msg: > - BUG NOT FIXED: either the assignment was silently accepted (no error) - or the error message is missing context. + BUG NOT FIXED: mismatch was accepted or error message is missing context. Result: {{ mismatch_project_org | to_nice_json }} - when: resource_roles_available + when: custom_resource_role is not failed - - name: "BUG#165 | Skip notice when resource-level roles not available" - ansible.builtin.debug: - msg: > - SKIPPED: Type-mismatch tests require EDA or Controller to be connected to - Gateway (resource-level role definitions needed). Gateway-only environment detected. - when: not resource_roles_available - - # reuse the same role for mismatch_activation_org variable - - name: "BUG#165 | Set mismatch_activation_org alias" - ansible.builtin.set_fact: - mismatch_activation_org: "{{ mismatch_project_org | default({'failed': true}) }}" - - # TEST 3: Correct org-level role assignment still works (no regression) + # -- Org-scoped assignment 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 }}" @@ -274,147 +251,903 @@ 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 + # -- Verify via API ------------------------------------------------------- + - name: "Add custom assignment back" ansible.platform.role_team_assignment: + role_definition: "{{ custom_role_name }}" + team: "{{ team1.name }}" assignment_objects: - name: "{{ org1.name }}" - type: "organizations" - role_definition: Organization Admin - team: "{{ team1.id }}" - register: org_admin_assignment_1 - ignore_errors: true # this may fail depending on AAP limitations - - # 2. Assign Platform role to Team3 on Org3 (Global role can't be assigned) - - name: Assign Platform Auditor to Team1 on Org1 + type: organizations + state: present + + - name: Fetch custom role assignment from API to confirm persistence + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/gateway/v1/role_team_assignments/?role_definition={{ custom_role.id }}&team={{ team1.id }}" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: assignment_query + delegate_to: localhost + + - name: Assert assignment exists in API + ansible.builtin.assert: + that: "assignment_query.json.count > 0" + fail_msg: "No assignment found for role={{ custom_role.id }} team={{ team1.id }}." + + # -- Gateway: team -------------------------------------------------------- + # Assigns a role against a team object — tests type=teams lookup. + + - name: Verify team role assignment (type=teams) + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ team2 is defined }}" + res_name: "{{ team2.name }}" + res_type: teams + res_role_def: "Team Admin" + team_name: "{{ team1.name }}" + + # -- EDA: decision environment -------------------------------------------- + # EDA uses its own org ID namespace; look up the synced org by name first. + + - name: Look up test organization in EDA + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/organizations/?name={{ org1.name | urlencode }}" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: eda_org + ignore_errors: true + delegate_to: localhost + + - name: Set EDA org ID fact + ansible.builtin.set_fact: + eda_org_id: "{{ eda_org.json.results[0].id }}" + when: eda_org is not failed and eda_org.json.count > 0 + + - name: Create EDA decision environment via EDA API + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/decision-environments/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ eda_de_name }}" + image_url: "quay.io/ansible/de-supported:latest" + organization_id: "{{ eda_org_id }}" + status_code: [200, 201] + register: eda_de + when: eda_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Verify EDA decision environment role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ eda_de is not failed and eda_de is not skipped }}" + res_name: "{{ eda_de_name }}" + res_type: eda.decisionenvironment + res_role_def: "Decision Environment Admin" + team_name: "{{ team1.name }}" + + # -- EDA: project --------------------------------------------------------- + # Uses type=eda.project (content_type format) which routes to /api/eda/v1/projects/. + # type=awx.project would incorrectly route to /api/controller/v2/projects/. + + - name: Create EDA project via EDA API + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/projects/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ eda_project_name }}" + url: "https://github.com/ansible/event-driven-ansible" + organization_id: "{{ eda_org_id }}" + status_code: [200, 201] + register: eda_project + when: eda_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Create EDA project role definition + ansible.platform.role_definition: + name: "GW-EDA-Project-Role-{{ test_id }}" + content_type: eda.project + permissions: + - eda.view_project + - eda.change_project + - eda.delete_project + - eda.sync_project + - eda.view_rulebook + state: present + register: eda_project_role_def + ignore_errors: true + + - name: Verify EDA project role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ eda_project is not failed and eda_project is not skipped and eda_project_role_def is not failed }}" + res_name: "{{ eda_project_name }}" + res_type: eda.project + res_role_def: "GW-EDA-Project-Role-{{ test_id }}" + team_name: "{{ team1.name }}" + + # Assign the same EDA project role via object_id — no type needed, the role's + # content_type (eda.project) tells the Gateway which resource the ID refers to. + - name: Assign EDA project role to team via object_id ansible.platform.role_team_assignment: + role_definition: "GW-EDA-Project-Role-{{ test_id }}" + team: "{{ team2.name }}" assignment_objects: - - name: "{{ org1.name }}" - type: "organizations" - role_definition: Platform Auditor - team: "{{ team1.name }}" - register: org_admin_assignment_2 - ignore_errors: true # this may fail depending on AAP limitations + - object_id: "{{ eda_project.json.id }}" + register: eda_proj_obj_id_assign + when: eda_project is not failed and eda_project is not skipped and eda_project_role_def is not failed + ignore_errors: true + + - name: Assert EDA project object_id assignment succeeded + ansible.builtin.assert: + that: + - eda_proj_obj_id_assign is not failed + - eda_proj_obj_id_assign is changed + fail_msg: "EDA project object_id assignment failed: {{ eda_proj_obj_id_assign.msg | default('') }}" + when: eda_project is not failed and eda_project is not skipped and eda_proj_obj_id_assign is defined - # 3. Assign Custom Role Assignment Test to Team1 on Org1 - - name: Assign Custom Role to Team1 on Org1 + - name: Remove EDA project object_id assignment ansible.platform.role_team_assignment: + role_definition: "GW-EDA-Project-Role-{{ test_id }}" + team: "{{ team2.name }}" assignment_objects: - - name: "{{ org1.name }}" - type: "organizations" - role_definition: "{{ custom_role_name }}" - team: "{{ team1.name }}" - state: present - register: custom_role_assignment + - object_id: "{{ eda_project.json.id }}" + state: absent + when: eda_project is not failed and eda_project is not skipped and eda_proj_obj_id_assign is not failed - # -------------------------------------------------------------------------- - # VERIFICATION: Query API to confirm assignment persists - # -------------------------------------------------------------------------- - - name: Fetch assignment for Team 1 and Custom Role + # -- EDA: eda_credentials ------------------------------------------------- + + - name: Look up EDA credential type ansible.builtin.uri: - url: "{{ gateway_hostname }}api/gateway/v1/role_team_assignments/?role_definition={{ custom_role.id }}&team={{ team1.id }}" + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/credential-types/?name=Source+Control" user: "{{ gateway_username }}" password: "{{ gateway_password }}" force_basic_auth: true validate_certs: "{{ gateway_validate_certs | bool }}" return_content: true - register: assignment_query + register: eda_cred_types + ignore_errors: true + delegate_to: localhost + + - name: Set EDA credential type fact + ansible.builtin.set_fact: + eda_cred_type_id: "{{ eda_cred_types.json.results[0].id }}" + when: eda_cred_types is not failed and eda_cred_types.json.count > 0 + + - name: Create EDA credential + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/eda-credentials/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ eda_cred_name }}" + credential_type_id: "{{ eda_cred_type_id }}" + inputs: {} + organization_id: "{{ eda_org_id }}" + status_code: [200, 201] + register: eda_cred + when: eda_cred_type_id is defined and eda_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Verify EDA credential role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ eda_cred is defined and eda_cred is not failed and eda_cred is not skipped }}" + res_name: "{{ eda_cred_name }}" + res_type: eda.edacredential + res_role_def: "Eda Credential Admin" + team_name: "{{ team1.name }}" + + # -- EDA: event streams --------------------------------------------------- + # Event streams require a credential of an event-stream-compatible type. + + - name: Look up Token Event Stream credential type + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/credential-types/?name=Token+Event+Stream" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: eda_stream_cred_types + ignore_errors: true + delegate_to: localhost + + - name: Set EDA stream credential type fact + ansible.builtin.set_fact: + eda_stream_cred_type_id: "{{ eda_stream_cred_types.json.results[0].id }}" + when: eda_stream_cred_types is not failed and eda_stream_cred_types.json.count > 0 + + - name: Create EDA event stream credential + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/eda-credentials/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ eda_stream_name }}-cred" + credential_type_id: "{{ eda_stream_cred_type_id }}" + inputs: + token: "integration-test-token" + organization_id: "{{ eda_org_id }}" + status_code: [200, 201] + register: eda_stream_cred + when: eda_stream_cred_type_id is defined and eda_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Create EDA event stream + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/event-streams/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ eda_stream_name }}" + organization_id: "{{ eda_org_id }}" + eda_credential_id: "{{ eda_stream_cred.json.id }}" + status_code: [200, 201] + register: eda_stream + when: eda_org_id is defined and eda_stream_cred is defined and eda_stream_cred is not failed and eda_stream_cred is not skipped + ignore_errors: true + delegate_to: localhost + + - name: Verify EDA event stream role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ eda_stream is defined and eda_stream is not failed and eda_stream is not skipped }}" + res_name: "{{ eda_stream_name }}" + res_type: eda.eventstream + res_role_def: "Event Stream Admin" + team_name: "{{ team1.name }}" + + # -- EDA: activations ----------------------------------------------------- + + - name: Look up existing EDA rulebook for activation test + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/rulebooks/?page_size=1" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: eda_rulebooks + ignore_errors: true + delegate_to: localhost + + - name: Look up existing EDA decision environment for activation test + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/decision-environments/?page_size=1" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: eda_des_list + ignore_errors: true + delegate_to: localhost + + - name: Create EDA activation (disabled) + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/activations/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ eda_activation_name }}" + rulebook_id: "{{ eda_rulebooks.json.results[0].id }}" + decision_environment_id: "{{ eda_des_list.json.results[0].id }}" + organization_id: "{{ eda_org_id }}" + is_enabled: false + status_code: [200, 201] + register: eda_activation + when: > + eda_rulebooks is not failed and eda_rulebooks.json.count > 0 + and eda_des_list is not failed and eda_des_list.json.count > 0 + and eda_org_id is defined + ignore_errors: true + delegate_to: localhost - - name: Assert Assignment exists + - name: Verify EDA activation role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ eda_activation is defined and eda_activation is not failed and eda_activation is not skipped }}" + res_name: "{{ eda_activation_name }}" + res_type: eda.activation + res_role_def: "Activation Admin" + team_name: "{{ team1.name }}" + + # -- Controller: look up Default org ID ----------------------------------- + + - name: Look up Default organization in Controller + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/organizations/?name=Default" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: ctrl_default_org + ignore_errors: true + delegate_to: localhost + + - name: Set Controller org ID fact + ansible.builtin.set_fact: + ctrl_org_id: "{{ ctrl_default_org.json.results[0].id }}" + when: ctrl_default_org is not failed and ctrl_default_org.json.count > 0 + + # -- Controller: inventory ------------------------------------------------ + + - name: Create Controller inventory + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/inventories/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ ctrl_inventory_name }}" + organization: "{{ ctrl_org_id }}" + status_code: [200, 201] + register: ctrl_inventory + when: ctrl_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Verify inventory role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ ctrl_inventory is not failed and ctrl_inventory is not skipped }}" + res_name: "{{ ctrl_inventory_name }}" + res_type: awx.inventory + res_role_def: "Inventory Admin" + team_name: "{{ team1.name }}" + + # Assign the same role via object_id — numeric ID bypasses name lookup entirely. + - name: Assign inventory role to team via object_id + ansible.platform.role_team_assignment: + role_definition: "Inventory Admin" + team: "{{ team2.name }}" + assignment_objects: + - object_id: "{{ ctrl_inventory.json.id }}" + register: inv_obj_id_assign + when: ctrl_inventory is not failed and ctrl_inventory is not skipped + ignore_errors: true + + - name: Assert inventory object_id assignment succeeded ansible.builtin.assert: that: - - "assignment_query.json.count > 0" - fail_msg: "No role assignment found for Custom Role ID {{ custom_role.id }} and Team ID {{ team1.id }}." - - # Once we have role_definition , module available we can uncomment these - # 3. Assign Org Inventory Admin role to Team2 on Org2 - # - name: Assign Org Inventory Admin to Team2 on Org2 - # ansible.platform.role_team_assignment: - # assignment_objects: - # - name: "{{ org1.name }}" - # type: "organizations" - # - name: "{{ org2.name }}" - # type: "organizations" - # role_definition: Organization Inventory Admin - # team: "{{ team2.name }}" - # state: present - # register: org_admin_assignment_3 - - # - name: Assert Team2 Org Inventory Admin assignment worked - # ansible.builtin.assert: - # that: - # - org_admin_assignment_3 is changed or org_admin_assignment_3 is not failed - - # 4. Idempotency check (should not change) - # - name: Re-run Org Inventory Admin removal for Team2 - # ansible.platform.role_team_assignment: - # assignment_objects: - # - name: "{{ org1.name }}" - # type: "organizations" - # - name: "{{ org2.name }}" - # type: "organizations" - # role_definition: Organization Inventory Admin - # team: "{{ team2.name }}" - # state: present - # register: org_admin_assignment_3_check - - # - name: Assert no change on idempotent re-run - # ansible.builtin.assert: - # that: - # - org_admin_assignment_3_check is not changed - - # 5. Assign Org Inventory Admin role to Team2 on Org1 (absent to test delete) - # - name: Assign Org Credential Admin to Team3 on Org3 - # ansible.platform.role_team_assignment: - # assignment_objects: - # - name: "{{ org3.name }}" - # type: "organizations" - # role_definition: Organization Credential Admin - # team: "{{ team3.name }}" - # state: present - # register: org_admin_assignment_5 - - # - name: Assert Org Credential Admin assignment worked - # ansible.builtin.assert: - # that: - # - org_admin_assignment_5 is changed + - inv_obj_id_assign is not failed + - inv_obj_id_assign is changed + fail_msg: "Inventory object_id assignment failed: {{ inv_obj_id_assign.msg | default('') }}" + when: ctrl_inventory is not failed and ctrl_inventory is not skipped and inv_obj_id_assign is defined + + - name: Idempotency — re-run inventory object_id assignment + ansible.platform.role_team_assignment: + role_definition: "Inventory Admin" + team: "{{ team2.name }}" + assignment_objects: + - object_id: "{{ ctrl_inventory.json.id }}" + register: inv_obj_id_idem + when: ctrl_inventory is not failed and ctrl_inventory is not skipped and inv_obj_id_assign is not failed + ignore_errors: true + + - name: Assert inventory object_id assignment idempotent + ansible.builtin.assert: + that: inv_obj_id_idem is not changed + fail_msg: "Inventory object_id re-run should not change." + when: ctrl_inventory is not failed and ctrl_inventory is not skipped and inv_obj_id_assign is not failed and inv_obj_id_idem is defined + + - name: Remove inventory object_id assignment + ansible.platform.role_team_assignment: + role_definition: "Inventory Admin" + team: "{{ team2.name }}" + assignment_objects: + - object_id: "{{ ctrl_inventory.json.id }}" + state: absent + when: ctrl_inventory is not failed and ctrl_inventory is not skipped and inv_obj_id_assign is not failed + + # -- Controller: project -------------------------------------------------- + + - name: Create Controller project (manual SCM) + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/projects/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ ctrl_project_name }}" + organization: "{{ ctrl_org_id }}" + scm_type: "" + status_code: [200, 201] + register: ctrl_project + when: ctrl_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Verify project role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ ctrl_project is not failed and ctrl_project is not skipped }}" + res_name: "{{ ctrl_project_name }}" + res_type: awx.project + res_role_def: "Project Admin" + team_name: "{{ team1.name }}" + + # Mixed list: one item uses name+type lookup, another uses object_id directly. + # type alongside object_id is optional — included here for readability only; + # the role definition's content_type (awx.project) already identifies the resource. + - name: Assign project role to team — mixed name+type and object_id in one task + ansible.platform.role_team_assignment: + role_definition: "Project Admin" + team: "{{ team3.name }}" + assignment_objects: + - name: "{{ ctrl_project_name }}" + type: awx.project + - object_id: "{{ ctrl_project.json.id }}" + type: awx.project + register: proj_mixed_assign + when: ctrl_project is not failed and ctrl_project is not skipped + ignore_errors: true + + - name: Assert mixed assignment created two entries + ansible.builtin.assert: + that: + - proj_mixed_assign is not failed + - proj_mixed_assign.assignments | length == 2 + fail_msg: "Mixed assignment failed or wrong count: {{ proj_mixed_assign | to_nice_json }}" + when: ctrl_project is not failed and ctrl_project is not skipped and proj_mixed_assign is defined + + - name: Remove mixed project assignments + ansible.platform.role_team_assignment: + role_definition: "Project Admin" + team: "{{ team3.name }}" + assignment_objects: + - name: "{{ ctrl_project_name }}" + type: awx.project + - object_id: "{{ ctrl_project.json.id }}" + state: absent + when: ctrl_project is not failed and ctrl_project is not skipped and proj_mixed_assign is not failed + + # -- Controller: execution environment ------------------------------------ + + - name: Create Controller execution environment + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/execution_environments/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ ctrl_ee_name }}" + image: "quay.io/ansible/awx-ee:latest" + status_code: [200, 201] + register: ctrl_ee + ignore_errors: true + delegate_to: localhost + + - name: Verify execution environment role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ ctrl_ee is not failed and ctrl_ee is not skipped }}" + res_name: "{{ ctrl_ee_name }}" + res_type: awx.executionenvironment + res_role_def: "ExecutionEnvironment Admin" + team_name: "{{ team1.name }}" + + # -- Controller: instance group ------------------------------------------- + + - name: Create Controller instance group + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/instance_groups/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ ctrl_ig_name }}" + status_code: [200, 201] + register: ctrl_ig + ignore_errors: true + delegate_to: localhost + + - name: Verify instance group role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ ctrl_ig is not failed and ctrl_ig is not skipped }}" + res_name: "{{ ctrl_ig_name }}" + res_type: awx.instancegroup + res_role_def: "InstanceGroup Admin" + team_name: "{{ team1.name }}" + + # -- Controller: credentials ---------------------------------------------- + + - name: Look up Machine credential type in Controller + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/credential_types/?namespace=ssh" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: ctrl_cred_types + ignore_errors: true + delegate_to: localhost + + - name: Set Controller credential type fact + ansible.builtin.set_fact: + ctrl_cred_type_id: "{{ ctrl_cred_types.json.results[0].id }}" + when: ctrl_cred_types is not failed and ctrl_cred_types.json.count > 0 + + - name: Create Controller credential + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/credentials/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ ctrl_cred_name }}" + credential_type: "{{ ctrl_cred_type_id }}" + inputs: {} + organization: "{{ ctrl_org_id }}" + status_code: [200, 201] + register: ctrl_cred + when: ctrl_cred_type_id is defined and ctrl_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Verify credential role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ ctrl_cred is defined and ctrl_cred is not failed and ctrl_cred is not skipped }}" + res_name: "{{ ctrl_cred_name }}" + res_type: awx.credential + res_role_def: "Credential Admin" + team_name: "{{ team1.name }}" + + # -- Controller: job templates -------------------------------------------- + + - name: Look up Controller project with playbooks for job template + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/projects/?status=successful&page_size=10" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: ctrl_synced_projects + ignore_errors: true + delegate_to: localhost + + - name: Look up playbooks from synced project + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}{{ ctrl_synced_projects.json.results[0].related.playbooks }}" + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + return_content: true + register: ctrl_playbooks + when: ctrl_synced_projects is not failed and ctrl_synced_projects.json.count > 0 + ignore_errors: true + delegate_to: localhost + + - name: Set job template project and playbook facts + ansible.builtin.set_fact: + ctrl_jt_project_id: "{{ ctrl_synced_projects.json.results[0].id }}" + ctrl_jt_playbook: "{{ ctrl_playbooks.json[0] }}" + when: > + ctrl_synced_projects is not failed and ctrl_synced_projects.json.count > 0 + and ctrl_playbooks is not failed and ctrl_playbooks is not skipped + and ctrl_playbooks.json | length > 0 + + - name: Create Controller job template + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/job_templates/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ ctrl_jt_name }}" + project: "{{ ctrl_jt_project_id }}" + inventory: "{{ ctrl_inventory.json.id }}" + playbook: "{{ ctrl_jt_playbook }}" + status_code: [200, 201] + register: ctrl_jt + when: ctrl_jt_project_id is defined and ctrl_jt_playbook is defined and ctrl_inventory is not failed and ctrl_inventory is not skipped + ignore_errors: true + delegate_to: localhost + + - name: Verify job template role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ ctrl_jt is defined and ctrl_jt is not failed and ctrl_jt is not skipped }}" + res_name: "{{ ctrl_jt_name }}" + res_type: awx.jobtemplate + res_role_def: "JobTemplate Admin" + team_name: "{{ team1.name }}" + + # -- Controller: workflow job templates ----------------------------------- + + - name: Create Controller workflow job template + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/workflow_job_templates/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ ctrl_wfjt_name }}" + organization: "{{ ctrl_org_id }}" + status_code: [200, 201] + register: ctrl_wfjt + when: ctrl_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Verify workflow job template role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ ctrl_wfjt is defined and ctrl_wfjt is not failed and ctrl_wfjt is not skipped }}" + res_name: "{{ ctrl_wfjt_name }}" + res_type: awx.workflowjobtemplate + res_role_def: "WorkflowJobTemplate Admin" + team_name: "{{ team1.name }}" + + # -- Controller: notification templates ----------------------------------- + + - name: Create Controller notification template + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/notification_templates/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ ctrl_nt_name }}" + organization: "{{ ctrl_org_id }}" + notification_type: "webhook" + notification_configuration: + url: "http://example.com" + http_method: "POST" + headers: {} + status_code: [200, 201] + register: ctrl_nt + when: ctrl_org_id is defined + ignore_errors: true + delegate_to: localhost + + - name: Verify notification template role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ ctrl_nt is defined and ctrl_nt is not failed and ctrl_nt is not skipped }}" + res_name: "{{ ctrl_nt_name }}" + res_type: awx.notificationtemplate + res_role_def: "NotificationTemplate Admin" + team_name: "{{ team1.name }}" + + # -- Hub: collection namespace -------------------------------------------- + + - name: Create Hub collection namespace via Galaxy API + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/galaxy/v3/namespaces/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ hub_ns_name }}" + status_code: [200, 201] + register: hub_ns + ignore_errors: true + delegate_to: localhost + + - name: Verify collection namespace role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ hub_ns is not failed and hub_ns is not skipped }}" + res_name: "{{ hub_ns_name }}" + res_type: galaxy.namespace + res_role_def: "galaxy.collection_namespace_owner" + team_name: "{{ team1.name }}" + + # Hub namespaces return pulp_href; the UUID at the end is the object_id. + - name: Set Hub namespace UUID fact from pulp_href + ansible.builtin.set_fact: + hub_ns_uuid: "{{ hub_ns.pulp_href | default('') | regex_replace('.*/', '') | regex_replace('/$', '') }}" + when: hub_ns is not failed and hub_ns is not skipped and hub_ns.pulp_href is defined + + - name: Assign namespace role to team via object_id (Hub UUID) + ansible.platform.role_team_assignment: + role_definition: "galaxy.collection_namespace_owner" + team: "{{ team2.name }}" + assignment_objects: + - object_id: "{{ hub_ns_uuid }}" + register: ns_obj_id_assign + when: hub_ns_uuid is defined and hub_ns_uuid != '' + ignore_errors: true + + - name: Assert namespace object_id assignment succeeded + ansible.builtin.assert: + that: + - ns_obj_id_assign is not failed + - ns_obj_id_assign is changed + fail_msg: "Hub namespace object_id assignment failed: {{ ns_obj_id_assign.msg | default('') }}" + when: hub_ns_uuid is defined and hub_ns_uuid != '' and ns_obj_id_assign is defined + + - name: Remove namespace object_id assignment + ansible.platform.role_team_assignment: + role_definition: "galaxy.collection_namespace_owner" + team: "{{ team2.name }}" + assignment_objects: + - object_id: "{{ hub_ns_uuid }}" + state: absent + when: hub_ns_uuid is defined and hub_ns_uuid != '' and ns_obj_id_assign is not failed + + # -- Hub: collection remote ----------------------------------------------- + + - name: Create Hub collection remote via Pulp API + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/galaxy/pulp/api/v3/remotes/ansible/collection/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ hub_remote_name }}" + url: "https://galaxy.ansible.com/api/" + status_code: [200, 201] + register: hub_remote + ignore_errors: true + delegate_to: localhost + + - name: Verify collection remote role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ hub_remote is not failed and hub_remote is not skipped }}" + res_name: "{{ hub_remote_name }}" + res_type: galaxy.collectionremote + res_role_def: "ansible.collectionremote_owner" + team_name: "{{ team1.name }}" + + # -- Hub: ansible repository ---------------------------------------------- + + - name: Create Hub ansible repository via Pulp API + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/galaxy/pulp/api/v3/repositories/ansible/ansible/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ hub_repo_name }}" + status_code: [200, 201] + register: hub_repo + ignore_errors: true + delegate_to: localhost + + - name: Verify ansible repository role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ hub_repo is not failed and hub_repo is not skipped }}" + res_name: "{{ hub_repo_name }}" + res_type: galaxy.ansiblerepository + res_role_def: "ansible.ansiblerepository_owner" + team_name: "{{ team1.name }}" + + # -- Hub: container namespace --------------------------------------------- + + - name: Create Hub container namespace via Pulp API + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/galaxy/pulp/api/v3/pulp_container/namespaces/" + method: POST + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + body_format: json + body: + name: "{{ hub_cns_name }}" + status_code: [200, 201] + register: hub_cns + ignore_errors: true + delegate_to: localhost + + - name: Verify container namespace role assignment + ansible.builtin.include_tasks: + file: verify_assignment.yml + vars: + res_ok: "{{ hub_cns is not failed and hub_cns is not skipped }}" + res_name: "{{ hub_cns_name }}" + res_type: galaxy.containernamespace + res_role_def: "container.containernamespace_collaborator" + team_name: "{{ team1.name }}" always: - # ---------------------------------------------------------------------- - # Explicit Role Cleanup (Uncomment once role_definition implemented) - # ---------------------------------------------------------------------- - # - name: Remove Org Inventory Admin assignment from Team2 on Org1,Org2 - # ansible.platform.role_team_assignment: - # assignment_objects: - # - name: "{{ org1.name }}" - # type: organizations - # - name: "{{ org2.name }}" - # type: organizations - # role_definition: Organization Inventory Admin - # team: "{{ team2.name }}" - # state: absent - - # - name: Remove Org Inventory Admin assignment from Team2 on Org3 - # ansible.platform.role_team_assignment: - # assignment_objects: - # - name: "{{ org3.name }}" - # type: organizations - # role_definition: Organization Inventory Admin - # team: "{{ team3.name }}" - # state: absent - # ---------------------------------------------------------------------- - # Cleanup - # ---------------------------------------------------------------------- + - name: Delete test teams ansible.platform.team: name: "{{ item.name }}" organization: "{{ item.organization }}" state: absent loop: - - { name: "{{ team1.name }}", organization: "{{ org1.name }}" } - - { name: "{{ team2.name }}", organization: "{{ org2.name }}" } - - { name: "{{ team3.name }}", organization: "{{ org3.name }}" } - - { name: "{{ team4.name }}", organization: "{{ org4.name }}" } + - {name: "{{ team1.name }}", organization: "{{ org1.name }}"} + - {name: "{{ team2.name }}", organization: "{{ org2.name }}"} + - {name: "{{ team3.name }}", organization: "{{ org3.name }}"} + - {name: "{{ team4.name }}", organization: "{{ org4.name }}"} - name: Delete test organizations ansible.platform.organization: @@ -439,5 +1172,263 @@ - "'Not found' not in role_delete.msg" - "'does not exist' not in role_delete.msg" - # No custom resource-level roles to clean up — tests now use existing built-in roles + - name: Delete resource-level role definition + ansible.platform.role_definition: + name: "{{ custom_resource_role_name }}" + content_type: "awx.inventory" + permissions: + - "awx.view_inventory" + state: absent + register: resource_role_delete + failed_when: + - resource_role_delete.failed + - "'Not found' not in (resource_role_delete.msg | default(''))" + - "'does not exist' not in (resource_role_delete.msg | default(''))" + + - name: Delete EDA project role definition + ansible.platform.role_definition: + name: "GW-EDA-Project-Role-{{ test_id }}" + content_type: eda.project + permissions: + - eda.view_project + state: absent + register: eda_role_def_delete + failed_when: + - eda_role_def_delete.failed + - "'Not found' not in (eda_role_def_delete.msg | default(''))" + - "'does not exist' not in (eda_role_def_delete.msg | default(''))" + + - name: Delete EDA activation + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/activations/{{ eda_activation.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [204, 404] + when: eda_activation is defined and eda_activation is not failed and eda_activation is not skipped and eda_activation.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete EDA event stream + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/event-streams/{{ eda_stream.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [204, 404] + when: eda_stream is defined and eda_stream is not failed and eda_stream is not skipped and eda_stream.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete EDA event stream credential + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/eda-credentials/{{ eda_stream_cred.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [204, 404] + when: eda_stream_cred is defined and eda_stream_cred is not failed and eda_stream_cred is not skipped and eda_stream_cred.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete EDA credential + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/eda-credentials/{{ eda_cred.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [204, 404] + when: eda_cred is defined and eda_cred is not failed and eda_cred is not skipped and eda_cred.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete EDA project + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/projects/{{ eda_project.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [204, 404] + when: eda_project is defined and eda_project is not failed and eda_project.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete EDA decision environment + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/eda/v1/decision-environments/{{ eda_de.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [204, 404] + when: eda_de is defined and eda_de is not failed and eda_de.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Controller notification template + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/notification_templates/{{ ctrl_nt.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 204, 404] + when: ctrl_nt is defined and ctrl_nt is not failed and ctrl_nt is not skipped and ctrl_nt.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Controller workflow job template + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/workflow_job_templates/{{ ctrl_wfjt.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 204, 404] + when: ctrl_wfjt is defined and ctrl_wfjt is not failed and ctrl_wfjt is not skipped and ctrl_wfjt.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Controller job template + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/job_templates/{{ ctrl_jt.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 204, 404] + when: ctrl_jt is defined and ctrl_jt is not failed and ctrl_jt is not skipped and ctrl_jt.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Controller credential + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/credentials/{{ ctrl_cred.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 204, 404] + when: ctrl_cred is defined and ctrl_cred is not failed and ctrl_cred is not skipped and ctrl_cred.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Controller inventory + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/inventories/{{ ctrl_inventory.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 204, 404] + when: ctrl_inventory is defined and ctrl_inventory is not failed and ctrl_inventory is not skipped and ctrl_inventory.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Controller project + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/projects/{{ ctrl_project.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 204, 404] + when: ctrl_project is defined and ctrl_project is not failed and ctrl_project is not skipped and ctrl_project.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Controller execution environment + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/execution_environments/{{ ctrl_ee.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 204, 404] + when: ctrl_ee is defined and ctrl_ee is not failed and ctrl_ee.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Controller instance group + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/controller/v2/instance_groups/{{ ctrl_ig.json.id }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 204, 404] + when: ctrl_ig is defined and ctrl_ig is not failed and ctrl_ig.json.id is defined + failed_when: false + delegate_to: localhost + + - name: Delete Hub collection namespace via Galaxy API + ansible.builtin.uri: + url: "{{ gateway_hostname | regex_replace('/$', '') }}/api/galaxy/v3/namespaces/{{ hub_ns_name }}/" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [204, 404] + when: hub_ns is defined and hub_ns is not failed + failed_when: false + delegate_to: localhost + + - name: Delete Hub collection remote via Pulp API + ansible.builtin.uri: + url: "{{ gateway_hostname }}{{ hub_remote.json.pulp_href | regex_replace('^/', '') }}" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 404] + when: hub_remote is defined and hub_remote is not failed and hub_remote.json.pulp_href is defined + failed_when: false + delegate_to: localhost + + - name: Delete Hub ansible repository via Pulp API + ansible.builtin.uri: + url: "{{ gateway_hostname }}{{ hub_repo.json.pulp_href | regex_replace('^/', '') }}" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 404] + when: hub_repo is defined and hub_repo is not failed and hub_repo.json.pulp_href is defined + failed_when: false + delegate_to: localhost + + - name: Delete Hub container namespace via Pulp API + ansible.builtin.uri: + url: "{{ gateway_hostname }}{{ hub_cns.json.pulp_href | regex_replace('^/', '') }}" + method: DELETE + user: "{{ gateway_username }}" + password: "{{ gateway_password }}" + force_basic_auth: true + validate_certs: "{{ gateway_validate_certs | bool }}" + status_code: [202, 404] + when: hub_cns is defined and hub_cns is not failed and hub_cns.json.pulp_href is defined + failed_when: false + delegate_to: localhost ... diff --git a/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml b/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml new file mode 100644 index 00000000..075c3a82 --- /dev/null +++ b/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml @@ -0,0 +1,69 @@ +--- +# Shared 6-task cycle for role_team_assignment validation. +# +# Required vars (set via include_tasks vars:): +# res_ok - bool guard (precomputed by caller, e.g. "resource is not failed") +# res_name - resource name for assignment_objects +# res_type - assignment type for assignment_objects (e.g. awx.inventory, galaxy.namespace) +# res_role_def - role definition name or ID +# team_name - team name string +# +# Internal registers use _rta_ prefix to avoid collisions with parent-scope vars. +# Sequential include_tasks invocations overwrite them safely. + +- name: "Assign to team — {{ res_type }}" + ansible.platform.role_team_assignment: + role_definition: "{{ res_role_def }}" + team: "{{ team_name }}" + assignment_objects: + - name: "{{ res_name }}" + type: "{{ res_type }}" + state: present + register: _rta_assign + when: res_ok | bool + ignore_errors: true + +- name: "Assert role assignment succeeded — {{ res_type }}" + ansible.builtin.assert: + that: + - _rta_assign is not failed + - _rta_assign is changed + fail_msg: "{{ res_type }} role assignment failed: {{ _rta_assign.msg | default('') }}" + when: res_ok | bool and _rta_assign is defined + +- name: "Idempotency re-run — {{ res_type }}" + ansible.platform.role_team_assignment: + role_definition: "{{ res_role_def }}" + team: "{{ team_name }}" + assignment_objects: + - name: "{{ res_name }}" + type: "{{ res_type }}" + state: present + register: _rta_idem + when: res_ok | bool and _rta_assign is not failed + +- name: "Assert assignment idempotent — {{ res_type }}" + ansible.builtin.assert: + that: _rta_idem is not changed + fail_msg: "{{ res_type }} re-run should not change." + when: res_ok | bool and _rta_assign is not failed and _rta_idem is defined + +- name: "State=absent removes assignment — {{ res_type }}" + ansible.platform.role_team_assignment: + role_definition: "{{ res_role_def }}" + team: "{{ team_name }}" + assignment_objects: + - name: "{{ res_name }}" + type: "{{ res_type }}" + state: absent + register: _rta_absent + when: res_ok | bool and _rta_assign is not failed + +- name: "Assert assignment removed — {{ res_type }}" + ansible.builtin.assert: + that: + - _rta_absent is not failed + - _rta_absent is changed + fail_msg: "state=absent on {{ res_type }} assignment failed: {{ _rta_absent.msg | default('') }}" + when: res_ok | bool and _rta_assign is not failed and _rta_absent is defined +... diff --git a/tests/unit/module_utils/test_resource_type_map.py b/tests/unit/module_utils/test_resource_type_map.py new file mode 100644 index 00000000..fa46c58f --- /dev/null +++ b/tests/unit/module_utils/test_resource_type_map.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest +from ansible_collections.ansible.platform.plugins.module_utils.resource_type_map import ( + ASSIGNMENT_TYPE_PATH_MAP, + get_expected_assignment_type, + lookup_path_for, +) + + +# --------------------------------------------------------------------------- +# get_expected_assignment_type +# --------------------------------------------------------------------------- + +def test_gateway_organization_maps_to_plural(): + assert get_expected_assignment_type("shared.organization") == "organizations" + + +def test_gateway_team_maps_to_plural(): + assert get_expected_assignment_type("shared.team") == "teams" + + +def test_eda_types_return_content_type_directly(): + assert get_expected_assignment_type("eda.project") == "eda.project" + assert get_expected_assignment_type("eda.activation") == "eda.activation" + assert get_expected_assignment_type("eda.edacredential") == "eda.edacredential" + assert get_expected_assignment_type("eda.eventstream") == "eda.eventstream" + assert get_expected_assignment_type("eda.decisionenvironment") == "eda.decisionenvironment" + + +def test_awx_types_return_content_type_directly(): + assert get_expected_assignment_type("awx.project") == "awx.project" + assert get_expected_assignment_type("awx.inventory") == "awx.inventory" + assert get_expected_assignment_type("awx.credential") == "awx.credential" + assert get_expected_assignment_type("awx.jobtemplate") == "awx.jobtemplate" + assert get_expected_assignment_type("awx.workflowjobtemplate") == "awx.workflowjobtemplate" + assert get_expected_assignment_type("awx.executionenvironment") == "awx.executionenvironment" + assert get_expected_assignment_type("awx.instancegroup") == "awx.instancegroup" + assert get_expected_assignment_type("awx.notificationtemplate") == "awx.notificationtemplate" + + +def test_galaxy_types_return_content_type_directly(): + assert get_expected_assignment_type("galaxy.namespace") == "galaxy.namespace" + assert get_expected_assignment_type("galaxy.collectionremote") == "galaxy.collectionremote" + assert get_expected_assignment_type("galaxy.ansiblerepository") == "galaxy.ansiblerepository" + assert get_expected_assignment_type("galaxy.containernamespace") == "galaxy.containernamespace" + + +def test_empty_content_type_returns_none(): + assert get_expected_assignment_type("") is None + assert get_expected_assignment_type(None) is None + + +def test_unknown_content_type_raises(): + with pytest.raises(ValueError, match="Unknown content_type"): + get_expected_assignment_type("unknown.type") + + +# --------------------------------------------------------------------------- +# lookup_path_for +# --------------------------------------------------------------------------- + +def test_gateway_types_return_bare_name(): + assert lookup_path_for("organizations") == "organizations" + assert lookup_path_for("teams") == "teams" + + +def test_eda_project_routes_to_eda_api(): + path = lookup_path_for("eda.project") + assert path == "/api/eda/v1/projects/" + assert path.startswith("/api/eda/") + + +def test_eda_activation_routes_to_eda_api(): + assert lookup_path_for("eda.activation") == "/api/eda/v1/activations/" + + +def test_awx_inventory_routes_to_controller_api(): + path = lookup_path_for("awx.inventory") + assert path == "/api/controller/v2/inventories/" + assert path.startswith("/api/controller/") + + +def test_awx_project_routes_to_controller_not_eda(): + # Ensures awx.project and eda.project resolve to different services + assert lookup_path_for("awx.project") == "/api/controller/v2/projects/" + assert lookup_path_for("eda.project") == "/api/eda/v1/projects/" + assert lookup_path_for("awx.project") != lookup_path_for("eda.project") + + +def test_galaxy_namespace_routes_to_hub_api(): + path = lookup_path_for("galaxy.namespace") + assert path == "/api/galaxy/v3/namespaces/" + assert path.startswith("/api/galaxy/") + + +def test_all_non_gateway_paths_start_with_api(): + """build_url passes through any path starting with /api/ unchanged.""" + for assignment_type, path in ASSIGNMENT_TYPE_PATH_MAP.items(): + if assignment_type not in ("organizations", "teams"): + assert path.startswith("/api/"), ( + "Expected full /api/ path for '%s' but got '%s'" % (assignment_type, path) + ) From 49ee393e78a20f7ecd6abc82a0a4c5fef0176925 Mon Sep 17 00:00:00 2001 From: Jayant Sogikar Date: Thu, 10 Sep 2026 15:24:21 +0530 Subject: [PATCH 08/10] Add additional name checks and integration changes --- plugins/module_utils/resource_type_map.py | 18 +++ plugins/modules/role_team_assignment.py | 123 ++++++++++++----- .../role_team_assignments_test/tasks/main.yml | 29 ++-- .../tasks/verify_assignment.yml | 24 ++-- .../test_role_team_assignment_lookup.py | 130 ++++++++++++++++++ 5 files changed, 269 insertions(+), 55 deletions(-) create mode 100644 tests/unit/modules/test_role_team_assignment_lookup.py diff --git a/plugins/module_utils/resource_type_map.py b/plugins/module_utils/resource_type_map.py index a897a0f9..67d3cb75 100644 --- a/plugins/module_utils/resource_type_map.py +++ b/plugins/module_utils/resource_type_map.py @@ -56,6 +56,12 @@ "galaxy.containernamespace": "/api/galaxy/pulp/api/v3/pulp_container/namespaces/", } +# Controller resources which do not belong to an organization. +CONTROLLER_NON_ORG_TYPES = frozenset({"awx.executionenvironment", "awx.instancegroup"}) + +# Gateway resources which accept organization scope. +GATEWAY_ORG_TYPES = frozenset({"teams"}) + def get_expected_assignment_type(content_type): """Return the assignment_objects type value for a role_definition content_type. @@ -83,3 +89,15 @@ def lookup_path_for(assignment_type): Dotted content_type values return their full /api//... path. """ return ASSIGNMENT_TYPE_PATH_MAP.get(assignment_type, assignment_type) + + +def service_kind(assignment_type): + """Return the service owning an assignment type.""" + path = lookup_path_for(assignment_type) + if path.startswith("/api/controller/"): + return "controller" + if path.startswith("/api/eda/"): + return "eda" + if path.startswith("/api/galaxy/"): + return "hub" + return "gateway" diff --git a/plugins/modules/role_team_assignment.py b/plugins/modules/role_team_assignment.py index 6889a626..8cf8f2e4 100644 --- a/plugins/modules/role_team_assignment.py +++ b/plugins/modules/role_team_assignment.py @@ -61,6 +61,12 @@ C(galaxy.ansiblerepository), C(galaxy.containernamespace)." type: str required: False + organization: + description: + - Organization name used to disambiguate named Controller, EDA, and Gateway team resources. + - Not supported for Hub resources or Controller execution environments and instance groups. + type: str + required: False object_id: description: - The primary key of the object (team/organization) this assignment applies to. @@ -170,8 +176,11 @@ from ..module_utils.aap_module import AAPModule from ..module_utils.resource_type_map import ( ASSIGNMENT_TYPE_PATH_MAP, + CONTROLLER_NON_ORG_TYPES, + GATEWAY_ORG_TYPES, get_expected_assignment_type, lookup_path_for, + service_kind, ) @@ -183,39 +192,88 @@ def _get_expected_endpoint(role_definition): return get_expected_assignment_type(raw) -def _lookup_hub_object_id(module, obj_type, name): - """Look up a Hub (Pulp) resource by name and return a dict with an 'id' key. +def _matches_org(item, org_id): + for key in ("organization_id", "organization"): + value = item.get(key) + if isinstance(value, dict): + value = value.get("id") + if value is not None and str(value) == str(org_id): + return True + return False - Pulp list endpoints return 'results' (or occasionally 'data') and objects - carry 'pulp_href' instead of a numeric 'id'. The UUID at the end of - pulp_href is what the Gateway RBAC API expects as object_id. - """ - path = lookup_path_for(obj_type) - url = module.build_url(path, query_params={"name": name}) - response = module.make_request("GET", url) +def _lookup_exact_named_resource(module, endpoint, name, organization_id=None, query=None): + """Return exactly one resource whose response name equals *name*.""" + query_params = dict(query or {}) + query_params["name"] = name + response = module.get_endpoint(endpoint, data=query_params) if response["status_code"] != 200: - module.fail_json( - msg="Failed to look up Hub resource '{0}' at {1}: HTTP {2}".format( - name, path, response["status_code"] - ) - ) + module.fail_json(msg="Failed to look up resource '{0}' at {1}: HTTP {2}".format(name, endpoint, response["status_code"])) payload = response.get("json", {}) items = payload.get("results") or payload.get("data") or [] - - if not items: - module.fail_json( - msg="No Hub resource named '{0}' found at {1}.".format(name, path) - ) - if len(items) > 1: + next_page = payload.get("next") + while next_page: + page = module.make_request("GET", next_page) + page_payload = page.get("json", {}) + items.extend(page_payload.get("results") or page_payload.get("data") or []) + next_page = page_payload.get("next") + + matches = [item for item in items if item.get("name") == name] + if organization_id is not None: + matches = [item for item in matches if _matches_org(item, organization_id)] + if len(matches) != 1: module.fail_json( - msg="Multiple Hub resources named '{0}' found at {1}, expected exactly one.".format( - name, path + msg="Expected exactly one resource named '{0}'{1} at {2}, got {3}.".format( + name, + " in organization '{0}'".format(organization_id) if organization_id is not None else "", + endpoint, + len(matches), ) ) + return matches[0] + - item = items[0] +def _resolve_organization_id(module, organization, service): + endpoint = { + "controller": "/api/controller/v2/organizations/", + "eda": "/api/eda/v1/organizations/", + "gateway": "organizations", + }[service] + return _lookup_exact_named_resource(module, endpoint, organization)["id"] + + +def _resolve_named_object(module, entry): + obj_type = entry["type"] + name = entry["name"] + organization = entry.get("organization") + service = service_kind(obj_type) + path = lookup_path_for(obj_type) + + if organization and service == "hub": + module.fail_json(msg="organization is not supported for Hub type '{0}'".format(obj_type)) + if organization and service == "controller" and obj_type in CONTROLLER_NON_ORG_TYPES: + module.fail_json(msg="organization is not supported for Controller type '{0}'".format(obj_type)) + if organization and service == "gateway" and obj_type not in GATEWAY_ORG_TYPES: + module.fail_json(msg="organization is only supported for Gateway type 'teams' (got '{0}')".format(obj_type)) + + if service == "hub": + return _lookup_hub_object_id(module, obj_type, name) + + org_id = _resolve_organization_id(module, organization, service) if organization else None + query = {"organization": org_id} if org_id is not None and service in ("controller", "gateway") else None + return _lookup_exact_named_resource(module, path, name, organization_id=org_id, query=query) + + +def _lookup_hub_object_id(module, obj_type, name): + """Look up a Hub (Pulp) resource by name and return a dict with an 'id' key. + + Pulp list endpoints return 'results' (or occasionally 'data') and objects + carry 'pulp_href' instead of a numeric 'id'. The UUID at the end of + pulp_href is what the Gateway RBAC API expects as object_id. + """ + path = lookup_path_for(obj_type) + item = _lookup_exact_named_resource(module, path, name) pulp_href = item.get("pulp_href", "") if pulp_href: uuid = pulp_href.rstrip("/").rsplit("/", 1)[-1] @@ -345,6 +403,7 @@ def main(): options=dict( name=dict(type="str", required=False), type=dict(type="str", required=False), + organization=dict(type="str", required=False), object_id=dict(required=False, type="int"), object_ansible_id=dict(required=False, type="str"), ), @@ -367,10 +426,16 @@ def main(): team_ansible_id = module.params.get("team_ansible_id") state = module.params.get("state") - role_definition = module.get_one( - "role_definitions", allow_none=False, name_or_id=role_definition_str + role_definition = ( + module.get_one("role_definitions", allow_none=False, name_or_id=role_definition_str) + if role_definition_str.isdigit() + else _lookup_exact_named_resource(module, "role_definitions", role_definition_str) + ) + team = ( + module.get_one("teams", allow_none=True, name_or_id=team_param) + if team_param and team_param.isdigit() + else (_lookup_exact_named_resource(module, "teams", team_param) if team_param else None) ) - team = module.get_one("teams", allow_none=True, name_or_id=team_param) kwargs = { "role_definition": role_definition["id"], @@ -419,11 +484,7 @@ def main(): ) if entity["name"] and entity["type"]: - path = lookup_path_for(entity["type"]) - if path.startswith("/api/galaxy/"): - obj = _lookup_hub_object_id(module, entity["type"], entity["name"]) - else: - obj = module.get_one(path, allow_none=False, name_or_id=entity["name"]) + obj = _resolve_named_object(module, entity) elif entity["object_id"]: obj = {"id": entity["object_id"]} else: 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 67f4a8f3..b53f26f4 100644 --- a/tests/integration/targets/role_team_assignments_test/tasks/main.yml +++ b/tests/integration/targets/role_team_assignments_test/tasks/main.yml @@ -82,6 +82,13 @@ description: "Test Team 1" register: team1 + - name: Create prefix-matching Team 2 in Organization 2 + ansible.platform.team: + name: "{{ team_name_prefix }}-Team-2-prefix-match" + organization: "{{ org2.name }}" + description: "Prefix collision for strict lookup coverage" + register: team2_prefix_match + - name: Create Team 2 in Organization 2 ansible.platform.team: name: "{{ team_name_prefix }}-Team-2" @@ -107,6 +114,7 @@ ansible.builtin.assert: that: - team1 is changed + - team2_prefix_match is changed - team2 is changed - team3 is changed - team4 is changed @@ -286,9 +294,10 @@ vars: res_ok: "{{ team2 is defined }}" res_name: "{{ team2.name }}" - res_type: teams - res_role_def: "Team Admin" - team_name: "{{ team1.name }}" + res_type: teams + res_role_def: "Team Admin" + team_name: "{{ team1.name }}" + res_organization: "{{ org2.name }}" # -- EDA: decision environment -------------------------------------------- # EDA uses its own org ID namespace; look up the synced org by name first. @@ -382,9 +391,10 @@ vars: res_ok: "{{ eda_project is not failed and eda_project is not skipped and eda_project_role_def is not failed }}" res_name: "{{ eda_project_name }}" - res_type: eda.project - res_role_def: "GW-EDA-Project-Role-{{ test_id }}" - team_name: "{{ team1.name }}" + res_type: eda.project + res_role_def: "GW-EDA-Project-Role-{{ test_id }}" + team_name: "{{ team1.name }}" + res_organization: "{{ org1.name }}" # Assign the same EDA project role via object_id — no type needed, the role's # content_type (eda.project) tells the Gateway which resource the ID refers to. @@ -639,9 +649,10 @@ vars: res_ok: "{{ ctrl_inventory is not failed and ctrl_inventory is not skipped }}" res_name: "{{ ctrl_inventory_name }}" - res_type: awx.inventory - res_role_def: "Inventory Admin" - team_name: "{{ team1.name }}" + res_type: awx.inventory + res_role_def: "Inventory Admin" + team_name: "{{ team1.name }}" + res_organization: Default # Assign the same role via object_id — numeric ID bypasses name lookup entirely. - name: Assign inventory role to team via object_id diff --git a/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml b/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml index 075c3a82..f63a0d64 100644 --- a/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml +++ b/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml @@ -14,11 +14,9 @@ - name: "Assign to team — {{ res_type }}" ansible.platform.role_team_assignment: role_definition: "{{ res_role_def }}" - team: "{{ team_name }}" - assignment_objects: - - name: "{{ res_name }}" - type: "{{ res_type }}" - state: present + team: "{{ team_name }}" + assignment_objects: + - "{{ {'name': res_name, 'type': res_type, 'state': 'present'} | combine({'organization': res_organization} if res_organization is defined else {}) }}" register: _rta_assign when: res_ok | bool ignore_errors: true @@ -34,11 +32,9 @@ - name: "Idempotency re-run — {{ res_type }}" ansible.platform.role_team_assignment: role_definition: "{{ res_role_def }}" - team: "{{ team_name }}" - assignment_objects: - - name: "{{ res_name }}" - type: "{{ res_type }}" - state: present + team: "{{ team_name }}" + assignment_objects: + - "{{ {'name': res_name, 'type': res_type, 'state': 'present'} | combine({'organization': res_organization} if res_organization is defined else {}) }}" register: _rta_idem when: res_ok | bool and _rta_assign is not failed @@ -51,11 +47,9 @@ - name: "State=absent removes assignment — {{ res_type }}" ansible.platform.role_team_assignment: role_definition: "{{ res_role_def }}" - team: "{{ team_name }}" - assignment_objects: - - name: "{{ res_name }}" - type: "{{ res_type }}" - state: absent + team: "{{ team_name }}" + assignment_objects: + - "{{ {'name': res_name, 'type': res_type, 'state': 'absent'} | combine({'organization': res_organization} if res_organization is defined else {}) }}" register: _rta_absent when: res_ok | bool and _rta_assign is not failed diff --git a/tests/unit/modules/test_role_team_assignment_lookup.py b/tests/unit/modules/test_role_team_assignment_lookup.py new file mode 100644 index 00000000..f355cd9f --- /dev/null +++ b/tests/unit/modules/test_role_team_assignment_lookup.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import sys +from pathlib import Path + +import pytest +from ansible.errors import AnsibleError + +sys.path.insert(0, str(Path(__file__).resolve().parents[6])) + +from ansible_collections.ansible.platform.plugins.modules.role_team_assignment import ( + _lookup_exact_named_resource, + _matches_org, + _resolve_named_object, +) + + +class FakeModule: + def __init__(self, response, pages=None): + self.response = response + self.pages = pages or [] + self.queries = [] + + def get_endpoint(self, endpoint, data=None): + self.queries.append((endpoint, data)) + return self.response + + def make_request(self, method, url): + return self.pages.pop(0) + + def fail_json(self, **kwargs): + raise AnsibleError(kwargs["msg"]) + + +def test_matches_org_accepts_flat_and_nested_ids(): + assert _matches_org({"organization_id": 2}, "2") + assert _matches_org({"organization": {"id": 2}}, 2) + assert not _matches_org({"organization_id": 1}, 2) + + +def test_exact_lookup_ignores_prefix_collision(): + module = FakeModule( + {"status_code": 200, "json": {"results": [{"id": 1, "name": "Demo-copy"}, {"id": 2, "name": "Demo"}]}} + ) + + result = _lookup_exact_named_resource(module, "teams", "Demo") + + assert result["id"] == 2 + assert module.queries == [("teams", {"name": "Demo"})] + + +def test_exact_lookup_fails_for_lone_prefix_match(): + module = FakeModule({"status_code": 200, "json": {"results": [{"id": 1, "name": "Demo-copy"}]}}) + + with pytest.raises(AnsibleError, match="Expected exactly one resource named 'Demo'"): + _lookup_exact_named_resource(module, "teams", "Demo") + + +def test_exact_lookup_follows_pages(): + module = FakeModule( + {"status_code": 200, "json": {"results": [{"id": 1, "name": "Demo-copy"}], "next": "/api/gateway/v1/teams/?page=2"}}, + [{"status_code": 200, "json": {"results": [{"id": 2, "name": "Demo"}], "next": None}}], + ) + + result = _lookup_exact_named_resource(module, "teams", "Demo") + + assert result["id"] == 2 + + +def test_controller_lookup_filters_exact_name_and_organization(): + module = FakeModule( + {"status_code": 200, "json": {"results": [{"id": 9, "name": "Production"}]}}, + [ + { + "status_code": 200, + "json": { + "results": [ + {"id": 1, "name": "Demo-copy", "organization": 9}, + {"id": 2, "name": "Demo", "organization": 8}, + {"id": 3, "name": "Demo", "organization": 9}, + ] + }, + } + ], + ) + original_get_endpoint = module.get_endpoint + + def get_endpoint(endpoint, data=None): + if endpoint == "/api/controller/v2/organizations/": + return original_get_endpoint(endpoint, data) + return module.pages.pop(0) + + module.get_endpoint = get_endpoint + + result = _resolve_named_object(module, {"type": "awx.project", "name": "Demo", "organization": "Production"}) + + assert result["id"] == 3 + + +def test_eda_lookup_filters_exact_name_and_organization_id(): + module = FakeModule( + {"status_code": 200, "json": {"results": [{"id": 9, "name": "Production"}]}}, + [ + { + "status_code": 200, + "json": { + "results": [ + {"id": 1, "name": "Demo-copy", "organization_id": 9}, + {"id": 2, "name": "Demo", "organization_id": 8}, + {"id": 3, "name": "Demo", "organization_id": 9}, + ] + }, + } + ], + ) + original_get_endpoint = module.get_endpoint + + def get_endpoint(endpoint, data=None): + if endpoint == "/api/eda/v1/organizations/": + return original_get_endpoint(endpoint, data) + return module.pages.pop(0) + + module.get_endpoint = get_endpoint + + result = _resolve_named_object(module, {"type": "eda.project", "name": "Demo", "organization": "Production"}) + + assert result["id"] == 3 From 4e3fdba9541897a988d0f5811fa25d012352c070 Mon Sep 17 00:00:00 2001 From: Jayant Sogikar Date: Thu, 10 Sep 2026 20:21:33 +0530 Subject: [PATCH 09/10] Fix integration tests --- plugins/module_utils/resource_type_map.py | 1 - .../role_team_assignments_test/tasks/main.yml | 24 +++++++++---------- .../tasks/verify_assignment.yml | 19 ++++++++------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/plugins/module_utils/resource_type_map.py b/plugins/module_utils/resource_type_map.py index 67d3cb75..f3db9a33 100644 --- a/plugins/module_utils/resource_type_map.py +++ b/plugins/module_utils/resource_type_map.py @@ -39,7 +39,6 @@ "eda.edacredential": "/api/eda/v1/eda-credentials/", "eda.eventstream": "/api/eda/v1/event-streams/", "eda.decisionenvironment": "/api/eda/v1/decision-environments/", - "eda.credentialinputsource": "/api/eda/v1/credential-input-sources/", # Controller "awx.project": "/api/controller/v2/projects/", "awx.inventory": "/api/controller/v2/inventories/", 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 b53f26f4..2295c48d 100644 --- a/tests/integration/targets/role_team_assignments_test/tasks/main.yml +++ b/tests/integration/targets/role_team_assignments_test/tasks/main.yml @@ -294,10 +294,10 @@ vars: res_ok: "{{ team2 is defined }}" res_name: "{{ team2.name }}" - res_type: teams - res_role_def: "Team Admin" - team_name: "{{ team1.name }}" - res_organization: "{{ org2.name }}" + res_type: teams + res_role_def: "Team Admin" + team_name: "{{ team1.name }}" + res_organization: "{{ org2.name }}" # -- EDA: decision environment -------------------------------------------- # EDA uses its own org ID namespace; look up the synced org by name first. @@ -391,10 +391,10 @@ vars: res_ok: "{{ eda_project is not failed and eda_project is not skipped and eda_project_role_def is not failed }}" res_name: "{{ eda_project_name }}" - res_type: eda.project - res_role_def: "GW-EDA-Project-Role-{{ test_id }}" - team_name: "{{ team1.name }}" - res_organization: "{{ org1.name }}" + res_type: eda.project + res_role_def: "GW-EDA-Project-Role-{{ test_id }}" + team_name: "{{ team1.name }}" + res_organization: "{{ org1.name }}" # Assign the same EDA project role via object_id — no type needed, the role's # content_type (eda.project) tells the Gateway which resource the ID refers to. @@ -649,10 +649,10 @@ vars: res_ok: "{{ ctrl_inventory is not failed and ctrl_inventory is not skipped }}" res_name: "{{ ctrl_inventory_name }}" - res_type: awx.inventory - res_role_def: "Inventory Admin" - team_name: "{{ team1.name }}" - res_organization: Default + res_type: awx.inventory + res_role_def: "Inventory Admin" + team_name: "{{ team1.name }}" + res_organization: Default # Assign the same role via object_id — numeric ID bypasses name lookup entirely. - name: Assign inventory role to team via object_id diff --git a/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml b/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml index f63a0d64..823d53e9 100644 --- a/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml +++ b/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml @@ -14,9 +14,9 @@ - name: "Assign to team — {{ res_type }}" ansible.platform.role_team_assignment: role_definition: "{{ res_role_def }}" - team: "{{ team_name }}" - assignment_objects: - - "{{ {'name': res_name, 'type': res_type, 'state': 'present'} | combine({'organization': res_organization} if res_organization is defined else {}) }}" + team: "{{ team_name }}" + assignment_objects: + - "{{ {'name': res_name, 'type': res_type} | combine({'organization': res_organization} if res_organization is defined else {}) }}" register: _rta_assign when: res_ok | bool ignore_errors: true @@ -32,9 +32,9 @@ - name: "Idempotency re-run — {{ res_type }}" ansible.platform.role_team_assignment: role_definition: "{{ res_role_def }}" - team: "{{ team_name }}" - assignment_objects: - - "{{ {'name': res_name, 'type': res_type, 'state': 'present'} | combine({'organization': res_organization} if res_organization is defined else {}) }}" + team: "{{ team_name }}" + assignment_objects: + - "{{ {'name': res_name, 'type': res_type} | combine({'organization': res_organization} if res_organization is defined else {}) }}" register: _rta_idem when: res_ok | bool and _rta_assign is not failed @@ -47,9 +47,10 @@ - name: "State=absent removes assignment — {{ res_type }}" ansible.platform.role_team_assignment: role_definition: "{{ res_role_def }}" - team: "{{ team_name }}" - assignment_objects: - - "{{ {'name': res_name, 'type': res_type, 'state': 'absent'} | combine({'organization': res_organization} if res_organization is defined else {}) }}" + team: "{{ team_name }}" + assignment_objects: + - "{{ {'name': res_name, 'type': res_type} | combine({'organization': res_organization} if res_organization is defined else {}) }}" + state: absent register: _rta_absent when: res_ok | bool and _rta_assign is not failed From 9e0feeee7640bf76bd5fe4bdf368b9d76ebca772 Mon Sep 17 00:00:00 2001 From: Jayant Sogikar Date: Fri, 11 Sep 2026 00:06:56 +0530 Subject: [PATCH 10/10] Update to enforce type check only when name is passed --- plugins/modules/role_team_assignment.py | 27 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/plugins/modules/role_team_assignment.py b/plugins/modules/role_team_assignment.py index 8cf8f2e4..157f52e9 100644 --- a/plugins/modules/role_team_assignment.py +++ b/plugins/modules/role_team_assignment.py @@ -448,7 +448,6 @@ def main(): # Derive the expected lookup endpoint from the role's content_type. # This is used to validate that assignment_objects[*].type is compatible # and to avoid sending a mismatched object_id to the Gateway API. - expected_endpoint = _get_expected_endpoint(role_definition) object_param = assignment_objects results = [] @@ -476,19 +475,31 @@ def main(): # entries using object_id / object_ansible_id (which bypass name # lookup and need no type validation) are always handled. for entity in object_param: - _validate_selector( - entity, - module, - expected_endpoint=expected_endpoint, - role_name=role_definition_str, - ) - if entity["name"] and entity["type"]: + expected_endpoint = _get_expected_endpoint(role_definition) + _validate_selector( + entity, + module, + expected_endpoint=expected_endpoint, + role_name=role_definition_str, + ) obj = _resolve_named_object(module, entity) elif entity["object_id"]: + _validate_selector( + entity, + module, + expected_endpoint=None, + role_name=role_definition_str, + ) obj = {"id": entity["object_id"]} else: # object_ansible_id path — pass through directly + _validate_selector( + entity, + module, + expected_endpoint=None, + role_name=role_definition_str, + ) kwargs["object_ansible_id"] = entity["object_ansible_id"] role_team_assignment = module.get_one( "role_team_assignments", **{"data": kwargs}