Files
sowai-fiscal-svc/alembic/versions/8f1a2c9d4b6e_fiscal_certificates_tenant_scoped_live_index.py
T
jonatanritterandClaude Opus 4.8 72bb089222 fix(certificates): scope the live certificate uniquely per tenant, not just per branch_ref
IMPORTANT (F2 review): certificate is per-tenant, matching series (already
tenant-scoped) and the GET/DELETE anti-oracle boundary. Emission's
_get_live_certificate and the upload-replace pre-check (certificates.
service, renamed _get_live_certificate_by_branch ->
_get_live_certificate_by_tenant_branch) both omitted tenant_ref -- two
tenants of one product reusing branch_ref="matriz" collapsed onto the same
slot: B's upload soft-deleted A's still-live certificate, and A's emission
went on to sign with B's certificate.

Migration 8f1a2c9d4b6e replaces the partial-unique index
ix_fiscal_certificates_product_branch_live with
ix_fiscal_certificates_product_tenant_branch_live on
(product_id, tenant_ref, branch_ref) WHERE deleted_at IS NULL, with a
working downgrade. Upload's two-layer defense (pre-check + IntegrityError ->
CertificateUploadConflictError) still holds against the new index.

Tests:
- tests/emission/test_emissao.py::
  test_dois_tenants_do_mesmo_produto_reusando_branch_ref_tem_certificados_isolados
  -- two tenants upload for the same product/branch_ref, both stay live;
  emission for each signs with its OWN certificate (observable via FIX 1's
  CNPJ check: without FIX 2, tenant A's emission would 409
  emitente_certificate_cnpj_mismatch because the "live" cert would
  actually be B's).
- tests/migrations/test_fiscal_documents_schema.py::
  test_two_tenants_can_both_hold_a_live_certificate_for_the_same_branch_ref_on_real_migration
  -- real alembic upgrade head, raw INSERTs proving both tenants' certs
  land live.
- tests/migrations/test_fiscal_documents_schema.py::
  test_two_live_certificates_for_same_product_tenant_branch_violate_unique_index_on_real_migration
  (renamed from ..._product_branch_...) -- same (product, tenant, branch)
  still rejects a second live certificate on the real migration.
- tests/certificates/test_certificates.py::
  test_concurrent_uploads_for_same_product_branch_only_one_wins_the_other_gets_409
  updated for the renamed/re-scoped precheck function (still same-tenant
  race, still 1 winner + 1 CertificateUploadConflictError).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 15:32:20 -03:00

54 lines
2.0 KiB
Python

"""fiscal_certificates: partial-unique live index scoped by tenant_ref too
(FIX 2, F2 review, 2026-07-17-sowai-fiscal-svc-design.md decisão #4) --
`ix_fiscal_certificates_product_branch_live` (`(product_id, branch_ref)
WHERE deleted_at IS NULL`) let two DIFFERENT tenants of the SAME product
reusing an identical opaque `branch_ref` (e.g. both `"matriz"`) collapse
onto the SAME certificate slot: the second tenant's upload soft-deleted the
first tenant's still-live certificate as a legitimate "replace" instead of
a 409 conflict, and emission for the first tenant would go on to sign with
the second tenant's certificate. Replaces the index with one scoped
`(product_id, tenant_ref, branch_ref) WHERE deleted_at IS NULL` -- matching
`fiscal_series`'s own tenant-scoped uniqueness and the GET/DELETE
certificate lookups, which already filtered by `tenant_ref`.
Revision ID: 8f1a2c9d4b6e
Revises: 30a80fe36910
Create Date: 2026-07-24 00:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "8f1a2c9d4b6e"
down_revision: Union[str, Sequence[str], None] = "30a80fe36910"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index("ix_fiscal_certificates_product_branch_live", table_name="fiscal_certificates")
op.create_index(
"ix_fiscal_certificates_product_tenant_branch_live",
"fiscal_certificates",
["product_id", "tenant_ref", "branch_ref"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_index(
"ix_fiscal_certificates_product_tenant_branch_live", table_name="fiscal_certificates"
)
op.create_index(
"ix_fiscal_certificates_product_branch_live",
"fiscal_certificates",
["product_id", "branch_ref"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)