feat: Implement multi-tenancy support in middleware and security layers

- Enhanced TenantMiddleware to validate tenant information from JWT tokens.
- Added LicenseValidationMiddleware to check tenant licenses before processing requests.
- Updated security utilities to extract tenant information from tokens and validate company access.
- Introduced CompanyStore to manage active company state and handle company switching in the frontend.
- Modified API routes to include company_id in requests for better resource management.
- Improved logging and error handling throughout the middleware and API layers.
- Updated frontend components to reflect changes in company management and selection.
- Added new API route for fetching user's companies with proper authentication handling.
This commit is contained in:
2025-11-11 14:00:56 -06:00
parent e1eb6bbd01
commit 52b8fcd434
242 changed files with 7067 additions and 3274 deletions

View File

@@ -1,9 +1,9 @@
from pydantic import BaseModel, Field
from pydantic import ConfigDict
class ContainerDTO(BaseModel):
key: str = Field(..., min_length=1, max_length=3)
description: str
model_config = ConfigDict(from_attributes=True)

View File

@@ -2,15 +2,20 @@ from sqlalchemy import String, PrimaryKeyConstraint
from sqlalchemy.orm import mapped_column, Mapped
from core.database import Base
class Container(Base):
__tablename__ = "containers" #GContenedores
__tablename__ = "containers" # GContenedores
__table_args__ = (
PrimaryKeyConstraint("key", name="containers_pkey"),
{"schema": "public"} # opcional
{"schema": "public"}, # opcional
)
key: Mapped[str] = mapped_column(String(3), nullable=False) # mantiene ceros iniciales
description: Mapped[str] = mapped_column(String(500), nullable=False) # descripción legal en español
key: Mapped[str] = mapped_column(
String(3), nullable=False
) # mantiene ceros iniciales
description: Mapped[str] = mapped_column(
String(500), nullable=False
) # descripción legal en español
def __repr__(self):
return f"<Container(key={self.key}, description={self.description})>"
return f"<Container(key={self.key}, description={self.description})>"

View File

@@ -1,4 +1,3 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
@@ -11,13 +10,12 @@ from typing import Any, Dict
router = APIRouter(prefix="/containers")
@router.get("/", response_model=Dict[str, Any])
async def list_containers(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
skip = (page - 1) * page_size
query = db.query(Container)
@@ -27,23 +25,27 @@ async def list_containers(
"items": [ContainerDTO.model_validate(obj) for obj in items],
"total": total,
"page": page,
"page_size": page_size
"page_size": page_size,
}
@router.get("/{key}", response_model=ContainerDTO)
async def get_container(key: str, db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)):
async def get_container(
key: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
obj = db.query(Container).filter(Container.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return obj
@router.post("/", response_model=ContainerDTO, status_code=201)
async def create_container(
data: ContainerDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
current_user: dict = Depends(has_role("admin")),
):
obj = Container(**data.dict())
db.add(obj)
@@ -51,13 +53,13 @@ async def create_container(
db.refresh(obj)
return obj
@router.put("/{key}", response_model=ContainerDTO)
async def update_container(
key: str,
data: ContainerDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
current_user: dict = Depends(has_role("admin")),
):
obj = db.query(Container).filter(Container.key == key).first()
if not obj:
@@ -68,12 +70,12 @@ async def update_container(
db.refresh(obj)
return obj
@router.delete("/{key}", status_code=204)
async def delete_container(
key: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
current_user: dict = Depends(has_role("admin")),
):
obj = db.query(Container).filter(Container.key == key).first()
if not obj:

View File

@@ -1,13 +1,13 @@
seed = [
("1", "CONTENEDOR ESTANDAR 20' (STANDARD CONTAINER 20')."),
("2", "CONTENEDOR ESTANDAR 40' (STANDARD CONTAINER 40')."),
("3", "CONTENEDOR ESTANDAR DE CUBO ALTO 40' (HIGH CUBE STANDARD CONTAINER 40')."),
("4", "CONTENEDOR TAPA DURA 20 (HARDTOP CONTAINER 20')."),
("5", "CONTENEDOR TAPA DURA 40 (HARDTOP CONTAINER 40')."),
("6", "CONTENEDOR TAPA ABIERTA 20 (OPEN TOP CONTAINER 20')."),
("7", "CONTENEDOR TAPA ABIERTA 40' (OPEN TOP CONTAINER 40')."),
("8", "FLAT 20' (FLAT 20')."),
("9", "FLAT 40' (FLAT 40')."),
("1", "CONTENEDOR ESTANDAR 20' (STANDARD CONTAINER 20')."),
("2", "CONTENEDOR ESTANDAR 40' (STANDARD CONTAINER 40')."),
("3", "CONTENEDOR ESTANDAR DE CUBO ALTO 40' (HIGH CUBE STANDARD CONTAINER 40')."),
("4", "CONTENEDOR TAPA DURA 20 (HARDTOP CONTAINER 20')."),
("5", "CONTENEDOR TAPA DURA 40 (HARDTOP CONTAINER 40')."),
("6", "CONTENEDOR TAPA ABIERTA 20 (OPEN TOP CONTAINER 20')."),
("7", "CONTENEDOR TAPA ABIERTA 40' (OPEN TOP CONTAINER 40')."),
("8", "FLAT 20' (FLAT 20')."),
("9", "FLAT 40' (FLAT 40')."),
("10", "PLATAFORMA 20' (PLATFORM 20')."),
("11", "PLATAFORMA 40' (PLATFORM 40')."),
("12", "CONTENEDOR VENTILADO 20 (VENTILATED CONTAINER 20')."),
@@ -15,9 +15,12 @@ seed = [
("14", "CONTENEDOR TERMICO 40' (INSULATED CONTAINER 40')."),
("15", "CONTENEDOR REFRIGERANTE 20 (REFRIGERATED CONTAINER 20')."),
("16", "CONTENEDOR REFRIGERANTE 40 (REFRIGERATED CONTAINER 40')."),
("17", "CONTENEDOR REFRIGERANTE CUBO ALTO 40 (HIGH CUBE REFRIGERATED CONTAINER 40')."),
(
"17",
"CONTENEDOR REFRIGERANTE CUBO ALTO 40 (HIGH CUBE REFRIGERATED CONTAINER 40').",
),
("18", "CONTENEDOR CARGA A GRANEL 20 (BULK CONTAINER 20')."),
("19", "CONTENEDOR TIPO TANQUE 20 (TANK CONTAINER 20')."),
("19", "CONTENEDOR TIPO TANQUE 20 (TANK CONTAINER 20')."),
("20", "CONTENEDOR ESTANDAR 45' (STANDARD CONTAINER 45')."),
("21", "CONTENEDOR ESTANDAR 48' (STANDARD CONTAINER 48')."),
("22", "CONTENEDOR ESTANDAR 53' (STANDARD CONTAINER 53')."),
@@ -27,7 +30,7 @@ seed = [
("26", "SEMIRREMOLQUE CON RACKS PARA ENVASES DE BEBIDAS."),
("27", "SEMIRREMOLQUE CUELLO DE GANZO."),
("28", "SEMIRREMOLQUE TOLVA CUBIERTO."),
("29", "SEMIRREMOLQUE TOLVA (ABIERTO)."),
("29", "SEMIRREMOLQUE TOLVA (ABIERTO)."),
("30", "AUTO-TOLVA CUBIERTO/DESCARGA NEUMATICA."),
("31", "SEMIRREMOLQUE CHASIS."),
("32", "SEMIRREMOLQUE AUTOCARGABLE (CON SISTEMA DE ELEVACION)."),
@@ -37,7 +40,7 @@ seed = [
("36", "PLATAFORMA DE 28."),
("37", "PLATAFORMA DE 45."),
("38", "PLATAFORMA DE 48."),
("39", "SEMIRREMOLQUE PARA TRANSPORTE DE CABALLOS."),
("39", "SEMIRREMOLQUE PARA TRANSPORTE DE CABALLOS."),
("40", "SEMIRREMOLQUE PARA TRANSPORTE DE GANADO."),
("41", "SEMIRREMOLQUE TANQUE (LIQUIDOS)/SIN CALEFACCION/SIN AISLAR."),
("42", "SEMIRREOLQUE TANQUE (LIQUIDOS)/CON CALEFACCION/SIN AISLAR."),
@@ -47,7 +50,7 @@ seed = [
("46", "SEMIRREMOLQUE TANQUE (GAS)/CON CALEFACCION/SIN AISLAR."),
("47", "SEMIRREMOLQUE TANQUE (GAS)/SIN CALEFACCION/AISLADO."),
("48", "SEMIRREMOLQUE TANQUE (GAS)/CON CALEFACCION/AISLADO."),
("49", "SEMIRREMOLQUE TANQUE (QUIMICOS)/SIN CALEFACCION/SIN AISLAR."),
("49", "SEMIRREMOLQUE TANQUE (QUIMICOS)/SIN CALEFACCION/SIN AISLAR."),
("50", "SEMIRREMOLQUE TANQUE (QUIMICOS)/CON CALEFACCION/SIN AISLAR."),
("51", "SEMIRREMOLQUE TANQUE (QUIMICOS)/SIN CALEFACCION/AISLADO."),
("52", "SEMIRREMOLQUE TANQUE (QUIMICOS)/CON CALEFACCION/AISLADO."),
@@ -68,4 +71,4 @@ seed = [
("67", "CAMIÓN UNITARIO DE TRES EJES"),
("68", "VEHÍCULOS CON CAPACIDAD DE CARGA DE HASTA 3.5. TONELADAS"),
("69", "TRACTOCAMIÓN"),
]
]

View File

@@ -7,6 +7,7 @@ app = FastAPI()
app.include_router(router)
client = TestClient(app)
@pytest.mark.usefixtures("client", "access_token")
def test_list_containers(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
@@ -16,20 +17,24 @@ def test_list_containers(client, access_token):
assert "page" in response.json()
assert "page_size" in response.json()
@pytest.mark.usefixtures("client", "access_token")
def test_get_container_not_found(client, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
response = client.get("/containers/invalid_key", headers=headers)
assert response.status_code == 404
def test_create_container_forbidden():
response = client.post("/containers/", json={"key": "TST", "description": "Test"})
assert response.status_code in (403, 405, 404)
def test_update_container_forbidden():
response = client.put("/containers/TST", json={"key": "TST", "description": "Test"})
assert response.status_code in (403, 405, 404)
def test_delete_container_forbidden():
response = client.delete("/containers/TST")
assert response.status_code in (403, 405, 404)