diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index f4a8bbfd56..7c36d0add9 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -94,6 +94,7 @@ CatalogServerRegisterResponse, CatalogServerStatusResponse, GatewayCreate, + GatewayCredentialRevealResponse, GatewayRead, GatewayTestRequest, GatewayTestResponse, @@ -11330,6 +11331,56 @@ async def admin_get_gateway(gateway_id: str, db: Session = Depends(get_db), user raise e +@admin_router.post("/gateways/{gateway_id}/reveal-credentials", response_model=GatewayCredentialRevealResponse) +@require_permission("gateways.read", allow_admin_bypass=False) +async def admin_reveal_gateway_credentials(gateway_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> GatewayCredentialRevealResponse: + """Reveal plaintext credentials for a gateway. + + Returns the decrypted authentication credentials for the specified gateway. + This endpoint is restricted to authorized users and every call is recorded + in the audit trail. + + Args: + gateway_id: Gateway ID. + db: Database session. + user: Authenticated user. + + Returns: + GatewayCredentialRevealResponse with plaintext credential fields. + + Raises: + HTTPException: 404 if the gateway is not found. + Exception: For any other unexpected errors. + + Examples: + >>> callable(admin_reveal_gateway_credentials) + True + >>> admin_reveal_gateway_credentials.__name__ + 'admin_reveal_gateway_credentials' + """ + user_email = get_user_email(user) + LOGGER.debug(f"User {user_email} requested credential reveal for gateway ID {gateway_id}") + + audit_service = get_audit_trail_service() + audit_service.log_action( + action="READ", + resource_type="gateway", + resource_id=gateway_id, + user_id=user_email, + user_email=user_email, + context={"action": "credential_reveal"}, + db=db, + ) + + try: + return await gateway_service.get_gateway_with_credentials(db, gateway_id) + except GatewayNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + LOGGER.error(f"Error revealing credentials for gateway {gateway_id}: {e}") + raise e + + @admin_router.post("/gateways") @require_permission("gateways.create", allow_admin_bypass=False) async def admin_add_gateway(request: Request, db: Session = Depends(get_db), user: dict[str, Any] = Depends(get_current_user_with_permissions)) -> JSONResponse: diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 1c80475ae8..5df3993d8f 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -3517,6 +3517,24 @@ def masked(self) -> "GatewayRead": return GatewayRead.model_validate(masked_data) +class GatewayCredentialRevealResponse(BaseModelWithConfigDict): + """Response schema for the gateway credential reveal endpoint. + + Returns plaintext credentials for a specific gateway, intended for + authorized administrative use only. Every call to this endpoint is + audit-logged. + """ + + gateway_id: str = Field(..., description="ID of the gateway whose credentials are revealed") + auth_type: Optional[str] = Field(None, description="Authentication type: basic, bearer, authheaders, etc.") + auth_token: Optional[str] = Field(None, description="Plaintext bearer token") + auth_username: Optional[str] = Field(None, description="Plaintext username for basic authentication") + auth_password: Optional[str] = Field(None, description="Plaintext password for basic authentication") + auth_header_key: Optional[str] = Field(None, description="Custom header key for authheaders authentication") + auth_header_value: Optional[str] = Field(None, description="Plaintext custom header value for authheaders authentication") + auth_headers: Optional[List[Dict[str, str]]] = Field(None, description="Plaintext list of custom authentication headers") + + class GatewayRefreshResponse(BaseModelWithConfigDict): """Response schema for manual gateway refresh API. diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index fc8a81373d..f4d9fbf120 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -90,7 +90,7 @@ from mcpgateway.db import Tool as DbTool from mcpgateway.db import ToolMetric from mcpgateway.observability import create_span -from mcpgateway.schemas import GatewayCreate, GatewayRead, GatewayUpdate, PromptCreate, ResourceCreate, ToolCreate +from mcpgateway.schemas import GatewayCreate, GatewayCredentialRevealResponse, GatewayRead, GatewayUpdate, PromptCreate, ResourceCreate, ToolCreate # logging.getLogger("httpx").setLevel(logging.WARNING) # Disables httpx logs for regular health checks from mcpgateway.services.audit_trail_service import get_audit_trail_service @@ -2441,6 +2441,66 @@ async def get_gateway(self, db: Session, gateway_id: str, include_inactive: bool raise GatewayNotFoundError(f"Gateway not found: {gateway_id}") + async def get_gateway_with_credentials(self, db: Session, gateway_id: str) -> GatewayCredentialRevealResponse: + """Retrieve plaintext credentials for a gateway. + + Fetches the gateway and returns its decrypted authentication credentials + without masking. This method must only be called from endpoints that + enforce strict authorization and audit logging. + + Args: + db: Database session + gateway_id: Gateway ID + + Returns: + GatewayCredentialRevealResponse with plaintext credential fields + + Raises: + GatewayNotFoundError: If the gateway is not found + + Examples: + >>> from unittest.mock import MagicMock + >>> service = GatewayService() + >>> db = MagicMock() + >>> db.execute.return_value.scalar_one_or_none.return_value = None + >>> import asyncio + >>> try: + ... asyncio.run(service.get_gateway_with_credentials(db, 'missing_id')) + ... except GatewayNotFoundError as e: + ... 'Gateway not found: missing_id' in str(e) + True + >>> asyncio.run(service._http_client.aclose()) + """ + gateway = db.execute(select(DbGateway).options(joinedload(DbGateway.email_team)).where(DbGateway.id == gateway_id)).scalar_one_or_none() + + if not gateway: + raise GatewayNotFoundError(f"Gateway not found: {gateway_id}") + + # Build the same dict that convert_gateway_to_read uses, but skip .masked() so that + # _populate_auth() leaves the plaintext values in the _unmasked fields. + gateway_dict = gateway.__dict__.copy() + gateway_dict.pop("_sa_instance_state", None) + if isinstance(gateway.auth_value, dict): + gateway_dict["auth_value"] = encode_auth(gateway.auth_value) + if gateway.tags: + gateway_dict["tags"] = validate_tags_field(gateway.tags) if isinstance(gateway.tags[0], str) else gateway.tags + else: + gateway_dict["tags"] = [] + for field in ("created_by", "modified_by", "created_at", "updated_at", "version", "team"): + gateway_dict[field] = getattr(gateway, field, None) + full_read = GatewayRead.model_validate(gateway_dict) + + return GatewayCredentialRevealResponse( + gateway_id=gateway_id, + auth_type=full_read.auth_type, + auth_token=full_read.auth_token_unmasked, + auth_username=full_read.auth_username, + auth_password=full_read.auth_password_unmasked, + auth_header_key=full_read.auth_header_key, + auth_header_value=full_read.auth_header_value_unmasked, + auth_headers=full_read.auth_headers_unmasked, + ) + async def set_gateway_state(self, db: Session, gateway_id: str, activate: bool, reachable: bool = True, only_update_reachable: bool = False, user_email: Optional[str] = None) -> GatewayRead: """ Set the activation status of a gateway. diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index 46afe1253d..fda85366c4 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -6311,15 +6311,27 @@ async function editGateway(gatewayId) { authUsernameField.value = gateway.authUsername || ""; } if (authPasswordField) { + authPasswordField.dataset.isMasked = "true"; + authPasswordField.dataset.gatewayId = gatewayId; if (gateway.authPasswordUnmasked) { - authPasswordField.dataset.isMasked = "true"; authPasswordField.dataset.realValue = gateway.authPasswordUnmasked; } else { - delete authPasswordField.dataset.isMasked; delete authPasswordField.dataset.realValue; } authPasswordField.value = MASKED_AUTH_VALUE; + authPasswordField.type = "password"; + const passwordShowBtn = authPasswordField + .closest(".relative") + ?.querySelector("button"); + if (passwordShowBtn) { + passwordShowBtn.textContent = "Show"; + passwordShowBtn.disabled = false; + passwordShowBtn.classList.remove( + "cursor-not-allowed", + "opacity-50", + ); + } } } break; @@ -6327,15 +6339,26 @@ async function editGateway(gatewayId) { if (authBearerSection) { authBearerSection.style.display = "block"; if (authTokenField) { + authTokenField.dataset.isMasked = "true"; + authTokenField.dataset.gatewayId = gatewayId; if (gateway.authTokenUnmasked) { - authTokenField.dataset.isMasked = "true"; authTokenField.dataset.realValue = gateway.authTokenUnmasked; - authTokenField.value = MASKED_AUTH_VALUE; } else { - delete authTokenField.dataset.isMasked; delete authTokenField.dataset.realValue; - authTokenField.value = gateway.authToken || ""; + } + authTokenField.value = MASKED_AUTH_VALUE; + authTokenField.type = "password"; + const tokenShowBtn = authTokenField + .closest(".relative") + ?.querySelector("button"); + if (tokenShowBtn) { + tokenShowBtn.textContent = "Show"; + tokenShowBtn.disabled = false; + tokenShowBtn.classList.remove( + "cursor-not-allowed", + "opacity-50", + ); } } } @@ -6343,13 +6366,18 @@ async function editGateway(gatewayId) { case "authheaders": if (authHeadersSection) { authHeadersSection.style.display = "block"; + const unmaskedHeaders = + Array.isArray(gateway.authHeadersUnmasked) && + gateway.authHeadersUnmasked.length > 0 + ? gateway.authHeadersUnmasked + : gateway.authHeaders; if ( - Array.isArray(gateway.authHeaders) && - gateway.authHeaders.length > 0 + Array.isArray(unmaskedHeaders) && + unmaskedHeaders.length > 0 ) { loadAuthHeaders( "auth-headers-container-gw-edit", - gateway.authHeaders, + unmaskedHeaders, { maskValues: true }, ); } else { @@ -6359,13 +6387,16 @@ async function editGateway(gatewayId) { authHeaderKeyField.value = gateway.authHeaderKey || ""; } if (authHeaderValueField) { + authHeaderValueField.dataset.isMasked = "true"; + authHeaderValueField.dataset.gatewayId = gatewayId; if ( - Array.isArray(gateway.authHeaders) && - gateway.authHeaders.length === 1 + Array.isArray(unmaskedHeaders) && + unmaskedHeaders.length === 1 ) { - authHeaderValueField.dataset.isMasked = "true"; authHeaderValueField.dataset.realValue = - gateway.authHeaders[0].value ?? ""; + unmaskedHeaders[0].value ?? ""; + } else { + delete authHeaderValueField.dataset.realValue; } authHeaderValueField.value = MASKED_AUTH_VALUE; } @@ -20238,11 +20269,37 @@ window.updateAvailableTags = updateAvailableTags; * @param {HTMLElement|string} inputOrId - Target input element or its ID * @param {HTMLElement} button - Button triggering the toggle * - * SECURITY NOTE: Stored secrets cannot be revealed. The "Show" button only works - * for newly entered values, not for existing credentials stored in the database. - * This is intentional - stored secrets are write-only for security. + * SECURITY NOTE: Stored secrets are retrieved on demand via the credential reveal + * endpoint. The "Show" button calls POST /admin/gateways/{id}/reveal-credentials for stored + * credentials, which is audit-logged on every use. + */ + +/** + * Populate data-real-value on credential input fields from a reveal response. + * @param {Object} creds - Response from POST /admin/gateways/{id}/reveal-credentials */ -function toggleInputMask(inputOrId, button) { +function populateRevealedCredentials(creds) { + const tokenField = document.querySelector( + "#auth-bearer-fields-gw-edit input[name='auth_token']", + ); + if (tokenField && creds.authToken) { + tokenField.dataset.realValue = creds.authToken; + } + const passwordField = document.querySelector( + "#auth-basic-fields-gw-edit input[name='auth_password']", + ); + if (passwordField && creds.authPassword) { + passwordField.dataset.realValue = creds.authPassword; + } + const headerValueField = document.querySelector( + "#auth-headers-fields-gw-edit input[name='auth_header_value']", + ); + if (headerValueField && creds.authHeaderValue) { + headerValueField.dataset.realValue = creds.authHeaderValue; + } +} + +async function toggleInputMask(inputOrId, button) { const input = typeof inputOrId === "string" ? document.getElementById(inputOrId) @@ -20253,17 +20310,61 @@ function toggleInputMask(inputOrId, button) { } // SECURITY: Check if this is a stored secret (isMasked=true but no realValue) - // Stored secrets cannot be revealed - they are write-only const hasStoredSecret = input.dataset.isMasked === "true"; + // Caching: the fetched value is stored in data-real-value after the first reveal, so the + // backend is only called once per session. Subsequent Show/Hide clicks skip this block. const hasRevealableValue = input.dataset.realValue && input.dataset.realValue.trim() !== ""; if (hasStoredSecret && !hasRevealableValue) { - // Stored secret with no revealable value - show tooltip/message - button.title = - "Stored secrets cannot be revealed. Enter a new value to replace."; - button.classList.add("cursor-not-allowed", "opacity-50"); - return; + const gatewayId = input.dataset.gatewayId; + if (gatewayId) { + // Fetch plaintext credentials via the reveal endpoint (audit-logged server-side). + // The button is disabled for the duration of the request, preventing duplicate + // calls if the user clicks multiple times before the response arrives. + const originalText = button.textContent; + button.disabled = true; + button.textContent = "Loading…"; + try { + const response = await fetchWithTimeout( + `${window.ROOT_PATH}/admin/gateways/${gatewayId}/reveal-credentials`, + { method: "POST" }, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const creds = await response.json(); + populateRevealedCredentials(creds); + } catch (err) { + button.title = `Could not reveal credentials: ${err.message}`; + button.classList.add("cursor-not-allowed", "opacity-50"); + button.disabled = false; + button.textContent = originalText; + return; + } + button.disabled = false; + button.textContent = originalText; + // Re-check — realValue should now be populated + if ( + !input.dataset.realValue || + input.dataset.realValue.trim() === "" + ) { + button.title = "No credentials stored for this field."; + button.classList.add("cursor-not-allowed", "opacity-50"); + return; + } + // Reveal immediately without requiring a second click + input.type = "text"; + input.value = input.dataset.realValue; + button.textContent = "Hide"; + button.setAttribute("aria-pressed", "true"); + return; + } else { + button.title = + "Stored secrets cannot be revealed. Enter a new value to replace."; + button.classList.add("cursor-not-allowed", "opacity-50"); + return; + } } const revealing = input.type === "password"; diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index b287ccc60b..d0e1d199da 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -9903,8 +9903,13 @@