Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Copiar a .env y completar. En test/CI, DEVISE_JWT_SECRET_KEY cae por defecto
# a Rails.application.secret_key_base si no está seteada.
# Copiar a .env y completar. En desarrollo y test, DEVISE_JWT_SECRET_KEY cae por
# defecto a Rails.application.secret_key_base si no está seteada.

# Clave secreta para firmar los JWT. Generar con: rails secret
# En producción es obligatoria, una distinta por entorno (ADR-018).
DEVISE_JWT_SECRET_KEY=

# Password del rol de PostgreSQL en desarrollo.
Expand All @@ -11,3 +12,20 @@ PROYECTO_API_DATABASE_PASSWORD=admin
# Generar cada una con: rails secret. En dev/test caen a secret_key_base.
ENCRYPTION_KEY=
ENCRYPTION_KEY_DERIVATION_SALT=

# --- Sólo producción (ADR-018) ----------------------------------------------
# Sin DEVISE_JWT_SECRET_KEY, CORS_ALLOWED_ORIGINS ni RAILS_ALLOWED_HOSTS la API
# no arranca en producción. En desarrollo no hacen falta.

# Orígenes del front, separados por comas. `*` vale para un nivel de subdominio
# (el de cada empresa). Sin la variable, fuera de producción: localhost.
# CORS_ALLOWED_ORIGINS=https://*.precision-logistics.duckdns.org

# Hosts a los que responde la API, separados por comas.
# RAILS_ALLOWED_HOSTS=api.precision-logistics.duckdns.org

# Contraseñas de las cuentas que crea db:seed (el entrypoint del Dockerfile lo
# corre sobre toda base nueva). En producción son obligatorias para sembrar y
# no pueden ser las de desarrollo que trae el repo.
# SEED_USER_PASSWORD=
# SEED_ADMIN_PASSWORD=
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@ db/schema.rb linguist-generated
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials

# Scripts que se ejecutan en Linux (la imagen de Docker, el server): siempre LF,
# aunque el checkout sea de Windows con core.autocrlf=true. Con CRLF el shebang
# busca `ruby\r` o `bash\r` y el script no arranca.
bin/* text eol=lf
*.sh text eol=lf
# kamal.cmd es un batch de Windows y se queda con CRLF.
bin/*.cmd text eol=crlf
17 changes: 9 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,22 @@ COPY . .
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/

# Adjust binfiles to be executable on Linux
# Tiene que ir antes del primer RUN que ejecute algo de bin/: un contexto que
# viene de un checkout de Windows (core.autocrlf=true) trae los scripts con CRLF
# y el shebang busca `ruby\r`. .gitattributes ya los fuerza a LF, pero un clon
# existente conserva los CRLF hasta que se vuelvan a extraer esos archivos.
RUN chmod +x bin/* && \
sed -i "s/\r$//g" bin/* && \
sed -i 's/ruby\.exe$/ruby/' bin/*

# Precompilar los assets (Propshaft) en build y no en runtime: el backoffice de
# Avo sirve CSS/JS con nombre digest desde public/assets, y sin este paso las
# páginas del panel piden esos archivos y reciben 404 (panel sin estilos).
# SECRET_KEY_BASE_DUMMY evita que el boot de Rails pida credenciales reales
# durante la compilación, cuando no hay base de datos ni secrets disponibles.
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

# Adjust binfiles to be executable on Linux
RUN chmod +x bin/* && \
sed -i "s/\r$//g" bin/* && \
sed -i 's/ruby\.exe$/ruby/' bin/*




# Final stage for app image
FROM base

Expand Down
45 changes: 31 additions & 14 deletions config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.

# Sin estas variables la API no arranca en producción (TESIS-130, ADR-018).
# Sin ellas, el JWT se firmaría con secret_key_base, el Host no se validaría y
# CORS no dejaría pasar al front: las dos primeras dejan la puerta abierta sin
# que nada falle a la vista, y la tercera rompe el front sin decir por qué.
# Que el contenedor no levante es mejor que cualquiera de las tres.
# SECRET_KEY_BASE_DUMMY marca el `assets:precompile` del Dockerfile, que
# arranca la app en el build, cuando todavía no hay secretos del entorno.
unless ENV['SECRET_KEY_BASE_DUMMY']
missing = %w[DEVISE_JWT_SECRET_KEY CORS_ALLOWED_ORIGINS RAILS_ALLOWED_HOSTS].select do |name|
ENV[name].blank?
end
if missing.any?
raise "Missing environment variables for production: #{missing.join(', ')} (see ADR-018)"
end
end

# Code is not reloaded between requests.
config.enable_reloading = false

Expand All @@ -21,11 +37,12 @@
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local

# Assume all access to the app is happening through a SSL-terminating reverse proxy.
# config.assume_ssl = true

# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
# config.force_ssl = true
# El TLS termina en el proxy (Caddy), que también redirige HTTP a HTTPS: a Rails
# le llega HTTP plano. assume_ssl hace que tome esos requests como HTTPS, y
# force_ssl suma Strict-Transport-Security y marca las cookies como Secure,
# entre ellas la de la sesión del backoffice (TESIS-130, ADR-018).
config.assume_ssl = true
config.force_ssl = true

# Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
Expand Down Expand Up @@ -81,15 +98,15 @@
# "example.com", # Allow requests from example.com
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
# ]
#
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }

# Despliegue en contenedor detrás de un proxy TLS (Caddy + DuckDNS): el Host
# que ve Rails es el subdominio público, y sin listarlo acá la protección
# contra DNS rebinding responde 403. Se configura por entorno
# (RAILS_ALLOWED_HOSTS, separado por comas); sin la variable se mantiene el
# comportamiento por defecto.
allowed_hosts = ENV.fetch('RAILS_ALLOWED_HOSTS', '').split(',').map(&:strip).reject(&:empty?)
config.hosts.concat(allowed_hosts) if allowed_hosts.any?
# que ve Rails es el subdominio público de la API. Los hosts salen de
# RAILS_ALLOWED_HOSTS, separados por comas, y la variable es obligatoria
# (ver arriba): sin lista, Rails no valida el Host en producción. Con ella, un
# request con otro Host recibe 403 (TESIS-120, TESIS-130).
config.hosts.concat(ENV.fetch('RAILS_ALLOWED_HOSTS', '').split(',').map(&:strip).reject(&:empty?))

# El health check de /up llega con el Host que use quien lo haga (la IP del
# contenedor, localhost), no con el público: validarlo lo daría por caído.
config.host_authorization = { exclude: ->(request) { request.path == '/up' } }
end
26 changes: 25 additions & 1 deletion config/initializers/cors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,33 @@

# Read more: https://github.com/cyu/rack-cors

# Sólo el front puede llamar a la API desde un navegador (TESIS-130, ADR-018).
# Con `origins '*'`, cualquier página podía hacerlo: por ejemplo, repartir intentos
# de login entre los navegadores de sus visitantes, cada uno con su IP, y esquivar
# el límite por IP de TESIS-82.
#
# Los orígenes salen de CORS_ALLOWED_ORIGINS, separados por comas, y la variable
# es obligatoria en producción (config/environments/production.rb). Como cada
# empresa entra por su subdominio, se acepta un comodín para ese nivel:
# `https://*.precision-logistics.duckdns.org` vale para norte., sur., etc., pero no
# para a.b. ni para el dominio sin subdominio. rack-cors compara los strings tal
# cual, así que el comodín se traduce a una regex anclada.
#
# Sin la variable (desarrollo y test) se acepta localhost en cualquier puerto: el
# dev server de Vite (5173), `vite preview` o el contenedor de nginx del front.
cors_origins = ENV.fetch('CORS_ALLOWED_ORIGINS', '').split(',').map(&:strip).reject(&:empty?)
cors_origins.map! do |origin|
next origin unless origin.include?('*')

/\A#{Regexp.escape(origin).sub('\*', '[a-z0-9-]+')}\z/
end
if cors_origins.empty? && !Rails.env.production?
cors_origins = [%r{\Ahttp://(localhost|127\.0\.0\.1)(:\d+)?\z}]
end

Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
origins(*cors_origins)
resource '*',
headers: :any,
methods: %i[get post put patch delete options head],
Expand Down
3 changes: 3 additions & 0 deletions config/initializers/devise_jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
end

config.jwt do |jwt|
# El fallback a secret_key_base es para desarrollo y test. En producción la
# variable es obligatoria (config/environments/production.rb): cada entorno
# firma con su propio secreto y un token emitido en otro no sirve (TESIS-130).
jwt.secret = ENV.fetch('DEVISE_JWT_SECRET_KEY') { Rails.application.secret_key_base }
jwt.expiration_time = 1.day.to_i
end
Expand Down
54 changes: 45 additions & 9 deletions db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@
#
# Multi-tenancy: todos los datos viven bajo una Company (tenant). Ver docs/guidelines/multi-tenancy-rls.md.

# ---------------------------------------------------------------------------
# TESIS-130 — Contraseñas de las cuentas sembradas
# ---------------------------------------------------------------------------
# Las contraseñas de desarrollo están en el repo: en un entorno desplegado le
# abrirían las cuentas a cualquiera que lo lea. El entrypoint del Dockerfile
# corre db:prepare, que siembra toda base nueva, así que en producción salen de
# SEED_USER_PASSWORD (usuarios de las empresas) y SEED_ADMIN_PASSWORD
# (administrador del backoffice). Sin ellas, o con una del repo, el seed no corre.

repo_passwords = { user: 'password123', admin: 'admin123' }.freeze

seed_passwords =
if Rails.env.production?
env_passwords = { user: ENV['SEED_USER_PASSWORD'], admin: ENV['SEED_ADMIN_PASSWORD'] }
if env_passwords.values.any?(&:blank?) || env_passwords.values.intersect?(repo_passwords.values)
raise 'Production seeds need SEED_USER_PASSWORD and SEED_ADMIN_PASSWORD, ' \
'and neither can be a password from the repo (see ADR-018)'
end

env_passwords
else
repo_passwords
end

# find_or_create_by! no toca una cuenta que ya existe, y una base sembrada antes
# de TESIS-130 las tiene con la contraseña del repo. En producción se rotan acá:
# correr `bin/rails db:seed` con las variables cierra esas cuentas, y las que ya
# tienen otra contraseña quedan como están.
rotate_repo_password = lambda do |account, kind|
return unless Rails.env.production? && account.valid_password?(repo_passwords[kind])

account.update!(password: seed_passwords[kind])
end

# ---------------------------------------------------------------------------
# TESIS-25 — Core & Tenancy: Companies, Users, Warehouses
# ---------------------------------------------------------------------------
Expand All @@ -31,8 +65,8 @@
'tagline' => 'Logística del norte'
},
users: [
{ email: 'admin@norte.com', password: 'password123' },
{ email: 'operador@norte.com', password: 'password123' }
{ email: 'admin@norte.com' },
{ email: 'operador@norte.com' }
],
warehouses: [
{ name: 'Depósito Central', zip_code: '1900', address: 'Av. 7 N° 1234, La Plata' },
Expand All @@ -59,8 +93,8 @@
'theme_mode' => 'light'
},
users: [
{ email: 'admin@sur.com', password: 'password123' },
{ email: 'deposito@sur.com', password: 'password123' }
{ email: 'admin@sur.com' },
{ email: 'deposito@sur.com' }
],
warehouses: [
{ name: 'Depósito Sur', zip_code: '8000', address: 'Av. Colón N° 789, Bahía Blanca' }
Expand All @@ -81,7 +115,7 @@
'tagline' => 'Empresa dada de baja'
},
users: [
{ email: 'admin@vieja.com', password: 'password123' }
{ email: 'admin@vieja.com' }
],
warehouses: [
{ name: 'Depósito en Liquidación', zip_code: '5000', address: 'Bv. San Juan N° 100, Córdoba' }
Expand Down Expand Up @@ -112,10 +146,11 @@
)

attrs[:users].each do |user_attrs|
User.find_or_create_by!(email: user_attrs[:email]) do |u|
u.password = user_attrs[:password]
user = User.find_or_create_by!(email: user_attrs[:email]) do |u|
u.password = seed_passwords[:user]
u.company = company
end
rotate_repo_password.call(user, :user)
end

attrs[:warehouses].each do |warehouse_attrs|
Expand All @@ -130,9 +165,10 @@
# TESIS-29 — Backoffice: administrador inicial del panel /admin
# ---------------------------------------------------------------------------

AdminUser.find_or_create_by!(email: 'admin@backoffice.com') do |admin|
admin.password = 'admin123'
backoffice_admin = AdminUser.find_or_create_by!(email: 'admin@backoffice.com') do |admin|
admin.password = seed_passwords[:admin]
end
rotate_repo_password.call(backoffice_admin, :admin)

# ---------------------------------------------------------------------------
# TESIS-28 — Integraciones: Services (plantillas globales) + CompanyIntegrations
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/ADR-017-backoffice.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ La consecuencia es que la seguridad del backoffice descansa entera en el login y
- **El logout invalida la cookie, no sólo la borra del navegador.** El cookie store no guarda nada del lado del servidor, así que una cookie copiada antes del logout seguía abriendo el panel. Devise valida la cookie comparando `authenticatable_salt`. `AdminUser` le suma una columna `session_token`, y el logout la rota (`AdminUser#expire_sessions!`). El efecto es que el logout cierra **todas** las sesiones de esa cuenta, también la de «Recordarme» y la de otro navegador. Cambiar la password también las cierra, como antes.
- **`Cache-Control: no-store`** en todas las páginas de Avo (`Admin::UncacheablePages`). Con el default de Rails (`private, must-revalidate`), después del logout el botón «atrás» podía mostrar una página del panel desde la cache del navegador. El concern se incluye en `Avo::ApplicationController` desde el initializer, que es la forma que documenta Avo para sumar comportamiento a todos sus controllers sin copiar el suyo.
- **Orden de los middlewares.** Cookies, sesión y flash van antes de `Warden::Manager`. Con `config.middleware.use` quedaban después, porque Devise registra Warden al cargarse. Cuando Warden corta un request con `throw :warden`, el throw se salteaba el commit de la sesión. No tuvo consecuencias visibles hasta que se agregó el timeout: el cierre por inactividad no llegaba a la cookie y el navegador entraba en un loop de redirecciones.
- **Cookie:** `HttpOnly` y `SameSite=Lax`, los defaults de Rails. ⚠️ `Secure` depende de servir la app por HTTPS (`config.assume_ssl` / `config.force_ssl`), y eso corresponde a la card de seguridad transversal y despliegue.
- **Cookie:** `HttpOnly` y `SameSite=Lax`, los defaults de Rails, y `Secure` en producción: `config.assume_ssl` y `config.force_ssl` se prendieron en TESIS-130 (ver [ADR-018](ADR-018-seguridad-del-despliegue.md)).
- **CSRF:** Avo y el controller de sesiones de Devise usan `protect_from_forgery with: :exception`, así que un POST sin token responde 422. En test la protección está apagada; `spec/requests/admin/sessions_spec.rb` la prende para verificarlo.

### Datos sensibles: credenciales de las integraciones
Expand Down Expand Up @@ -103,7 +103,7 @@ Un store en la base (gema `activerecord-session_store`) o en la cache (`:cache_s
- ✅ Las credenciales de las empresas no salen de la base en claro por ningún camino: ni la API ni el backoffice las muestran
- ✅ El alta de usuarios desde el backoffice funciona, y un usuario no se puede pasar a otra empresa
- ✅ La búsqueda de los listados funciona
- ✅ La cookie lleva `Secure` en producción (TESIS-130, ADR-018)
- ⚠️ El logout cierra todas las sesiones de la cuenta, no sólo la del navegador que sale
- ⚠️ Con «Recordarme», la sesión dura 2 semanas aunque no haya actividad
- ⚠️ El límite por IP no frena un ataque distribuido
- ⚠️ La cookie no lleva `Secure` hasta que la app se sirva por HTTPS
Loading
Loading