ATLAS + GOTCHA -- Parte 5

Manos a la Obra: Construye una API de Usuarios en 45 Minutos con ATLAS + GOTCHA

#ai #atlas #gotcha #dotnet #postgresql #kubernetes #jwt

The Problem

Cuatro artículos. Dos frameworks. Mucha teoría.

Si has seguido la serie, ya conoces ATLAS (un checklist de 5 fases que te obliga a pensar antes de escribir un prompt) y GOTCHA (un formato de prompt con 6 capas que traduce ese pensamiento en instrucciones estructuradas para la IA). Si no, la versión corta: ATLAS te obliga a definir bien el problema. GOTCHA se asegura de que la IA reciba una especificación completa y organizada en vez de una petición vaga.

Pero saber algo y hacerlo son cosas diferentes. Este artículo va de hacer.

Vamos a construir una API de usuarios real. Base de datos PostgreSQL, autenticación JWT, patrón repository, desplegada en Kubernetes. El tipo de cosa que lleva un día entero hacerla a mano, llena de decisiones, vueltas atrás y “espera, ¿cómo funcionaban las migraciones de EF Core?”

Lo haremos en 45 minutos. Porque no vamos a ir adivinando. Primero ejecutaremos ATLAS, lo traduciremos a GOTCHA, y le daremos a la IA un prompt que no deje nada abierto a interpretación.

Después revisaremos el código, entenderemos qué se generó, y sabremos exactamente por qué existe cada pieza.

The Solution

El workflow ATLAS + GOTCHA aplicado a un proyecto real. El objetivo no es ser listo — es ser sistemático.

Aquí está el checklist ATLAS, completado para este proyecto:

[A] ARCHITECT
  Purpose: REST API for user management. CRUD operations + JWT auth.
  Users: Frontend React app and other internal services (machine-to-machine).
  Constraints:
    - JWT tokens expire in 1 hour (refresh tokens not in scope for this article)
    - Passwords hashed with Argon2id (OWASP recommended: m=64MB, t=3, p=4)
    - Email must be unique per user
    - API must return RFC 7807 problem details on errors
  Tech decisions: .NET 10, PostgreSQL 16, Entity Framework Core, Docker, AKS
  Out of scope: email verification, OAuth2, password reset flow

[T] TRACE
  Registration:
    1. POST /users → validate body → check email unique → hash password → insert → return 201
  Login:
    2. POST /auth/login → validate body → find user by email → verify password → issue JWT → return 200
  Get user:
    3. GET /users/{id} → validate JWT → find user → return 200 (no password hash)
  Update user:
    4. PUT /users/{id} → validate JWT → check ownership → validate body → update → return 200
  Delete user:
    5. DELETE /users/{id} → validate JWT → check ownership → soft delete → return 204

[L] LINK
  | From            | To           | Method    | Contract              | Failure mode              |
  | --------------- | ------------ | --------- | --------------------- | ------------------------- |
  | React frontend  | Users API    | HTTPS/JWT | OpenAPI 3.0           | 401/403 on auth failure   |
  | Users API       | PostgreSQL   | TCP/EF    | EF Core entities      | Retry 3x, then 503        |
  | Other services  | Users API    | HTTPS/JWT | Bearer token (m2m)    | 401 on expired token      |
  | K8s liveness    | /healthz     | HTTP      | 200 OK                | Pod restart after 3 fails |

[A] ASSEMBLE
  Phase 1: Data layer
    - PostgreSQL schema + EF Core migration
    - User entity + AppDbContext
    - IUserRepository interface + UserRepository
  Phase 2: Business logic
    - IUserService + UserService (create, get, update, delete, authenticate)
    - PasswordService (hash + verify with Argon2id)
    - JwtService (issue token with claims)
  Phase 3: API layer
    - UsersController (CRUD)
    - AuthController (login)
    - JWT middleware configuration
    - FluentValidation request validators
  Phase 4: Cross-cutting
    - Global error handler → RFC 7807 problem details
    - Health check endpoint (/healthz)
    - Structured logging (Serilog)
  Phase 5: Deployment
    - Dockerfile (multi-stage, non-root user)
    - K8s Deployment + Service + Ingress
    - K8s Secret for DB connection string + JWT secret

[S] STRESS-TEST
  Scenario 1: Concurrency
    - 200 concurrent registrations with same email → only 1 succeeds, rest get 409
    - Verify unique constraint at database level, not just application
  Scenario 2: Auth
    - Expired JWT → 401 with clear message
    - Tampered JWT → 401
    - Valid JWT for user A accessing user B → 403
  Scenario 3: Input validation
    - Empty email → 422 with field-level error details
    - Password shorter than 8 chars → 422
    - SQL injection attempt in email field → 422 (FluentValidation + parameterized queries)

Ahora el prompt GOTCHA, mapeado directamente desde ATLAS:

=== GOALS (from Architect) ===
Build a .NET 10 Web API for user management:
- POST /users — register (returns 201 with user id)
- POST /auth/login — returns JWT (1h expiry)
- GET /users/{id} — requires valid JWT (returns user without password hash)
- PUT /users/{id} — requires JWT, owner only (returns updated user)
- DELETE /users/{id} — requires JWT, owner only, soft delete (returns 204)
All errors return RFC 7807 problem details.

=== ORCHESTRATION (from Assemble) ===
Build in this order:
1. User entity + AppDbContext + EF Core migration
2. IUserRepository + UserRepository (PostgreSQL via EF Core)
3. PasswordService (Argon2id hash + verify)
4. JwtService (issue HS256 token with sub, email, exp claims)
5. IUserService + UserService (orchestrates repository + password + JWT)
6. FluentValidation validators for each request DTO
7. UsersController + AuthController
8. JWT middleware + global error handler (RFC 7807)
9. Health check (/healthz), Serilog structured logging
10. Dockerfile (multi-stage) + K8s manifests

=== TOOLS (from Link) ===
- .NET 10 Web API
- Entity Framework Core 10 + Npgsql.EntityFrameworkCore.PostgreSQL
- Konscious.Security.Cryptography.Argon2 (password hashing)
- System.IdentityModel.Tokens.Jwt (JWT)
- FluentValidation.AspNetCore
- Serilog + Serilog.AspNetCore
- xUnit + Moq + Testcontainers.PostgreSql (integration tests)
- Docker + Kubernetes

=== CONTEXT (from Trace + Link) ===
- Consumed by a React frontend and internal services (machine-to-machine)
- Deploys to AKS (Azure Kubernetes Service)
- No external identity provider — this service owns user credentials
- Soft delete: IsDeleted boolean flag, filtered via EF Core global query filter
- Password hash stored in DB, never returned in API responses

=== HEURISTICS (from Assemble) ===
DO:
- Repository pattern: IUserRepository injected into UserService
- Async/await for all DB and I/O operations
- Return DTOs, never EF entities directly from controllers
- Validate all inputs with FluentValidation before hitting service layer
- Use parameterized queries (EF Core handles this)
- Log structured events (user created, login attempt, auth failure)

DON'T:
- Don't put business logic in controllers
- Don't catch and swallow exceptions — let global handler format them
- Don't return stack traces in error responses
- Don't store plain-text passwords, even temporarily
- Don't use AutoMapper — manual mapping is fine and explicit

=== ARGS (from Stress-test + Architect) ===
DB_CONNECTION: from env var (PostgreSQL connection string)
JWT_SECRET: from env var (min 32 chars)
JWT_EXPIRY_MINUTES: 60
ARGON2_MEMORY: 65536, ARGON2_ITERATIONS: 3, ARGON2_PARALLELISM: 4
PORT: 8080
K8s namespace: users-api
K8s replicas: 2
Health check: GET /healthz → 200 OK

Execute

Vamos a repasar las partes clave del código que la IA genera a partir de este prompt. No todos los ficheros — solo las partes que importan.

The Entity and DbContext

// Domain/User.cs
public class User
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Email { get; set; } = string.Empty;
    public string PasswordHash { get; set; } = string.Empty;
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public bool IsDeleted { get; set; }
}
// Infrastructure/UsersDbContext.cs
public class UsersDbContext(DbContextOptions<UsersDbContext> options) : DbContext(options)
{
    public DbSet<User> Users => Set<User>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>(e =>
        {
            e.HasKey(u => u.Id);
            e.HasIndex(u => u.Email).IsUnique()
                .HasFilter("\"IsDeleted\" = false");
            e.Property(u => u.Email).HasMaxLength(320).IsRequired();
            e.Property(u => u.FirstName).HasMaxLength(100).IsRequired();
            e.Property(u => u.LastName).HasMaxLength(100).IsRequired();
            // Global query filter: soft delete
            e.HasQueryFilter(u => !u.IsDeleted);
        });
    }
}

El global query filter en IsDeleted es importante. Cada consulta a través de EF Core excluye automáticamente los usuarios borrados. No tienes que acordarte de añadir WHERE "IsDeleted" = false en todos los sitios. El índice único en Email también filtra los usuarios borrados — así un usuario puede re-registrarse con el mismo email después de borrarse.

The Repository

// Application/IUserRepository.cs
public interface IUserRepository
{
    Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<User?> GetByEmailAsync(string email, CancellationToken ct = default);
    Task<bool> ExistsAsync(string email, CancellationToken ct = default);
    Task CreateAsync(User user, CancellationToken ct = default);
    Task SoftDeleteAsync(Guid id, CancellationToken ct = default);
}
// Infrastructure/UserRepository.cs
public class UserRepository(UsersDbContext db) : IUserRepository
{
    public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
        db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);

    public Task<User?> GetByEmailAsync(string email, CancellationToken ct = default) =>
        db.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);

    public Task<bool> ExistsAsync(string email, CancellationToken ct = default) =>
        db.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct);

    public async Task CreateAsync(User user, CancellationToken ct = default)
    {
        db.Users.Add(user);
        await db.SaveChangesAsync(ct);
    }

    public async Task SoftDeleteAsync(Guid id, CancellationToken ct = default)
    {
        await db.Users.Where(u => u.Id == id)
            .ExecuteUpdateAsync(u => u.SetProperty(x => x.IsDeleted, true), ct);
    }
}

The JWT Service

// Services/JwtService.cs
public class JwtService(IConfiguration config)
{
    private readonly string _secret = config["JWT_SECRET"]
        ?? throw new InvalidOperationException("JWT_SECRET not configured");
    private readonly int _expiryMinutes = int.Parse(config["JWT_EXPIRY_MINUTES"] ?? "60");

    public string IssueToken(User user)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        };

        var token = new JwtSecurityToken(
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_expiryMinutes),
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

The Controller

// Controllers/UsersController.cs
[ApiController]
[Route("users")]
public class UsersController(IUserService userService) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Register(
        [FromBody] RegisterRequest req, CancellationToken ct)
    {
        var result = await userService.RegisterAsync(req.Email, req.Name, req.Password, ct);
        return result.IsSuccess
            ? CreatedAtAction(nameof(GetById), new { id = result.Value.Id }, result.Value)
            : Problem(result.Error, statusCode: result.StatusCode);
    }

    [HttpGet("{id:guid}")]
    [Authorize]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var result = await userService.GetByIdAsync(id, ct);
        return result.IsSuccess ? Ok(result.Value) : Problem(result.Error, statusCode: 404);
    }

    [HttpPut("{id:guid}")]
    [Authorize]
    public async Task<IActionResult> Update(
        Guid id, [FromBody] UpdateUserRequest req, CancellationToken ct)
    {
        var callerId = Guid.Parse(User.FindFirstValue(JwtRegisteredClaimNames.Sub)!);
        if (callerId != id)
            return Problem("You can only update your own account.", statusCode: 403);

        var result = await userService.UpdateAsync(id, req.Name, ct);
        return result.IsSuccess ? Ok(result.Value) : Problem(result.Error, statusCode: 404);
    }

    [HttpDelete("{id:guid}")]
    [Authorize]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        var callerId = Guid.Parse(User.FindFirstValue(JwtRegisteredClaimNames.Sub)!);
        if (callerId != id)
            return Problem("You can only delete your own account.", statusCode: 403);

        await userService.DeleteAsync(id, ct);
        return NoContent();
    }
}

El método Problem() aquí usa el soporte de problem details integrado en ASP.NET Core, que produce respuestas compatibles con RFC 7807 automáticamente.

The Kubernetes Manifests

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: users-api
  namespace: users-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: users-api
  template:
    metadata:
      labels:
        app: users-api
    spec:
      containers:
        - name: users-api
          image: myacr.azurecr.io/users-api:latest
          ports:
            - containerPort: 8080
          env:
            - name: DB_CONNECTION
              valueFrom:
                secretKeyRef:
                  name: users-api-secrets
                  key: db-connection
            - name: JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: users-api-secrets
                  key: jwt-secret
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            allowPrivilegeEscalation: false

Dos cosas que vale la pena mencionar aquí. Primero, tanto la cadena de conexión a la base de datos como el secret del JWT vienen de un Kubernetes Secret — nunca de variables de entorno metidas en la imagen. Segundo, runAsNonRoot: true y allowPrivilegeEscalation: false son hardening de seguridad básico que muchos equipos olvidan hasta que lo necesitan para cumplir normativas.

Lo que la IA hizo bien (y lo que ajustamos)

Esta es la parte honesta. La IA generó el 90% de esto correctamente a partir del prompt GOTCHA. Tres cosas que ajustamos:

  1. El global query filter para soft delete. La IA añadió WHERE deleted_at IS NULL a cada método del repository individualmente. Lo reemplazamos con el global query filter de EF Core — menos código, menos posibilidades de olvidarlo en un método nuevo.

  2. El return de Problem() en los controllers. La IA usó un formato de respuesta de error personalizado. Lo cambiamos al método Problem() integrado de ASP.NET Core para tener compatibilidad con RFC 7807 gratis.

  3. El securityContext de K8s. La IA generó el deployment sin security context. Añadimos runAsNonRoot y allowPrivilegeEscalation: false basándonos en las heuristics que definimos — pero tuvimos que añadirlas manualmente porque el prompt GOTCHA no las especificaba de forma explícita.

Ese último punto es una lección: las Heuristics en GOTCHA necesitan ser lo bastante específicas como para que “hardening de seguridad” se convierta en “añadir runAsNonRoot: true y allowPrivilegeEscalation: false a todos los container specs de K8s.” Cuanto más específica sea la heuristic, más podrá la IA seguirla sin tener que adivinar.

Template

Aquí tienes el prompt GOTCHA completo para la API de usuarios, listo para copiar:

=== GOALS ===
Build a .NET 10 Web API for user management:
POST /users (register), POST /auth/login (JWT),
GET /users/{id} (auth required), PUT /users/{id} (auth + owner),
DELETE /users/{id} (auth + owner, soft delete).
All errors → RFC 7807 problem details.

=== ORCHESTRATION ===
1. User entity + AppDbContext + EF Core migration
2. IUserRepository + UserRepository
3. PasswordService (Argon2id, m=64MB, t=3, p=4)
4. JwtService (HS256, claims: sub, email, jti)
5. IUserService + UserService
6. FluentValidation validators for all request DTOs
7. UsersController + AuthController
8. JWT middleware + global error handler
9. Health check (/healthz) + Serilog
10. Dockerfile (multi-stage, non-root) + K8s manifests

=== TOOLS ===
.NET 10, EF Core 10, Npgsql, Konscious.Security.Cryptography.Argon2,
System.IdentityModel.Tokens.Jwt, FluentValidation.AspNetCore,
Serilog.AspNetCore, xUnit + Moq + Testcontainers.PostgreSql

=== CONTEXT ===
Deploys to AKS. Consumed by React frontend + internal services.
Soft delete via IsDeleted boolean (global EF query filter).
No external identity provider — this service owns credentials.

=== HEURISTICS ===
DO: repository pattern, async/await, return DTOs not entities,
log auth events, EF Core parameterized queries, manual DTO mapping.
DON'T: business logic in controllers, swallow exceptions,
return stack traces, store plain-text passwords.
K8s: runAsNonRoot: true, allowPrivilegeEscalation: false.

=== ARGS ===
DB_CONNECTION: env var, JWT_SECRET: env var (32+ chars),
JWT_EXPIRY_MINUTES: 60, ARGON2_MEMORY: 65536, ARGON2_ITERATIONS: 3, ARGON2_PARALLELISM: 4, PORT: 8080,
K8s namespace: users-api, replicas: 2,
CPU: 100m/500m, memory: 128Mi/256Mi

Challenge

Tienes una API funcionando. Ahora necesita un pipeline de CI/CD.

Antes del Artículo 6, piensa en esto: ¿qué necesita hacer un pipeline para esta API? No solo “build y deploy” — piensa en los pasos, en orden. ¿Qué se ejecuta primero? ¿Qué tiene que pasar antes de que empiece el siguiente paso? ¿Qué comprobaciones de seguridad quieres antes de que el código llegue a producción?

En el Artículo 6, construiremos ese pipeline en Azure DevOps — con escaneo de seguridad automático, hardening de imágenes de contenedor, y un despliegue a AKS. Usando ATLAS + GOTCHA para diseñar el pipeline antes de escribir una sola línea de YAML.

Si esta serie te resulta útil, puedes invitarme a un café.

Comments

Loading comments...