Auditoría Vertical Travel — Estándares SV + Multitenancy/RLS
Fecha: 2026-07-10
Alcance: apps/travel/* (backend, admin-panel, public-hotel, marketing-site, docs) contra docs/standards/SV_Standard_Backend.md (v1.2), SV_Standard_Frontend.md (v1.1) y documentation_standard.md (v3). Comparación de multitenancy con Gastro (ADR-002 RLS).
Método: exploración exhaustiva de código + gates reales ejecutados (ruff, mypy --strict, pytest con Testcontainers/Postgres 16, tsc, eslint, vitest). Los veredictos citan la salida real de cada gate.
⚑ Actualización 2026-07-10 — P0, P1 y P2 IMPLEMENTADOS; P3 en su mayoría. - P0 (RLS +
tenant_blocks): B1, B2 RESUELTOS vía ADR-010. B11 (suite verde) resuelto. - P1: B3 (paginaciónle=100), B5 (rate limiter Redis-ready), B10 (ruff verde) HECHOS. B4 pendiente (breaking para el orquestador). - P2 (public-hotel): P2a (BookingModal→RHF+Zod+useMutation) y P2b (cliente OpenAPI) HECHOS; P2c (Tailwind v4/React 19) diferido con decisión. Nuevo follow-up PH-7 (gap del backend:PublicPropertyResponseno exponelegal_name). - P3: I2 (eliminadofrontend/legacy + fix compose), D2 (enlace/fecha AGENTS), D3 (CHANGELOG backend), D1 (ADR multitenancy = ADR-010) HECHOS. I1 (npm workspaces) diferido — ver §6. D4/D5 opcionales pendientes.
1. Resumen ejecutivo
| Área | Score | Veredicto |
|---|---|---|
Backend (backend/) |
A− | Cumplimiento estructural alto (vertical slicing, services puros, async 2.0, RFC 7807, Testcontainers). Gap principal: sin RLS y bypass latente en tenant_blocks. |
| Frontend admin-panel | A | Cumple el estándar casi al 100%. Deuda: 6 componentes >200 líneas. |
| Frontend public-hotel | C | Incumplimientos estructurales: Tailwind v3+config, sin FSD, sin RHF/Zod, fetch crudo, sin cliente OpenAPI. |
| Frontend marketing-site | B | Next.js 16 (fuera del estándar Vite — decisión aceptable); 5 any, 12 colores hardcodeados. |
| Documentación | B+ | Diátaxis + 03_REFERENCE auto-generado + 9 ADRs. Gaps de gobernanza vs Gastro (source-of-truth, CHANGELOG backend, enlace roto). |
| Multitenancy (foco RLS) | B− | Aislamiento solo aplicativo (Capa 1). No existe defensa en profundidad a nivel de PostgreSQL como en Gastro. |
Resultados de gates (ejecutados 2026-07-10)
| Gate | Resultado |
|---|---|
ruff check . (backend) |
❌ 54 errores — pero solo 2 en app/ (F541 en email_service.py:234, I001 en knowledge/router.py:2); 31 en alembic/, 21 en scripts/ (I001/E402/F401 mayormente, 35 auto-fixables) |
mypy --strict app/ |
✅ 0 errores en 144 archivos |
pytest test_bola_isolation + test_input_hardening |
⚠️ 12 passed, 1 failed — test_null_session_data_does_not_break_list: espera 200 en GET /conversation-sessions sin property_id, pero el endpoint ahora lo exige (Query(...), hardening multitenant posterior). Drift de test, no bug de runtime — el test quedó desactualizado tras el endurecimiento. |
| pytest suite completa (41 archivos, Testcontainers) | ❌ 332 passed, 10 failed (5m45s). Los 10 fallos son drift de tests tras features recientes, no bugs de runtime: 9 en test_property_profile_* (fixtures SimpleNamespace sin los campos de billing subscription_tier/subscription_status/trial_ends_at que PropertyProfileRead ahora exige — commits Wompi/decoy-pricing) + 1 en test_input_hardening (ver fila anterior). La suite NO está verde en main. |
admin-panel tsc --noEmit |
✅ 0 errores (TS 5.x local; nota: con el TS 6.0.3 hoisted de la raíz falla por baseUrl deprecado — TS5101) |
admin-panel eslint --max-warnings 0 |
✅ exit 0 |
admin-panel vitest run |
✅ 125 tests / 17 archivos, todos pasan (26s) |
public-hotel tsc --noEmit |
✅ 0 errores |
2. Backend vs SV_Standard_Backend.md
2.1 Cumple (verificado)
- §2 Vertical slicing real: 17 dominios en
app/domains/, cada uno conmodels/schemas/repository/service/router/exceptions.reservationsaplicó Técnica 2 (use_cases/),roomsla Técnica 1 (routers/+services/como subpaquetes). Carpetas layered globales prácticamente vacías. - §2.3 Services puros: inyección por constructor vía
Protocol(p.ej.ReservationRepositoryProtocol); no importan FastAPI/SQLAlchemy — con una excepción (ver hallazgo B5). - §4 SQLAlchemy 2.0 async:
select()en todoapp/(0 usos dedb.query()),create_async_engine+ asyncpg (core/database.py),joinedload/selectinloadcontra N+1. - §2.5 Errores: excepciones de dominio + handlers globales RFC 7807 con
request_id(main.py:131-171), handlerIntegrityError→409 (incl. constraint anti-overbooking23P01), 500 genérico que nunca expone traceback. - §6 Seguridad: JWT HS256 access 30min + refresh revocables en DB; bcrypt; rate limit estricto en login (5/min), register (5/h), reset (3/h); CORS explícito;
SecretStren todos los secretos;.envno commiteado;/docscerrado en producción; TEST_TOKEN bloqueado en producción (security.py:142). - §7 Testing: 41 archivos, Testcontainers con
postgres:16-alpinereal, suite BOLA dedicada (tests/test_bola_isolation.py— pasa), contrato OpenAPI testeado. - §8 Observabilidad: structlog JSON + request_id propagado, Sentry condicional, OpenTelemetry FastAPI+SQLAlchemy. 0
print()enapp/. - Tooling: mypy
strict=true(verificado: 0 errores), ruff con reglas S/ASYNC/B, bandit+pip-audit en dev deps, Dockerfile multistage sobre Chainguard (no-root por defecto).
2.2 Hallazgos backend
| ID | Sev | Hallazgo | Evidencia | Estándar |
|---|---|---|---|---|
| B1 | Alta | Sin RLS en PostgreSQL. El aislamiento multitenant es 100% aplicativo (tenant_blocks). 0 coincidencias de CREATE POLICY / ROW LEVEL SECURITY en las 19 migraciones. Un bug en un service (u olvido del helper en un dominio nuevo) = fuga cross-tenant sin red de seguridad. Gastro sí tiene la doble capa. |
alembic/versions/* (ausencia) |
§6.1 defensa en profundidad; CLAUDE.md "Multitenant Isolation" |
| B2 | Alta (latente) | tenant_blocks() retorna False (no bloquea) si requesting_property_id is None — y User.property_id es nullable (auth/models.py:21). Verificado: hoy el registro siempre asigna property (crea hotel+usuario en una transacción, auth/service.py:134-138) y no se encontró flujo activo que cree usuarios sin property; el bypass es latente, no explotable hoy, pero cualquier flujo futuro (invitaciones, staff, seeds) que cree un usuario con property_id=NULL obtiene acceso cross-tenant total silencioso. |
core/tenancy.py:13-17, auth/models.py:21 |
§6.1 BOLA |
| B3 | Media | Paginación sin cota superior en la mayoría de listados: size sin le=100 en reservations/router.py:96, rooms/routers/*, guests/router.py:34, housekeeping/router.py:43. Solo knowledge y conversations acotan (le=100). ?size=1000000 es servible. |
routers citados | §3.4, §6.4 (API4) |
| B4 | Media | Inconsistencia de envelope: knowledge y conversations devuelven list[...] crudo (sin Page{items,total,...}) — justo los dos que sí acotan le=100. El resto usa Page[T] pero sin cota. Ningún listado cumple §3.4 completo (envelope + límite). |
conversations/router.py:29, knowledge/router.py |
§3.4 |
| B5 | Media | Rate limiter slowapi sin backend Redis (estado en memoria por proceso): con múltiples workers/pods los límites reales se multiplican. | core/rate_limit.py |
§3.5 |
| B6 | Baja | Service impuro: auth/service.py:9 importa y usa HTTPException (líneas 100, 207). Único service que viola §2.3. Además varios routers usan HTTPException directo (verificación de token interno). |
auth/service.py |
§2.3 |
| B7 | Baja | IDs String(50) con slugs predecibles (kasiri-01) en vez de UUID → superficie de enumeración de tenants. Nota: el registro nuevo ya genera property_id = str(uuid.uuid4()) (auth/service.py:134); el problema es el tipo de columna y los tenants legacy con slug. |
*/models.py |
§2.3 modelos (UUID) |
| B8 | Baja | Dockerfile instala con pip install -r requirements.txt pese a existir uv.lock (433KB); requirements.txt como "espejo legacy" es duplicación de fuente de verdad de deps. |
Dockerfile:15, pyproject.toml |
§1 |
| B9 | Baja | Headers de seguridad sin Content-Security-Policy ni Referrer-Policy (sí tiene nosniff, X-Frame-Options DENY, HSTS). |
main.py:113-116 |
§6.8 |
| B10 | Baja | Higiene ruff fuera de app/: 52 errores en alembic/ (31) y scripts/ (21) — imports desordenados, E402, unused imports, B904. 35 auto-fixables con --fix. Y 2 triviales en app/. |
salida ruff check . |
§1 linting |
| B11 | Media | Suite no verde en main: 10 tests fallan (de 342). 9 en test_property_profile_route/service — fixtures sin los campos billing que PropertyProfileRead ahora exige (drift de los commits Wompi/decoy-pricing) — y 1 en test_input_hardening.py:46 que no envía el property_id ahora obligatorio. Ninguno es bug de runtime, pero un gate rojo permanente entrena al equipo a ignorar fallos (viola §7 "sin tests no hay deploy" y la regla del repo de no cerrar tareas sin gates verdes). |
salida pytest -q: 332 passed, 10 failed |
§7 |
3. Multitenancy: Travel vs Gastro (foco de la auditoría)
3.1 Estado actual de Travel (solo Capa 1 — aplicativa)
property_idpresente e indexado en casi todas las tablas de negocio.- Listados filtran por property en el repository (
list_by_property); losget_by_idno filtran — la verificación se difiere al service víatenant_blocks(obj.property_id, current_user.property_id, is_superuser)con 404 genérico (anti-enumeración, correcto §6.1). - Es disciplina, no garantía estructural: cada dominio nuevo debe acordarse de llamar al helper. Sin RLS, no hay red si falla.
- Tests BOLA a nivel service pasan (kasiri-01 vs baum-01).
3.2 Referencia Gastro (Capa 1 + Capa 2 RLS)
Patrón implementado (ADR-002 + migraciones 6d37e99e36d9, c4e8a1f2b3d4):
ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;
ALTER TABLE <t> FORCE ROW LEVEL SECURITY; -- aplica también al owner de la tabla
CREATE POLICY tenant_isolation_policy ON <t>
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
- Tenant seteado por request:
TenantResolverMiddleware→ ContextVar →get_db()ejecutaset_config('app.current_tenant_id', :tid, false). - Usuario DB
gastro_user(no superuser) — RLS aplica de verdad. current_setting(..., true)(missing_ok): sin tenant seteado → NULL → 0 filas (fail-safe).
Salvedades de Gastro que Travel NO debe copiar a ciegas:
1. Cobertura incompleta (verificado): solo 5 tablas tienen RLS (gastro_categories, gastro_products, gastro_price_history, gastro_users, gastro_audit_log). Las tablas POS/floor añadidas después (gastro_tickets, gastro_ticket_items, gastro_tables, gastro_zones, gastro_payments, gastro_floor_settings — migraciones 32984c1213ee, fb5e27f7d1c2, etc.) no tienen RLS. Lección: la política debe ser parte del checklist de toda migración que cree tabla con tenant_id.
2. Discrepancia doc↔código: ADR-002 promete SET LOCAL (scope transacción) pero el código usa set_config(..., is_local=false) (scope sesión/conexión). Con pooling, el aislamiento depende de que get_db re-ejecute set_config en cada request (lo hace, pero es más frágil). Travel debería usar SET LOCAL/set_config(..., true) dentro de la transacción, o documentar explícitamente el trade-off.
3.3 Qué necesita Travel para igualar (y superar) a Gastro
- Migración Alembic que aplique ENABLE + FORCE + POLICY a todas las tablas con
property_id(checklist completo, no parcial como Gastro). - Adaptación del patrón: en Travel
property_idesString(50), no UUID — la policy compara strings (current_setting('app.current_property_id', true)), sin cast::uuid. - Seteo del property en
get_db()(Travel ya tiene elproperty_iden el JWT; falta el paso ContextVar/middleware →set_config). Preferir scope de transacción. - Usuario de DB de la app no-superuser (verificar rol actual en docker-compose/config).
- Mantener la Capa 1 (
tenant_blocks+ filtros en repos) — defensa en profundidad, igual que el diseño de Gastro. - Endurecer
tenant_blocks: eliminar el casoNone → no bloquea(hallazgo B2); los usos internos sin auth deben pasar por un camino explícito, no por un default permisivo. - ADR nuevo (Travel no tiene ADR de multitenancy; Gastro tiene ADR-002) + test que verifique RLS con conexión directa (bypass del service layer).
4. Frontend vs SV_Standard_Frontend.md
4.1 admin-panel (nido-frontend) — cumple casi al 100% (gates verificados: tsc ✅, eslint ✅, vitest 125/125 ✅)
React 19.2 + Vite 8 + React Compiler, Tailwind v4 con tokens @theme en src/app/index.css (sin tailwind.config.*), FSD completo (7 capas exactas), TanStack Query + cliente generado desde OpenAPI (generate:api ← backend/docs/03_REFERENCE/openapi.json), RHF+Zod (17 formularios), Zustand solo cliente, nuqs para URL state, 0 any, 0 @ts-ignore, 0 console.log, 0 <div onClick>, 0 imports cross-feature, container queries (5 usos, 0 @media en CSS), i18n react-i18next con check de paridad en CI local, MSW + Playwright e2e + Storybook (19 stories) + jest-axe.
| ID | Sev | Hallazgo |
|---|---|---|
| F1 | Media | 6 componentes >200 líneas (línea roja §16.5): ReservationModal.tsx (429), CalendarMonthMobile.tsx (297), Dropdown.tsx (260), CustomTimeline.tsx (258), DatePicker.tsx (235), ExperienceEditFields.tsx (211) |
| F2 | Baja | 1 color hardcodeado decorativo: pages/LoginPage.tsx:27 (bg-purple-500/5) — línea roja §16.18 |
| F3 | Baja | 2 eslint-disable react-hooks/exhaustive-deps (ReservationModal.tsx:100, Dropdown.tsx:127) |
4.2 public-hotel — incumplimientos estructurales (tsc ✅ verificado; lo demás no cumple)
| ID | Sev | Hallazgo | Estándar |
|---|---|---|---|
| P1 | Alta | Tailwind v3 con tailwind.config.ts — el estándar exige v4 + @theme CSS |
§3.1 |
| P2 | Alta | Formularios con useState por campo + fetch() crudo en handler (sections/BookingModal.tsx:45-51,~78), sin RHF/Zod ni useMutation. Es el flujo de creación de reservas del huésped — el más crítico de la app |
§6, §16.1-2 |
| P3 | Media | Sin cliente OpenAPI generado: interfaces TS a mano en api/hotel.ts — drift silencioso con el backend |
§4.1 |
| P4 | Media | Sin FSD (estructura plana components/hooks/pages/sections), React 18, Vite 5 |
§2 |
| P5 | Media | Testing casi inexistente: 2 tests, 0 stories, sin Playwright | §10 |
Positivo: 0 any, 0 console.log, colores vía var(--accent), lecturas con TanStack Query, i18n con check de paridad.
4.3 marketing-site (Next.js 16)
Stack Next (fuera del estándar Vite; decisión razonable para marketing/SEO — pero no hay ADR que la documente, y el estándar §1 exige ADR para desviaciones de stack).
| ID | Sev | Hallazgo |
|---|---|---|
| M1 | Baja | 5 any: ProblemSection.tsx:14, HowItWorksSection.tsx:7, 3× ref={ref as any} (CTASection:64, HowItWorks:56, GuaranteeSection:66) — línea roja §16.7 |
| M2 | Baja | 12 colores red-* hardcodeados en SignupForm.tsx (estados de error) — deben ser tokens semánticos (danger) §16.18 |
| M3 | Baja | Testing mínimo (1 test, 0 stories propias) |
4.4 Infra frontend (hallazgo transversal)
| ID | Sev | Hallazgo |
|---|---|---|
| I1 | Media | Los frontends de Travel NO son miembros de los npm workspaces de la raíz (package.json raíz lista apps/* y los de gastro explícitos; apps/* no matchea apps/travel/admin-panel). Sin node_modules instalado, fuera de turbo (dev/build/lint/test raíz no los cubren), a diferencia de Gastro. Además el TS hoisted de la raíz es 6.0.3 y rompe el tsconfig del admin-panel (baseUrl deprecado) si se usa por accidente vía npx. |
| I2 | Baja | apps/travel/frontend/ es un directorio legacy sin código fuente (solo dist/, storybook-static/, node_modules/, un .env) — sin README/AGENTS, genera ambigüedad con las 3 apps reales. Candidato a eliminación (revisar antes el .env que contiene). |
5. Documentación vs documentation_standard.md
Cumple
- README + AGENTS.md en travel raíz (26 líneas), backend (103), admin-panel (104), public-hotel (61), marketing-site (61) — todos ≤150 ✅.
- Diátaxis en
apps/travel/docs/ybackend/docs/✅. - 03_REFERENCE auto-generado por 5 scripts (
export_openapi.py,generate_endpoints_doc.py,generate_schema_doc.py,generate_env_docs.py,generate_error_codes_docs.py) ✅ — cumple la regla crítica §5.1. - 9 ADRs (ADR-001..009) ✅.
llms.txt+llms-full.txta nivel travel y raíz, regenerados (jul 9-10) ✅.
Hallazgos
| ID | Sev | Hallazgo |
|---|---|---|
| D1 | Media | Sin ADR de multitenancy/aislamiento — la decisión "app-layer only, sin RLS" no está documentada ni justificada (Gastro tiene ADR-002). Si es deliberada, debe ser un ADR; si no, es el gap B1. |
| D2 | Baja | Enlace roto en apps/travel/AGENTS.md:4,7: referencia .agent/context/task_active.md pero el archivo real es tasks_active.md. Cabecera "Estado (2026-07-01)" rezagada. |
| D3 | Baja | Sin CHANGELOG.md en backend/ (travel raíz sí tiene). |
| D4 | Baja | Sin source-of-truth.md/current-state.md como autoridad única (Gastro los tiene + quality gates documentales + auditorías de alineación periódicas). |
| D5 | Baja | apps/travel/docs/03_REFERENCE/ (nivel workspace) casi vacío (solo un README de 820 bytes) — poblar con links al backend o eliminarlo (anti-duplicación §8). |
6. Roadmap de remediación priorizado (sin implementar — pendiente de decisión)
P0 — Seguridad multitenant ✅ HECHO (2026-07-10, ADR-010)
- ✅ RLS en Travel (B1): migración
0019con ENABLE+FORCE+POLICY en las 22 tablas conproperty_id; contexto por request con scope transaccional (app/core/tenant_context.py); rol DB no-superuser (travel_app);tests/test_rls_isolation.py(7, con rol no-superuser real, verifica cobertura+aislamiento+fail-safe+bypass+WITH CHECK); ADR-010. Evitados los 2 errores de Gastro: cobertura completa enforced por test + scope transacción. - ✅ Endurecer
tenant_blocks(B2):Noneahora BLOQUEA (fail-closed); sentinelINTERNAL_ACCESSpara flujos internos;tests/test_tenancy.py(7) + caso entest_bola_isolation.py. Pendiente de despliegue: correrscripts/create_app_role.sql+POSTGRES_APP_USER/PASSWORDen prod (rollout seguro; sin el rol RLS queda inerte con warning, no rompe).
P1 — Backend hardening ✅ HECHO (2026-07-10)
- ✅ Cota
le=100(B3) en los 6 listados sin tope (reservations, rooms, guests, housekeeping, room_status, seasons) +pageconge=1. Testtest_list_size_over_100_is_422. Envelope (B4): knowledge/conversations siguen devolviendolist[...]— cambiar aPage[T]es breaking para el orquestador que los consume; se deja como decisión pendiente (no es quick-win). - ✅ Rate limiter Redis-ready (B5): el limiter estaba hard-coded a memoria. Ahora lee
REDIS_URLopcional (storage_uri); sin URL usa memoria — correcto con el despliegue actual de 1 worker uvicorn (verificado en Dockerfile: sin--workers/gunicorn). Redis solo es necesario al escalar a multi-worker: un env var. No se añade infra prematura (honra "simplicidad"). - ✅ Los 10 tests con drift (B11): reparados en el P0.
- ✅
ruff(B10): verde en todo el backend (ruff check .→ All checks passed). Auto-fix + per-file-ignores para patrones idiomáticos (E402 bootstrap sys.path en alembic/scripts, B008typer.Option) + 3 B904 arreglados confrom.
P2 — public-hotel alineación al estándar ✅ (parcial, 2026-07-10)
- ✅ RHF+Zod +
useMutationenBookingModal(flujo de reserva del huésped, el más crítico) + fix a11y (labelshtmlFor). ✅ cliente OpenAPI generado (camino de escritura). ⏸️ Tailwind v4 + React 19 diferidos con decisión (PH-8): public-hotel ya cumple la sustancia (CSS vars, 0 colores hardcodeados). Nuevo follow-up PH-7: migrar tipos de lectura requiere cerrar antes un gap del backend (PublicPropertyResponsesinemail/legal_name/timezone/active).
P3 — Infra y docs ✅ (mayormente, 2026-07-10)
- ✅ Eliminado
apps/travel/frontend/legacy (557MB, sin código fuente;.envsin secretos) + removido el serviciotravel-frontroto dedocker-compose.local.yml(apuntaba a ese dir sin Dockerfile; el frontend real se levanta conmanage.sh dev travel_front→ admin-panel). I1 (npm workspaces) DIFERIDO: añadir los frontends de Travel a los workspaces raíz dispara una re-resolución de dependencias de TODO el monorepo (gastro/cmautos/main/travel) con conflicto de versión de TS ya observado (root 6.0.3 vs admin-panel ^5.2); los frontends funcionan standalone víamanage.sh. Requiere primero alinear versiones de TS en el monorepo + verificar el build de cada app. Mismo criterio que Redis/Tailwind v4. - ✅ D1 ADR multitenancy = ADR-010. ✅ D2 enlace roto
task_active.md→tasks_active.md+ fecha enapps/travel/AGENTS.md. ✅ D3CHANGELOG.mden backend. Pendiente opcional: D4source-of-truth.md, D5 poblardocs/03_REFERENCE/de nivel travel, ADR stack Next en marketing-site. - Pendiente: partir los 6 componentes >200 líneas del admin-panel (F1); limpiar
any/colores en marketing-site (M1, M2).
Anexo — Suite completa backend
pytest -q (Testcontainers Postgres 16, 5m45s): 332 passed, 10 failed.
Fallos: test_property_profile_route.py (5: get/update/replace_features/replace_policies/replace_payment_methods), test_property_profile_service.py (4, incl. test_update_slug_free_ok), test_input_hardening.py::test_null_session_data_does_not_break_list (1).
Causa raíz común: tests no actualizados tras dos cambios legítimos de producto — (a) campos billing en PropertyProfileRead (error Pydantic Field required: subscription_tier/subscription_status/trial_ends_at sobre fixtures SimpleNamespace), (b) property_id obligatorio en GET /conversation-sessions. Detalle en hallazgo B11.