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:
2026-07-22 13:40:59 +02:00
co-authored by Claude Sonnet 4.6
parent a11d2fb1e7
commit b69190dd86
5 changed files with 25 additions and 5 deletions
+2 -2
View File
@@ -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
+8 -2
View File
@@ -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
+2 -1
View File
@@ -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):
+4
View File
@@ -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