CreaRack-SL

Endpoint: Obtener credenciales SSH descifradas de DeviceProfile

Endpoint: GET /api/network/auto-provision/profiles/{profile_id}/credentials

Firma

@router.get(
    "/auto-provision/profiles/{profile_id}/credentials", 
    response={200: dict, 404: dict}, 
    tags=["Auto-Provision"]
)
def get_profile_credentials(request, profile_id: int):
    """Credenciales SSH (descifradas) de un DeviceProfile para abrir el Terminal."""

Ubicación: network/api/profiles.py, línea ~250.

Propósito

Descifra y devuelve las credenciales SSH almacenadas en un DeviceProfile para que el Terminal las pre-rellene en la conexión manual sin re-pedirlas al usuario. Este endpoint es sensible: expone credenciales en claro, por lo que está gateado por permisos y organización.

Autenticación y permisos

  • Autenticado: Sí, requiere login.
  • Gateado por permiso: require_perm(request, "network", "edit") — solo usuarios con permiso editar en Network.
  • Aislado por organización (RLS): Valida que el DeviceProfile.organization coincida con get_current_org(request).

Parámetros

ParámetroTipoUbicaciónDescripción
profile_idintPathID del DeviceProfile cuyas credenciales se solicitan.

Respuesta exitosa (200)

{
  "has_credentials": true,
  "ip": "10.5.5.5",
  "username": "admin",
  "password": "topsecret",
  "port": 22
}

Campos:

  • has_credentials (bool): true si el profile tiene ssh_username + ssh_password_encrypted no vacíos.
  • ip (str): IP del dispositivo (profile.ip_address).
  • username (str): Usuario SSH descifrado (o "" si no hay creds).
  • password (str): Contraseña descifrada (o "" si no hay creds).
  • port (int): Puerto SSH (siempre 22 por ahora).

Respuesta sin credenciales (200 con bandera falsa)

Si el DeviceProfile no tiene credenciales guardadas:

{
  "has_credentials": false,
  "ip": "10.5.5.8",
  "username": "",
  "password": "",
  "port": 22
}

Comportamiento: El frontend recibe has_credentials: false y sabe que debe solicitar las credenciales al usuario manualmente en el modal del Terminal.

Errores

CódigoEscenarioRespuesta
404DeviceProfile no existe o pertenece a otra orgNo encontrado
403Usuario no tiene permiso network.editForbidden
500Error descifrado (clave corrupta, DB inconsistente)Internal Server Error

Descifrado de credenciales

Mecanismo interno:

  1. Se valida que profile.ssh_password_encrypted exista y no esté vacío.
  2. Se llama a CredentialManager.safe_decrypt(encrypted_text, "ssh_password").
  3. Si el descifrado falla (error de Fernet, clave corrupta), safe_decrypt retorna None, y el endpoint devuelve password: "" (no se propaga excepción; fallsafe).

Código relevante (DeviceProfile.get_ssh_password()):

def get_ssh_password(self):
    """Descifra la contraseña SSH guardada (o '' si no hay / no descifra)."""
    if not self.ssh_password_encrypted:
        return ""
    from core.security import CredentialManager
    return CredentialManager.safe_decrypt(self.ssh_password_encrypted, "ssh_password") or ""

Casos de uso

1. Terminal pre-rellena credenciales en dispositivos detectados

const creds = await fetch(`/api/network/auto-provision/profiles/123/credentials`);
if (creds.has_credentials) {
  document.getElementById('manual-ssh-username').value = creds.username;
  document.getElementById('manual-ssh-password').value = creds.password;
}

2. Validar que un profile tiene credenciales antes de UI

const status = await fetch(`/api/network/auto-provision/profiles/456/credentials`);
if (status.has_credentials) {
  showBadge("SSH credentials stored");
} else {
  showBadge("No SSH credentials");
}

Eventos relacionados (telemetría)

  • Al descifrar credenciales, se no registra la contraseña en logs. El endpoint es transparente en auditoría (por diseño de seguridad).
  • Si safe_decrypt falla silenciosamente, se logs error interno pero no se expone al cliente.

Integración con Terminal

Contexto en terminal.js::quickConnectHost():

async quickConnectHost(ip, name, profileId, hasStored) {
  // ... setup initial fields ...
  if (hasStored && profileId && window.ApiService) {
    try {
      const creds = await window.ApiService.get(
        `/api/network/auto-provision/profiles/${profileId}/credentials`
      );
      if (creds && creds.has_credentials) {
        document.getElementById('manual-ssh-username').value = creds.username;
        document.getElementById('manual-ssh-password').value = creds.password;
        if (creds.port) document.getElementById('manual-ssh-port').value = creds.port;
      }
    } catch (e) {
      console.warn('No stored SSH credentials for profile', profileId, e);
    }
  }
  this.openManualConnectionModal();
}

Flujo:

  1. Usuario hace clic en un dispositivo detectado en Terminal.
  2. quickConnectHost() se invoca con profileId y hasStored=true.
  3. Se llama a este endpoint.
  4. Si las credenciales están presentes, se rellenan en el modal.
  5. Si no, el modal queda vacío y el usuario ingresa manualmente.

Testing

Tests en tests/api/test_terminal_hosts.py::TestProfileSshCredentials:

def test_endpoint_returns_decrypted_creds(self, admin_user, organization):
    p = self._profile_with_creds(organization, ip="10.5.5.7", password="topsecret")
    client = self._client_for(admin_user, organization)
    resp = client.get(f"/api/network/auto-provision/profiles/{p.id}/credentials")
    assert resp.status_code == 200
    body = resp.json()
    assert body["has_credentials"] is True
    assert body["username"] == "admin"
    assert body["password"] == "topsecret"

def test_viewer_cannot_get_credentials(self, viewer_user, organization):
    p = self._profile_with_creds(organization, ip="10.5.5.9")
    client = self._client_for(viewer_user, organization)
    assert client.get(f"/api/network/auto-provision/profiles/{p.id}/credentials").status_code == 403

Notas de seguridad

  1. Credenciales en tránsito: Se devuelven en claro solo mediante HTTPS (validar en production).
  2. Permisos estrictos: Solo network.edit puede acceder. Un viewer de red no puede ver credenciales.
  3. RLS por org: No puedes acceder a profiles de otra organización.
  4. No se registran en logs: Intentar no incluir password en error messages.
  5. Fallsafe descifrado: Si algo falla, se devuelve password: "" en lugar de propagar excepción.

Véase también

  • [[feature—terminal—ssh-credential-inheritance]]
  • [[entity—network—model—device-profile]]
  • [[concept—saas—encryption-and-secrets]]
  • [[concept—saas—multi-tenancy]]