mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
feat(mcp): add users and roles tools to the Prowler MCP Server (#12088)
This commit is contained in:
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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 []
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user