# API keys secretas (acceso programático)

Sistema de llaves secretas rotables para exponer la API de SumaYa a scripts,
integraciones de terceros y (a futuro) un servidor MCP.

> Estado: **implementado y con tests** (`backend/tests/test_api_keys.py`).
> Lo marcado como _(pendiente)_ aún no existe en código.

---

## API vs MCP

| | Qué es | Quién lo usa |
|---|---|---|
| **API REST** | Endpoints HTTP autenticados con una key secreta | Scripts, otras apps, integraciones |
| **MCP** _(pendiente)_ | Servidor que expone "tools" a agentes LLM (Claude/ChatGPT/Cursor) | El usuario conecta sus finanzas a una IA |

**No son lo mismo.** MCP es una capa **encima** de la API: el servidor MCP llama
a estos mismos endpoints REST. Primero existe la API (ya está); el MCP se
construye después y reutiliza la misma autenticación.

---

## Modelo de autenticación

- La key tiene forma `sk_live_<32 bytes url-safe>`.
- **Solo se guarda el hash SHA-256** en la tabla `api_keys`. El secreto en claro
  se muestra **una sola vez** (al crear o al rotar). No se puede recuperar
  después — solo rotar.
- En la lista, la key se identifica por `prefix` (ej. `sk_live_ab12cd34`) y
  `last_four`, que **no** son secretos.
- El lookup es por hash único indexado (no comparación de strings) → sin fuga por
  _timing_.
- Una key está atada a un `user_id` y **solo** actúa como su dueño. Es imposible
  suplantar a otro usuario con una key.

### Cómo autenticar una petición

Header `X-API-Key`:

```bash
curl https://financeperu-production.up.railway.app/api/v1/<ruta> \
  -H "X-API-Key: sk_live_xxxxxxxx..."
```

O `Authorization: Bearer` (solo si el token empieza con el prefijo de key):

```bash
curl ... -H "Authorization: Bearer sk_live_xxxxxxxx..."
```

La dependency `get_api_key_user` valida la key. Las rutas de datos autenticadas
por key ya disponibles están bajo `/api/v1/public/*` (ver abajo).

---

## Gestión de keys (endpoints)

Bajo `/api/v1/api-keys`, autenticados con el **JWT de Supabase** (no con la key).
El modo demo es de solo lectura (bloqueado en writes).

| Método | Ruta | Qué hace |
|---|---|---|
| `POST` | `/api-keys/` | Crea una key. Devuelve el secreto en claro **una vez**. |
| `GET` | `/api-keys/` | Lista las keys del usuario (sin secreto). |
| `POST` | `/api-keys/{id}/rotate` | Genera un secreto nuevo; el anterior deja de servir. |
| `DELETE` | `/api-keys/{id}` | Revoca (soft): `is_active=False`, `revoked_at`. |

Body de creación (`POST /api-keys/`):

```json
{
  "name": "CI bot",
  "scopes": "transactions:read,goals:read",
  "allowed_ips": "203.0.113.10,10.0.0.0/24",
  "expires_at": "2027-01-01T00:00:00Z"
}
```

Todos los campos salvo `name` son opcionales.

---

## Endpoints de datos (autenticados por key)

Bajo `/api/v1/public/*`, **solo se aceptan API keys** (no el JWT de la app), y
cada ruta exige el scope `read` vía `require_scopes("read")`. Todo se filtra por
el `user_id` dueño de la key. Solo lectura por ahora.

| Método | Ruta | Scope | Qué devuelve |
|---|---|---|---|
| `GET` | `/public/me` | `read` | Identidad del dueño de la key (user_id, tier, scopes) |
| `GET` | `/public/credits` | `read` | Créditos IA: restantes, límite mensual, uso, si IA está activa |
| `GET` | `/public/transactions?limit=&offset=` | `read` | Transacciones del usuario (máx. 500) |
| `GET` | `/public/goals` | `read` | Metas activas (incluye `projection`) |

```bash
curl https://financeperu-production.up.railway.app/api/v1/public/goals \
  -H "X-API-Key: sk_live_xxxx..."
```

Una key **solo-lectura** (`read`) pasa; una `read,write` también. Escrituras
(`require_scopes("write")`) aún no se exponen.

---

## Límites y anti-abuso

### Cupo por tier (keys activas)

| Tier | Máx. keys activas |
|---|---|
| free | **1** |
| pro / familia | **3** |

Al exceder → `403` con mensaje del cupo. Configurable
(`API_KEY_QUOTA_FREE` / `API_KEY_QUOTA_PRO`).

### Rate limit (sliding window, por minuto)

| Ámbito | Límite por defecto |
|---|---|
| Por key (free) | 60 / min |
| Por key (pro/familia) | 300 / min |
| Por IP | 120 / min |

Al exceder → `429`. Los `429` por rate limit **no** cuentan como intento fallido
(un cliente legítimo que agota su cupo no es un atacante).

### Cupo diario de pedidos (por tier) — el máximo real

Además del rate limit por minuto (que solo suaviza ráfagas), cada key tiene un
**tope duro de pedidos por día UTC**, **persistido en la fila de la key**
(sobrevive reinicios del backend, a diferencia de los contadores en memoria):

| Tier | Pedidos / día |
|---|---|
| free | **1 000** |
| pro / familia | **20 000** |

Se cuenta por request exitoso; al agotarse → `429` "Daily request quota
exhausted". El contador se reinicia solo al cambiar de día. `<= 0` en config =
ilimitado. La persistencia es _best-effort_: si la DB falla al guardar el
contador, la request válida NO se cae (peor caso: se subcuenta 1 pedido).

### Lockout por intentos fallidos (fuerza bruta)

Cada intento inválido desde una IP (key ausente / inválida / expirada / IP no
permitida) se cuenta. Si una IP supera `API_KEY_MAX_FAILED_PER_MINUTE`
(por defecto **10/min**), se le responde `429` **antes** de hacer cualquier
lookup — así no se puede amortizar el adivinar keys.

### Allowlist de IP (opcional, por key)

`allowed_ips` acepta IPs y CIDRs separados por coma. Vacío/nulo = cualquier IP.
Si se define y la IP del cliente no calza → `403`.

### Expiración (opcional, por key)

`expires_at`; pasada esa fecha la key da `401`.

### Auditoría

Cada auth (éxito o rechazo) se registra con `logging` (`logger "api_keys"`):
IP, prefijo de key, motivo. `last_used_at` se actualiza en cada uso exitoso.
_(Pendiente: tabla de eventos consultable por el usuario.)_

---

## Scopes

Catálogo **cerrado**: solo `read` y `write`. Al crear una key, `scopes` se valida
con `normalize_scopes()` — cualquier otro valor (incluidos `*`/`admin`) se
rechaza con `422`. No hay tokens mágicos de acceso total.

- Key sin scopes (nulo/vacío) = **acceso completo sobre los datos del dueño**.
- Solo lectura = `read`. Lectura y escritura = `read,write`.
- La dependency `require_scopes("read")` (o `"write"`) exige el/los scope(s) y
  responde `403` si faltan. **No** hay auto-grant por wildcard.

Ejemplo de uso en una ruta:

```python
from app.features.api_keys.security import require_scopes

@router.get("/transactions")
async def list_txns(ctx: dict = Depends(require_scopes("read"))):
    user_id = ctx["user_id"]
    ...
```

---

## Referencia de configuración (`app/core/config.py`)

| Setting | Default | Descripción |
|---|---|---|
| `API_KEY_PREFIX` | `sk_live` | Prefijo no secreto de cada key |
| `API_KEY_QUOTA_FREE` | `1` | Keys activas máx. (free) |
| `API_KEY_QUOTA_PRO` | `3` | Keys activas máx. (pro/familia) |
| `API_KEY_RATE_PER_MINUTE_FREE` | `60` | Requests/min por key (free) |
| `API_KEY_RATE_PER_MINUTE_PRO` | `300` | Requests/min por key (pro) |
| `API_KEY_IP_RATE_PER_MINUTE` | `120` | Requests/min por IP |
| `API_KEY_MAX_FAILED_PER_MINUTE` | `10` | Intentos fallidos/min por IP antes del lockout |
| `API_KEY_DAILY_QUOTA_FREE` | `1000` | Tope duro de pedidos/día (free); `<=0` = ilimitado |
| `API_KEY_DAILY_QUOTA_PRO` | `20000` | Tope duro de pedidos/día (pro/familia) |

---

## Aislamiento de datos y RLS

**¿Se puede adivinar la key de otro?** No es factible: la key tiene ~256 bits de
entropía (`secrets.token_urlsafe(32)`), se guarda **solo** como hash SHA-256, el
lookup es por hash exacto, y el lockout por IP (10 fallos/min) corta cualquier
fuerza bruta. Además una key está atada a su `user_id`.

**¿Se puede consultar data de otro usuario?** No: **cada query filtra por el
`user_id` autenticado** (`WHERE user_id = ...`), en las rutas JWT y en `/public`.
Una key nunca ve datos de otro dueño.

**¿Está protegido por RLS?** El *enforcement* NO es RLS: el backend se conecta a
Postgres con un rol privilegiado (que **bypassa RLS**) y hace el filtrado por
`user_id` en la app. Como **defensa en profundidad** —igual que las tablas
`nudge_*`/`whatsapp_*`— la tabla `api_keys` ahora tiene **RLS habilitada + `REVOKE
ALL ... FROM anon, authenticated`** al arrancar (`app/main.py`), para que el
Data API del navegador (roles anon/authenticated de Supabase) **no pueda leer los
hashes** aunque alguien intente ir directo a Supabase.

> Nota: no se habilitó RLS sobre `goals`/`transactions` porque el frontend podría
> leerlas vía el cliente de Supabase; hacerlo sin revisar rompería esas lecturas.
> Recomendado a futuro: políticas RLS per-user (`user_id = auth.uid()`) en esas
> tablas para blindar también el acceso directo desde el navegador.

---

## Errores posibles (respuestas del sistema de keys)

La dependency de auth nunca devuelve `500` por entradas del cliente — mapea todo
a códigos claros:

| Código | Cuándo | Cuenta como intento fallido |
|---|---|---|
| `401 Missing API key` | No hay header `X-API-Key` ni `Bearer sk_...` | Sí |
| `401 Invalid API key` | El hash no existe o la key fue revocada | Sí |
| `401 API key expired` | `expires_at` pasó | Sí |
| `403 IP not allowed` | La IP no calza con `allowed_ips` | Sí |
| `403 missing scope` | La ruta exige un scope que la key no tiene | No |
| `429 rate limit (key)` | Superó requests/min de su tier | No |
| `429 rate limit (IP)` | La IP superó requests/min | No |
| `429 daily quota exhausted` | Agotó el tope diario del tier | No |
| `429 too many failed attempts` | La IP superó el umbral de fallos → lockout | No (ya está bloqueada) |

En gestión (`/api-keys`): `403` cupo de keys excedido, `404` key inexistente,
`403` demo (solo lectura), `401` JWT inválido.

Casos límite conocidos:
- **Colisión de hash SHA-256** al crear/rotar (constraint `unique`) → `500`.
  Probabilidad ~0; no se maneja explícitamente.
- **DB caída** en el lookup de usuario → `500` (inherente). El guardado del
  contador de uso es _best-effort_ y NO tumba la request.

---

## Qué pueden — y qué NO pueden — hacer las keys

Modelo **deny-by-default**: una key solo llega a las rutas que **explícitamente**
la aceptan (vía `get_api_key_user` / `require_scopes`). Hoy están expuestas solo
las **lecturas** bajo `/public/*` (scope `read`); las escrituras aún no.

**Nunca podrán** (son rutas con **JWT de Supabase**, no aceptan keys):
- Crear / rotar / revocar keys (`/api-keys`) — evita que una key filtrada se
  auto-perpetúe o cree más.
- Pagos / suscripciones (`/payments`).
- Auth, borrar cuenta, cambiar tier.

**Podrán** (cuando se protejan esas rutas), limitado por scopes:
- Lectura: `transactions:read`, `goals:read`, etc.
- Escritura: solo si se otorga explícitamente (`transactions:write`).

Recomendación: emitir keys **read-only por defecto**; exigir scope de escritura
explícito. Convención de scope: `<recurso>:<read|write>`.

---

## Notas de despliegue

- La tabla `api_keys` la crea `Base.metadata.create_all` al arrancar
  (`app/main.py`). **No hay migración Alembic** por esto — además la cadena
  Alembic actual tiene una **revisión duplicada** (`e5f6a7b8c9d0`, dos archivos)
  que habría que arreglar antes de generar migraciones nuevas.
- Los limitadores (rate + lockout) son **en memoria por proceso**. Sirven para
  una instancia. Si el backend escala horizontalmente, mover a Redis (si no,
  cada réplica lleva su propia ventana y el límite efectivo se multiplica).
- **HTTPS obligatorio**: la key viaja en un header. Railway ya sirve TLS; nunca
  aceptar la key por `http`.

---

## Roadmap (siguiente)

1. ~~Proteger rutas read-only con `require_scopes`~~ ✅ hecho (`/public/*`).
   Falta: exponer **escrituras** (`require_scopes("write")`) y añadir **políticas
   RLS per-user** en `goals`/`transactions` para blindar acceso directo Supabase.
2. **UI** para crear/rotar/revocar keys (mobile + web).
3. **Tabla de auditoría** consultable (IP, endpoint, timestamp por request).
4. **Servidor MCP** que envuelva la API — idealmente con **OAuth 2.1** (consent
   del usuario + token con scope) en vez de pegar la key en claro.
5. **Firma HMAC** para webhooks salientes (el receptor verifica que somos
   nosotros) y, opcionalmente, requests firmadas (timestamp + HMAC) anti-replay.
6. **Re-auth** (login fresco) al crear/rotar keys.
7. **Rate limit distribuido** (Redis) si hay múltiples réplicas.
