mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
fix(mcp): multiple issues
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user