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 @@

id="auth-query-param-value-gw-edit" autocomplete="off" data-sensitive-label="API key" - class="mt-1 px-1.5 block w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" + class="mt-1 px-1.5 block w-full pr-16 rounded-md border border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" /> + @@ -9931,8 +9936,13 @@

id="auth-password-gw-edit" autocomplete="off" data-sensitive-label="password" - class="mt-1 px-1.5 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" + class="mt-1 px-1.5 block w-full pr-16 rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" /> + @@ -9948,8 +9958,13 @@

id="auth-token-gw-edit" autocomplete="off" data-sensitive-label="token" - class="mt-1 px-1.5 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" + class="mt-1 px-1.5 block w-full pr-16 rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" /> + diff --git a/tests/unit/mcpgateway/services/test_gateway_service.py b/tests/unit/mcpgateway/services/test_gateway_service.py index de090ae95e..6b177a28ea 100644 --- a/tests/unit/mcpgateway/services/test_gateway_service.py +++ b/tests/unit/mcpgateway/services/test_gateway_service.py @@ -36,7 +36,7 @@ from mcpgateway.db import Tool as DbTool from mcpgateway.db import Resource as DbResource from mcpgateway.db import Prompt as DbPrompt -from mcpgateway.schemas import GatewayCreate, GatewayUpdate +from mcpgateway.schemas import GatewayCreate, GatewayCredentialRevealResponse, GatewayRead, GatewayUpdate from mcpgateway.services.encryption_service import get_encryption_service from mcpgateway.services.gateway_service import ( GatewayConnectionError, @@ -4540,6 +4540,46 @@ async def test_get_inactive_gateway_without_include(self, gateway_service, mock_ await gateway_service.get_gateway(db, "gw-1", include_inactive=False) +# --------------------------------------------------------------------------- +# get_gateway_with_credentials tests +# --------------------------------------------------------------------------- + + +class TestGetGatewayWithCredentials: + @pytest.mark.asyncio + async def test_success_returns_reveal_response(self, gateway_service, mock_gateway, monkeypatch): + db = MagicMock() + db.execute.return_value = _make_execute_result(scalar=mock_gateway) + + mock_full_read = MagicMock(spec=GatewayRead) + mock_full_read.auth_type = "bearer" + mock_full_read.auth_token_unmasked = "plaintext-token" + mock_full_read.auth_username = None + mock_full_read.auth_password_unmasked = None + mock_full_read.auth_header_key = None + mock_full_read.auth_header_value_unmasked = None + mock_full_read.auth_headers_unmasked = None + + import mcpgateway.services.gateway_service as _gs + + monkeypatch.setattr(_gs.GatewayRead, "model_validate", staticmethod(lambda obj: mock_full_read)) + + result = await gateway_service.get_gateway_with_credentials(db, "gw-1") + + assert isinstance(result, GatewayCredentialRevealResponse) + assert result.gateway_id == "gw-1" + assert result.auth_type == "bearer" + assert result.auth_token == "plaintext-token" + + @pytest.mark.asyncio + async def test_not_found_raises_error(self, gateway_service): + db = MagicMock() + db.execute.return_value = _make_execute_result(scalar=None) + + with pytest.raises(GatewayNotFoundError): + await gateway_service.get_gateway_with_credentials(db, "missing") + + # --------------------------------------------------------------------------- # update_gateway tests # --------------------------------------------------------------------------- diff --git a/tests/unit/mcpgateway/test_admin.py b/tests/unit/mcpgateway/test_admin.py index de6a6714bc..56e6ef472b 100644 --- a/tests/unit/mcpgateway/test_admin.py +++ b/tests/unit/mcpgateway/test_admin.py @@ -107,6 +107,7 @@ admin_get_all_team_ids, admin_get_all_tool_ids, admin_get_gateway, + admin_reveal_gateway_credentials, admin_get_grpc_methods, admin_get_grpc_service, admin_get_import_status, @@ -256,6 +257,7 @@ from mcpgateway.config import settings, UI_HIDABLE_HEADER_ITEMS, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES from mcpgateway.middleware.request_logging_middleware import RequestLoggingMiddleware from mcpgateway.schemas import ( + GatewayCredentialRevealResponse, GatewayTestRequest, GlobalConfigRead, GlobalConfigUpdate, @@ -3058,6 +3060,83 @@ async def test_admin_set_gateway_state_success_include_inactive(self, monkeypatc assert "include_inactive=true" in response.headers["location"] +class TestAdminRevealGatewayCredentials: + """Tests for POST /admin/gateways/{gateway_id}/reveal-credentials endpoint (issue #3435).""" + + @pytest.mark.asyncio + @patch("mcpgateway.admin.gateway_service") + @patch("mcpgateway.admin.get_audit_trail_service") + async def test_reveal_returns_plaintext_credentials(self, mock_get_audit, mock_gateway_service, mock_db): + """AC1: Returns plaintext credentials for an authorized user.""" + mock_audit = MagicMock() + mock_get_audit.return_value = mock_audit + + expected = GatewayCredentialRevealResponse( + gateway_id="gw-1", + auth_type="bearer", + auth_token="supersecrettoken123", + ) + mock_gateway_service.get_gateway_with_credentials = AsyncMock(return_value=expected) + + result = await admin_reveal_gateway_credentials("gw-1", mock_db, user={"email": "admin@example.com"}) + + assert result.gateway_id == "gw-1" + assert result.auth_type == "bearer" + assert result.auth_token == "supersecrettoken123" + mock_audit.log_action.assert_called_once() + + @pytest.mark.asyncio + @patch("mcpgateway.admin.gateway_service") + @patch("mcpgateway.admin.get_audit_trail_service") + async def test_reveal_gateway_not_found(self, mock_get_audit, mock_gateway_service, mock_db): + """AC2: Returns 404 for a non-existent gateway.""" + mock_get_audit.return_value = MagicMock() + mock_gateway_service.get_gateway_with_credentials = AsyncMock(side_effect=GatewayNotFoundError("not found")) + + with pytest.raises(HTTPException) as excinfo: + await admin_reveal_gateway_credentials("missing-gw", mock_db, user={"email": "admin@example.com"}) + + assert excinfo.value.status_code == 404 + + @pytest.mark.asyncio + async def test_reveal_endpoint_requires_permission(self): + """AC3/AC4: Endpoint is decorated with require_permission gating gateways.read.""" + import inspect + # The function must be wrapped by require_permission + assert hasattr(admin_reveal_gateway_credentials, "__wrapped__") + + @pytest.mark.asyncio + @patch("mcpgateway.admin.gateway_service") + @patch("mcpgateway.admin.get_audit_trail_service") + async def test_reveal_audit_logged_before_return(self, mock_get_audit, mock_gateway_service, mock_db): + """AC5: Audit trail is logged on every successful reveal call.""" + mock_audit = MagicMock() + mock_get_audit.return_value = mock_audit + + mock_gateway_service.get_gateway_with_credentials = AsyncMock( + return_value=GatewayCredentialRevealResponse(gateway_id="gw-2", auth_type="basic", auth_username="admin", auth_password="secret") + ) + + await admin_reveal_gateway_credentials("gw-2", mock_db, user={"email": "admin@example.com"}) + + mock_audit.log_action.assert_called_once() + call_kwargs = mock_audit.log_action.call_args.kwargs + assert call_kwargs["action"] == "READ" + assert call_kwargs["resource_type"] == "gateway" + assert call_kwargs["resource_id"] == "gw-2" + + @pytest.mark.asyncio + @patch("mcpgateway.admin.gateway_service") + @patch("mcpgateway.admin.get_audit_trail_service") + async def test_reveal_propagates_unexpected_errors(self, mock_get_audit, mock_gateway_service, mock_db): + """Unexpected errors are re-raised (not swallowed).""" + mock_get_audit.return_value = MagicMock() + mock_gateway_service.get_gateway_with_credentials = AsyncMock(side_effect=RuntimeError("db exploded")) + + with pytest.raises(RuntimeError, match="db exploded"): + await admin_reveal_gateway_credentials("gw-3", mock_db, user={"email": "admin@example.com"}) + + class TestAdminRootRoutes: """Test admin routes for root management with enhanced coverage."""