feat(tenant): add operator provisioning for tenants, API keys, and domains
Why: - nothing over HTTP could create the first tenant: every /v1 route needs an API key, and a key cannot exist before its tenant. The service was unusable by a human without hand-written SQL. Changes: - add `provision_tenant`, owning tenant reuse-or-create, key generation and hashing, and domain registration in one transaction - expose it as `python -m src.cli.provision_tenant`, alongside `alembic upgrade head` and `qdrant_bootstrap` - add `tenants.get_by_slug`/`create` and `api_keys.create` - log `tenant.provisioned` / `api_key.provisioned` with the key prefix only Impact: - a third deployment step; the plaintext key is printed once and never logged or stored (ADR-0011, ADR-0009) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
9
src/application/tenants/__init__.py
Normal file
9
src/application/tenants/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Operator-run tenant provisioning (ADR-0008, ADR-0009)."""
|
||||
|
||||
from src.application.tenants.provisioning import (
|
||||
DEFAULT_SCOPES,
|
||||
ProvisionResult,
|
||||
provision_tenant,
|
||||
)
|
||||
|
||||
__all__ = ["DEFAULT_SCOPES", "ProvisionResult", "provision_tenant"]
|
||||
123
src/application/tenants/provisioning.py
Normal file
123
src/application/tenants/provisioning.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Provision a tenant, its first API key, and its domains (ADR-0008, ADR-0009).
|
||||
|
||||
Nothing in the HTTP surface can bootstrap a tenant: every `/v1` route needs a
|
||||
key, and a key can only exist once a tenant does. That chicken-and-egg is why
|
||||
this is an operator-run deployment step (`src/cli/provision_tenant.py`) rather
|
||||
than an endpoint — the same reasoning that keeps `alembic upgrade head` and
|
||||
`qdrant_bootstrap` off the request path.
|
||||
|
||||
This is the package's only caller-facing entry point. It owns the whole
|
||||
composition — tenant reuse-or-create, key generation and hashing, domain
|
||||
registration, and the single transaction the three share — so a caller cannot
|
||||
get the order wrong or commit a key whose tenant never landed (CLAUDE.md,
|
||||
"prefer deep modules"). The plaintext key is returned exactly once and is never
|
||||
logged (ADR-0011 forbids plaintext keys in logs); only its non-secret
|
||||
`key_prefix` appears in the event.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.application.auth.keys import generate_api_key, hash_secret
|
||||
from src.infrastructure.postgres.repositories import api_keys as api_keys_repo
|
||||
from src.infrastructure.postgres.repositories import tenant_domains as domains_repo
|
||||
from src.infrastructure.postgres.repositories import tenants as tenants_repo
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
DEFAULT_SCOPES = ("files:write", "domains:read", "domains:write")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvisionResult:
|
||||
tenant_id: uuid.UUID
|
||||
tenant_slug: str
|
||||
tenant_created: bool
|
||||
api_key_id: uuid.UUID
|
||||
api_key_prefix: str
|
||||
api_key: str
|
||||
"""The plaintext bearer token. Only ever returned here — never stored, never logged."""
|
||||
domains_created: tuple[str, ...]
|
||||
domains_existing: tuple[str, ...]
|
||||
|
||||
|
||||
async def provision_tenant(
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
*,
|
||||
slug: str,
|
||||
name: str | None = None,
|
||||
key_name: str = "bootstrap",
|
||||
scopes: tuple[str, ...] = DEFAULT_SCOPES,
|
||||
domains: tuple[str, ...] = (),
|
||||
actor_type: str = "backend",
|
||||
) -> ProvisionResult:
|
||||
"""Create (or reuse) the tenant, issue a key, and register `domains`.
|
||||
|
||||
Re-running with the same `slug` reuses the tenant and its existing domains
|
||||
rather than failing, so an operator can add a key to a live tenant with the
|
||||
same command they used to create it. A *new* key is issued on every run —
|
||||
keys are write-once by construction (only the hash is stored), so there is
|
||||
nothing to return for an existing one.
|
||||
"""
|
||||
key_prefix, secret, full_key = generate_api_key()
|
||||
|
||||
async with sessionmaker() as session:
|
||||
tenant = await tenants_repo.get_by_slug(session, slug)
|
||||
tenant_created = tenant is None
|
||||
if tenant is None:
|
||||
tenant = tenants_repo.create(session, slug=slug, name=name or slug)
|
||||
# `api_keys.tenant_id` and `tenant_domains.tenant_id` FK to this row
|
||||
# and the mapped classes carry no ORM relationship for the unit of
|
||||
# work to order by itself, so the insert has to land first.
|
||||
await session.flush()
|
||||
|
||||
api_key = api_keys_repo.create(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
name=key_name,
|
||||
key_prefix=key_prefix,
|
||||
key_hash=hash_secret(secret),
|
||||
scopes=list(scopes),
|
||||
actor_type=actor_type,
|
||||
created_by="cli:provision_tenant",
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
existing: list[str] = []
|
||||
for domain in domains:
|
||||
if await domains_repo.get(session, tenant_id=tenant.id, domain=domain) is not None:
|
||||
existing.append(domain)
|
||||
continue
|
||||
domains_repo.create(session, tenant_id=tenant.id, domain=domain, display_name=domain)
|
||||
created.append(domain)
|
||||
|
||||
await session.flush()
|
||||
tenant_id, api_key_id, tenant_slug = tenant.id, api_key.id, tenant.slug
|
||||
await session.commit()
|
||||
|
||||
if tenant_created:
|
||||
logger.info("tenant.provisioned", tenant_id=str(tenant_id), tenant_slug=tenant_slug)
|
||||
for domain in created:
|
||||
logger.info("domain.created", tenant_id=str(tenant_id), domain=domain)
|
||||
logger.info(
|
||||
"api_key.provisioned",
|
||||
tenant_id=str(tenant_id),
|
||||
api_key_id=str(api_key_id),
|
||||
key_prefix=key_prefix,
|
||||
scopes=list(scopes),
|
||||
actor_type=actor_type,
|
||||
)
|
||||
|
||||
return ProvisionResult(
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
tenant_created=tenant_created,
|
||||
api_key_id=api_key_id,
|
||||
api_key_prefix=key_prefix,
|
||||
api_key=full_key,
|
||||
domains_created=tuple(created),
|
||||
domains_existing=tuple(existing),
|
||||
)
|
||||
Reference in New Issue
Block a user