fix: billing tenant isolation, invoice status validation, SMTP password masking
H2: list_invoices now passes tenant_id (from current_user) to get_invoices;
get_invoices applies WHERE tenant_id filter for non-global-admin users;
get_invoice_endpoint returns 404 when tenant mismatch for non-admins.
H3: InvoiceStatusUpdate.status changed to Literal["draft","sent","paid","cancelled"]
for schema-level validation; guard also added in update_invoice_status service.
H5: _settings_to_out masks smtp_password as "***" when set, "" when empty;
update_settings skips writing when value is the "***" sentinel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -232,7 +232,7 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
|
||||
smtp_host=raw.get("smtp_host", ""),
|
||||
smtp_port=int(raw.get("smtp_port", "587")),
|
||||
smtp_user=raw.get("smtp_user", ""),
|
||||
smtp_password=raw.get("smtp_password", ""),
|
||||
smtp_password="***" if raw.get("smtp_password", "") else "",
|
||||
smtp_from_address=raw.get("smtp_from_address", ""),
|
||||
scene_linear_deflection=float(raw.get("scene_linear_deflection", "0.1")),
|
||||
scene_angular_deflection=float(raw.get("scene_angular_deflection", "0.1")),
|
||||
@@ -357,7 +357,7 @@ async def update_settings(
|
||||
updates["smtp_port"] = str(body.smtp_port)
|
||||
if body.smtp_user is not None:
|
||||
updates["smtp_user"] = body.smtp_user
|
||||
if body.smtp_password is not None:
|
||||
if body.smtp_password is not None and body.smtp_password != "***":
|
||||
updates["smtp_password"] = body.smtp_password
|
||||
if body.smtp_from_address is not None:
|
||||
updates["smtp_from_address"] = body.smtp_from_address
|
||||
|
||||
@@ -6,7 +6,8 @@ from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.utils.auth import require_admin_or_pm
|
||||
from app.utils.auth import require_admin_or_pm, _role_value
|
||||
from app.domains.auth.models import ADMIN_ROLES
|
||||
from app.domains.billing.schemas import InvoiceCreate, InvoiceOut, InvoiceStatusUpdate
|
||||
from app.domains.billing.service import (
|
||||
create_invoice, get_invoices, get_invoice,
|
||||
@@ -26,7 +27,8 @@ async def list_invoices(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(require_admin_or_pm),
|
||||
):
|
||||
return await get_invoices(db, skip=skip, limit=limit)
|
||||
tenant_id = None if _role_value(current_user) in ADMIN_ROLES else getattr(current_user, "tenant_id", None)
|
||||
return await get_invoices(db, tenant_id=tenant_id, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@invoice_router.post("/invoices", response_model=InvoiceOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -57,6 +59,10 @@ async def get_invoice_endpoint(
|
||||
inv = await get_invoice(db, invoice_id)
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
if _role_value(current_user) not in ADMIN_ROLES:
|
||||
user_tenant = getattr(current_user, "tenant_id", None)
|
||||
if inv.tenant_id != user_tenant:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
return inv
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, computed_field
|
||||
|
||||
|
||||
@@ -35,7 +36,7 @@ class InvoiceCreate(BaseModel):
|
||||
|
||||
|
||||
class InvoiceStatusUpdate(BaseModel):
|
||||
status: str # draft|sent|paid|cancelled
|
||||
status: Literal["draft", "sent", "paid", "cancelled"]
|
||||
|
||||
|
||||
class InvoiceOut(BaseModel):
|
||||
|
||||
@@ -283,6 +283,8 @@ async def get_invoices(
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
if tenant_id is not None:
|
||||
q = q.where(Invoice.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -297,6 +299,8 @@ async def get_invoice(db: AsyncSession, invoice_id: uuid.UUID) -> Invoice | None
|
||||
|
||||
|
||||
async def update_invoice_status(db: AsyncSession, invoice_id: uuid.UUID, status: str) -> Invoice | None:
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_STATUSES))}")
|
||||
invoice = await get_invoice(db, invoice_id)
|
||||
if not invoice:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user