Autenticación y multitenencia
Esta página describe el servicio de autenticación del backend en Go: cuentas, sesiones, doble factor, roles, invitaciones y cómo Postgres hace cumplir la frontera entre organizaciones. Está pensada para quien despliega u opera AI Workforce OS o integra un cliente propio con la API.
En esta página
- Dos modos: AUTH_ENABLED false y true
- Cuentas y registro
- Verificación de correo y cuentas de prueba
- Login, bloqueo y límites de tasa
- Sesiones: token de acceso y refresh rotatorio
- Doble factor y restablecimiento de contraseña
- Roles y permisos (RBAC)
- Organizaciones, miembros y cambio de organización
- Invitaciones
- Aislamiento con row-level security de Postgres
- Referencia de endpoints
Dos modos: AUTH_ENABLED false y true#
Con AUTH_ENABLED=false (el valor por defecto del binario) no hay login: la API está abierta, cada solicitud usa una única organización demo fija y el servidor registra una advertencia al arrancar. Sirve para desarrollo local y demos.
Con AUTH_ENABLED=true la API exige un token de acceso bearer en las rutas protegidas, resuelve el rol en cada solicitud y limita todo acceso a datos a la organización de quien llama. El arranque falla si:
JWT_SECRETtiene menos de 32 bytes.- Falta
DATABASE_URL(cuentas y organizaciones viven en Postgres). ALLOWED_ORIGINScontiene*. Si lo dejas vacío, solo se acepta el mismo origen.
AUTH_ENABLED=true
JWT_SECRET=<at least 32 random bytes>
DATABASE_URL=postgres://... # required with auth
MIGRATE_DATABASE_URL=postgres://... # owner/DDL role for migrations (default: DATABASE_URL)
DB_APP_ROLE=app_user # default
ALLOWED_ORIGINS=https://app.example.com # no * when auth is on
TRUSTED_PROXIES=10.0.0.0/8 # honour X-Forwarded-For from these addresses
AUTH_COOKIE_SECURE=true # default; false only for plain-http development
AUTH_COOKIE_NAME=wfai_refresh # default name
Nota
El docker-compose.yml de desarrollo arranca con AUTH_ENABLED=true y AUTH_COOKIE_SECURE=false; docker-compose.demo.yml lo pone en false.
Atención
Con AUTH_ENABLED=false cualquiera que alcance el puerto puede leer y modificar todo. Nunca expongas ese modo a una red.
Cuentas y registro#
POST /api/v1/auth/register crea en un solo paso un usuario, una organización nueva y una membresía de owner, y responde 201 con una sesión. La organización parte con el presupuesto por defecto (BUDGET_USD). Los correos se normalizan (recorte y minúsculas) antes de buscar o guardar, así que Ana@Example.com y ana@example.com son la misma cuenta.
Las contraseñas se guardan con Argon2id (64 MiB, 3 iteraciones, 2 hilos). Un hash creado con parámetros más débiles se recalcula en el siguiente login correcto. Si una regla falla, la respuesta es 422 validation_failed con el detalle en error.fields.
| Campo | Regla |
|---|---|
email | Se recorta y pasa a minúsculas; máximo 254 caracteres; dirección simple con un punto en el dominio. |
password | 10 a 128 caracteres; fuera de una lista corta de contraseñas comunes; distinta del correo y de su parte local; no un solo carácter repetido; al menos dos clases entre minúsculas, mayúsculas, dígitos y símbolos. |
name | 1 a 100 caracteres, sin caracteres de control. |
org_name | 2 a 100 caracteres, sin caracteres de control. El slug se deriva del nombre más un sufijo aleatorio. |
Verificación de correo y cuentas de prueba#
Con EMAIL_VERIFICATION=true, el registro crea la cuenta pero no abre sesión: responde 202 con verification_required y envía un enlace de un solo uso (token aleatorio guardado como hash, válido 24 horas). Requiere AUTH_ENABLED=true, UI_BASE_URL y SMTP (SMTP_*) o EMAIL_DEV_LOG=true, que escribe los enlaces en el log en lugar de enviarlos.
Con TRIAL_ENABLED=true (también requiere AUTH_ENABLED=true), POST /api/v1/auth/trial crea una cuenta desechable con su propia organización y devuelve una sola vez el correo generado, una contraseña (formato XXXX-XXXX-XXXX) y expires_at. Solo se guarda el hash de la contraseña. GET /api/v1/auth/config indica a la interfaz qué funciones están activas.
POST /auth/logincon contraseña correcta y correo sin verificar responde403 email_not_verified; con contraseña incorrecta sigue respondiendoinvalid_credentials, así que no revela qué correos existen.POST /auth/verifycon{token}consume el enlace, marca el correo como verificado y abre la sesión.POST /auth/verify/resendcon{email}reenvía el enlace (3 por hora por dirección) y responde204exista o no la cuenta.- Una cuenta de prueba deja de funcionar a las
TRIAL_TTL_HOURS(24 por defecto): el token de acceso nunca dura más que la prueba, el login responde403 trial_expiredy la renovación se rechaza. - La creación de pruebas se limita por IP (
TRIAL_PER_IP_PER_DAY, 3) y en total (TRIAL_GLOBAL_PER_DAY, 300) por día.
{
"email": "prueba-1a2b3c4d@prueba.invalid",
"password": "K7QD-3MWX-H9TR",
"expires_at": "2026-10-23T12:00:00Z"
}
Login, bloqueo y límites de tasa#
Los fallos de login se cuentan por cuenta. Tras cinco contraseñas erróneas la cuenta se bloquea 15 minutos y los intentos siguientes responden 429 account_locked con la cabecera Retry-After. Un correo desconocido se verifica contra un hash ficticio, así que una cuenta inexistente tarda lo mismo que una contraseña errónea.
Además hay limitadores por correo y por IP. Los contadores viven en Redis cuando REDIS_URL está definido (sobreviven a reinicios) y en memoria si no. Los límites por IP solo respetan X-Forwarded-For de las direcciones de TRUSTED_PROXIES. Al excederse, la respuesta es 429 rate_limited con Retry-After.
HTTP/1.1 429 Too Many Requests
Retry-After: 840
Content-Type: application/json
{"error":{"code":"account_locked","message":"too many failed attempts; try again later"}}
| Límite | Por defecto | Clave |
|---|---|---|
| Intentos de login | 10 por 15 min | Correo (con hash), exista o no la cuenta |
POST /auth/login y endpoints públicos de doble factor | 20 por 15 min | IP del cliente |
POST /auth/register, /invitations/accept, /verify, /verify/resend, /password/forgot, /password/reset | 5 por hora cada uno | IP del cliente |
POST /auth/refresh y /auth/logout | 60 por 15 min | IP del cliente |
Endpoints autenticados de /auth e /invitations | 120 por min | Usuario |
| Contraseñas erróneas antes del bloqueo | 5, bloqueo de 15 min | Cuenta |
Nota
Si el backend de límites (Redis) falla, los limitadores dejan pasar las solicitudes en lugar de bloquear el login. El bloqueo por cuenta no depende de Redis: vive en Postgres.
Sesiones: token de acceso y refresh rotatorio#
El token de acceso es un JWT HS256 de vida corta (15 minutos) que lleva usuario, organización y rol, y se envía en Authorization: Bearer. El refresh token es un valor aleatorio opaco, válido 30 días, guardado en el servidor solo como hash SHA-256 y entregado en la cookie wfai_refresh (HttpOnly, SameSite=Strict, Secure por defecto, Path=/api/v1/auth). Nunca aparece en un cuerpo JSON.
/auth/refresh y /auth/logout actúan sobre esa cookie, así que exigen la cabecera X-WFAI-CSRF y rechazan solicitudes con Sec-Fetch-Site: cross-site o con un Origin fuera de ALLOWED_ORIGINS (o del propio host). Cada refresh rota el token dentro de su familia:
- Presentar un refresh token ya usado revoca la familia completa, lo que delata un robo.
- Un token rechazado (expirado, revocado o reutilizado) responde
401 invalid_tokeny borra la cookie. POST /auth/logoutrevoca la familia y responde204;POST /auth/logout-allrevoca todos los refresh tokens del usuario en todas sus organizaciones.
POST /api/v1/auth/login
Content-Type: application/json
{"email":"ana@example.com","password":"<password>"}
HTTP/1.1 200 OK
Cache-Control: no-store
Set-Cookie: wfai_refresh=<opaque>; Path=/api/v1/auth; Max-Age=2592000; HttpOnly; Secure; SameSite=Strict
Content-Type: application/json
{"token_type":"Bearer","access_token":"<jwt>","expires_in":900,
"expires_at":"2026-10-22T12:15:00Z",
"user":{"id":"<user id>","email":"ana@example.com","name":"Ana"},
"org_id":"<org id>","role":"owner"}
curl -X POST http://localhost:8080/api/v1/auth/refresh \
-b 'wfai_refresh=<cookie value>' -H 'X-WFAI-CSRF: 1'
Doble factor y restablecimiento de contraseña#
Cada usuario puede activar un segundo factor TOTP: POST /auth/mfa/setup devuelve secret y otpauth_uri (para el código QR), y POST /auth/mfa/enable con un código válido lo activa y devuelve 8 códigos de recuperación que se muestran una sola vez. Desde entonces, un login con contraseña correcta no abre sesión: responde 200 con mfa_required y un mfa_token de 5 minutos, y la sesión se abre con POST /auth/mfa/verify ({mfa_token, code}). Un código TOTP no puede reutilizarse y los códigos erróneos cuentan para el mismo bloqueo que las contraseñas.
Una organización puede exigir doble factor a sus miembros (el login devuelve mfa_enrollment_required con un token para inscribirse) o exigir SSO (el login con contraseña de quien no es owner responde 403 sso_required).
Con correo configurado (SMTP o EMAIL_DEV_LOG) y UI_BASE_URL, POST /auth/password/forgot con {email} envía un enlace válido 1 hora y responde 204 exista o no la cuenta. POST /auth/password/reset con {token, password} aplica la política de contraseñas, cierra todas las sesiones del usuario, levanta el bloqueo y no abre sesión: hay que volver a iniciarla.
Roles y permisos (RBAC)#
Cada membresía tiene un rol: owner, admin, member o viewer. Un rol es un conjunto de permisos con nombre recurso:acción y cada uno es un superconjunto estricto del inferior. El rol se vuelve a leer de la base de datos en cada solicitud, así que degradar o quitar a un miembro surte efecto de inmediato y no al expirar el token. GET /auth/me devuelve el rol y su lista de permissions.
Un owner puede asignar cualquier rol; un admin solo asigna y gestiona roles inferiores a admin y no puede cambiar el suyo; nadie otorga un rol superior al propio. El último owner no puede eliminarse (409 last_owner).
| Rol | Añade sobre el rol inferior |
|---|---|
viewer | Solo lectura: miembros, agentes, tareas, solicitudes, conversaciones, aprobaciones, informes, memorias, actividad, métricas, artefactos, archivos, habilidades y tienda. |
member | Crear solicitudes y tareas, escribir en conversaciones y memorias, crear, editar, comentar y exportar artefactos, escribir archivos y habilidades, comprar en la tienda, leer conexiones. |
admin | Gestionar la organización, invitar y gestionar miembros, escribir agentes, gestionar tareas, decidir aprobaciones (approvals:decide), leer la auditoría, gestionar conexiones y sus permisos, pausa y kill switch, aprobar y borrar artefactos, borrar archivos, revisar habilidades, borrar conversaciones. |
owner | Eliminar la organización, facturación, liberar el kill switch (controls:release) y editar la política de aprobaciones (policy:manage). |
Nota
Los agentes nunca reciben permisos de aprobación como artifacts:approve: su autoridad no puede aprobar lo que ellos mismos proponen.
Organizaciones, miembros y cambio de organización#
Un usuario puede pertenecer a varias organizaciones, con un rol distinto en cada una. GET /auth/me devuelve el usuario y la lista orgs con nombre y rol. POST /auth/switch-org con {org_id} abre una sesión nueva en otra organización del usuario y revoca la familia del refresh token anterior; pedir una organización sin membresía responde 403 forbidden.
Toda la gestión de miembros ocurre dentro de la organización de quien actúa, y los usuarios de otras organizaciones se reportan como no encontrados:
GET /auth/memberslista los miembros (members:read).POST /auth/memberscon{email, role}agrega a un usuario que ya tiene cuenta (members:invite).PATCH /auth/members/{id}con{role}cambia el rol (members:manage).DELETE /auth/members/{id}quita a un miembro (members:manage) o permite que cualquiera se salga a sí mismo. También revoca sus refresh tokens de esa organización.
Invitaciones#
Admins y owners invitan un correo con un rol que puedan asignar (POST /api/v1/invitations). El servidor guarda solo el SHA-256 del token de un solo uso y devuelve el texto plano una única vez. No envía correo: quien invita comparte el enlace /invite?token=... que sirve el frontend. Una invitación nueva al mismo correo revoca la pendiente anterior, y los enlaces expiran a los 7 días.
POST /api/v1/auth/invitations/accept es público y recibe {token, name, password}. Una persona nueva elige nombre y contraseña; una cuenta existente demuestra la propiedad con su contraseña actual, con el mismo bloqueo que el login. Los tokens desconocidos, expirados, revocados o usados fallan igual (401 invalid_token). Si quien invitó fue degradado o eliminado, la invitación deja de funcionar. Creación, revocación y aceptación quedan en la auditoría, guardando solo el dominio del correo.
{
"id": "<invitation id>",
"org_id": "<org id>",
"email": "luis@example.com",
"role": "member",
"invited_by": "<user id>",
"created_at": "2026-10-22T12:00:00Z",
"expires_at": "2026-10-29T12:00:00Z",
"status": "pending",
"accepted_at": null,
"revoked_at": null,
"token": "<one-time token>"
}
Atención
El campo token es la única copia del secreto: quien lo tenga puede unirse a la organización con ese rol. Compártelo por un canal privado.
Aislamiento con row-level security de Postgres#
Los roles deciden qué puede hacer un usuario; la row-level security decide qué filas existen para él. Cada tabla de tenant tiene la política tenant_isolation, que compara org_id con app_current_org(), y FORCE ROW LEVEL SECURITY la aplica incluso al dueño de la tabla. Cada solicitud corre en una transacción que empieza con set_config('app.org_id', ..., true): el ajuste es local a la transacción, así que una conexión del pool no arrastra un tenant a otra solicitud.
Falla cerrado: sin app.org_id, ninguna fila es visible ni escribible. Cada conexión de la aplicación ejecuta SET ROLE app_user (DB_APP_ROLE), un rol que no es superusuario, no tiene BYPASSRLS y no es dueño de ninguna tabla. Para ese rol el log de auditoría es de solo anexado (UPDATE y DELETE revocados). Las migraciones corren con MIGRATE_DATABASE_URL si está definida.
-- One transaction per request, scoped to the caller's organization
SELECT set_config('app.org_id', '<org id>', true); -- == SET LOCAL
CREATE OR REPLACE FUNCTION app_current_org() RETURNS TEXT
LANGUAGE sql STABLE PARALLEL SAFE AS
$$ SELECT NULLIF(current_setting('app.org_id', true), '') $$;
CREATE POLICY tenant_isolation ON agents
USING (org_id = app_current_org())
WITH CHECK (org_id = app_current_org());
Referencia de endpoints#
El router de autenticación se monta en /api/v1/auth y el de invitaciones en /api/v1/invitations. Los permisos se comprueban en un middleware antes del handler, y el servicio vuelve a leer la membresía actual de quien actúa en cada operación sobre miembros. Los errores usan la forma {"error": {"code", "message"}}.
| Endpoint | Acceso |
|---|---|
POST /api/v1/auth/register, /login, /refresh, /logout | Público; refresh y logout usan la cookie más X-WFAI-CSRF. |
POST /api/v1/auth/verify, /verify/resend, /trial, /password/forgot, /password/reset | Público; cada uno exige su función activa. |
POST /api/v1/auth/mfa/verify, /mfa/enroll/setup, /mfa/enroll/enable | Público, con el mfa_token del login. |
POST /api/v1/auth/invitations/accept | Público, token en el cuerpo. |
GET /api/v1/auth/config | Público: enabled, registration, verification, trial, password_reset, mfa. |
GET /auth/me, POST /auth/logout-all, /auth/switch-org, /auth/mfa/setup, /auth/mfa/enable, /auth/mfa/disable | Cualquier usuario autenticado. |
GET, POST, PATCH, DELETE /auth/members | members:read, members:invite, members:manage; un miembro puede salirse solo. |
GET, POST, DELETE /api/v1/invitations | members:read para listar; members:invite para crear y revocar. |
Consejo
La referencia de la API lista el resto de rutas y el modelo de seguridad analiza las amenazas.