Saltar al contenido
AI Workforce OS
ES EN
Crear cuenta

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.

10 min de lectura

En esta página
  1. Dos modos: AUTH_ENABLED false y true
  2. Cuentas y registro
  3. Verificación de correo y cuentas de prueba
  4. Login, bloqueo y límites de tasa
  5. Sesiones: token de acceso y refresh rotatorio
  6. Doble factor y restablecimiento de contraseña
  7. Roles y permisos (RBAC)
  8. Organizaciones, miembros y cambio de organización
  9. Invitaciones
  10. Aislamiento con row-level security de Postgres
  11. Referencia de endpoints
Volver arriba

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_SECRET tiene menos de 32 bytes.
  • Falta DATABASE_URL (cuentas y organizaciones viven en Postgres).
  • ALLOWED_ORIGINS contiene *. Si lo dejas vacío, solo se acepta el mismo origen.
.env (backend)Bash
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.

CampoRegla
emailSe recorta y pasa a minúsculas; máximo 254 caracteres; dirección simple con un punto en el dominio.
password10 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.
name1 a 100 caracteres, sin caracteres de control.
org_name2 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/login con contraseña correcta y correo sin verificar responde 403 email_not_verified; con contraseña incorrecta sigue respondiendo invalid_credentials, así que no revela qué correos existen.
  • POST /auth/verify con {token} consume el enlace, marca el correo como verificado y abre la sesión.
  • POST /auth/verify/resend con {email} reenvía el enlace (3 por hora por dirección) y responde 204 exista 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 responde 403 trial_expired y 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.
201 de POST /api/v1/auth/trialJSON
{
  "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.

Cuenta bloqueadaHTTP
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ímitePor defectoClave
Intentos de login10 por 15 minCorreo (con hash), exista o no la cuenta
POST /auth/login y endpoints públicos de doble factor20 por 15 minIP del cliente
POST /auth/register, /invitations/accept, /verify, /verify/resend, /password/forgot, /password/reset5 por hora cada unoIP del cliente
POST /auth/refresh y /auth/logout60 por 15 minIP del cliente
Endpoints autenticados de /auth e /invitations120 por minUsuario
Contraseñas erróneas antes del bloqueo5, bloqueo de 15 minCuenta

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_token y borra la cookie.
  • POST /auth/logout revoca la familia y responde 204; POST /auth/logout-all revoca todos los refresh tokens del usuario en todas sus organizaciones.
LoginHTTP
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"}
Renovar la sesiónBash
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).

RolAñade sobre el rol inferior
viewerSolo lectura: miembros, agentes, tareas, solicitudes, conversaciones, aprobaciones, informes, memorias, actividad, métricas, artefactos, archivos, habilidades y tienda.
memberCrear solicitudes y tareas, escribir en conversaciones y memorias, crear, editar, comentar y exportar artefactos, escribir archivos y habilidades, comprar en la tienda, leer conexiones.
adminGestionar 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.
ownerEliminar 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/members lista los miembros (members:read).
  • POST /auth/members con {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.

201 de POST /api/v1/invitationsJSON
{
  "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.

SQL
-- 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"}}.

EndpointAcceso
POST /api/v1/auth/register, /login, /refresh, /logoutPúblico; refresh y logout usan la cookie más X-WFAI-CSRF.
POST /api/v1/auth/verify, /verify/resend, /trial, /password/forgot, /password/resetPúblico; cada uno exige su función activa.
POST /api/v1/auth/mfa/verify, /mfa/enroll/setup, /mfa/enroll/enablePúblico, con el mfa_token del login.
POST /api/v1/auth/invitations/acceptPúblico, token en el cuerpo.
GET /api/v1/auth/configPú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/disableCualquier usuario autenticado.
GET, POST, PATCH, DELETE /auth/membersmembers:read, members:invite, members:manage; un miembro puede salirse solo.
GET, POST, DELETE /api/v1/invitationsmembers: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.