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:
@@ -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
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user