bLOQUE p1 BACKEND Y SEGURIDAD, AUTENTICACION CON SUPABASE. jwt. RBAC CRUD

This commit is contained in:
shinra32
2026-05-22 19:45:05 -06:00
parent 5dc8390855
commit fc28333e3f
52 changed files with 1605 additions and 109 deletions

View File

View File

@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.schemas.addresses import AddressCreate, AddressResponse
from app.core.deps import get_current_user, require_role
from app.core.supabase_client import supabase_admin
from app.services.simulation import get_colonias
router = APIRouter(prefix="/addresses", tags=["addresses"])
def _resolve_route_id(colonia: str) -> str:
"""Deriva routeId desde colonias-rutas.json; lanza 404 si la colonia no existe."""
mapping = get_colonias()
match = next(
(c for c in mapping if c.get("colonia", "").lower() == colonia.lower()), None
)
if not match:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Colonia '{colonia}' no encontrada. Usa GET /colonias para ver las opciones.",
)
return match["routeId"]
@router.post("", response_model=AddressResponse, status_code=status.HTTP_201_CREATED)
def create_address(
body: AddressCreate,
current_user: dict = Depends(require_role("citizen", "admin")),
):
"""Alta de domicilio. El routeId se deriva automáticamente de la colonia elegida."""
route_id = _resolve_route_id(body.colonia)
result = (
supabase_admin.table("addresses")
.insert(
{
"user_id": current_user["user_id"],
"label": body.label,
"calle": body.calle,
"colonia": body.colonia,
"route_id": route_id,
"verified": False,
}
)
.execute()
)
if not result.data:
raise HTTPException(status_code=500, detail="No se pudo guardar el domicilio")
return result.data[0]
@router.get("", response_model=list[AddressResponse])
def list_addresses(current_user: dict = Depends(get_current_user)):
"""
Lista de domicilios.
- Ciudadano: solo sus propios (filtro por user_id en código + RLS en BD).
- Admin: todos los domicilios.
"""
if current_user["role"] == "admin":
result = supabase_admin.table("addresses").select("*").execute()
else:
result = (
supabase_admin.table("addresses")
.select("*")
.eq("user_id", current_user["user_id"])
.execute()
)
return result.data or []
@router.get("/{address_id}", response_model=AddressResponse)
def get_address(
address_id: str,
current_user: dict = Depends(get_current_user),
):
"""Detalle de un domicilio. El ciudadano solo puede ver los suyos."""
result = (
supabase_admin.table("addresses")
.select("*")
.eq("id", address_id)
.maybe_single()
.execute()
)
if not result.data:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
address = result.data
if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]:
raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio")
return address

90
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,90 @@
from fastapi import APIRouter, HTTPException, status
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse
from app.core.supabase_client import supabase, supabase_admin
router = APIRouter(prefix="/auth", tags=["auth"])
def _fetch_role(user_id: str) -> str:
result = (
supabase_admin.table("users")
.select("role")
.eq("id", user_id)
.maybe_single()
.execute()
)
return result.data["role"] if result.data else "citizen"
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
def register(body: RegisterRequest):
"""
Registro por email o teléfono.
- Email: flujo estándar Supabase email+password.
- Teléfono: requiere que Supabase tenga configurado un proveedor SMS (Twilio).
"""
if not body.email and not body.phone:
raise HTTPException(status_code=400, detail="Se requiere email o teléfono")
try:
if body.email:
resp = supabase.auth.sign_up({"email": body.email, "password": body.password})
else:
resp = supabase.auth.sign_up({"phone": body.phone, "password": body.password})
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
auth_user = resp.user
if not auth_user:
raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth")
# Crear entrada en public.users con el rol elegido
supabase_admin.table("users").upsert(
{
"id": str(auth_user.id),
"email": body.email,
"phone": body.phone,
"role": body.role,
}
).execute()
# Si no hubo sesión (email confirmation pendiente) devolvemos token vacío con aviso
if not resp.session:
raise HTTPException(
status_code=202,
detail="Cuenta creada. Confirma tu email antes de iniciar sesión.",
)
return TokenResponse(
access_token=resp.session.access_token,
user_id=str(auth_user.id),
role=body.role,
)
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest):
"""Login por email o teléfono; devuelve JWT de Supabase."""
if not body.email and not body.phone:
raise HTTPException(status_code=400, detail="Se requiere email o teléfono")
try:
if body.email:
resp = supabase.auth.sign_in_with_password(
{"email": body.email, "password": body.password}
)
else:
resp = supabase.auth.sign_in_with_password(
{"phone": body.phone, "password": body.password}
)
except Exception:
raise HTTPException(status_code=401, detail="Credenciales inválidas")
auth_user = resp.user
role = _fetch_role(str(auth_user.id))
return TokenResponse(
access_token=resp.session.access_token,
user_id=str(auth_user.id),
role=role,
)

View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.schemas.colonias import ColoniaResponse
from app.services.simulation import get_colonias
router = APIRouter(tags=["colonias"])
@router.get("/colonias", response_model=list[ColoniaResponse])
def list_colonias():
return get_colonias()

View File

@@ -5,11 +5,6 @@ from app.services import simulation
router = APIRouter()
@router.get("/colonias")
def list_colonias():
return simulation.get_colonias()
@router.get("/eta")
def get_eta(colonia: Optional[str] = None, routeId: Optional[str] = None):
# Resolver routeId a partir de colonia si es necesario

View File

View File

@@ -0,0 +1,21 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
SUPABASE_URL: str
SUPABASE_ANON_KEY: str
SUPABASE_SERVICE_ROLE_KEY: str
FIREBASE_CREDENTIALS_PATH: str = "app/data/secrets/recoleccion-app-firebase-adminsdk-fbsvc-3da79d2a4c.json"
SIMULATION_TICK_SECONDS: int = 10
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

46
backend/app/core/deps.py Normal file
View File

@@ -0,0 +1,46 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.core.supabase_client import supabase, supabase_admin
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""Valida el JWT de Supabase y devuelve {user_id, email, role}."""
token = credentials.credentials
try:
resp = supabase.auth.get_user(token)
auth_user = resp.user
if auth_user is None:
raise ValueError("usuario nulo")
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido o expirado",
headers={"WWW-Authenticate": "Bearer"},
)
result = (
supabase_admin.table("users")
.select("role")
.eq("id", str(auth_user.id))
.maybe_single()
.execute()
)
role = result.data["role"] if result.data else "citizen"
return {"user_id": str(auth_user.id), "email": auth_user.email, "role": role}
def require_role(*roles: str):
"""Factory: devuelve una dependencia que exige que el usuario tenga uno de los roles dados."""
async def checker(current_user: dict = Depends(get_current_user)) -> dict:
if current_user["role"] not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Rol requerido: {' o '.join(roles)}",
)
return current_user
return checker

View File

@@ -0,0 +1,8 @@
from supabase import create_client, Client
from app.core.config import settings
# Cliente con anon key — para operaciones de auth y llamadas ciudadanas
supabase: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_ANON_KEY)
# Cliente con service_role — bypasea RLS; solo para operaciones de backend admin
supabase_admin: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_ROLE_KEY)

View File

@@ -0,0 +1,146 @@
-- ============================================================
-- RLS Policies — Sistema de Recolección Inteligente
-- Ejecutar en: Supabase > SQL Editor
-- Regla innegociable: el ciudadano NUNCA ve coordenadas ni datos ajenos.
-- ============================================================
-- ------------------------------------------------------------
-- 1. Tabla public.users (espejo de auth.users con rol)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT,
phone TEXT,
role TEXT DEFAULT 'citizen'
CHECK (role IN ('citizen', 'driver', 'admin')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Cada usuario ve y edita solo su propia fila
DROP POLICY IF EXISTS "users_select_own" ON public.users;
CREATE POLICY "users_select_own" ON public.users
FOR SELECT USING (auth.uid() = id);
DROP POLICY IF EXISTS "users_update_own" ON public.users;
CREATE POLICY "users_update_own" ON public.users
FOR UPDATE USING (auth.uid() = id);
-- El backend (service_role) inserta al registrar; no necesita policy
-- porque service_role bypasea RLS por diseño.
-- ------------------------------------------------------------
-- 2. Tabla public.addresses
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.addresses (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
calle TEXT NOT NULL,
colonia TEXT NOT NULL,
route_id TEXT NOT NULL,
verified BOOLEAN DEFAULT FALSE,
verified_method TEXT,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE public.addresses ENABLE ROW LEVEL SECURITY;
-- Ciudadano: solo lee sus domicilios; admin lee todos
DROP POLICY IF EXISTS "addresses_select" ON public.addresses;
CREATE POLICY "addresses_select" ON public.addresses
FOR SELECT USING (
auth.uid() = user_id
OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
-- Ciudadano solo inserta domicilios propios
DROP POLICY IF EXISTS "addresses_insert" ON public.addresses;
CREATE POLICY "addresses_insert" ON public.addresses
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Ciudadano solo modifica los suyos (para verified=true tras OCR)
DROP POLICY IF EXISTS "addresses_update" ON public.addresses;
CREATE POLICY "addresses_update" ON public.addresses
FOR UPDATE USING (
auth.uid() = user_id
OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
-- ------------------------------------------------------------
-- 3. Tabla public.route_positions ← ÚNICA QUE TIENE LAT/LNG
-- Solo admin puede leerla. Regla innegociable #1 y #4.
-- ------------------------------------------------------------
ALTER TABLE public.route_positions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "route_positions_admin_only" ON public.route_positions;
CREATE POLICY "route_positions_admin_only" ON public.route_positions
FOR SELECT USING (
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
-- Sin policy para INSERT/UPDATE/DELETE → el backend usa service_role para el seed.
-- ------------------------------------------------------------
-- 4. Tabla public.notifications
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.notifications (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES public.users(id) ON DELETE CASCADE,
route_id TEXT,
type TEXT,
payload JSONB,
sent_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "notifications_select" ON public.notifications;
CREATE POLICY "notifications_select" ON public.notifications
FOR SELECT USING (
auth.uid() = user_id
OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
-- ------------------------------------------------------------
-- 5. Tabla public.feedback
-- Las quejas van a target_unit_id (unidad), NUNCA al chofer.
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.feedback (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES public.users(id) ON DELETE SET NULL,
address_id UUID REFERENCES public.addresses(id) ON DELETE SET NULL,
type TEXT,
target_unit_id INT, -- unidad (no chofer): privacidad del chofer
message TEXT,
rating SMALLINT CHECK (rating BETWEEN 1 AND 5),
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE public.feedback ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "feedback_select" ON public.feedback;
CREATE POLICY "feedback_select" ON public.feedback
FOR SELECT USING (
auth.uid() = user_id
OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
DROP POLICY IF EXISTS "feedback_insert" ON public.feedback;
CREATE POLICY "feedback_insert" ON public.feedback
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- ------------------------------------------------------------
-- 6. Índices útiles para rendimiento
-- ------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_addresses_user_id ON public.addresses(user_id);
CREATE INDEX IF NOT EXISTS idx_addresses_route_id ON public.addresses(route_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON public.notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_feedback_user ON public.feedback(user_id);

View File

@@ -1,18 +0,0 @@
from fastapi import FastAPI
from app.api.eta import router as eta_router
from app.services import simulation
from app.services import notifications
import os
app = FastAPI(title="Recoleccion API")
app.include_router(eta_router)
@app.on_event("startup")
async def startup_event():
# Carga los datos en memoria al iniciar la app
simulation.load_data()
simulation.start_simulation_state()
# Inicializar Firebase Admin si hay credenciales
cred_path = os.environ.get("FIREBASE_CREDENTIALS_PATH", "backend/secrets/firebase-adminsdk.json")
notifications.init_firebase(cred_path)

View File

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel
from typing import Optional
class AddressCreate(BaseModel):
label: str
calle: str
colonia: str # el backend deriva route_id a partir de colonias-rutas.json
class AddressResponse(BaseModel):
id: str
user_id: str
label: str
calle: str
colonia: str
route_id: str
verified: bool
verified_method: Optional[str] = None
verified_at: Optional[str] = None
created_at: Optional[str] = None

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel, EmailStr
from typing import Optional, Literal
class RegisterRequest(BaseModel):
email: Optional[str] = None
phone: Optional[str] = None
password: str
role: Literal["citizen", "driver", "admin"] = "citizen"
class LoginRequest(BaseModel):
email: Optional[str] = None
phone: Optional[str] = None
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
role: str

View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel
class ColoniaResponse(BaseModel):
colonia: str
routeId: str
turno: str | None = None
horario_estimado: str | None = None

View File

@@ -23,8 +23,11 @@ def init_firebase(cred_path: str):
print(f"ADVERTENCIA: Credenciales no encontradas en '{cred_path}'.")
print("Las notificaciones se ejecutarán en modo SIMULADO (solo consola).")
def send_to_topic(topic: str, title: str, body: str):
def send_to_topic(topic: str, payload: dict):
"""Envía una notificación push a todos los dispositivos suscritos a un topic (ej. RUTA-01)."""
title = payload.get("title", "")
body = payload.get("body", "")
if not _firebase_initialized:
print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}'")
return