bLOQUE p1 BACKEND Y SEGURIDAD, AUTENTICACION CON SUPABASE. jwt. RBAC CRUD
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
94
backend/app/api/addresses.py
Normal file
94
backend/app/api/addresses.py
Normal 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
90
backend/app/api/auth.py
Normal 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,
|
||||
)
|
||||
11
backend/app/api/colonias.py
Normal file
11
backend/app/api/colonias.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
21
backend/app/core/config.py
Normal file
21
backend/app/core/config.py
Normal 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
46
backend/app/core/deps.py
Normal 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
|
||||
8
backend/app/core/supabase_client.py
Normal file
8
backend/app/core/supabase_client.py
Normal 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)
|
||||
146
backend/app/db/rls_policies.sql
Normal file
146
backend/app/db/rls_policies.sql
Normal 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);
|
||||
@@ -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)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
21
backend/app/schemas/addresses.py
Normal file
21
backend/app/schemas/addresses.py
Normal 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
|
||||
22
backend/app/schemas/auth.py
Normal file
22
backend/app/schemas/auth.py
Normal 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
|
||||
8
backend/app/schemas/colonias.py
Normal file
8
backend/app/schemas/colonias.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user