Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions plugins/action/role_team_assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,44 @@
from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin
from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.role_team_assignment import AnsibleRoleTeamAssignment

# Maps the suffix of a role definition's content_type to the Gateway API
# endpoint used for name-based object lookup.
# e.g. "awx.project" → suffix "project" → endpoint "projects"
_CONTENT_TYPE_ENDPOINT_MAP = {
"organization": "organizations",
"team": "teams",
# Controller (awx)
"project": "projects",
"inventory": "inventories",
"credential": "credentials",
"jobtemplate": "job_templates",
"workflowjobtemplate": "workflow_job_templates",
"executionenvironment": "execution_environments",
"instancegroup": "instance_groups",
"notificationtemplate": "notification_templates",
# EDA (eda)
"activation": "activations",
"edacredential": "eda_credentials",
"eventstream": "event_streams",
"decisionenvironment": "decision_environments",
"credentialinputsource": "credential_input_sources",
# Hub (galaxy)
"namespace": "namespaces",
"collectionremote": "collection_remotes",
"ansiblerepository": "ansible_repositories",
"containernamespace": "container_namespaces",
"containerrepository": "container_repositories",
}


def _get_expected_endpoint(content_type):
"""Derive the expected lookup endpoint from a role definition's content_type."""
raw = (content_type or "").strip()
if not raw:
return None
suffix = raw.split(".")[-1] if "." in raw else raw
return _CONTENT_TYPE_ENDPOINT_MAP.get(suffix, "{0}s".format(suffix))


class ActionModule(BaseResourceActionPlugin):
MODULE_NAME = "role_team_assignment"
Expand Down Expand Up @@ -78,6 +116,20 @@ def run(self, tmp=None, task_vars=None):
# ---- single-object path: standard run logic -------------------
return self._run_standard(result, manager, argspec, validated_params, state)

# ---- resolve role_definition content_type for type validation -----
role_def_name = validated_params.get("role_definition", "")
_role_def_obj = None
try:
_role_def_obj = manager.execute(
operation="find",
module_name="role_definition",
ansible_data={"name": role_def_name},
)
except Exception:
pass
_role_content_type = (_role_def_obj or {}).get("content_type") if _role_def_obj else None
_expected_endpoint = _get_expected_endpoint(_role_content_type)

# ---- multi-object path: iterate over assignment_objects -----------
# Base data shared across all assignments (role + team, no object_id)
_skip = self._AUTH_PARAMS | {
Expand All @@ -102,6 +154,23 @@ def run(self, tmp=None, task_vars=None):
elif obj.get("object_ansible_id"):
per_obj["object_ansible_id"] = str(obj["object_ansible_id"])
elif obj.get("name") and obj.get("type"):
# Validate that the user-provided type matches what the
# role's content_type expects. Mismatches cause the Gateway
# API to reject the assignment with 400/500.
if _expected_endpoint and obj["type"] != _expected_endpoint:
raise AnsibleError(
"Role '{role}' has content_type that requires type '{expected}' "
"for name-based lookup, but assignment_objects specifies "
"type '{provided}'. To grant access to all {resource}s within "
"an organization, use the organization-scoped variant of this "
"role. To target a specific {resource}, use "
"type '{expected}' with the resource name.".format(
role=role_def_name,
expected=_expected_endpoint,
provided=obj["type"],
resource=_expected_endpoint.rstrip("s"),
)
)
try:
oid = manager.lookup_resource_id(obj["type"], "name", obj["name"])
per_obj["object_id"] = str(oid) # CRITICAL: Must be string
Expand Down
16 changes: 9 additions & 7 deletions plugins/modules/role_team_assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@
remove those permissions.
- Not all role assignments are valid. See Limitations below.
notes:
- This module is subject to limitations of the RBAC system in AAP 2.6.
- Global roles (e.g. Platform Auditor) cannot be assigned to teams.
- Team roles cannot be assigned to another team
(Team Admin to Team is not supported).
- Organization Member role cannot be assigned to teams.
- Only resource-scoped organization roles such as Organization Inventory Admin
and Organization Credential Admin can be meaningfully assigned to teams.
- The C(type) field in C(assignment_objects) must match the resource type
expected by the role definition's C(content_type). For example, a role
with C(content_type=awx.project) requires C(type=projects). Using
C(type=organizations) for such a role will result in an error. Use the
organization-scoped variant of the role (e.g. "Organization Project Admin")
to grant access to all resources of that type within an organization.
- Attempting unsupported role assignments will result in errors.
options:
role_definition:
Expand Down Expand Up @@ -66,6 +67,7 @@
type:
description:
- The object type used for name lookup.
- Must match the content_type of the role definition.
- Supported values are C(organizations) and C(teams).
type: str
required: false
Expand Down Expand Up @@ -119,9 +121,9 @@
team: "APAC-BLR"
assignment_objects:
- name: "org-emea"
type: "organizations"
type: organizations
- name: "org-apac"
type: "organizations"
type: organizations
state: present
register: result

Expand Down
5 changes: 5 additions & 0 deletions plugins/plugin_utils/api/v1/role_team_assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ def _get(key):
if isinstance(object_id, int) or str(object_id).isdigit():
api_data["object_id"] = str(object_id)
elif manager:
# Fallback name→id lookup for the single-object path where
# object_id is passed as a name with no explicit type.
# Scoped to Gateway resources only (organizations, teams).
# Resource-level name resolution (EDA/Hub/Controller) is
# handled in the action plugin via type + assignment_objects.
for endpoint in ("organizations", "teams"):
resolved = _resolve_fk(manager, endpoint, "name", object_id)
if resolved is not None:
Expand Down
184 changes: 183 additions & 1 deletion tests/integration/targets/role_team_assignments_test/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,170 @@
state: present
register: custom_role

# --------------------------------------------------------------------------
# Resource-level role definitions for type-mismatch tests
# --------------------------------------------------------------------------
- name: Create project-scoped custom role (content_type awx.project)
ansible.platform.role_definition:
name: "{{ custom_role_name }}-project"
description: "Project-scoped role for content_type mismatch tests"
content_type: "awx.project"
permissions:
- "awx.view_project"
state: present
register: custom_project_role

- name: Create activation-scoped custom role (content_type eda.activation)
ansible.platform.role_definition:
name: "{{ custom_role_name }}-activation"
description: "Activation-scoped role for content_type mismatch tests"
content_type: "eda.activation"
permissions:
- "eda.view_activation"
state: present
register: custom_activation_role

# --------------------------------------------------------------------------
# Root cause: resource-level role + type:organizations caused 400/500.
# Fix: module now validates type matches content_type and raises a clear error.
# --------------------------------------------------------------------------

# TEST 1: Type mismatch — project role + type:organizations must give clear error
- name: "BUG#165 | project-scoped role with type:organizations must fail with clear error"
ansible.platform.role_team_assignment:
role_definition: "{{ custom_role_name }}-project"
team: "{{ team1.name }}"
assignment_objects:
- name: "{{ org1.name }}"
type: organizations
state: present
register: mismatch_project_org
ignore_errors: true

- name: "BUG#165 | Assert error message tells user the correct type to use"
ansible.builtin.assert:
that:
- mismatch_project_org is failed
- "'projects' in mismatch_project_org.msg"
- "'organizations' in mismatch_project_org.msg"
success_msg: >
BUG FIX CONFIRMED: module rejects project role + type:organizations with
a clear error pointing to 'projects' as the expected type.
fail_msg: >
BUG NOT FIXED: either the assignment was silently accepted (no error)
or the error message does not mention the correct type 'projects'.
Result: {{ mismatch_project_org | to_nice_json }}

# TEST 2: Type mismatch — activation role + type:organizations must give clear error
- name: "BUG#165 | activation-scoped role with type:organizations must fail with clear error"
ansible.platform.role_team_assignment:
role_definition: "{{ custom_role_name }}-activation"
team: "{{ team1.name }}"
assignment_objects:
- name: "{{ org1.name }}"
type: organizations
state: present
register: mismatch_activation_org
ignore_errors: true

- name: "BUG#165 | Assert activation mismatch error mentions activations"
ansible.builtin.assert:
that:
- mismatch_activation_org is failed
- "'activations' in mismatch_activation_org.msg"
- "'organizations' in mismatch_activation_org.msg"
success_msg: "BUG FIX CONFIRMED: activation role + type:organizations correctly rejected."
fail_msg: "Activation mismatch not caught. Result: {{ mismatch_activation_org | to_nice_json }}"

# TEST 3: Correct org-level role assignment still works (no regression)
- name: "BUG#165 | org-scoped role with type:organizations must still work (regression check)"
ansible.platform.role_team_assignment:
role_definition: "{{ custom_role_name }}"
team: "{{ team1.name }}"
assignment_objects:
- name: "{{ org1.name }}"
type: organizations
state: present
register: correct_org_assignment

- name: "BUG#165 | Assert org-level assignment succeeds"
ansible.builtin.assert:
that:
- correct_org_assignment is not failed
success_msg: "No regression: org-scoped role + type:organizations still works correctly."
fail_msg: "REGRESSION: org-scoped role assignment is broken. {{ correct_org_assignment.msg | default('') }}"

# TEST 4: Idempotency — re-running org assignment produces no change
- name: "BUG#165 | Idempotency — re-run org assignment"
ansible.platform.role_team_assignment:
role_definition: "{{ custom_role_name }}"
team: "{{ team1.name }}"
assignment_objects:
- name: "{{ org1.name }}"
type: organizations
state: present
register: idempotent_check

- name: "BUG#165 | Assert idempotency"
ansible.builtin.assert:
that:
- idempotent_check is not failed
- idempotent_check is not changed
success_msg: "Idempotency confirmed — second run made no changes."
fail_msg: "Idempotency broken — second run changed state or failed."

# TEST 5: object_ansible_id path bypasses type check and still works
- name: "BUG#165 | Assignment via object_ansible_id must work regardless of content_type"
ansible.platform.role_team_assignment:
role_definition: "{{ custom_role_name }}"
team: "{{ team2.name }}"
assignment_objects:
- object_ansible_id: "{{ org2.organization.ansible_id | default(omit) }}"
state: present
register: ansible_id_assignment
when: org2.organization.ansible_id is defined
ignore_errors: true

# TEST 6: state:absent removes assignment correctly
- name: "BUG#165 | state:absent removes the assignment"
ansible.platform.role_team_assignment:
role_definition: "{{ custom_role_name }}"
team: "{{ team1.name }}"
assignment_objects:
- name: "{{ org1.name }}"
type: organizations
state: absent
register: absent_assignment

- name: "BUG#165 | Assert assignment was removed"
ansible.builtin.assert:
that:
- absent_assignment is not failed
- absent_assignment is changed
success_msg: "state:absent removed assignment correctly."
fail_msg: "state:absent failed or made no change. {{ absent_assignment.msg | default('') }}"

# TEST 7: Multi-org assignment — single task, multiple objects
- name: "BUG#165 | Multi-org assignment in one task"
ansible.platform.role_team_assignment:
role_definition: "{{ custom_role_name }}"
team: "{{ team3.name }}"
assignment_objects:
- name: "{{ org3.name }}"
type: organizations
- name: "{{ org4.name }}"
type: organizations
state: present
register: multi_org_assignment

- name: "BUG#165 | Assert multi-org assignment succeeded"
ansible.builtin.assert:
that:
- multi_org_assignment is not failed
- multi_org_assignment.assignments | length == 2
success_msg: "Multi-org assignment: both orgs assigned in one task."
fail_msg: "Multi-org assignment failed. {{ multi_org_assignment | to_nice_json }}"

# 1. Assign Org Admin role to Team1 on Org1 (Global role can't be assigned)
- name: Assign Org Admin to Team1 on Org1
ansible.platform.role_team_assignment:
Expand Down Expand Up @@ -258,7 +422,7 @@
- "{{ org3.name }}"
- "{{ org4.name }}"

- name: Delete custom role
- name: Delete custom org-scoped role
ansible.platform.role_definition:
name: "{{ custom_role_name }}"
content_type: "shared.organization"
Expand All @@ -270,4 +434,22 @@
- role_delete.failed
- "'Not found' not in role_delete.msg"
- "'does not exist' not in role_delete.msg"

- name: Delete custom project-scoped role (bug#165 test)
ansible.platform.role_definition:
name: "{{ custom_role_name }}-project"
content_type: "awx.project"
permissions:
- "awx.view_project"
state: absent
failed_when: false

- name: Delete custom activation-scoped role (bug#165 test)
ansible.platform.role_definition:
name: "{{ custom_role_name }}-activation"
content_type: "eda.activation"
permissions:
- "eda.view_activation"
state: absent
failed_when: false
...
Loading