Skip to content

fix: [TESIS-82] close the auth and multi-tenancy gaps found by the module QA - #89

Merged
TomasMartin2004 merged 13 commits into
masterfrom
TESIS-82-auth-multitenancy-qa-fixes
Sep 25, 2026
Merged

TomasMartin2004 merged 13 commits into
masterfrom
TESIS-82-auth-multitenancy-qa-fixes

Conversation

@LoLoo03

@LoLoo03 LoLoo03 commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Ticket de Jira

https://proyectofinalfrlp.atlassian.net/browse/TESIS-82


Descripción

Corrige los hallazgos de backend de la validación del módulo de Auth y Multi-tenancy (TESIS-82). El más grave: POST /auth/register creaba una cuenta con acceso inmediato en la empresa que nombraba el header X-Tenant-Slug. Como el slug es el subdominio, cualquiera podía darse de alta en cualquier empresa y leer todos sus datos. Además, una empresa dada de baja seguía operando con sus tokens hasta 24 h, el login no tenía límite de intentos ni encontraba la cuenta si el email venía con mayúsculas, el flag integrations solo lo aplicaba el front, y dos casos del CRUD de depósitos respondían 500.

Cada hallazgo va en su propio commit, con sus specs. Las decisiones quedaron en la actualización de docs/adr/ADR-002-authentication.md:

  • El registro pasa a ser una solicitud, en vez de eliminarse: el diseño tiene la pantalla S02 («Solicitá acceso al espacio de operación de tu organización»). Por ahora la aprobación se hace desde el backoffice.
  • El límite es por IP, con el rate_limit de Rails y no con :lockable de Devise: bloquear la cuenta permitiría dejar afuera a otro usuario tipeando mal su contraseña a propósito.
  • El chequeo va en authenticate_user! y no en active_for_authentication?: el hook de Devise corta con 401 cualquier request que traiga el token, incluidos el login, el registro y tenant-config.
  • El email sigue siendo único en toda la base: el 202 con cuerpo idéntico ya cierra la enumeración, sin reemplazar la validación de Devise ni migrar índices.

Limitaciones conocidas:

  • Todavía no se pueden crear usuarios desde el backoffice, porque el recurso User no tiene campo de contraseña. Queda para TESIS-129.
  • El patrón require + permit que causaba el 500 en depósitos sigue en productos, transferencias, órdenes y mappings, que son de otros módulos.

Cambios:

  • Convierte POST /api/v1/auth/register en una solicitud de acceso: la cuenta nace con approved: false, no obtiene token hasta que se aprueba, y el endpoint responde 202 {"status":"pending_approval"} exista o no el email. Los errores de formato se informan antes de mirar el email, para que no lo delaten (hallazgos 1 y 3)
  • Agrega la columna users.approved (boolean, default true) y el campo approved en el recurso User del backoffice para aprobar cuentas
  • Extiende ApplicationController#authenticate_user! para responder 401 cuando la empresa está inactiva o la cuenta perdió la aprobación; así se cortan también las sesiones abiertas (hallazgo 2)
  • Agrega el concern Api::V1::Auth::AttemptLimit: login y registro aceptan 10 intentos cada 3 minutos por IP y después responden 429 con Retry-After (hallazgo 5)
  • Corrige Auth::AuthenticateUser para normalizar el email (minúsculas, sin espacios) antes de buscar la cuenta (hallazgo 10)
  • Agrega Company#feature_enabled? y hace que PUT /api/v1/integrations/:service_id responda 403 si la empresa no tiene la feature integrations. El GET sigue abierto porque lo usa el widget de nodos del panel, y solo lista las plantillas globales (hallazgo 4)
  • Declara en Warehouse las transferencias de origen y de destino con restrict_with_error: borrar un depósito al que referencia una transferencia responde 409 con el motivo, en vez de 500 (hallazgo 6)
  • Reemplaza require + permit por params.expect en WarehousesController: si warehouse no es un objeto responde 400 en vez de 500. También corrige el comentario que afirmaba que expect rechazaba el company_id del body (hallazgo 7)
  • Actualiza ADR-002 con las decisiones y con lo que necesita el entorno desplegado

Evidencia visual

N/A. Es un cambio de API; en el backoffice solo aparece el campo approved en el recurso User.


Cómo probar

Precondición: API local con los seeds (norte tiene integraciones y sur no; contraseña password123). Todos los requests llevan el header X-Tenant-Slug con el slug de la empresa.

Caso 1: el registro es una solicitud

  1. POST /api/v1/auth/register con X-Tenant-Slug: norte y { "email": "nuevo@test.com", "password": "password123" }
    → 202 {"status":"pending_approval"}
  2. POST /api/v1/auth/login con esas credenciales
    → 401, con la misma respuesta que una contraseña incorrecta
  3. En /admin → Users, tildar Approved en la cuenta nueva y repetir el login
    → 200 con token

Caso 2: el registro no delata emails de otras empresas

  1. POST /api/v1/auth/register con X-Tenant-Slug: norte y el email admin@sur.com
    → 202 con el mismo cuerpo del caso 1, y no se crea ninguna cuenta
  2. Repetir con la contraseña 123
    → 422 con el error de la contraseña, igual que con un email nuevo

Caso 3: una empresa dada de baja pierde las sesiones abiertas

  1. Loguearse como admin@norte.com y hacer GET /api/v1/warehouses con el token
    → 200
  2. En /admin → Companies, destildar Is active en Distribuidora Norte y repetir el GET
    → 401 {"error":"The company of this account is not active"}

Caso 4: límite de intentos

  1. Hacer POST /api/v1/auth/login 10 veces seguidas con una contraseña incorrecta
  2. Intentar una vez más con la contraseña correcta
    → 429 {"error":"Too many attempts, try again later"} con el header Retry-After: 180
  3. Esperar 3 minutos y repetir
    → 200

Caso 5: email con mayúsculas

  1. POST /api/v1/auth/login con X-Tenant-Slug: norte y el email Admin@Norte.com
    → 200

Caso 6: el flag de integraciones se aplica en la API

Precondición: token de admin@sur.com.

  1. PUT /api/v1/integrations/1 con { "credentials": { "access_token": "x" } }
    → 403 {"error":"The integrations feature is not enabled for this company"}
  2. GET /api/v1/integrations
    → 200, porque el panel lo sigue usando

Caso 7: bordes del CRUD de depósitos

  1. Crear un depósito vacío con POST /api/v1/warehouses y despacharle una transferencia con POST /api/v1/stock-transfers ({ "stock_transfer": { "product_id", "origin_warehouse_id", "destination_warehouse_id", "quantity" } })
  2. DELETE /api/v1/warehouses/:id del depósito nuevo
    → 409 {"error":"Cannot delete warehouse with stock transfers from or to it"}
  3. POST /api/v1/warehouses con { "warehouse": "x" }
    → 400

bundle exec rspec pasa los 1285 ejemplos y bundle exec rubocop no marca nada.


Impacto y consideraciones

¿Introduce breaking changes?
Sí, uno acotado: POST /api/v1/auth/register responde 202 {"status":"pending_approval"} en lugar de 201 con el usuario, y la cuenta no entra hasta que la aprueben. El front actual no usa el registro (S02 no está implementada), así que no rompe nada en uso. El login ahora puede responder 429; el PR de TESIS-82 en proyecto-web le agrega su mensaje.

¿Requiere nuevas variables de entorno?
No. Pero hay que correr la migración AddApprovedToUsers (default true, sin backfill: las cuentas existentes siguen entrando). En el entorno desplegado, el límite de intentos necesita la base de Solid Cache (proyecto_api_production_cache), y que request.remote_ip sea la IP del cliente y no la del proxy; si no, todos comparten un solo contador. Queda para TESIS-130.

¿Afecta la arquitectura o genera un nuevo patrón?
Sí. Se actualizó docs/adr/ADR-002-authentication.md con el registro como solicitud, el chequeo de la sesión en cada request, el límite de intentos (nuevo concern Api::V1::Auth::AttemptLimit) y la normalización del email en el login.


🤖 Generated with Claude Code

LorenzoBellomo and others added 8 commits September 24, 2026 15:46
The JWT of a company that was deactivated stayed valid until it expired, so
its users kept operating for up to 24 hours after the company was turned off.
is_active was only checked on login, registration and tenant-config.

authenticate_user! now refuses a session whose company is not active. The
check sits there and not in Devise's active_for_authentication? on purpose:
that hook answers 401 to any request carrying the token, including the ones
that do not require a session (login, registration, tenant-config).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /auth/register created an account that could log in right away, inside
whatever company the X-Tenant-Slug header named. The slug is public (it is the
subdomain), so anyone could join any company and read all of its data.

Registering is now asking for access, which is what the S02 screen says
("Solicitá acceso al espacio de operación de tu organización"). The account is
created with approved = false and cannot log in until someone approves it from
the backoffice, where User gains the approved field. The column defaults to
true, so existing accounts and the ones created by the backoffice, the seeds
or the console keep working.

The endpoint also stops telling which emails exist. The email is unique across
the whole database, so "Email has already been taken" revealed accounts of any
company. It now answers 202 with the same body whether the email was new or
not, and it reports invalid input before looking at the email, so a short
password cannot be used to tell the two cases apart either.

A session whose account loses its approval ends on the next request, like the
one of a deactivated company.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…email

Devise stores the email downcased and stripped (case_insensitive_keys and
strip_whitespace_keys), but it only normalizes on save and inside its own
finders. AuthenticateUser looks the account up with its own find_by and passed
the email as typed, so someone who registered as Ana@Norte.com got a 401 with
the right password when typing it the same way. The frontend does not
normalize it either.

The email is now normalized the same way before the lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Passwords could be guessed without limit: after 30 wrong ones in a row, the
right one still logged in.

Login and registration now allow 10 attempts every 3 minutes per IP and then
answer 429 with a Retry-After header. The limit is Rails' rate_limit and not
Devise's :lockable on purpose: locking the account would let anyone lock
someone else out by mistyping their password.

The counter lives in the app cache, Solid Cache in production. The test cache
is :null_store, so the rest of the suite never reaches the limit; the new spec
gives the counter a real store for each example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integrations flag of a company was only enforced by the frontend, which
hides the section. A company without the feature still configured
integrations by calling PUT /integrations directly.

The update now answers 403 when the flag is off. The listing stays open: the
dashboard's integration nodes widget requests it for every company, and it
only shows the global service templates with the company's own status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erence

Deleting the destination of a transfer in transit answered 500. That
warehouse has no stock of its own yet (the units are added when the transfer
is received) and no order lines, so neither restrict_with_error let it
through, and the delete reached the foreign keys of stock_transfers, which are
restrict too: ActiveRecord::InvalidForeignKey.

Warehouse now declares its outgoing and incoming transfers with
restrict_with_error, so the delete answers 409 and the message says the
transfers are what block it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST and PUT /warehouses with {"warehouse": "x"} answered 500. require
returns whatever the key holds, and permit on a String raises NoMethodError.

warehouse_params now uses params.expect, which answers 400 for anything that
is not an object. The comment it replaces said expect would also answer 400 to
a company_id in the body; in Rails 8.1 it drops unpermitted keys silently, and
the company_id specs of TESIS-97 still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Registration as an access request, the per-request check of the company and
the approval, the attempt limit per IP and the email normalization of the
login, with the alternatives that were discarded and what the deployed
environment needs for the attempt limit to count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LoLoo03
LoLoo03 requested a review from a team as a code owner September 24, 2026 23:43

@LauAubert LauAubert left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Buen PR: cada hallazgo en su commit, con specs, y las decisiones en el ADR. Dejo comentarios en línea. Los dos primeros (el logout que no revoca con la empresa inactiva y el límite de intentos que cuenta los logins exitosos) me parece que conviene resolverlos antes de mergear. El resto son menores o pueden ir en una card aparte.

# propósito: ese hook corta con 401 cualquier request que traiga el token,
# también los que no exigen sesión (login, registro, tenant-config). Acá sólo
# corre donde se pide sesión.
def authenticate_user!(*)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Este chequeo también corre en sessions#destroy, así que con la empresa inactiva (o la cuenta desaprobada) el logout responde 401 antes de llegar a RevokeToken, y el token nunca entra en la denylist.

El flujo: dan de baja a Norte → el próximo request da 401 → el front hace clearSession() → revokeSession() manda DELETE /auth/logout → 401 acá (el front ignora ese 401). Si Norte se reactiva dentro de las 24 h, cualquier copia de ese token (otro dispositivo, uno filtrado) vuelve a andar. Lo mismo si se quita la aprobación y después se vuelve a dar.

Propuesta: saltear el rechazo en destroy, o revocar antes de rechazar.

WINDOW = 3.minutes

included do
rate_limit to: MAX_ATTEMPTS, within: WINDOW, only: :create,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rate_limit cuenta todos los POST /auth/login, también los exitosos, y la clave es solo la IP. Un depósito con 12 operarios detrás del mismo NAT que entran al empezar el turno: el 11.º recibe 429 aunque ponga la contraseña correcta. Y hasta que se resuelva TESIS-130 (remote_ip detrás del proxy), toda la app comparte un único contador: 10 logins cada 3 minutos para todos.

¿Qué tal contar solo los intentos fallidos, o usar IP+email como clave?

Comment thread app/poros/auth/authenticate_user.rb Outdated

user = find_user
return nil unless user&.valid_password?(@password)
return nil unless user&.valid_password?(@password) && user.approved?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Esto ya pasaba antes del PR, pero como se toca esta línea y el comentario repite la garantía: si el email no existe en el tenant, user&.valid_password? corta antes y no se corre bcrypt. El 401 vuelve mucho más rápido que para un email existente, así que el tiempo de respuesta permite enumerar usuarios aunque el cuerpo sea idéntico. En RegisterUser pasa lo mismo, más chico: el camino del email tomado se saltea el INSERT.

La mitigación habitual es comparar contra un hash dummy cuando no se encuentra el usuario. Puede ir en otra card, pero entonces ajustaría el comentario para no prometer algo que no se cumple.

# endpoint directo. Se corta el alta y la modificación. El listado queda
# abierto porque lo usa el widget de nodos del panel, y sólo muestra las
# plantillas globales con el estado de la propia empresa.
before_action :require_integrations_feature, only: :update

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Menor: es una regla de autorización y el CLAUDE.md pide «autorizar con Pundit, delegar a PORO». Con una IntegrationPolicy#update? + authorize, verify_authorized obligaría al próximo endpoint de integraciones a pasar por la misma regla; con el before_action hay que acordarse de repetirlo.

# vez de ignorarlo — rompiendo el requisito de la card.
# rubocop:disable-next Rails/StrongParametersExpect
params.require(:warehouse).permit(:name, :zip_code, :address)
params.expect(warehouse: %i[name zip_code address])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

El mismo require + permit que daba 500 sigue en products_controller.rb:123, stock_transfers_controller.rb:69, product_mappings_controller.rb:74 y orders_controller.rb:172 (por ejemplo, POST /products con {"product": "x"} → 500). Ya está en las limitaciones conocidas; propongo abrir una card para migrarlos todos a params.expect, así el hallazgo 7 no queda cerrado a medias.

end

def session_refusal
return 'The company of this account is not active' unless current_company.is_active?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: ahora cada request autenticado hace un SELECT a companies para leer is_active, también en los endpoints que no usan current_company. Es barato, pero está en el camino de toda la API. No bloquea.

@TomasMartin2004 TomasMartin2004 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisión — TESIS-82 (PR #89) · Cierre de los hallazgos de Auth y Multi-tenancy

Revisado sobre TESIS-82-auth-multitenancy-qa-fixes (87ea8f9) contra origin/master, con el ADR-002 actualizado y el lado del front (web#51) al lado.

Check Resultado
Suite completa (local) 1285 ejemplos, 0 fallas
RuboCop · Brakeman (local) 258 archivos sin ofensas · 0 warnings
db/schema.rb Sólo la columna nueva. Sin ruido de la versión local de PostgreSQL, que es la trampa habitual

✅ El hallazgo grave está cerrado, y lo verifiqué por HTTP

Sondas propias sobre la rama, sin mirar tus specs:

registro con el email de OTRA empresa  -> 202 {"status":"pending_approval"}, usuarios creados: 0
registro de un email nuevo             -> 202, la cuenta nace con approved=false
login de esa cuenta pendiente          -> 401 {"error":"Invalid email or password"}  (idéntico al de password incorrecta)

El registro dejó de ser una puerta de entrada a cualquier empresa y no delata qué emails existen, ni en esta empresa ni en otra. Que los errores de formato se levanten antes de mirar el email es el detalle que cierra la enumeración de verdad: si no, una password corta respondería distinto según el email estuviera tomado.

El corte de las sesiones abiertas también funciona, que es la mitad que un cambio en el login no puede resolver:

empresa dada de baja, token vivo  -> 401 "The company of this account is not active"
cuenta que pierde la aprobación   -> 401 "This account is pending approval"
tenant-config (público)           -> 200, no lo toca

Y la decisión de ponerlo en authenticate_user! y no en active_for_authentication? es la correcta: ahí el hook de Devise cortaría también el login y el registro, que son justo los que no tienen que exigir sesión.

✅ El resto de los hallazgos, verificados uno por uno

login con "ANA@NORTE.TEST" y con "  Ana@Norte.Test  "  -> 200 con token
PUT /integrations sin la feature                        -> 403
GET /integrations sin la feature                        -> 200 (el widget del panel sigue andando)
PUT /integrations con features {"integrations": false}  -> 403
DELETE de un depósito con una transferencia             -> 409 "Cannot delete warehouse with stock transfers from or to it"
POST /warehouses con un body que no es objeto           -> 400

Dos cosas que me gustaron:

  • feature_enabled? compara con == true, igual que isFeatureEnabled del front (features?.[feature] === true). Lo verifiqué en shared/api/tenant.ts: un "true" string da apagado en los dos lados. Que el back y el front decidan igual es lo que evita una UI que ofrece lo que la API rechaza.
  • El límite es por IP y no por cuenta. Coincido con el motivo del ADR, y es el tipo de decisión que se toma mal por default.

El spec del límite tiene dientes: saqué el rate_limit del concern y fallan 3 ejemplos, incluido el del registro. Darle una cache de verdad sólo a ese spec, dejando :null_store para el resto, está bien resuelto — el resto de la suite loguea decenas de veces y no choca.

🟡 Dar de baja una empresa suspende las sesiones, pero no las cierra

El corte es reversible, y el usuario no puede cerrar la suya mientras dura. Secuencia, verificada:

1. login, GET /warehouses                        -> 200
2. la empresa se da de baja, mismo token         -> 401
3. el usuario intenta DELETE /auth/logout        -> 401   ← el token NO entra a la denylist
4. la empresa se reactiva, MISMO token de nuevo  -> 200

El paso 3 es el que me hace ruido: el logout es la única acción que un usuario bloqueado igual querría poder hacer, y hoy le responde 401. Como nunca se revoca, un token emitido antes de la baja vuelve a servir si la empresa se reactiva dentro de sus 24 h — incluido el de una sesión que la persona creyó cerrar.

Con la cuenta pendiente de aprobación pasa lo mismo (approved: false → true y el token viejo vuelve).

No lo bloqueo: el impacto es bajo y en la práctica el front borra la sesión al primer 401 (tu web#51 hace justo eso). Pero son dos salidas baratas, cualquiera sirve:

  • Dejar pasar sessions#destroy por el chequeo (revocar y devolver 204 igual), o
  • Revocar los tokens de la empresa al desactivarla.

Si te parece que el corte debe ser reversible —una suspensión no es una baja—, alcanza con que el ADR lo diga, y el punto queda cerrado.

🟡 Choca con TESIS-107 en el cuerpo de error del registro

registrations_controller.rb sigue respondiendo { "errors": [...] }, plural y array. api#86 (TESIS-107) lo cambia a { "error": "..." }, que es la forma única que fija el ADR-015, y lo agrega a api_contract_spec.rb.

No es un problema de este PR —tu rama salió de master, donde todavía está así—, pero los dos tocan el mismo archivo y el que entre segundo tiene conflicto. Aviso para coordinarlo: si entra este primero, en #86 lo reescribo yo sobre tu versión nueva.

Las limitaciones que declarás

Las dos están bien elegidas y bien separadas: no poder crear usuarios desde el backoffice (queda en TESIS-129) y el require + permit que sigue en los otros módulos. La segunda es exactamente el tipo de cosa que conviene que quede escrita en vez de arreglarse a medias acá.

Veredicto

APPROVE.

Cierra el agujero más serio que teníamos —registro abierto a cualquier empresa por un slug público— y lo hace sin dejar que la respuesta delate nada. El resto de los hallazgos están cubiertos, con specs que fallan si se deshacen. Lo del logout lo dejo a tu criterio: si preferís el corte reversible, que lo diga el ADR; si no, es una línea.

🤖 Generated with Claude Code

https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

@TomasMartin2004

Copy link
Copy Markdown
Contributor

Confirmo los dos que Lautaro pide resolver antes de mergear: los reproduje sobre la rama.

1. El límite cuenta también los logins exitosos. rate_limit cuenta requests a create, no fallos, así que la undécima entrada correcta desde la misma IP rebota:

login correcto  #9 -> 200
login correcto #10 -> 200
login correcto #11 -> 429
login correcto #12 -> 429

Importa más de lo que parece en esta etapa: en la demo los dos portales se prueban desde la misma IP, y entre norte, sur y los reintentos se llega a diez entradas correctas sin hacer nada raro. La salida barata es contar sólo lo que falló —incrementar el contador en la rama del 401 en vez de delegarlo al filtro—, que además es lo que la card pedía: frenar el que prueba contraseñas, no el que entra.

2. El logout con la empresa inactiva es el 🟡 de mi review: el token nunca llega a la denylist y vuelve a servir si la empresa se reactiva dentro de sus 24 h.

empresa dada de baja, mismo token  -> 401
DELETE /auth/logout                -> 401   ← no revoca
empresa reactivada, MISMO token    -> 200

Mi aprobación queda en pie —el fondo del PR está bien y cierra el agujero grave—, pero no lo mergeo con estos dos abiertos: el primero se nota en la demo y el segundo deja un token vivo que alguien creyó cerrar. Avisá cuando estén y lo mergeo.

Mergeé web#51 aparte: no depende de este, y así el front queda listo para cuando entre.

🤖 Generated with Claude Code

https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

@TomasMartin2004

Copy link
Copy Markdown
Contributor

Aviso aparte, porque afecta a este PR aunque no sea suyo: api#90 (TESIS-131) trae una migración con el mismo número que la de acá.

#89 (este)   db/migrate/20260924120000_add_approved_to_users.rb
#90          db/migrate/20260924120000_add_quote_service_to_services.rb

Con los dos mergeados, Rails no arranca:

ActiveRecord::DuplicateMigrationVersionError:
Multiple migrations have the version number 20260924120000.

Ninguna de las dos se aplicó en ningún lado todavía, así que renumerar una es gratis. En la review de #90 propuse que renumere esa —es la que abrió después— a 20260924130000, pero me da igual cuál de las dos se mueva: lo importante es decidirlo antes de mergear, no después.

🤖 Generated with Claude Code

https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

LorenzoBellomo and others added 5 commits September 25, 2026 09:46
The session check answered 401 to DELETE /auth/logout when the company was
inactive or the account unapproved, so the token never reached the denylist
and worked again if the company was reactivated within its 24 h.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rate_limit counted every POST /auth/login, so the eleventh correct login
from the same IP (operators behind one NAT, the demo) got 429. The login now
counts only the 401s; the registration keeps counting every request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unknown email or tenant skipped bcrypt and answered the 401 much faster
than a wrong password, so the response time enumerated users. Compare
against a throwaway digest with the same cost instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the integrations feature check from a before_action to
CompanyIntegrationPolicy#update?, so verify_authorized enforces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TomasMartin2004

Copy link
Copy Markdown
Contributor

Verifiqué los dos que quedaban pendientes, sobre 46b9b29:

Logout de una sesión suspendida — resuelto, y revoca de verdad:

1. empresa suspendida                      -> 401
2. DELETE /auth/logout                     -> 204
3. empresa reactivada, con el MISMO token  -> 401   ← quedó en la denylist

Que el paso 3 dé 401 es lo que importa: el token no revive con la empresa.

El límite cuenta sólo los fallidos — resuelto:

12 logins correctos seguidos desde la misma IP -> 200 todos
10 fallidos, el 11º                            -> 429, Retry-After: 180
correcto con los intentos agotados             -> 429  (no lo deja pasar)
correcto desde otra IP                         -> 200  (el límite es por IP)

Me gustó que el rechazo no evalúe la password: si la evaluara, el que prueba contraseñas seguiría probando y la correcta le daría el token igual. Y que el registro siga contando todos los pedidos, porque ahí no hay un «pedido exitoso» que se repita en el uso normal, está bien distinguido.

Los otros dos commits que sumaste —bcrypt en los logins sin cuenta, y la autorización de integraciones por Pundit— van en la misma dirección y no cambian nada de lo que ya había revisado.

Verificación completa sobre la rama: rspec → 1294 ejemplos, 0 fallas; RuboCop limpio en 260 archivos; Brakeman 0 warnings; CI 4/4 en verde.

Mergeado. Queda pendiente lo de la migración duplicada, pero ahora es de api#90: al entrar éste primero, 20260924120000 ya está tomado y esa rama tiene que renumerar la suya. Lo dejé dicho allá.

🤖 Generated with Claude Code

https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

@TomasMartin2004
TomasMartin2004 merged commit 05d1ee1 into master Sep 25, 2026
4 checks passed
@TomasMartin2004
TomasMartin2004 deleted the TESIS-82-auth-multitenancy-qa-fixes branch September 25, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants