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
+42
View File
@@ -0,0 +1,42 @@
"""products (Task 2 -- tenancy): the only tenant table this service owns.
`api_key_hash` is bcrypt ciphertext (never the plaintext key -- see
`scripts/create_product.py`); `webhook_secret` is nullable, unused until F4.
Revision ID: fd0ffe65c993
Revises:
Create Date: 2026-07-22 00:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "fd0ffe65c993"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"products",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("api_key_hash", sa.String(length=255), nullable=False),
sa.Column("webhook_secret", sa.String(length=255), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("products")