# IAM y Gestión de Secretos — Implementaciones Completas

## ABAC — Control de Acceso Basado en Atributos

ABAC se usa cuando RBAC no es suficientemente granular.

```python
# Ejemplo: un usuario OPERADOR sólo puede editar usuarios de su departamento
from dataclasses import dataclass
from typing import Any


@dataclass
class ContextoAutorizacion:
    sujeto: dict      # Atributos del usuario que hace la acción
    recurso: dict     # Atributos del recurso al que accede
    accion: str       # Acción que intenta realizar
    entorno: dict     # Hora, IP, etc.


def puede_editar_usuario(
    usuario_solicitante: dict,
    usuario_objetivo: dict,
) -> bool:
    """
    Política ABAC: un OPERADOR sólo puede editar usuarios de su departamento.
    Un ADMIN puede editar cualquier usuario en su organización.
    Un SUPER puede editar cualquier usuario.
    """
    rol = usuario_solicitante.get("rol")

    if rol == "SUPER":
        return True

    if rol == "ADMIN":
        return (
            usuario_solicitante.get("organizacion_id")
            == usuario_objetivo.get("organizacion_id")
        )

    if rol == "OPERADOR":
        return (
            usuario_solicitante.get("departamento_id")
            == usuario_objetivo.get("departamento_id")
            and usuario_solicitante.get("organizacion_id")
            == usuario_objetivo.get("organizacion_id")
        )

    return False


# En el endpoint — prevención de IDOR
@router.put("/usuarios/{usuario_id}")
async def actualizar_usuario(
    usuario_id: str,
    datos: ActualizarUsuarioSchema,
    usuario_actual: Usuario = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    usuario_objetivo = await db.get(Usuario, usuario_id)
    if not usuario_objetivo:
        raise HTTPException(404, "Usuario no encontrado")

    # ABAC — verificar autorización con atributos de ambos
    if not puede_editar_usuario(
        usuario_solicitante=usuario_actual.__dict__,
        usuario_objetivo=usuario_objetivo.__dict__,
    ):
        raise HTTPException(403, "Sin permisos para editar este usuario")

    # Continuar con la actualización...
```

---

## HashiCorp Vault — Integración en Python

```python
# vault_client.py
import hvac
import os
from functools import lru_cache
from typing import Any


@lru_cache(maxsize=1)
def obtener_cliente_vault() -> hvac.Client:
    """Cliente Vault singleton — se autentica una vez por proceso."""
    cliente = hvac.Client(url=os.environ["VAULT_ADDR"])

    # Autenticación con rol de Kubernetes (en producción)
    if os.environ.get("KUBERNETES_SERVICE_HOST"):
        with open("/var/run/secrets/kubernetes.io/serviceaccount/token") as f:
            jwt_token = f.read()

        cliente.auth.kubernetes.login(
            role=os.environ["VAULT_ROLE"],
            jwt=jwt_token,
        )
    else:
        # Autenticación con token (desarrollo local)
        cliente.token = os.environ["VAULT_TOKEN"]

    if not cliente.is_authenticated():
        raise RuntimeError("No se pudo autenticar con Vault")

    return cliente


def obtener_secreto(ruta: str, clave: str) -> str:
    """
    Lee un secreto de Vault KV v2.

    Args:
        ruta: Ruta en Vault, ej: "secret/data/produccion/base-de-datos"
        clave: Clave específica dentro del secreto

    Returns:
        El valor del secreto como string.
    """
    cliente = obtener_cliente_vault()

    try:
        respuesta = cliente.secrets.kv.v2.read_secret_version(
            path=ruta.replace("secret/data/", ""),
            mount_point="secret",
        )
        valor = respuesta["data"]["data"].get(clave)

        if valor is None:
            raise KeyError(f"Clave {clave!r} no encontrada en {ruta!r}")

        return valor

    except hvac.exceptions.InvalidPath:
        raise KeyError(f"Ruta de Vault no encontrada: {ruta!r}")


def obtener_credenciales_bd() -> dict[str, str]:
    """Obtiene credenciales de base de datos con rotación automática vía Vault Dynamic Secrets."""
    cliente = obtener_cliente_vault()

    # Vault genera credenciales temporales que expiran automáticamente
    creds = cliente.secrets.database.generate_credentials(
        name="mi-aplicacion-rol",
        mount_point="database",
    )

    return {
        "usuario": creds["data"]["username"],
        "contrasena": creds["data"]["password"],
        "ttl": creds["lease_duration"],
    }
```

---

## AWS Secrets Manager — Integración con Caché

```python
# aws_secrets.py
import boto3
import json
import time
from typing import Any
from dataclasses import dataclass, field


@dataclass
class SecretoEnCache:
    valor: Any
    expira_en: float  # Unix timestamp


class ClienteSecretos:
    """Cliente con caché local para reducir llamadas a AWS Secrets Manager."""

    def __init__(self, region: str = "us-east-1", ttl_segundos: int = 300):
        self._cliente = boto3.client("secretsmanager", region_name=region)
        self._cache: dict[str, SecretoEnCache] = {}
        self._ttl = ttl_segundos

    def obtener(self, nombre_secreto: str) -> dict[str, Any]:
        ahora = time.time()
        entrada = self._cache.get(nombre_secreto)

        if entrada and entrada.expira_en > ahora:
            return entrada.valor

        respuesta = self._cliente.get_secret_value(SecretId=nombre_secreto)

        if "SecretString" in respuesta:
            valor = json.loads(respuesta["SecretString"])
        else:
            valor = json.loads(respuesta["SecretBinary"].decode("utf-8"))

        self._cache[nombre_secreto] = SecretoEnCache(
            valor=valor,
            expira_en=ahora + self._ttl,
        )

        return valor

    def rotar(self, nombre_secreto: str) -> None:
        """Fuerza rotación inmediata y limpia el caché."""
        self._cliente.rotate_secret(SecretId=nombre_secreto)
        self._cache.pop(nombre_secreto, None)


# Uso en la aplicación
_secretos = ClienteSecretos(region="us-east-1")


def get_db_url() -> str:
    creds = _secretos.obtener("produccion/bd-principal")
    return (
        f"postgresql+asyncpg://{creds['usuario']}:{creds['contrasena']}"
        f"@{creds['host']}:{creds['puerto']}/{creds['nombre_bd']}"
    )
```

---

## Gestión de API Keys

```python
# api_keys.py
import secrets
import hashlib
from datetime import datetime, timedelta, timezone


def generar_api_key() -> tuple[str, str]:
    """
    Genera una API key y su hash para almacenamiento.
    La clave en texto plano se muestra UNA SOLA VEZ al usuario.
    Solo el hash se almacena en la base de datos.
    """
    clave = f"sk_{secrets.token_urlsafe(32)}"
    hash_clave = hashlib.sha256(clave.encode()).hexdigest()
    return clave, hash_clave


def verificar_api_key(clave_recibida: str, hash_almacenado: str) -> bool:
    """Verifica una API key en tiempo constante para prevenir timing attacks."""
    hash_recibido = hashlib.sha256(clave_recibida.encode()).hexdigest()
    return secrets.compare_digest(hash_recibido, hash_almacenado)


# Modelo de API Key con scopes y expiración
from sqlalchemy import String, DateTime, ARRAY
from sqlalchemy.orm import Mapped, mapped_column


class APIKey(Base):
    __tablename__ = "api_keys"

    id: Mapped[str] = mapped_column(String, primary_key=True)
    nombre: Mapped[str] = mapped_column(String(100))
    hash_clave: Mapped[str] = mapped_column(String(64), unique=True, index=True)
    scopes: Mapped[list[str]] = mapped_column(ARRAY(String))
    propietario_id: Mapped[str] = mapped_column(String(255))
    expira_en: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    ultimo_uso: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    activa: Mapped[bool] = mapped_column(default=True)

    def esta_vigente(self) -> bool:
        if not self.activa:
            return False
        if self.expira_en and self.expira_en < datetime.now(timezone.utc):
            return False
        return True
```

---

## Rotación de Secretos — Estrategia

```yaml
# Política de rotación por tipo de secreto
rotacion:
  credenciales_bd:
    frecuencia: "cada 7 días"
    estrategia: "doble escritura"
    pasos:
      1: "Crear nuevo usuario de BD"
      2: "Actualizar secreto en Vault/AWS SM"
      3: "Desplegar nueva versión de la app"
      4: "Verificar que la nueva versión funciona"
      5: "Revocar credenciales antiguas"

  jwt_secret:
    frecuencia: "cada 30 días"
    estrategia: "período de gracia de 24h"
    pasos:
      1: "Generar nuevo secret"
      2: "Configurar validación de AMBOS secrets (nuevo y viejo) durante 24h"
      3: "Después de 24h, eliminar el viejo"

  api_keys_externas:
    frecuencia: "cada 90 días"
    estrategia: "reemplazo directo con downtime cero"
    notas: "Las APIs externas dan período de transición de 24-72h"

  certificados_tls:
    frecuencia: "cada 90 días (automático con cert-manager/Let's Encrypt)"
    alerta_anticipacion: "30 días antes de expiración"
```

---

## Configuración de Cuentas de Servicio — Kubernetes

```yaml
# k8s/service-account.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-backend
  namespace: produccion
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/api-backend-role

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: api-backend-role
  namespace: produccion
rules:
  # Solo leer secrets propios — NO acceso a todos los secrets
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["api-backend-secrets"]
    verbs: ["get"]
  # Solo leer configmaps propios
  - apiGroups: [""]
    resources: ["configmaps"]
    resourceNames: ["api-backend-config"]
    verbs: ["get", "watch"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: api-backend-binding
  namespace: produccion
subjects:
  - kind: ServiceAccount
    name: api-backend
    namespace: produccion
roleRef:
  kind: Role
  name: api-backend-role
  apiGroup: rbac.authorization.k8s.io
```
