diff --git a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index 66702e7bce..8a5e5c951e 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -110,7 +110,7 @@ Tools for viewing compliance status and framework details across all cloud provi Tools for viewing the users in your tenant and identifying the authenticated user. - **`prowler_list_users`** - List the users in the tenant with their names and emails -- **`prowler_get_user`** - Get detailed information about a specific user by ID, including verification status, join date, and role/membership IDs +- **`prowler_get_user`** - Get detailed information about a specific user by ID, including join date and role/membership IDs - **`prowler_get_current_user`** - Identify which user the current credentials authenticate as ### Role Management diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/users.py b/mcp_server/prowler_mcp_server/prowler_app/models/users.py index 369911b989..0dcd4ac501 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/users.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/users.py @@ -58,42 +58,32 @@ class SimplifiedUser(MinimalSerializerMixin, BaseModel): class DetailedUser(SimplifiedUser): """Detailed user representation with account metadata and relationships. - Extends SimplifiedUser with verification status, join date, and the IDs of - the roles and memberships associated with the user. + Extends SimplifiedUser with the join date and the IDs of the roles and + memberships associated with the user. Used by get_user() and get_current_user() tools. + + Note: ``role_ids`` and ``membership_ids`` are omitted when empty because an + empty list is ambiguous here. The API hides another user's roles/memberships + from callers without MANAGE_ACCOUNT (returning them empty rather than + forbidden), so an empty list cannot be told apart from "genuinely none". They + are only reported when at least one ID is visible. """ model_config = ConfigDict(frozen=True) - is_verified: bool | None = Field( - default=None, - description="Whether the user has verified their email address", - ) date_joined: str | None = Field( default=None, description="ISO 8601 timestamp when the user joined", ) role_ids: list[str] | None = Field( default=None, - description="UUIDv4 identifiers of the roles assigned to the user", + description="UUIDv4 identifiers of the roles assigned to the user (omitted when none are visible)", ) membership_ids: list[str] | None = Field( default=None, - description="UUIDv4 identifiers of the tenant memberships of the user", + description="UUIDv4 identifiers of the tenant memberships of the user (omitted when none are visible)", ) - def _should_exclude(self, key: str, value: Any) -> bool: - """Keep fields whose "empty" form carries meaning. - - ``is_verified`` is kept even when ``False``, and ``role_ids`` / - ``membership_ids`` are kept even when empty so that an empty list - explicitly signals "not assigned to any role / not a member of any - tenant" instead of looking like an omitted, unknown field to an agent. - """ - if key in ("is_verified", "role_ids", "membership_ids"): - return value is None - return super()._should_exclude(key, value) - @classmethod def from_api_response(cls, data: dict[str, Any]) -> "DetailedUser": """Transform a JSON:API user resource into a detailed model. @@ -112,7 +102,6 @@ class DetailedUser(SimplifiedUser): name=attributes["name"], email=attributes["email"], company_name=attributes.get("company_name"), - is_verified=attributes.get("is_verified"), date_joined=attributes.get("date_joined"), role_ids=extract_relationship_ids(relationships, "roles"), membership_ids=extract_relationship_ids(relationships, "memberships"), diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/utils.py b/mcp_server/prowler_mcp_server/prowler_app/models/utils.py index ae4e6876b9..a20687c03a 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/utils.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/utils.py @@ -11,21 +11,32 @@ from typing import Any def extract_relationship_ids( relationships: dict[str, Any], relationship_name: str -) -> list[str]: +) -> list[str] | None: """Extract related resource IDs from a JSON:API relationship. Handles both to-one (``data`` is an object) and to-many (``data`` is a list) - relationships, returning a flat list of IDs in either case. Missing or empty - relationships yield an empty list. + relationships, returning a flat list of IDs in either case. + + The absent and present-but-empty cases are deliberately distinguished so + callers can tell "the relationship was not part of this document" from "the + relationship is genuinely empty": + + - Relationship key absent → ``None`` (unknown; the serializer did not expose + it, e.g. a role included via ``?include=roles`` carries no ``users``). + - Relationship key present but with no members → ``[]`` (explicitly none). Args: relationships: The ``relationships`` object from a JSON:API resource relationship_name: The relationship key to read (e.g. ``"roles"``) Returns: - List of related resource IDs (empty if the relationship is absent/empty) + List of related resource IDs, ``[]`` if the relationship is present but + empty, or ``None`` if the relationship is absent from the document. """ - data = relationships.get(relationship_name, {}).get("data") + relationship = relationships.get(relationship_name) + if relationship is None: + return None + data = relationship.get("data") if not data: return [] if isinstance(data, list): diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py index 98ce8f77d1..3a38cd3ba7 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py @@ -33,10 +33,6 @@ class RolesTools(BaseTool): async def list_roles( self, - search: str | None = Field( - default=None, - description="Free-text search term to find roles (matches on name and related fields).", - ), page_size: int = Field( default=50, description="Number of results to return per page" ), @@ -64,8 +60,6 @@ class RolesTools(BaseTool): "page[number]": page_number, "page[size]": page_size, } - if search: - params["filter[search]"] = search clean_params = self.api_client.build_filter_params(params) @@ -112,6 +106,11 @@ class RolesTools(BaseTool): Returns the user's roles with the concrete capabilities each one grants, so you can see what the user is allowed to do in the tenant. + Note: this reads the user's record, so it requires MANAGE_USERS (the same + permission `prowler_get_user` needs). Each role's `user_ids` and + `provider_group_ids` are not resolved here; use `prowler_get_role` for a + role's full assignment and provider-group scope. + Workflow: 1. Use `prowler_list_users` (or `prowler_get_current_user`) to find the user 'id' 2. Use this tool to see which roles they hold and what those roles grant @@ -136,8 +135,9 @@ class RolesTools(BaseTool): change and reports `changed: false`. It always returns the user's full, up-to-date role set after the operation. - Note: role management requires MANAGE_ACCOUNT permission. The API also - enforces guard rails (for example, it keeps at least one user with + Note: this operation requires both MANAGE_ACCOUNT (to change role + assignments) and MANAGE_USERS (to read the user's current roles). The API + also enforces guard rails (for example, it keeps at least one user with MANAGE_ACCOUNT in the tenant); such rejections are surfaced as errors. Workflow: @@ -161,6 +161,20 @@ class RolesTools(BaseTool): ) updated_roles = await self._fetch_user_roles(user_id) + if not any(role.id == role_id for role in updated_roles): + # The relationship endpoint silently ignores role IDs that don't + # exist in this tenant (an unknown ID, or a role from another + # tenant): it returns success without assigning anything. Detect that + # here so we never report a change that did not actually happen. + # + # TODO: This should be a raise ValueError(...) instead of returning an error dict, but we don't have + # a good standard for error handling in the tools yet. We should unify that across all tools. + return { + "error": f"Role {role_id} could not be assigned to user {user_id}: the role " + f"was not found in this tenant. Use `prowler_list_roles` to find a " + f"valid role ID." + } + return UserRolesResult.build( user_id=user_id, roles=updated_roles, @@ -183,10 +197,10 @@ class RolesTools(BaseTool): change and reports `changed: false`. It always returns the user's full, up-to-date role set after the operation. - Note: role management requires MANAGE_ACCOUNT permission. The API also - enforces guard rails — users cannot remove their own role assignments, - and the last user holding MANAGE_ACCOUNT in the tenant cannot lose it; - such rejections are surfaced as errors. + Note: this operation requires both MANAGE_ACCOUNT (to change role + assignments) and MANAGE_USERS (to read the user's current roles). The API + also enforces a guard rail — the last user holding MANAGE_ACCOUNT in the + tenant cannot lose it; such rejections are surfaced as errors. Workflow: 1. Use `prowler_get_user_roles` to see the user's current roles @@ -201,10 +215,18 @@ class RolesTools(BaseTool): message=f"Role {role_id} is not assigned to user {user_id}; no change made.", ).model_dump() - # DELETE with a body removes only the listed role, leaving the rest. - await self.api_client.delete( + # The DELETE relationship endpoint cannot remove a single role reliably: + # a JSON:API to-many payload is parsed as a list, which the API treats as + # "clear all of the user's roles". Instead, PATCH the relationship with + # the roles the user should keep, replacing the full set in one request. + remaining_roles = [ + {"type": "roles", "id": role.id} + for role in current_roles + if role.id != role_id + ] + await self.api_client.patch( f"/users/{user_id}/relationships/roles", - json_data={"data": [{"type": "roles", "id": role_id}]}, + json_data={"data": remaining_roles}, ) updated_roles = await self._fetch_user_roles(user_id) @@ -220,8 +242,15 @@ class RolesTools(BaseTool): async def _fetch_user_roles(self, user_id: str) -> list[DetailedRole]: """Fetch the roles currently assigned to a user. - Uses a single ``GET /users/{id}?include=roles`` request and reads the - role resources from the JSON:API ``included`` section. + Uses a single `GET /users/{id}?include=roles` request and reads the + role resources from the JSON:API `included` section. This request + requires MANAGE_USERS (it reads the user record). + + The included role resources do not carry their `users` / + `provider_groups` relationships, so the returned `DetailedRole` + instances omit `user_ids` / `provider_group_ids` (unknown here) + rather than reporting them as empty. Use `get_role` for a role's full + assignment and provider-group scope. Args: user_id: The Prowler UUID of the user diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/users.py b/mcp_server/prowler_mcp_server/prowler_app/tools/users.py index a7f910c365..a7e31b60ad 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/users.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/users.py @@ -54,8 +54,8 @@ class UsersTools(BaseTool): - company_name: Company the user belongs to, when set To find out which user the current credentials authenticate as, use - `prowler_get_current_user`. For a single user's roles, membership links, - verification status and join date, use `prowler_get_user`. + `prowler_get_current_user`. For a single user's roles, membership links + and join date, use `prowler_get_user`. """ self.api_client.validate_page_size(page_size) @@ -86,11 +86,14 @@ class UsersTools(BaseTool): """Retrieve detailed information about a specific user by their ID. Returns everything `prowler_list_users` returns PLUS: - - is_verified: Whether the user has verified their email address - date_joined: When the user joined - role_ids: UUIDs of the roles assigned to the user - membership_ids: UUIDs of the user's tenant memberships + Reading another user's roles/memberships requires MANAGE_ACCOUNT; without + it the API hides them and `role_ids`/`membership_ids` are omitted rather + than reported as empty. + Workflow: 1. Use `prowler_list_users` to browse users and find the target user 'id' 2. Use this tool with that 'id' to inspect the user's roles and account details @@ -109,7 +112,7 @@ class UsersTools(BaseTool): Returns the same detailed information as `prowler_get_user`: - id, name, email, company_name - - is_verified, date_joined + - date_joined - role_ids, membership_ids """ api_response = await self.api_client.get("/users/me")