feat: tenancy — products + API keys

Product is the only tenant table this service owns (porte table:
organization_id -> product_id, everything below it -- tenant_ref/
branch_ref -- stays an opaque string owned by the consuming product, never
a row here). Bcrypt-hashed API keys (passlib, same CryptContext shape as
the auto's auth.service), generated once by scripts/create_product.py and
never persisted in the clear. require_product (X-Api-Key -> Product, 401
on missing/wrong/soft-deleted) is the porte adaptation of the auto's
require_permission — every /v1/* route will depend on it instead of a JWT
bearer token.

Also ports shared/base_model.py and shared/errors.py verbatim (Global
Constraints: soft delete mixins, structured 409 bodies).

products migration + real "alembic upgrade head" round-trip test
(tests/migrations/, new shared _helpers.py instead of the auto's ad hoc
cross-file _run_psql reuse). 13 tests green via `make k8s-test`.
This commit is contained in:
jonatanritter
2026-07-22 16:18:25 -03:00
parent 923848af33
commit 1791435a8d
15 changed files with 522 additions and 1 deletions
View File
+30
View File
@@ -0,0 +1,30 @@
"""Ported verbatim from `auto/backend/app/shared/base_model.py` (Task 2) --
no adaptation needed, these three mixins carry no tenancy-shaped columns of
their own."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
class UUIDPKMixin:
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SoftDeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), default=None
)
+11
View File
@@ -0,0 +1,11 @@
"""Ported verbatim from `auto/backend/app/shared/errors.py` (Task 2) --
structured 409 error bodies for money/data-integrity conflicts. Every OTHER
409 in this service (like the auto) is a plain `HTTPException(409,
detail=str(exc))`; `conflict()` is for the ones a consumer needs to branch
on programmatically (e.g. a produto's own retry logic reacting to
`certificate_upload_conflict` vs `duplicate_fiscal_series` differently)."""
from fastapi import HTTPException, status
def conflict(code: str, message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": code, "message": message})
View File
+35
View File
@@ -0,0 +1,35 @@
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from fiscal_svc.core.db import get_session
from fiscal_svc.tenancy.models import Product
from fiscal_svc.tenancy.service import authenticate_product
async def require_product(
x_api_key: str | None = Header(default=None, alias="X-Api-Key"),
session: AsyncSession = Depends(get_session),
) -> Product:
"""Adaptation of the auto's `auth.deps.get_current_user`/
`require_permission` (porte table: `require_permission(...)` ->
`require_product(...)`, API key -> Product): every `/v1/*` route in this
service depends on this instead of a JWT bearer token -- there is no
per-request USER here, only a per-request PRODUCT (the SowAI app calling
on behalf of one of ITS OWN tenants, identified by the opaque
`tenant_ref`/`branch_ref` the request body/query carries, never resolved
by this service).
401 (not 403) for BOTH a missing header and a key that matches no live
product -- same "don't distinguish absent-credential from wrong-
credential" fail-closed shape as a bearer token that fails to decode in
`get_current_user`. A soft-deleted product's key also 401s here (
`authenticate_product` only looks at `deleted_at IS NULL` rows), same
"revoked = gone" semantics as a deactivated auto user."""
if x_api_key is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key ausente")
product = await authenticate_product(session, x_api_key)
if product is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key inválida")
return product
+43
View File
@@ -0,0 +1,43 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from fiscal_svc.core.db import Base
from fiscal_svc.shared.base_model import SoftDeleteMixin, TimestampMixin, UUIDPKMixin
class Product(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""A SowAI product consuming this service (auto, crm, ...) -- the ONLY
tenant concept this service itself owns (Task 2). Per the porte table
(2026-07-17-fiscal-svc-f2-servico.md): the auto's `Organization`/`Branch`
rows have no equivalent here -- `product_id` (this table's PK) is the
top of the tenancy hierarchy, and everything below it
(`tenant_ref`/`branch_ref`) is an OPAQUE string the product hands us,
never a row this service owns or validates the shape of (decision #4 of
the design spec: "o serviço não conhece o modelo de org de nenhum
produto").
`api_key_hash`: bcrypt (via passlib, same `CryptContext(schemes=
["bcrypt"])` as the auto's `auth.service.hash_password`), generated by
`scripts/create_product.py` and never stored/logged in the clear -- see
that script's docstring for the "printed once" flow. Auth (Task 2's
`require_product`) has no per-product identifier to look the row up
BY (unlike the auto's `authenticate_user`, which first resolves the User
by `organization_slug` + `email` and THEN verifies the password against
THAT ONE row) -- an API key IS the only credential, so `require_product`
must verify the candidate key against every live product's hash instead
of a single one. That's fine at this service's cardinality: `products`
holds one row per SowAI PRODUCT (auto, crm, ...), not per end customer --
low tens at most, not the O(n) problem it would be for user accounts.
`webhook_secret`: nullable HMAC secret for the `documento.status_changed`
webhook (design spec's decision #5, F4) -- generated/rotated the same
way as `api_key_hash` once that feature lands; nullable now so this
migration doesn't need to change shape later, mirroring how the auto's
`Branch` fiscal columns were added nullable ahead of the feature that
fills them (Bloco A)."""
__tablename__ = "products"
name: Mapped[str] = mapped_column(String(255), nullable=False)
api_key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
webhook_secret: Mapped[str | None] = mapped_column(String(255), nullable=True)
+50
View File
@@ -0,0 +1,50 @@
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fiscal_svc.tenancy.models import Product
# Same scheme/config as the auto's `auth.service._pwd_context`
# (`app/modules/auth/service.py`) -- bcrypt via passlib, `deprecated="auto"`
# so passlib itself flags (and re-hashes on next write) any hash produced by
# a scheme this CryptContext no longer lists as current.
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_api_key(plain: str) -> str:
return _pwd_context.hash(plain)
def verify_api_key(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
async def create_product(session: AsyncSession, name: str, api_key: str) -> Product:
"""Used by `scripts/create_product.py` -- the ONLY writer of `products`
for now (no HTTP endpoint; provisioning a new SowAI product consumer is
an operator action, not self-service). `api_key` is the PLAINTEXT key
the caller generated; only its bcrypt hash is persisted."""
product = Product(name=name, api_key_hash=hash_api_key(api_key))
session.add(product)
await session.commit()
await session.refresh(product)
return product
async def authenticate_product(session: AsyncSession, api_key: str) -> Product | None:
"""Resolves a plaintext `X-Api-Key` to its `Product`, or None if it
matches no live product.
Unlike the auto's `authenticate_user` (which first narrows to ONE row
via `organization_slug` + `email`, THEN verifies a password against that
single hash), an API key carries no separate identifier to narrow the
lookup by -- bcrypt hashes are salted, so `WHERE api_key_hash = hash(
candidate)` can never match. This verifies the candidate against every
LIVE (`deleted_at IS NULL`) product's hash in turn, first match wins.
See `Product`'s docstring for why that's fine at this table's
cardinality (one row per SowAI PRODUCT, not per end customer)."""
result = await session.execute(select(Product).where(Product.deleted_at.is_(None)))
for product in result.scalars().all():
if verify_api_key(api_key, product.api_key_hash):
return product
return None