Saltar a contenido

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ón le=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: PublicPropertyResponse no expone email/legal_name). - P3: I2 (eliminado frontend/ 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 failedtest_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 con models/schemas/repository/service/router/exceptions. reservations aplicó Técnica 2 (use_cases/), rooms la 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 todo app/ (0 usos de db.query()), create_async_engine + asyncpg (core/database.py), joinedload/selectinload contra N+1.
  • §2.5 Errores: excepciones de dominio + handlers globales RFC 7807 con request_id (main.py:131-171), handler IntegrityError→409 (incl. constraint anti-overbooking 23P01), 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; SecretStr en todos los secretos; .env no commiteado; /docs cerrado en producción; TEST_TOKEN bloqueado en producción (security.py:142).
  • §7 Testing: 41 archivos, Testcontainers con postgres:16-alpine real, 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() en app/.
  • 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_id presente e indexado en casi todas las tablas de negocio.
  • Listados filtran por property en el repository (list_by_property); los get_by_id no filtran — la verificación se difiere al service vía tenant_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() ejecuta set_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

  1. Migración Alembic que aplique ENABLE + FORCE + POLICY a todas las tablas con property_id (checklist completo, no parcial como Gastro).
  2. Adaptación del patrón: en Travel property_id es String(50), no UUID — la policy compara strings (current_setting('app.current_property_id', true)), sin cast ::uuid.
  3. Seteo del property en get_db() (Travel ya tiene el property_id en el JWT; falta el paso ContextVar/middleware → set_config). Preferir scope de transacción.
  4. Usuario de DB de la app no-superuser (verificar rol actual en docker-compose/config).
  5. Mantener la Capa 1 (tenant_blocks + filtros en repos) — defensa en profundidad, igual que el diseño de Gastro.
  6. Endurecer tenant_blocks: eliminar el caso None → no bloquea (hallazgo B2); los usos internos sin auth deben pasar por un camino explícito, no por un default permisivo.
  7. 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:apibackend/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/ y backend/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.txt a 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)

  1. RLS en Travel (B1): migración 0019 con ENABLE+FORCE+POLICY en las 22 tablas con property_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.
  2. Endurecer tenant_blocks (B2): None ahora BLOQUEA (fail-closed); sentinel INTERNAL_ACCESS para flujos internos; tests/test_tenancy.py (7) + caso en test_bola_isolation.py. Pendiente de despliegue: correr scripts/create_app_role.sql + POSTGRES_APP_USER/PASSWORD en prod (rollout seguro; sin el rol RLS queda inerte con warning, no rompe).

P1 — Backend hardening ✅ HECHO (2026-07-10)

  1. Cota le=100 (B3) en los 6 listados sin tope (reservations, rooms, guests, housekeeping, room_status, seasons) + page con ge=1. Test test_list_size_over_100_is_422. Envelope (B4): knowledge/conversations siguen devolviendo list[...] — cambiar a Page[T] es breaking para el orquestador que los consume; se deja como decisión pendiente (no es quick-win).
  2. Rate limiter Redis-ready (B5): el limiter estaba hard-coded a memoria. Ahora lee REDIS_URL opcional (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").
  3. ✅ Los 10 tests con drift (B11): reparados en el P0.
  4. 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, B008 typer.Option) + 3 B904 arreglados con from.

P2 — public-hotel alineación al estándar ✅ (parcial, 2026-07-10)

  1. RHF+Zod + useMutation en BookingModal (flujo de reserva del huésped, el más crítico) + fix a11y (labels htmlFor). ✅ 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 (PublicPropertyResponse sin email/legal_name/timezone/active).

P3 — Infra y docs ✅ (mayormente, 2026-07-10)

  1. Eliminado apps/travel/frontend/ legacy (557MB, sin código fuente; .env sin secretos) + removido el servicio travel-front roto de docker-compose.local.yml (apuntaba a ese dir sin Dockerfile; el frontend real se levanta con manage.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ía manage.sh. Requiere primero alinear versiones de TS en el monorepo + verificar el build de cada app. Mismo criterio que Redis/Tailwind v4.
  2. D1 ADR multitenancy = ADR-010. ✅ D2 enlace roto task_active.mdtasks_active.md + fecha en apps/travel/AGENTS.md. ✅ D3 CHANGELOG.md en backend. Pendiente opcional: D4 source-of-truth.md, D5 poblar docs/03_REFERENCE/ de nivel travel, ADR stack Next en marketing-site.
  3. 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.