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>
This commit is contained in:
jonatanritter
2026-08-08 15:32:20 -03:00
co-authored by Claude Opus 4.8
parent 3e3a1abc6f
commit 72bb089222
7 changed files with 272 additions and 60 deletions
+27 -20
View File
@@ -109,27 +109,33 @@ class FiscalSeries(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""Ported from the auto's `FiscalCertificate` (`app/modules/fiscal/
models.py`) -- A1 certificate (.pfx) for a `branch_ref`. Per the porte
table, the emitter is `(product_id, branch_ref)`, with `cnpj` carried
on the row itself (the "vínculo forte", design spec decision #4: the
CNPJ is what's validated against the certificate at upload and against
the emitente at emission -- `branch_ref` alone is an opaque string this
service never interprets). No fallback to a tenant-level or
table, the emitter is `(product_id, tenant_ref, branch_ref)`, with
`cnpj` carried on the row itself (the "vínculo forte", design spec
decision #4: the CNPJ is what's validated against the certificate at
upload and against the emitente at emission -- `branch_ref` alone is an
opaque string this service never interprets). No fallback to a
product-level certificate -- fail-closed by construction, same as the
auto: a branch_ref without its own live certificate cannot emit.
auto: a `(tenant_ref, branch_ref)` without its own live certificate
cannot emit.
One VIVO (`deleted_at IS NULL`) row per `(product_id, branch_ref)`: a
second upload soft-deletes the previous live row (Task 4's
`certificates.service`). `ix_fiscal_certificates_product_branch_live`
is a PARTIAL UNIQUE index enforcing that at the database level (same
"two concurrent uploads must not both land a live row" reasoning as the
auto's own migration `a1b2c3d4e5f6`'s fix) -- scoped by `product_id` in
ADDITION to `branch_ref` (the auto's version only needed `branch_id`,
already product-scoped by being a real FK; here `branch_ref` is an
OPAQUE string owned by the calling product, so two DIFFERENT products
could coincidentally pick the identical string for two DIFFERENT real
branches -- scoping the uniqueness by `product_id` too is what keeps
that from cross-contaminating one product's certificate slot with
another's).
One VIVO (`deleted_at IS NULL`) row per `(product_id, tenant_ref,
branch_ref)`: a second upload soft-deletes the previous live row
(Task 4's `certificates.service`). `ix_fiscal_certificates_product_
tenant_branch_live` is a PARTIAL UNIQUE index enforcing that at the
database level (same "two concurrent uploads must not both land a live
row" reasoning as the auto's own migration `a1b2c3d4e5f6`'s fix) --
scoped by `product_id` AND `tenant_ref` in ADDITION to `branch_ref`
(the auto's version only needed `branch_id`, already product-scoped by
being a real FK; here `branch_ref` is an OPAQUE string owned by the
calling product, so two DIFFERENT products -- or two DIFFERENT tenants
of the SAME product -- could coincidentally pick the identical string
for two DIFFERENT real branches -- scoping the uniqueness by
`product_id`+`tenant_ref` too is what keeps that from
cross-contaminating one tenant's certificate slot with another's; FIX 2,
F2 review, 2026-07-17-sowai-fiscal-svc-design.md decisão #4 turned this
into a shipped bug otherwise: two tenants both using `branch_ref=
"matriz"` would collapse onto one slot, the second tenant's upload
silently soft-deleting the first's still-live certificate).
`pfx_encrypted`/`password_encrypted` are Fernet ciphertext (Task 4,
`FISCAL_CERT_ENCRYPTION_KEY` env var, own key -- never
@@ -139,8 +145,9 @@ class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
__tablename__ = "fiscal_certificates"
__table_args__ = (
Index(
"ix_fiscal_certificates_product_branch_live",
"ix_fiscal_certificates_product_tenant_branch_live",
"product_id",
"tenant_ref",
"branch_ref",
unique=True,
postgresql_where=text("deleted_at IS NULL"),