Backend .NET Web API (C#) "tonto pero no débil": sin lógica de negocio (vive en Stored Procedures/Views de SQL Server), con seguridad perimetral en cada capa. Especificación completa en CLAUDE.md.
Controllers/ Auth, Provisioning, Dashboard, Landing
Services/ CookieJwtService, RateLimitPolicies, CacheService, PasswordGenerator,
LoginAttemptTracker, MySqlProvisioningService, ProvisioningOrchestrator,
ProvisioningRetryService, MySqlQuotaEnforcementService
Repositories/ Interfaces + SqlServer (solo invocan Stored Procedures)
Contracts/ DTOs con validación estricta
Middleware/ SecurityHeaders, ExceptionHandling, RequestAudit
sql/ Scripts en orden de ejecución (01 → 08)
infra/ Hardening de SO/kernel para la VPS + backup de la clave de cifrado
hooks/ Git hooks versionados (pre-commit anti-secretos)
- SQL Server (
MasterControl): control plane — usuarios, auditoría, y el registro de cada base de datos aprovisionada. Vive endocker-compose.yml, dentro del presupuesto de RAM del Módulo 6. - MySQL: motor destino de cada base de datos por usuario (control 2.3: "servicio
intermediario controlado"). Es externo a este
docker-compose.yml— el presupuesto de RAM del Módulo 6 (4GB) ya está totalmente asignado a SQL Server + backend + Nginx + SO; añadir un motor MySQL adicional en el mismo VPS lo rompería. El backend se conecta a él víaMySql:AdminConnectionString(.env).
- Copia
.env.examplea.envy rellena los valores reales (nunca los commitees). - Ejecuta los scripts de
sql/en orden contra la instancia de SQL Server:01_schema.sql→02_sp_ObtenerOCrearUsuario.sql→03_sp_AprovisionarBaseDatos.sql→04_dashboard.sql→05_landing.sql→06_memory_budget.sql→07_provisioning_lifecycle.sql→08_encryption_key_management.sql. - Crea en el MySQL externo la cuenta administrativa que usará el backend, con
privilegios mínimos (nunca
SUPERniGRANT ALL PRIVILEGES ON *.*):(No hace faltaCREATE USER 'aba_provisioner'@'%' IDENTIFIED BY '<password-fuerte>'; GRANT CREATE, DROP, CREATE USER ON *.* TO 'aba_provisioner'@'%'; GRANT GRANT OPTION ON *.* TO 'aba_provisioner'@'%';
GRANT SELECT ON information_schema.*— en MySQL 8 ese esquema es virtual y el acceso ya es implícito para cualquier cuenta; intentar otorgarlo explícitamente falla conERROR 1044. Confirmado al reconstruir el MySQL de producción el 2026-08-06.) - Levanta todo con:
Nginx queda como único punto de entrada (
docker compose up --buildlocalhost:80);backendysqlserverno publican puertos al host (Módulo 5.3). El backend falla el arranque siSecurity:EncryptionPassphraseno coincide con el hash ya registrado (ver § Módulo 2).
Inventario completo de los 11 controllers en Controllers/ — no solo los 4 módulos originales.
"Auth" indica el esquema real de autenticación ([Authorize] = cookie JWT del usuario;
ApiKey/PartnerApiKey = header, para consumo server-to-server, nunca navegador).
| Método | Ruta | Auth |
|---|---|---|
| GET | /auth/google/login, /auth/github/login |
público |
| GET | /auth/{google|github}/procesar |
público (callback OAuth) |
| POST | /auth/refresh |
cookie JWT |
| POST | /auth/logout |
[Authorize] |
| GET | /auth/csrf |
público |
| Método | Ruta | Auth | Rate limit |
|---|---|---|---|
| POST | /provisioning/crear |
[Authorize] |
provisioning (1 base / 10 min por usuario) |
| Método | Ruta | Auth |
|---|---|---|
| GET | /dashboard/bases |
[Authorize] |
| GET | /dashboard/bases/{id} |
[Authorize] |
| GET | /dashboard/bases/{id}/credencial |
[Authorize] |
| DELETE | /dashboard/bases/{id} |
[Authorize] |
| GET | /dashboard/perfil |
[Authorize] |
| Método | Ruta | Auth |
|---|---|---|
| GET | /stats |
público (rate limit landing) |
Módulo 8 — Aprovisionamiento para células socias (PartnersProvisioningController, /partners/databases)
Server-to-server; ver Aba/API-CELULAS-SOCIAS.md para el contrato completo pensado
para equipos externos.
| Método | Ruta | Auth | Rate limit |
|---|---|---|---|
| POST | /partners/databases |
AuthSchemes.PartnerApiKey (Authorization: Bearer <key>) |
partners (ráfaga 5, recarga 1/2min por célula) |
| GET | /partners/databases |
AuthSchemes.PartnerApiKey |
partners |
| GET | /partners/databases/{id} |
AuthSchemes.PartnerApiKey |
partners |
| DELETE | /partners/databases/{id} |
AuthSchemes.PartnerApiKey |
partners |
| POST | /partners/databases/{id}/credenciales/reset |
AuthSchemes.PartnerApiKey |
partners |
Gestión vía cookie JWT (la app web del usuario administrando sus keys). El uso de la key es un
esquema separado, ver AiController abajo.
| Método | Ruta | Auth | Rate limit |
|---|---|---|---|
| POST | /apikeys/crear |
[Authorize] |
apikeys-crear |
| GET | /apikeys |
[Authorize] |
— |
| POST | /apikeys/{id}/revocar |
[Authorize] |
— |
| GET | /apikeys/{id}/consumo |
[Authorize] |
— |
| Método | Ruta | Auth |
|---|---|---|
| POST | /ai/completar |
AuthSchemes.ApiKey (header X-API-Key) |
Andamiaje de auth/rate-limit/auditoría completo; la integración con un proveedor real de IA queda pendiente (swap trivial una vez decidido el proveedor).
| Método | Ruta | Auth |
|---|---|---|
| POST | /dns/crear |
[Authorize] |
| GET | /dns/mis-registros |
[Authorize] |
| DELETE | /dns/{id} |
[Authorize] |
| Método | Ruta | Auth |
|---|---|---|
| GET | /admin/dns |
[Authorize(Roles = "Admin")] |
| DELETE | /admin/dns/{id} |
[Authorize(Roles = "Admin")] |
| Método | Ruta | Auth |
|---|---|---|
| POST | /n8n/crear |
[Authorize] |
| GET | /n8n/mi-workspace |
[Authorize] |
| DELETE | /n8n/mi-workspace |
[Authorize] |
| Método | Ruta | Auth |
|---|---|---|
| GET | /sesiones |
[Authorize] |
Estado en dbo.BasesDatos refleja la realidad, no una suposición:
| Estado | Significado |
|---|---|
pendiente |
Reservado en MasterControl, aún no confirmado en MySQL |
activa |
Existe y funciona en MySQL |
error_aprovisionamiento |
MySQL falló; ProvisioningRetryService reintenta cada 5 min con la misma contraseña ya generada |
cuota_excedida |
Superó MaxSizeMB; MySqlQuotaEnforcementService (cada 10 min) le revocó escritura en MySQL hasta que el uso vuelva a bajar |
Ninguna base queda "activa" en MasterControl sin existir realmente en MySQL — si el
paso de MySQL falla, ProvisioningOrchestrator revierte el registro en la misma operación.
Sin esta clave, todas las contraseñas cifradas en PasswordCifrada son irrecuperables
para siempre (cifrado simétrico: sin la clave exacta, DECRYPTBYPASSPHRASE devuelve NULL,
no un error). Ejecuta el backup en una ubicación distinta de donde vive el backup de
MasterControl — nunca el mismo disco/bucket:
ENV_FILE=.env ./infra/backup-encryption-key.sh /ruta/fuera-del-servidor-de-bd
sp_RotarClaveCifrado (en 08_encryption_key_management.sql)
descifra con la clave vieja y re-cifra con la nueva en una sola transacción — si la clave
vieja es incorrecta para una sola fila, se aborta TODA la rotación (no queda nada a medio
rotar). No está expuesto como endpoint HTTP a propósito — rotar esta clave es una
operación de altísimo privilegio; se invoca vía sqlcmd/SSMS con acceso restringido:
sqlcmd -S <servidor> -d MasterControl -Q "EXEC sp_RotarClaveCifrado @ClaveVieja='...', @ClaveNueva='...'"
Después de rotar, actualiza ENCRYPTION_PASSPHRASE en .env con la clave nueva y reinicia
el backend — el chequeo de arranque comparará contra el hash que el propio SP ya actualizó.
Program.cs llama a sp_VerificarOInicializarClaveCifrado antes de aceptar tráfico. Si
la clave configurada no coincide con el hash esperado, el proceso lanza una excepción y
no arranca — preferible a operar silenciosamente con descifrados corruptos.
infra/sysctl.conf no se aplica solo. Tras copiarlo a la VPS:
sudo cp infra/sysctl.conf /etc/sysctl.d/99-abaproblem.conf
sudo sysctl -p /etc/sysctl.d/99-abaproblem.conf
Y confirma que cada valor quedó activo (no asumas que sysctl -p sin errores basta):
sysctl net.ipv4.tcp_syncookies net.ipv4.tcp_max_syn_backlog net.core.somaxconn net.ipv4.tcp_fin_timeout
Debe imprimir exactamente los valores de infra/sysctl.conf, no los defaults del kernel.
El CD (cd.yml) solo actualiza el servicio Swarm, que no sirve tráfico real —
aba-backend (el contenedor manual al que apunta Nginx Proxy Manager) hay que
redesplegarlo a mano después de cada merge a main que deba llegar a producción
(ver Aba/INFRAESTRUCTURA.md § 7.5 para el porqué de esta desconexión).
El script vive suelto en la VPS (/home/admin/redeploy-backend.sh), no como un
git clone del repo — así que un git push a este repo no lo actualiza solo.
Cada vez que se modifique infra/redeploy-backend.sh, hay que sincronizarlo a mano
antes de volver a correrlo:
# Desde tu máquina, con el repo actualizado:
scp infra/redeploy-backend.sh admin@178.105.217.45:/home/admin/redeploy-backend.shDespués, conectado por SSH a la VPS:
chmod +x /home/admin/redeploy-backend.sh # solo hace falta si scp no preservó el permiso
ENV_FILE=/opt/aba-cluster/.env /home/admin/redeploy-backend.shEl script hace docker pull de la imagen más reciente de GHCR, para/elimina el
contenedor anterior, lo vuelve a levantar (incluido el volumen de
/opt/aba-cluster/keys:/keys que usa AddDataProtection().PersistKeysToFileSystem
para el cifrado de tokens CSRF — ver Program.cs), y termina verificando
GET /stats contra https://api.aba.andrescortes.dev para confirmar que el
contenedor nuevo realmente responde antes de darlo por terminado.
Configurar en GitHub → Settings → Branches → Add branch ruleset (o Branch protection rule en el modo clásico) sobre main:
- Require a pull request before merging — mínimo 1 aprobación.
- Require status checks to pass before merging — selecciona los jobs
secret-scanybuildde .github/workflows/ci.yml. - Do not allow force pushes (deshabilitar force-push).
- Do not allow deletions de la rama.
- Opcional: Require review from Code Owners para que .github/CODEOWNERS sea vinculante (control 7.4: nadie cambia parámetros de recursos de la VPS sin revisión).
El repo trae el hook versionado en hooks/pre-commit (usa gitleaks si está instalado; si no, cae a un escaneo por patrones). Actívalo una vez por clon:
git config core.hooksPath hooks
En Linux/macOS/Git Bash asegúrate de que sea ejecutable: chmod +x hooks/pre-commit.
Ya incluye bin/, obj/, .idea/, .vs/, *.user, .env, appsettings.Development.json y appsettings.Production.json.
Program.cs, docker-compose.yml, nginx.conf, infra/ y sql/06_memory_budget.sql están cubiertos por .github/CODEOWNERS: cualquier cambio pasa por PR con revisión, nunca por push directo a main.