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``. diff --git a/plugins/module_utils/resource_type_map.py b/plugins/module_utils/resource_type_map.py new file mode 100644 index 00000000..f3db9a33 --- /dev/null +++ b/plugins/module_utils/resource_type_map.py @@ -0,0 +1,102 @@ +""" +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/", + # 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/", +} + +# 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. + + 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) + + +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 94e38b2b..157f52e9 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,51 @@ 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 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: 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: + - 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 + 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: @@ -76,72 +101,207 @@ 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: awx.project + 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: 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: 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 +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, +) + + +def _get_expected_endpoint(role_definition): + """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 + return get_expected_assignment_type(raw) + + +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 + + +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 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 [] + 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="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] + + +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) -def assign_team_role(module, state, role_team_assignment, kwargs, - role_definition_str, team_param, team_ansible_id, auto_exit=False): + 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] + 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( + 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 +309,240 @@ 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.") + if has_name and (not has_type): + 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(["organizations", "teams"]) | set(ASSIGNMENT_TYPE_PATH_MAP.keys()) + ) 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: + 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 " + "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=resource, + ) + ) 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), + organization=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) + 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) + ) 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. 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) - - 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"]: + 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: - 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 + _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} + ) + 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/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 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..2295c48d 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 @@ -64,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" @@ -89,6 +114,7 @@ ansible.builtin.assert: that: - team1 is changed + - team2_prefix_match is changed - team2 is changed - team3 is changed - team4 is changed @@ -106,147 +132,1033 @@ state: present register: custom_role - # 1. Assign Org Admin role to Team1 on Org1 (Global role can't be assigned) - - name: Assign Org Admin to Team1 on Org1 + # -- 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: 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 + + - name: "BUG#165 | resource-level role with type:organizations must fail with clear error" ansible.platform.role_team_assignment: + role_definition: "{{ custom_resource_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 + register: mismatch_project_org + ignore_errors: true + when: custom_resource_role is not failed + + - 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: type mismatch rejected with clear error." + fail_msg: > + BUG NOT FIXED: mismatch was accepted or error message is missing context. + Result: {{ mismatch_project_org | to_nice_json }} + when: custom_resource_role is not failed + + # -- 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 }}" + team: "{{ team1.name }}" assignment_objects: - name: "{{ org1.name }}" - type: "organizations" - role_definition: Platform Auditor + 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 }}" - register: org_admin_assignment_2 - ignore_errors: true # this may fail depending on AAP limitations + 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." - # 3. Assign Custom Role Assignment Test to Team1 on Org1 - - name: Assign Custom Role to Team1 on Org1 + # 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" + 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 }}" + + # -- 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 state: present - register: custom_role_assignment - # -------------------------------------------------------------------------- - # VERIFICATION: Query API to confirm assignment persists - # -------------------------------------------------------------------------- - - name: Fetch assignment for Team 1 and Custom Role + - name: Fetch custom role assignment from API to confirm persistence 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/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 }}" + res_organization: "{{ org2.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 }}" + 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. + - 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: + - 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 + + - 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: + - 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 + + # -- EDA: eda_credentials ------------------------------------------------- + + - name: Look up EDA credential type + ansible.builtin.uri: + 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: 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: 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: Assert Assignment exists + - 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 }}" + 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 + 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: @@ -258,7 +1170,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 +1182,264 @@ - role_delete.failed - "'Not found' not in role_delete.msg" - "'does not exist' not in role_delete.msg" + + - 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..823d53e9 --- /dev/null +++ b/tests/integration/targets/role_team_assignments_test/tasks/verify_assignment.yml @@ -0,0 +1,64 @@ +--- +# 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} | combine({'organization': res_organization} if res_organization is defined else {}) }}" + 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} | combine({'organization': res_organization} if res_organization is defined else {}) }}" + 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} | 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 + +- 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) + ) 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