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:
Ali Zarinkolah
2026-08-20 22:26:25 +03:30
parent c7a5b69c0a
commit 133f565704
8 changed files with 397 additions and 2 deletions

View File

View File

@@ -0,0 +1,34 @@
"""Argument parsing for `python -m src.cli.provision_tenant`.
Only the argv -> arguments mapping is covered here; the provisioning behaviour
itself needs a real database and lives in
`tests/integration/postgres/test_provisioning.py`.
"""
import pytest
from src.application.tenants import DEFAULT_SCOPES
from src.cli.provision_tenant import _parse_args
pytestmark = pytest.mark.unit
def test_parse_args_defaults_scopes_and_domains() -> None:
args = _parse_args(["--slug", "acme"])
assert args.slug == "acme"
assert args.name is None
assert args.key_name == "bootstrap"
assert tuple(args.scopes.split(",")) == DEFAULT_SCOPES
assert args.domains == []
def test_parse_args_collects_repeated_domain_flags() -> None:
args = _parse_args(["--slug", "acme", "--domain", "fire", "--domain", "life"])
assert args.domains == ["fire", "life"]
def test_parse_args_requires_a_slug() -> None:
with pytest.raises(SystemExit):
_parse_args(["--domain", "fire"])