Skip to content
Draft
51 changes: 51 additions & 0 deletions mcpgateway/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
CatalogServerRegisterResponse,
CatalogServerStatusResponse,
GatewayCreate,
GatewayCredentialRevealResponse,
GatewayRead,
GatewayTestRequest,
GatewayTestResponse,
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions mcpgateway/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
62 changes: 61 additions & 1 deletion mcpgateway/services/gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
147 changes: 124 additions & 23 deletions mcpgateway/static/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -6311,45 +6311,73 @@ 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;
case "bearer":
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",
);
}
}
}
break;
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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Comment thread
gandhipratik203 marked this conversation as resolved.
const input =
typeof inputOrId === "string"
? document.getElementById(inputOrId)
Expand All @@ -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";
Expand Down
Loading
Loading