diff --git a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index acb50c9e81..68abeaf2ae 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool |----------|------------|------------------------| | Prowler Hub | 10 tools | No | | Prowler Documentation | 2 tools | No | -| Prowler Cloud, Private Cloud & Local Server | 32 tools | Yes | +| Prowler Cloud, Private Cloud & Local Server | 39 tools | Yes | ## Tool Naming Convention @@ -105,6 +105,23 @@ Tools for viewing compliance status and framework details across all cloud provi - **`prowler_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework - **`prowler_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs +### User Management + +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 join date and role/membership IDs +- **`prowler_get_current_user`** - Identify which user the current credentials authenticate as + +### Role Management + +Tools for browsing RBAC roles and managing the role assigned to a user. A user holds exactly one role, so setting a role replaces the one they held before. + +- **`prowler_list_roles`** - List the roles defined in the tenant with their permission scope +- **`prowler_get_role`** - Get detailed information about a specific role by ID, including granted capabilities, visibility scope, assigned users, and provider groups +- **`prowler_get_user_roles`** - List the roles assigned to a specific user, with the capabilities each role grants +- **`prowler_set_user_role`** - Set the role a user holds, replacing the role they had before (idempotent) + ## Prowler Hub Tools Access Prowler's security check catalog and compliance frameworks. **No authentication required.** diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index a30c4e5488..e31b13f12f 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -50,6 +50,7 @@ Full access to your Prowler deployment — Prowler Cloud, Prowler Private Cloud, - **Resource Inventory**: Search and view detailed information about your audited resources - **Muting Management**: Create and manage muting lists/rules to suppress non-relevant findings - **Attack Paths Analysis**: Analyze privilege escalation chains and security misconfigurations through graph-based analysis of cloud resource relationships +- **User & Role Management**: List the users in your tenant, identify the authenticated user, browse RBAC roles, and set the role a user holds ### 2. Prowler Hub diff --git a/mcp_server/README.md b/mcp_server/README.md index c0e4fdb0fe..48ed370dcf 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -16,6 +16,7 @@ Full access to your Prowler data (Prowler Cloud, Prowler Private Cloud, or Prowl - **Resource Inventory**: Search and view detailed information about your audited resources - **Muting Management**: Create and manage muting rules to suppress non-critical findings - **Compliance Reporting**: View compliance status across frameworks and drill into requirement-level details +- **User & Role Management**: List the users in your tenant, identify the authenticated user, browse RBAC roles, and set the role a user holds ### Prowler Hub diff --git a/mcp_server/changelog.d/roles-tools.added.md b/mcp_server/changelog.d/roles-tools.added.md new file mode 100644 index 0000000000..f06343dfb0 --- /dev/null +++ b/mcp_server/changelog.d/roles-tools.added.md @@ -0,0 +1 @@ +RBAC role tools `prowler_list_roles`, `prowler_get_role`, `prowler_get_user_roles`, and `prowler_set_user_role` for browsing roles and setting the role a user holds diff --git a/mcp_server/changelog.d/users-read-tools.added.md b/mcp_server/changelog.d/users-read-tools.added.md new file mode 100644 index 0000000000..3bc55952d8 --- /dev/null +++ b/mcp_server/changelog.d/users-read-tools.added.md @@ -0,0 +1 @@ +Read-only user management tools `prowler_list_users`, `prowler_get_user`, and `prowler_get_current_user` for listing tenant users with their emails and identifying the authenticated user diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/roles.py b/mcp_server/prowler_mcp_server/prowler_app/models/roles.py new file mode 100644 index 0000000000..91499243c5 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/models/roles.py @@ -0,0 +1,230 @@ +"""Data models for Prowler RBAC roles. + +This module provides Pydantic models for representing Prowler roles with +two-tier complexity: +- SimplifiedRole: For list operations with essential identification fields +- DetailedRole: Extends simplified with the capabilities the role grants and + its related users / provider groups + +It also provides UserRolesResult, used by the tools that read or change the +roles assigned to a specific user. + +All models inherit from MinimalSerializerMixin to exclude None/empty values +for optimal LLM token usage. +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin +from prowler_mcp_server.prowler_app.models.utils import extract_relationship_ids + +# Role capabilities are exposed by the API as boolean "manage_*" attributes. +_PERMISSION_ATTRIBUTE_PREFIX = "manage_" + + +class SimplifiedRole(MinimalSerializerMixin, BaseModel): + """Simplified role representation for list operations. + + Includes core identification fields for efficient overview. + Used by list_roles() tool. + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field( + description="Unique UUIDv4 identifier for this role in Prowler database" + ) + name: str = Field(description="Human-readable name of the role") + permission_state: str | None = Field( + default=None, + description="Summary of the role's permissions: 'unlimited' (all), 'limited' (some), or 'none'", + ) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "SimplifiedRole": + """Transform a JSON:API role resource into a simplified model. + + Args: + data: Role data from API response['data'] (single item or list item) + + Returns: + SimplifiedRole instance + """ + attributes = data["attributes"] + + return cls( + id=data["id"], + name=attributes["name"], + permission_state=attributes.get("permission_state"), + ) + + +class DetailedRole(SimplifiedRole): + """Detailed role representation with granted capabilities and relationships. + + Extends SimplifiedRole with the concrete management capabilities the role + grants, its visibility scope, and the IDs of related users and provider + groups. Used by get_role(), get_user_roles() and set_user_role(). + """ + + model_config = ConfigDict(frozen=True) + + permissions: list[str] | None = Field( + default=None, + description="Management capabilities granted by this role, as reported by the API (only the enabled ones), e.g. ['manage_users', 'manage_scans']. Deployments do not all expose the same capabilities, so read `permission_state` for the authoritative summary: 'unlimited' means the role grants every capability, including any not listed here.", + ) + unlimited_visibility: bool | None = Field( + default=None, + description="Whether the role can see all providers (True) or only those in its provider groups (False)", + ) + provider_group_ids: list[str] | None = Field( + default=None, + description="UUIDv4 identifiers of the provider groups this role is scoped to. An empty list means the role is not scoped to any provider group.", + ) + user_ids: list[str] | None = Field( + default=None, + description="UUIDv4 identifiers of the users this role is assigned to. An empty list means the role is not assigned to any user.", + ) + inserted_at: str | None = Field( + default=None, description="ISO 8601 timestamp when the role was created" + ) + updated_at: str | None = Field( + default=None, description="ISO 8601 timestamp when the role was last modified" + ) + + def _should_exclude(self, key: str, value: Any) -> bool: + """Keep fields whose "empty" form carries meaning. + + ``unlimited_visibility`` is kept even when ``False``, and ``permissions`` + and the relationship lists are kept even when empty so that an empty + ``permissions``/``user_ids``/``provider_group_ids`` explicitly signals + "grants no capabilities / not assigned to any user / not scoped to any + provider group" instead of looking like an omitted, unknown field to an + agent. + """ + if key in ( + "unlimited_visibility", + "permissions", + "user_ids", + "provider_group_ids", + ): + return value is None + return super()._should_exclude(key, value) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "DetailedRole": + """Transform a JSON:API role resource into a detailed model. + + Args: + data: Role data from API response['data'] or an included role + + Returns: + DetailedRole instance with all fields populated + """ + attributes = data["attributes"] + relationships = data.get("relationships", {}) + + permissions = [ + name + for name, enabled in attributes.items() + if name.startswith(_PERMISSION_ATTRIBUTE_PREFIX) and enabled + ] + + return cls( + id=data["id"], + name=attributes["name"], + permission_state=attributes.get("permission_state"), + permissions=permissions, + unlimited_visibility=attributes.get("unlimited_visibility"), + provider_group_ids=extract_relationship_ids( + relationships, "provider_groups" + ), + user_ids=extract_relationship_ids(relationships, "users"), + inserted_at=attributes.get("inserted_at"), + updated_at=attributes.get("updated_at"), + ) + + +class RolesListResponse(BaseModel): + """Response model for list_roles() with pagination metadata. + + Follows the established pattern from ScansListResponse and UsersListResponse. + """ + + roles: list[SimplifiedRole] + total_num_roles: int + total_num_pages: int + current_page: int + + @classmethod + def from_api_response(cls, response: dict[str, Any]) -> "RolesListResponse": + """Transform a JSON:API list response into a roles list with pagination. + + Args: + response: Full API response with data and meta + + Returns: + RolesListResponse with simplified roles and pagination metadata + """ + data = response.get("data", []) + meta = response.get("meta", {}) + pagination = meta.get("pagination", {}) + + roles = [SimplifiedRole.from_api_response(item) for item in data] + + return cls( + roles=roles, + total_num_roles=pagination.get("count", 0), + total_num_pages=pagination.get("pages", 0), + current_page=pagination.get("page", 1), + ) + + +class UserRolesResult(MinimalSerializerMixin, BaseModel): + """The roles currently assigned to a user. + + Used by get_user_roles() to report a user's roles, and by set_user_role() + to report the authoritative role set after a change (with `changed` and + `message` describing the outcome). + """ + + user_id: str = Field(description="UUIDv4 identifier of the user") + total_num_roles: int = Field( + description="Number of roles currently assigned to the user" + ) + roles: list[DetailedRole] = Field( + description="The roles currently assigned to the user, with their granted capabilities" + ) + changed: bool | None = Field( + default=None, + description="For assignment operations: whether this call actually modified the user's roles", + ) + message: str | None = Field( + default=None, + description="For assignment operations: human-readable description of the outcome", + ) + + def _should_exclude(self, key: str, value: Any) -> bool: + """Always include the roles list, even when empty (explicit 'no roles').""" + if key == "roles": + return False + return super()._should_exclude(key, value) + + @classmethod + def build( + cls, + user_id: str, + roles: list[DetailedRole], + changed: bool | None = None, + message: str | None = None, + ) -> "UserRolesResult": + """Assemble a result from a user's role list, filling the count.""" + return cls( + user_id=user_id, + total_num_roles=len(roles), + roles=roles, + changed=changed, + message=message, + ) diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/users.py b/mcp_server/prowler_mcp_server/prowler_app/models/users.py new file mode 100644 index 0000000000..0dcd4ac501 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/models/users.py @@ -0,0 +1,143 @@ +"""Data models for Prowler users. + +This module provides Pydantic models for representing Prowler users with +two-tier complexity: +- SimplifiedUser: For list operations with essential identification fields +- DetailedUser: Extends simplified with account metadata and role/membership links + +All models inherit from MinimalSerializerMixin to exclude None/empty values +for optimal LLM token usage. +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin +from prowler_mcp_server.prowler_app.models.utils import extract_relationship_ids + + +class SimplifiedUser(MinimalSerializerMixin, BaseModel): + """Simplified user representation for list operations. + + Includes core identification fields for efficient overview. + Used by list_users() tool. + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field( + description="Unique UUIDv4 identifier for this user in Prowler database" + ) + name: str = Field(description="Display name of the user") + email: str = Field(description="Email address of the user") + company_name: str | None = Field( + default=None, description="Company the user belongs to, if provided" + ) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "SimplifiedUser": + """Transform a JSON:API user resource into a simplified model. + + Args: + data: User data from API response['data'] (single item or list item) + + Returns: + SimplifiedUser instance + """ + attributes = data["attributes"] + + return cls( + id=data["id"], + name=attributes["name"], + email=attributes["email"], + company_name=attributes.get("company_name"), + ) + + +class DetailedUser(SimplifiedUser): + """Detailed user representation with account metadata and relationships. + + 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) + + 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 (omitted when none are visible)", + ) + membership_ids: list[str] | None = Field( + default=None, + description="UUIDv4 identifiers of the tenant memberships of the user (omitted when none are visible)", + ) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "DetailedUser": + """Transform a JSON:API user resource into a detailed model. + + Args: + data: User data from API response['data'] + + Returns: + DetailedUser instance with all fields populated + """ + attributes = data["attributes"] + relationships = data.get("relationships", {}) + + return cls( + id=data["id"], + name=attributes["name"], + email=attributes["email"], + company_name=attributes.get("company_name"), + date_joined=attributes.get("date_joined"), + role_ids=extract_relationship_ids(relationships, "roles"), + membership_ids=extract_relationship_ids(relationships, "memberships"), + ) + + +class UsersListResponse(BaseModel): + """Response model for list_users() with pagination metadata. + + Follows the established pattern from ScansListResponse and ProvidersListResponse. + """ + + users: list[SimplifiedUser] + total_num_users: int + total_num_pages: int + current_page: int + + @classmethod + def from_api_response(cls, response: dict[str, Any]) -> "UsersListResponse": + """Transform a JSON:API list response into a users list with pagination. + + Args: + response: Full API response with data and meta + + Returns: + UsersListResponse with simplified users and pagination metadata + """ + data = response.get("data", []) + meta = response.get("meta", {}) + pagination = meta.get("pagination", {}) + + users = [SimplifiedUser.from_api_response(item) for item in data] + + return cls( + users=users, + total_num_users=pagination.get("count", 0), + total_num_pages=pagination.get("pages", 0), + current_page=pagination.get("page", 1), + ) diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/utils.py b/mcp_server/prowler_mcp_server/prowler_app/models/utils.py new file mode 100644 index 0000000000..a20687c03a --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/models/utils.py @@ -0,0 +1,45 @@ +"""Shared helpers for building models from Prowler API responses. + +Stateless utilities used by the models' ``from_api_response()`` factory methods +to read the JSON:API document structure (relationships, linkage, etc.). Keeping +them here leaves ``base.py`` focused on the base model/mixin and gives these +response-parsing helpers a single, discoverable home. +""" + +from typing import Any + + +def extract_relationship_ids( + relationships: dict[str, Any], relationship_name: 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. + + 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, ``[]`` if the relationship is present but + empty, or ``None`` if the relationship is absent from the document. + """ + relationship = relationships.get(relationship_name) + if relationship is None: + return None + data = relationship.get("data") + if not data: + return [] + if isinstance(data, list): + return [item["id"] for item in data if item and item.get("id")] + # to-one relationship + return [data["id"]] if data.get("id") else [] diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py new file mode 100644 index 0000000000..113694d8e8 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py @@ -0,0 +1,222 @@ +"""Role (RBAC) tools for Prowler MCP Server. + +This module provides read tools for browsing roles and inspecting the role a +user holds, plus a tool for setting it. + +A user holds exactly one role: the API resolves a user's permissions from a +single role (`get_role` in `api.rbac.permissions`) and the UI only ever assigns +one, so setting a role replaces the one the user currently holds instead of +adding to it. +""" + +from typing import Any + +from pydantic import Field + +from prowler_mcp_server.prowler_app.models.roles import ( + DetailedRole, + RolesListResponse, + UserRolesResult, +) +from prowler_mcp_server.prowler_app.tools.base import BaseTool + + +class RolesTools(BaseTool): + """Tools for RBAC role operations. + + Provides tools for: + - prowler_list_roles: List the roles defined in the tenant + - prowler_get_role: Get detailed information about a specific role by ID + - prowler_get_user_roles: List the roles assigned to a specific user + - prowler_set_user_role: Set the role a user holds (idempotent) + """ + + async def list_roles( + self, + page_size: int = Field( + default=50, description="Number of results to return per page" + ), + page_number: int = Field( + default=1, description="Page number to retrieve (1-indexed)" + ), + ) -> dict[str, Any]: + """List the RBAC roles defined in the authenticated tenant. + + Use this to discover which roles exist and their permission scope before + assigning one to a user. Returns LIGHTWEIGHT role information. + + Each role includes: + - id: Prowler internal UUID (v4), used with `prowler_get_role` and the assignment tools + - name: Human-readable role name + - permission_state: Summary of what the role grants ('unlimited', 'limited' or 'none') + + For the concrete capabilities a role grants and the users/provider groups + it relates to, use `prowler_get_role`. + """ + self.api_client.validate_page_size(page_size) + + params: dict[str, Any] = { + "fields[roles]": "name,permission_state", + "page[number]": page_number, + "page[size]": page_size, + } + + clean_params = self.api_client.build_filter_params(params) + + api_response = await self.api_client.get("/roles", params=clean_params) + simplified_response = RolesListResponse.from_api_response(api_response) + + return simplified_response.model_dump() + + async def get_role( + self, + role_id: str = Field( + description="Prowler's internal UUID (v4) for the role to retrieve. Use `prowler_list_roles` to find role IDs if you only know a name." + ), + ) -> dict[str, Any]: + """Retrieve detailed information about a specific role by its ID. + + Returns everything `prowler_list_roles` returns PLUS: + - permissions: The management capabilities the role grants (only the enabled ones). Read `permission_state` for the authoritative summary: 'unlimited' means the role grants every capability, including any this deployment does not list individually + + - unlimited_visibility: Whether the role can see all providers or only its provider groups + - provider_group_ids: Provider groups the role is scoped to (empty list means it is scoped to no provider group) + - user_ids: Users the role is assigned to (empty list means it is assigned to no user) + - inserted_at / updated_at: Lifecycle timestamps + + The `user_ids` and `provider_group_ids` fields are always present: an + empty list means "none", not "unknown". + + Workflow: + 1. Use `prowler_list_roles` to browse roles and find the target role 'id' + 2. Use this tool with that 'id' to inspect exactly what the role grants + """ + api_response = await self.api_client.get(f"/roles/{role_id}") + detailed_role = DetailedRole.from_api_response(api_response["data"]) + + return detailed_role.model_dump() + + async def get_user_roles( + self, + user_id: str = Field( + description="Prowler's internal UUID (v4) for the user whose roles you want. Use `prowler_list_users` to find user IDs, or `prowler_get_current_user` for the caller." + ), + ) -> dict[str, Any]: + """List the roles currently assigned to a specific user. + + 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. A user + normally holds a single role. + + 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 role they hold and what it grants + 3. Use `prowler_set_user_role` to change it + """ + roles = await self._fetch_user_roles(user_id) + + return UserRolesResult.build(user_id=user_id, roles=roles).model_dump() + + async def set_user_role( + self, + user_id: str = Field( + description="Prowler's internal UUID (v4) for the user whose role you want to set. Use `prowler_list_users` to find user IDs." + ), + role_id: str = Field( + description="Prowler's internal UUID (v4) for the role the user should hold. Use `prowler_list_roles` to find role IDs." + ), + ) -> dict[str, Any]: + """Set the role a user holds, replacing the role they had before. + + A user holds exactly one role in Prowler: their permissions are resolved + from a single role, so granting a new one REPLACES the previous one + instead of adding to it. To change what a user can do, set the role that + grants the capabilities they should have. + + This tool is idempotent: if the user already holds only this role, it + makes no change and reports `changed: false`. It always returns the + user's up-to-date role after the operation. + + Note: this operation requires both MANAGE_ACCOUNT (to change role + assignments) and MANAGE_USERS (to read the user's current role). The API + rejects the change when it would leave the tenant without a user holding + MANAGE_ACCOUNT; such rejections are surfaced as errors. + + Workflow: + 1. Use `prowler_list_roles` to find the role 'id' to grant + 2. Use `prowler_list_users` to find the target user 'id' + 3. Use this tool to set the user's role + """ + current_roles = await self._fetch_user_roles(user_id) + if [role.id for role in current_roles] == [role_id]: + return UserRolesResult.build( + user_id=user_id, + roles=current_roles, + changed=False, + message=f"User {user_id} already holds role {role_id}; no change made.", + ).model_dump() + + # The relationship endpoint accepts any well-formed UUID and silently + # drops role IDs that do not exist in this tenant, which would leave the + # user with no role at all. Confirm the role exists before replacing. + try: + await self.api_client.get(f"/roles/{role_id}") + except Exception as e: + raise ValueError( + f"Role {role_id} could not be read ({e}), so user {user_id} was left " + f"unchanged. Use `prowler_list_roles` to find a valid role ID." + ) from e + + # PATCH replaces the user's whole role set with this single role, the + # same call the Prowler UI makes when changing a user's role. + await self.api_client.patch( + f"/users/{user_id}/relationships/roles", + json_data={"data": [{"type": "roles", "id": role_id}]}, + ) + + # After the change, fetch the user's roles again to report the authoritative state + updated_roles = await self._fetch_user_roles(user_id) + + return UserRolesResult.build( + user_id=user_id, + roles=updated_roles, + changed=True, + message=f"Role {role_id} set for user {user_id}.", + ).model_dump() + + # Private helper methods + + 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. 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 + + Returns: + The user's roles as DetailedRole instances (empty list if none) + """ + response = await self.api_client.get( + f"/users/{user_id}", params={"include": "roles"} + ) + included = response.get("included", []) or [] + + return [ + DetailedRole.from_api_response(item) + for item in included + if item.get("type") == "roles" + ] diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/users.py b/mcp_server/prowler_mcp_server/prowler_app/tools/users.py new file mode 100644 index 0000000000..a7e31b60ad --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/users.py @@ -0,0 +1,121 @@ +"""User management tools for Prowler MCP Server. + +This module provides read-only tools for viewing the users that belong to the +authenticated tenant, including identifying which user the current credentials +(API key or JWT) authenticate as. +""" + +from typing import Any + +from pydantic import Field + +from prowler_mcp_server.prowler_app.models.users import ( + DetailedUser, + UsersListResponse, +) +from prowler_mcp_server.prowler_app.tools.base import BaseTool + + +class UsersTools(BaseTool): + """Tools for user management operations (read-only). + + Provides tools for: + - 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 + - prowler_get_current_user: Identify which user the current credentials authenticate as + """ + + async def list_users( + self, + name: str | None = Field( + default=None, + description="Filter by user display name. Partial match supported (case-insensitive).", + ), + email: str | None = Field( + default=None, + description="Filter by user email address. Partial match supported (case-insensitive).", + ), + page_size: int = Field( + default=50, description="Number of results to return per page" + ), + page_number: int = Field( + default=1, description="Page number to retrieve (1-indexed)" + ), + ) -> dict[str, Any]: + """List the users that belong to the authenticated tenant. + + Use this to see who has access to the tenant and to look up their email + addresses. Returns LIGHTWEIGHT user information optimized for browsing. + + Each user includes: + - id: Prowler internal UUID (v4), used with `prowler_get_user` + - name: Display name + - email: Email address + - 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 + and join date, use `prowler_get_user`. + """ + self.api_client.validate_page_size(page_size) + + params: dict[str, Any] = { + "fields[users]": "name,email,company_name", + "page[number]": page_number, + "page[size]": page_size, + } + + if name: + params["filter[name__icontains]"] = name + if email: + params["filter[email__icontains]"] = email + + clean_params = self.api_client.build_filter_params(params) + + api_response = await self.api_client.get("/users", params=clean_params) + simplified_response = UsersListResponse.from_api_response(api_response) + + return simplified_response.model_dump() + + async def get_user( + self, + user_id: str = Field( + description="Prowler's internal UUID (v4) for the user to retrieve. Use `prowler_list_users` to find user IDs if you only know a name or email." + ), + ) -> dict[str, Any]: + """Retrieve detailed information about a specific user by their ID. + + Returns everything `prowler_list_users` returns PLUS: + - 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 + """ + api_response = await self.api_client.get(f"/users/{user_id}") + detailed_user = DetailedUser.from_api_response(api_response["data"]) + + return detailed_user.model_dump() + + async def get_current_user(self) -> dict[str, Any]: + """Identify which user the current credentials authenticate as. + + Use this to determine the identity behind the credentials this MCP server + is currently using, e.g. before performing actions on behalf of that user + or when reporting who is connected. + + Returns the same detailed information as `prowler_get_user`: + - id, name, email, company_name + - date_joined + - role_ids, membership_ids + """ + api_response = await self.api_client.get("/users/me") + detailed_user = DetailedUser.from_api_response(api_response["data"]) + + return detailed_user.model_dump() diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py index 187364bee1..3222454dcb 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py @@ -176,13 +176,19 @@ class ProwlerAPIClient(metaclass=SingletonMeta): ) async def delete( - self, path: str, params: dict[str, any] | None = None + self, + path: str, + params: dict[str, any] | None = None, + json_data: dict[str, any] | None = None, ) -> dict[str, any]: """Make DELETE request. Args: path: API endpoint path params: Optional query parameters + json_data: Optional JSON body data. Some JSON:API relationship + endpoints (e.g. ``/users/{id}/relationships/roles``) accept a + body listing the specific members to remove. Returns: API response as dictionary @@ -190,7 +196,9 @@ class ProwlerAPIClient(metaclass=SingletonMeta): Raises: Exception: If API request fails """ - return await self._make_request(HTTPMethod.DELETE, path, params=params) + return await self._make_request( + HTTPMethod.DELETE, path, params=params, json_data=json_data + ) async def fetch_external_url(self, url: str) -> str: """Fetch content from an allowed external URL (unauthenticated).