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:
@@ -1,4 +1,4 @@
|
||||
"""API-key lookups (ADR-0008, ADR-0009).
|
||||
"""API-key lookups and issuance (ADR-0008, ADR-0009).
|
||||
|
||||
Plain functions over an `AsyncSession` the caller owns. No function here
|
||||
commits, rolls back, or closes the session (ADR-0012). Secret comparison
|
||||
@@ -6,6 +6,8 @@ happens in `src/application/auth`, not here — this module only fetches rows
|
||||
by their non-secret `key_prefix`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -15,3 +17,29 @@ from src.infrastructure.postgres.models.api_key import ApiKey
|
||||
async def get_by_prefix(session: AsyncSession, key_prefix: str) -> ApiKey | None:
|
||||
result = await session.execute(select(ApiKey).where(ApiKey.key_prefix == key_prefix))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def create(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
name: str,
|
||||
key_prefix: str,
|
||||
key_hash: str,
|
||||
scopes: list[str],
|
||||
actor_type: str = "backend",
|
||||
created_by: str | None = None,
|
||||
) -> ApiKey:
|
||||
"""Persist an issued key. The caller hashes the secret; this never sees it."""
|
||||
api_key = ApiKey(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
key_prefix=key_prefix,
|
||||
key_hash=key_hash,
|
||||
scopes=scopes,
|
||||
actor_type=actor_type,
|
||||
created_by=created_by,
|
||||
)
|
||||
session.add(api_key)
|
||||
return api_key
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tenant lookups (ADR-0009).
|
||||
"""Tenant lookups and creation (ADR-0009).
|
||||
|
||||
Plain functions over an `AsyncSession` the caller owns. No function here
|
||||
commits, rolls back, or closes the session (ADR-0012).
|
||||
@@ -6,6 +6,7 @@ commits, rolls back, or closes the session (ADR-0012).
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.infrastructure.postgres.models.tenant import Tenant
|
||||
@@ -13,3 +14,14 @@ from src.infrastructure.postgres.models.tenant import Tenant
|
||||
|
||||
async def get_by_id(session: AsyncSession, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
return await session.get(Tenant, tenant_id)
|
||||
|
||||
|
||||
async def get_by_slug(session: AsyncSession, slug: str) -> Tenant | None:
|
||||
result = await session.execute(select(Tenant).where(Tenant.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def create(session: AsyncSession, *, slug: str, name: str) -> Tenant:
|
||||
tenant = Tenant(id=uuid.uuid4(), slug=slug, name=name)
|
||||
session.add(tenant)
|
||||
return tenant
|
||||
|
||||
Reference in New Issue
Block a user