fix(mcp): multiple issues

This commit is contained in:
Rubén De la Torre Vico
2026-07-23 13:51:13 +02:00
parent e33995c665
commit 0e93c7ed13
5 changed files with 80 additions and 48 deletions
@@ -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
@@ -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"),
@@ -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):
@@ -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
@@ -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")