enforce unique tenant resolution keys at every write (clean 409)
Browse files- app/routes/admin.py +2 -6
- app/tenancy.py +30 -0
- tests/test_multitenant.py +23 -0
app/routes/admin.py
CHANGED
|
@@ -185,12 +185,8 @@ async def get_tenants(db: AsyncSession = Depends(get_session)) -> list[TenantOut
|
|
| 185 |
async def post_tenant(body: TenantIn, db: AsyncSession = Depends(get_session)) -> TenantOut:
|
| 186 |
if await get_tenant_by_slug(db, body.slug) is not None:
|
| 187 |
raise HTTPException(status_code=409, detail="slug already exists")
|
| 188 |
-
#
|
| 189 |
-
#
|
| 190 |
-
if body.shopify_shop and (
|
| 191 |
-
await db.execute(select(Tenant).where(Tenant.shopify_shop == body.shopify_shop))
|
| 192 |
-
).scalar_one_or_none() is not None:
|
| 193 |
-
raise HTTPException(status_code=409, detail="shopify_shop already assigned to another tenant")
|
| 194 |
fields = body.model_dump(
|
| 195 |
exclude={"slug", "shopify_client_secret", "whatsapp_token"}, exclude_none=True
|
| 196 |
)
|
|
|
|
| 185 |
async def post_tenant(body: TenantIn, db: AsyncSession = Depends(get_session)) -> TenantOut:
|
| 186 |
if await get_tenant_by_slug(db, body.slug) is not None:
|
| 187 |
raise HTTPException(status_code=409, detail="slug already exists")
|
| 188 |
+
# Uniqueness of shopify_shop / whatsapp_phone_id is enforced centrally in
|
| 189 |
+
# create_tenant (clean 409 if another tenant owns the value).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
fields = body.model_dump(
|
| 191 |
exclude={"slug", "shopify_client_secret", "whatsapp_token"}, exclude_none=True
|
| 192 |
)
|
app/tenancy.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
| 9 |
|
| 10 |
from typing import Any
|
| 11 |
|
|
|
|
| 12 |
from sqlalchemy import delete, select
|
| 13 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 14 |
|
|
@@ -76,6 +77,31 @@ _TENANT_FIELDS = {
|
|
| 76 |
}
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
async def create_tenant(
|
| 80 |
db: AsyncSession,
|
| 81 |
slug: str,
|
|
@@ -85,6 +111,7 @@ async def create_tenant(
|
|
| 85 |
**fields: Any,
|
| 86 |
) -> Tenant:
|
| 87 |
data = {k: v for k, v in fields.items() if k in _TENANT_FIELDS and v is not None}
|
|
|
|
| 88 |
tenant = Tenant(slug=slug, **data)
|
| 89 |
if shopify_client_secret:
|
| 90 |
tenant.shopify_client_secret_enc = encrypt(shopify_client_secret)
|
|
@@ -104,6 +131,9 @@ async def update_tenant(
|
|
| 104 |
whatsapp_token: str | None = None,
|
| 105 |
**fields: Any,
|
| 106 |
) -> Tenant:
|
|
|
|
|
|
|
|
|
|
| 107 |
for k, v in fields.items():
|
| 108 |
if k in _TENANT_FIELDS and v is not None:
|
| 109 |
setattr(tenant, k, v)
|
|
|
|
| 9 |
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
+
from fastapi import HTTPException
|
| 13 |
from sqlalchemy import delete, select
|
| 14 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
|
| 80 |
+
# A shop / WhatsApp number / Stripe customer maps to EXACTLY ONE tenant. These keys
|
| 81 |
+
# are how inbound requests resolve a client, so a duplicate would mix two accounts.
|
| 82 |
+
_UNIQUE_TENANT_KEYS = ("shopify_shop", "whatsapp_phone_id", "stripe_customer_id")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def _assert_unique_keys(
|
| 86 |
+
db: AsyncSession, fields: dict[str, Any], *, exclude_id: int | None = None
|
| 87 |
+
) -> None:
|
| 88 |
+
"""Refuse to assign a shop/phone/customer-id another tenant already owns — a
|
| 89 |
+
clean 409 at write time instead of a later MultipleResultsFound 500 (empty
|
| 90 |
+
values never collide). The partial-unique DB indexes (migration 0027) are the
|
| 91 |
+
backstop; this is the friendly, early guard at every create/edit path."""
|
| 92 |
+
for key in _UNIQUE_TENANT_KEYS:
|
| 93 |
+
val = fields.get(key)
|
| 94 |
+
if not val:
|
| 95 |
+
continue
|
| 96 |
+
stmt = select(Tenant.id).where(getattr(Tenant, key) == val)
|
| 97 |
+
if exclude_id is not None:
|
| 98 |
+
stmt = stmt.where(Tenant.id != exclude_id)
|
| 99 |
+
if (await db.execute(stmt)).first() is not None:
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=409, detail=f"{key} already assigned to another tenant"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
async def create_tenant(
|
| 106 |
db: AsyncSession,
|
| 107 |
slug: str,
|
|
|
|
| 111 |
**fields: Any,
|
| 112 |
) -> Tenant:
|
| 113 |
data = {k: v for k, v in fields.items() if k in _TENANT_FIELDS and v is not None}
|
| 114 |
+
await _assert_unique_keys(db, data)
|
| 115 |
tenant = Tenant(slug=slug, **data)
|
| 116 |
if shopify_client_secret:
|
| 117 |
tenant.shopify_client_secret_enc = encrypt(shopify_client_secret)
|
|
|
|
| 131 |
whatsapp_token: str | None = None,
|
| 132 |
**fields: Any,
|
| 133 |
) -> Tenant:
|
| 134 |
+
await _assert_unique_keys(
|
| 135 |
+
db, {k: v for k, v in fields.items() if k in _TENANT_FIELDS}, exclude_id=tenant.id
|
| 136 |
+
)
|
| 137 |
for k, v in fields.items():
|
| 138 |
if k in _TENANT_FIELDS and v is not None:
|
| 139 |
setattr(tenant, k, v)
|
tests/test_multitenant.py
CHANGED
|
@@ -122,3 +122,26 @@ async def test_get_tenant_by_slug(db_session):
|
|
| 122 |
await db_session.flush()
|
| 123 |
assert (await get_tenant_by_slug(db_session, "toorx")).slug == "toorx"
|
| 124 |
assert await get_tenant_by_slug(db_session, "missing") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
await db_session.flush()
|
| 123 |
assert (await get_tenant_by_slug(db_session, "toorx")).slug == "toorx"
|
| 124 |
assert await get_tenant_by_slug(db_session, "missing") is None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def test_resolution_keys_unique_clean_409(db_session):
|
| 128 |
+
"""shopify_shop / whatsapp_phone_id map to exactly ONE tenant: a duplicate is a
|
| 129 |
+
clean 409 at write time, never two rows that would mix two client accounts."""
|
| 130 |
+
from fastapi import HTTPException
|
| 131 |
+
|
| 132 |
+
from app.tenancy import update_tenant
|
| 133 |
+
|
| 134 |
+
a = await create_tenant(db_session, "shop-a", shopify_shop="a.myshopify.com")
|
| 135 |
+
b = await create_tenant(db_session, "shop-b", shopify_shop="b.myshopify.com")
|
| 136 |
+
with pytest.raises(HTTPException) as e1: # new tenant grabbing A's shop
|
| 137 |
+
await create_tenant(db_session, "shop-a-dup", shopify_shop="a.myshopify.com")
|
| 138 |
+
assert e1.value.status_code == 409
|
| 139 |
+
with pytest.raises(HTTPException) as e2: # B editing onto A's shop
|
| 140 |
+
await update_tenant(db_session, b, shopify_shop="a.myshopify.com")
|
| 141 |
+
assert e2.value.status_code == 409
|
| 142 |
+
await update_tenant(db_session, a, shopify_shop="a.myshopify.com") # own value -> ok
|
| 143 |
+
await update_tenant(db_session, a, whatsapp_phone_id="PHONE1")
|
| 144 |
+
with pytest.raises(HTTPException) as e3: # B grabbing A's WhatsApp number
|
| 145 |
+
await update_tenant(db_session, b, whatsapp_phone_id="PHONE1")
|
| 146 |
+
assert e3.value.status_code == 409
|
| 147 |
+
await update_tenant(db_session, b, whatsapp_phone_id="") # empty never collides
|